Implement debounced auto save

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-17 16:55:02 +04:00
parent a9f4ecacfa
commit 3a054c70ba
8 changed files with 458 additions and 61 deletions

View File

@@ -23,18 +23,17 @@ import {
Input,
Label,
PropertyRow,
Textarea,
useDialogRef,
} from "@probo/ui";
import { type ReactNode } from "react";
import { graphql } from "relay-runtime";
import type { z } from "zod";
import { z } from "zod";
import type { CreateDocumentDialogMutation } from "#/__generated__/core/CreateDocumentDialogMutation.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
import { documentSchema, useDocumentForm } from "#/hooks/forms/useDocumentForm";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -65,6 +64,13 @@ const createDocumentMutation = graphql`
}
`;
const documentSchema = z.object({
title: z.string().min(1, "Title is required"),
approverIds: z.array(z.string()).min(1, "At least one approver is required"),
documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]),
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
});
/**
* Dialog to create or update a document
*/
@@ -72,8 +78,15 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { control, handleSubmit, register, formState, reset }
= useDocumentForm();
const { control, handleSubmit, register, formState, reset } = useFormWithSchema(
documentSchema,
{
defaultValues: {
documentType: "POLICY",
classification: "INTERNAL",
},
},
);
const errors = formState.errors ?? {};
const [createDocument, isLoading]
= useMutationWithToasts<CreateDocumentDialogMutation>(createDocumentMutation);
@@ -115,14 +128,6 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
placeholder={__("Document title")}
{...register("title")}
/>
<Textarea
id="content"
variant="ghost"
autogrow
placeholder={__("Add content")}
aria-label={__("Content")}
{...register("content")}
/>
</div>
{/* Properties form */}
<div className="py-5 px-6 bg-subtle">

View File

@@ -12,10 +12,15 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { RichEditor } from "@probo/ui";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { RichEditor, useToast } from "@probo/ui";
import { useCallback } from "react";
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import { useDebounceCallback } from "usehooks-ts";
import type { DocumentDescriptionPage_updateContentMutation } from "#/__generated__/core/DocumentDescriptionPage_updateContentMutation.graphql";
import type { DocumentDescriptionPageQuery } from "#/__generated__/core/DocumentDescriptionPageQuery.graphql";
export const documentDescriptionPageQuery = graphql`
@@ -24,6 +29,7 @@ export const documentDescriptionPageQuery = graphql`
version: node(id: $versionId) @include(if: $versionSpecified) {
__typename
... on DocumentVersion {
id
content
}
}
@@ -34,6 +40,7 @@ export const documentDescriptionPageQuery = graphql`
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT, direction: DESC }) @skip(if: $versionSpecified) {
edges {
node {
id
content
}
}
@@ -43,9 +50,20 @@ export const documentDescriptionPageQuery = graphql`
}
`;
const updateContentMutation = graphql`
mutation DocumentDescriptionPage_updateContentMutation($input: UpdateDocumentVersionContentInput!) {
updateDocumentVersionContent(input: $input) {
content
}
}
`;
export function DocumentDescriptionPage(props: { queryRef: PreloadedQuery<DocumentDescriptionPageQuery> }) {
const { queryRef } = props;
const { __ } = useTranslate();
const { toast } = useToast();
const { document, version } = usePreloadedQuery<DocumentDescriptionPageQuery>(
documentDescriptionPageQuery,
queryRef,
@@ -54,12 +72,54 @@ export function DocumentDescriptionPage(props: { queryRef: PreloadedQuery<Docume
throw new Error("invalid type for node");
}
// const lastVersion = document.lastVersion?.edges[0].node;
// const currentVersion = lastVersion ?? version as NonNullable<typeof lastVersion | typeof version>;
const lastVersion = document.lastVersion?.edges[0].node;
const currentVersion = lastVersion ?? version as NonNullable<typeof lastVersion | typeof version>;
const [updateContent, _] = useMutation<DocumentDescriptionPage_updateContentMutation>(updateContentMutation);
const handleUpdate = useDebounceCallback(
useCallback((content: string) => {
updateContent({
variables: {
input: {
id: currentVersion.id,
content,
},
},
onCompleted: (_, errors) => {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(__("Content not saved"), errors),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Content saved"),
variant: "success",
});
},
onError: (error) => {
toast({
title: __("Error"),
description: error.message ?? __("Content not saved"),
variant: "error",
});
},
});
}, [currentVersion.id, updateContent, toast, __]),
500,
);
return (
<div>
<RichEditor />
<RichEditor
content={currentVersion.content}
onChange={handleUpdate}
/>
{/* <Markdown content={currentVersion.content} /> */}
</div>
);