Implement debounced auto save
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
93
apps/console/src/__generated__/core/DocumentDescriptionPage_updateContentMutation.graphql.ts
generated
Normal file
93
apps/console/src/__generated__/core/DocumentDescriptionPage_updateContentMutation.graphql.ts
generated
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @generated SignedSource<<1f7c6a92ab7920b470a03799e04f4ea3>>
|
||||||
|
* @lightSyntaxTransform
|
||||||
|
* @nogrep
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { ConcreteRequest } from 'relay-runtime';
|
||||||
|
export type UpdateDocumentVersionContentInput = {
|
||||||
|
content: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
export type DocumentDescriptionPage_updateContentMutation$variables = {
|
||||||
|
input: UpdateDocumentVersionContentInput;
|
||||||
|
};
|
||||||
|
export type DocumentDescriptionPage_updateContentMutation$data = {
|
||||||
|
readonly updateDocumentVersionContent: {
|
||||||
|
readonly content: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type DocumentDescriptionPage_updateContentMutation = {
|
||||||
|
response: DocumentDescriptionPage_updateContentMutation$data;
|
||||||
|
variables: DocumentDescriptionPage_updateContentMutation$variables;
|
||||||
|
};
|
||||||
|
|
||||||
|
const node: ConcreteRequest = (function(){
|
||||||
|
var v0 = [
|
||||||
|
{
|
||||||
|
"defaultValue": null,
|
||||||
|
"kind": "LocalArgument",
|
||||||
|
"name": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
v1 = [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"kind": "Variable",
|
||||||
|
"name": "input",
|
||||||
|
"variableName": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"concreteType": "UpdateDocumentVersionContentPayload",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "updateDocumentVersionContent",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "content",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
"fragment": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Fragment",
|
||||||
|
"metadata": null,
|
||||||
|
"name": "DocumentDescriptionPage_updateContentMutation",
|
||||||
|
"selections": (v1/*: any*/),
|
||||||
|
"type": "Mutation",
|
||||||
|
"abstractKey": null
|
||||||
|
},
|
||||||
|
"kind": "Request",
|
||||||
|
"operation": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Operation",
|
||||||
|
"name": "DocumentDescriptionPage_updateContentMutation",
|
||||||
|
"selections": (v1/*: any*/)
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"cacheID": "2a43a5cf619ca1d475b3e480a132878b",
|
||||||
|
"id": null,
|
||||||
|
"metadata": {},
|
||||||
|
"name": "DocumentDescriptionPage_updateContentMutation",
|
||||||
|
"operationKind": "mutation",
|
||||||
|
"text": "mutation DocumentDescriptionPage_updateContentMutation(\n $input: UpdateDocumentVersionContentInput!\n) {\n updateDocumentVersionContent(input: $input) {\n content\n }\n}\n"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
(node as any).hash = "2bfee42c8fe67762416dec052c5f051b";
|
||||||
|
|
||||||
|
export default node;
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
|
||||||
//
|
|
||||||
// Permission to use, copy, modify, and/or distribute this software for any
|
|
||||||
// purpose with or without fee is hereby granted, provided that the above
|
|
||||||
// copyright notice and this permission notice appear in all copies.
|
|
||||||
//
|
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
||||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
||||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
||||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
||||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { useFormWithSchema } from "../useFormWithSchema";
|
|
||||||
|
|
||||||
export const documentSchema = z.object({
|
|
||||||
title: z.string().min(1, "Title is required"),
|
|
||||||
content: z.string().min(1, "Content is required"),
|
|
||||||
documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]),
|
|
||||||
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useDocumentForm = () => {
|
|
||||||
return useFormWithSchema(documentSchema, {
|
|
||||||
defaultValues: {
|
|
||||||
documentType: "POLICY",
|
|
||||||
classification: "INTERNAL",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -23,18 +23,17 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Label,
|
Label,
|
||||||
PropertyRow,
|
PropertyRow,
|
||||||
Textarea,
|
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
import type { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { CreateDocumentDialogMutation } from "#/__generated__/core/CreateDocumentDialogMutation.graphql";
|
import type { CreateDocumentDialogMutation } from "#/__generated__/core/CreateDocumentDialogMutation.graphql";
|
||||||
import { ControlledField } from "#/components/form/ControlledField";
|
import { ControlledField } from "#/components/form/ControlledField";
|
||||||
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
|
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
|
||||||
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
|
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
|
||||||
import { documentSchema, useDocumentForm } from "#/hooks/forms/useDocumentForm";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
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
|
* Dialog to create or update a document
|
||||||
*/
|
*/
|
||||||
@@ -72,8 +78,15 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
|
|||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
|
|
||||||
const { control, handleSubmit, register, formState, reset }
|
const { control, handleSubmit, register, formState, reset } = useFormWithSchema(
|
||||||
= useDocumentForm();
|
documentSchema,
|
||||||
|
{
|
||||||
|
defaultValues: {
|
||||||
|
documentType: "POLICY",
|
||||||
|
classification: "INTERNAL",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
const errors = formState.errors ?? {};
|
const errors = formState.errors ?? {};
|
||||||
const [createDocument, isLoading]
|
const [createDocument, isLoading]
|
||||||
= useMutationWithToasts<CreateDocumentDialogMutation>(createDocumentMutation);
|
= useMutationWithToasts<CreateDocumentDialogMutation>(createDocumentMutation);
|
||||||
@@ -115,14 +128,6 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
|
|||||||
placeholder={__("Document title")}
|
placeholder={__("Document title")}
|
||||||
{...register("title")}
|
{...register("title")}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
|
||||||
id="content"
|
|
||||||
variant="ghost"
|
|
||||||
autogrow
|
|
||||||
placeholder={__("Add content")}
|
|
||||||
aria-label={__("Content")}
|
|
||||||
{...register("content")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Properties form */}
|
{/* Properties form */}
|
||||||
<div className="py-5 px-6 bg-subtle">
|
<div className="py-5 px-6 bg-subtle">
|
||||||
|
|||||||
@@ -12,10 +12,15 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { RichEditor } from "@probo/ui";
|
import { formatError } from "@probo/helpers";
|
||||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
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 { 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";
|
import type { DocumentDescriptionPageQuery } from "#/__generated__/core/DocumentDescriptionPageQuery.graphql";
|
||||||
|
|
||||||
export const documentDescriptionPageQuery = graphql`
|
export const documentDescriptionPageQuery = graphql`
|
||||||
@@ -24,6 +29,7 @@ export const documentDescriptionPageQuery = graphql`
|
|||||||
version: node(id: $versionId) @include(if: $versionSpecified) {
|
version: node(id: $versionId) @include(if: $versionSpecified) {
|
||||||
__typename
|
__typename
|
||||||
... on DocumentVersion {
|
... on DocumentVersion {
|
||||||
|
id
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +40,7 @@ export const documentDescriptionPageQuery = graphql`
|
|||||||
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT, direction: DESC }) @skip(if: $versionSpecified) {
|
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT, direction: DESC }) @skip(if: $versionSpecified) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
|
id
|
||||||
content
|
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> }) {
|
export function DocumentDescriptionPage(props: { queryRef: PreloadedQuery<DocumentDescriptionPageQuery> }) {
|
||||||
const { queryRef } = props;
|
const { queryRef } = props;
|
||||||
|
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { document, version } = usePreloadedQuery<DocumentDescriptionPageQuery>(
|
const { document, version } = usePreloadedQuery<DocumentDescriptionPageQuery>(
|
||||||
documentDescriptionPageQuery,
|
documentDescriptionPageQuery,
|
||||||
queryRef,
|
queryRef,
|
||||||
@@ -54,12 +72,54 @@ export function DocumentDescriptionPage(props: { queryRef: PreloadedQuery<Docume
|
|||||||
throw new Error("invalid type for node");
|
throw new Error("invalid type for node");
|
||||||
}
|
}
|
||||||
|
|
||||||
// const lastVersion = document.lastVersion?.edges[0].node;
|
const lastVersion = document.lastVersion?.edges[0].node;
|
||||||
// const currentVersion = lastVersion ?? version as NonNullable<typeof lastVersion | typeof version>;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<RichEditor />
|
<RichEditor
|
||||||
|
content={currentVersion.content}
|
||||||
|
onChange={handleUpdate}
|
||||||
|
/>
|
||||||
{/* <Markdown content={currentVersion.content} /> */}
|
{/* <Markdown content={currentVersion.content} /> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,204 @@
|
|||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
import { Blockquote } from "@tiptap/extension-blockquote";
|
||||||
|
import { Bold } from "@tiptap/extension-bold";
|
||||||
|
import { Code } from "@tiptap/extension-code";
|
||||||
|
import { CodeBlock } from "@tiptap/extension-code-block";
|
||||||
|
import { Document } from "@tiptap/extension-document";
|
||||||
|
import { HardBreak } from "@tiptap/extension-hard-break";
|
||||||
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
|
import { HorizontalRule } from "@tiptap/extension-horizontal-rule";
|
||||||
|
import { Italic } from "@tiptap/extension-italic";
|
||||||
|
import { Link } from "@tiptap/extension-link";
|
||||||
|
import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list";
|
||||||
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||||
|
import { Strike } from "@tiptap/extension-strike";
|
||||||
|
import { Text } from "@tiptap/extension-text";
|
||||||
|
import { Underline } from "@tiptap/extension-underline";
|
||||||
|
import { Dropcursor, Gapcursor, UndoRedo } from "@tiptap/extensions";
|
||||||
|
import { type Content, EditorContent, useEditor, useEditorState } from "@tiptap/react";
|
||||||
import { BubbleMenu, FloatingMenu } from "@tiptap/react/menus";
|
import { BubbleMenu, FloatingMenu } from "@tiptap/react/menus";
|
||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { useEffect } from "react";
|
||||||
|
import { tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
const extensions = [
|
||||||
|
Document,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
Heading.configure({ levels: [1, 2, 3] }),
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Strike,
|
||||||
|
Underline,
|
||||||
|
Code,
|
||||||
|
CodeBlock,
|
||||||
|
Link.configure({ openOnClick: false }),
|
||||||
|
Blockquote,
|
||||||
|
BulletList,
|
||||||
|
OrderedList,
|
||||||
|
ListItem,
|
||||||
|
HorizontalRule,
|
||||||
|
HardBreak,
|
||||||
|
Dropcursor,
|
||||||
|
Gapcursor,
|
||||||
|
UndoRedo,
|
||||||
|
];
|
||||||
|
|
||||||
|
const richEditorVariants = tv({
|
||||||
|
slots: {
|
||||||
|
bubbleMenu: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md"],
|
||||||
|
floatingMenu: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md"],
|
||||||
|
menuButton: ["px-2 py-1 text-sm rounded-sm font-semibold bg-level-0 hover:bg-subtle"],
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
true: {
|
||||||
|
menuButton: ["bg-active"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { bubbleMenu, floatingMenu, menuButton } = richEditorVariants();
|
||||||
|
|
||||||
|
type MenuButtonProps = {
|
||||||
|
label: string;
|
||||||
|
active?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MenuButton({ label, active, onClick }: MenuButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={menuButton({ active })}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RichEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RichEditor(props: RichEditorProps) {
|
||||||
|
const { content, onChange } = props;
|
||||||
|
|
||||||
export const RichEditor = () => {
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [StarterKit], // define your extension array
|
extensions,
|
||||||
content: "<p>Hello World!</p>", // initial content
|
content: (content ? JSON.parse(content) : "") as Content,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchedContent = useEditorState({
|
||||||
|
editor,
|
||||||
|
selector: ({ editor }) => {
|
||||||
|
return JSON.stringify(editor.getJSON());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchedContent !== content) {
|
||||||
|
onChange(watchedContent);
|
||||||
|
}
|
||||||
|
}, [content, watchedContent, onChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
|
<BubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
className={bubbleMenu()}
|
||||||
|
>
|
||||||
|
<MenuButton
|
||||||
|
label="Bold"
|
||||||
|
active={editor.isActive("bold")}
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
/>
|
||||||
|
<MenuButton
|
||||||
|
label="Italic"
|
||||||
|
active={editor.isActive("italic")}
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
/>
|
||||||
|
<MenuButton
|
||||||
|
label="Underline"
|
||||||
|
active={editor.isActive("underline")}
|
||||||
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||||
|
/>
|
||||||
|
<MenuButton
|
||||||
|
label="Strike"
|
||||||
|
active={editor.isActive("strike")}
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
/>
|
||||||
|
<MenuButton
|
||||||
|
label="Code"
|
||||||
|
active={editor.isActive("code")}
|
||||||
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
/>
|
||||||
|
<MenuButton
|
||||||
|
label="Link"
|
||||||
|
active={editor.isActive("link")}
|
||||||
|
onClick={() => {
|
||||||
|
if (editor.isActive("link")) {
|
||||||
|
editor.chain().focus().unsetLink().run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = window.prompt("URL");
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setLink({ href: url }).run();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</BubbleMenu>
|
||||||
|
|
||||||
|
<FloatingMenu
|
||||||
|
editor={editor}
|
||||||
|
className={floatingMenu()}
|
||||||
|
>
|
||||||
|
<MenuButton
|
||||||
|
label="H1"
|
||||||
|
active={editor.isActive("heading", { level: 1 })}
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
/>
|
||||||
|
<MenuButton
|
||||||
|
label="H2"
|
||||||
|
active={editor.isActive("heading", { level: 2 })}
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
/>
|
||||||
|
<MenuButton
|
||||||
|
label="H3"
|
||||||
|
active={editor.isActive("heading", { level: 3 })}
|
||||||
|
onClick={() =>
|
||||||
|
editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
|
/>
|
||||||
|
<MenuButton
|
||||||
|
label="Bullet List"
|
||||||
|
active={editor.isActive("bulletList")}
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
/>
|
||||||
|
<MenuButton
|
||||||
|
label="Ordered List"
|
||||||
|
active={editor.isActive("orderedList")}
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
/>
|
||||||
|
<MenuButton
|
||||||
|
label="Code Block"
|
||||||
|
active={editor.isActive("codeBlock")}
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
|
/>
|
||||||
|
<MenuButton
|
||||||
|
label="Blockquote"
|
||||||
|
active={editor.isActive("blockquote")}
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
/>
|
||||||
|
<MenuButton
|
||||||
|
label="Divider"
|
||||||
|
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||||
|
/>
|
||||||
|
</FloatingMenu>
|
||||||
|
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
<FloatingMenu editor={editor}>This is the floating menu</FloatingMenu>
|
</div>
|
||||||
<BubbleMenu editor={editor}>This is the bubble menu</BubbleMenu>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ type (
|
|||||||
OrganizationID gid.GID
|
OrganizationID gid.GID
|
||||||
Title string
|
Title string
|
||||||
Content string
|
Content string
|
||||||
|
ApproverIDs []gid.GID
|
||||||
Classification coredata.DocumentClassification
|
Classification coredata.DocumentClassification
|
||||||
DocumentType coredata.DocumentType
|
DocumentType coredata.DocumentType
|
||||||
TrustCenterVisibility *coredata.TrustCenterVisibility
|
TrustCenterVisibility *coredata.TrustCenterVisibility
|
||||||
@@ -121,6 +122,10 @@ func (cdr *CreateDocumentRequest) Validate() error {
|
|||||||
v.Check(cdr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
v.Check(cdr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||||
v.Check(cdr.Title, "title", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
|
v.Check(cdr.Title, "title", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
|
||||||
v.Check(cdr.Content, "content", validator.Required(), validator.NotEmpty(), validator.MaxLen(documentMaxLength))
|
v.Check(cdr.Content, "content", validator.Required(), validator.NotEmpty(), validator.MaxLen(documentMaxLength))
|
||||||
|
v.Check(cdr.ApproverIDs, "approver_ids", validator.Required(), validator.NotEmpty())
|
||||||
|
for _, id := range cdr.ApproverIDs {
|
||||||
|
v.Check(id, "approver_ids", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
|
||||||
|
}
|
||||||
v.Check(cdr.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications()))
|
v.Check(cdr.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications()))
|
||||||
v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes()))
|
v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes()))
|
||||||
v.Check(cdr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
v.Check(cdr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
||||||
@@ -1576,6 +1581,45 @@ func (s *DocumentService) Update(
|
|||||||
return document, nil
|
return document, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DocumentService) UpdateDocumentVersionContent(
|
||||||
|
ctx context.Context,
|
||||||
|
req UpdateDocumentVersionRequest,
|
||||||
|
) (string, error) {
|
||||||
|
documentVersion := &coredata.DocumentVersion{}
|
||||||
|
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.svc.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(conn pg.Conn) error {
|
||||||
|
if err := documentVersion.LoadByID(ctx, conn, s.svc.scope, req.ID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load document version %q: %w", req.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||||
|
return &ErrDocumentVersionNotDraft{}
|
||||||
|
}
|
||||||
|
|
||||||
|
documentVersion.Content = req.Content
|
||||||
|
documentVersion.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := documentVersion.Update(ctx, conn, s.svc.scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot update document version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return documentVersion.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DocumentService) Archive(
|
func (s *DocumentService) Archive(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
documentID gid.GID,
|
documentID gid.GID,
|
||||||
|
|||||||
@@ -3766,6 +3766,7 @@ type Mutation {
|
|||||||
updateDocument(input: UpdateDocumentInput!): UpdateDocumentPayload!
|
updateDocument(input: UpdateDocumentInput!): UpdateDocumentPayload!
|
||||||
archiveDocument(input: ArchiveDocumentInput!): ArchiveDocumentPayload!
|
archiveDocument(input: ArchiveDocumentInput!): ArchiveDocumentPayload!
|
||||||
unarchiveDocument(input: UnarchiveDocumentInput!): UnarchiveDocumentPayload!
|
unarchiveDocument(input: UnarchiveDocumentInput!): UnarchiveDocumentPayload!
|
||||||
|
updateDocumentVersionContent(input: UpdateDocumentVersionContentInput!): UpdateDocumentVersionContentPayload!
|
||||||
deleteDocument(input: DeleteDocumentInput!): DeleteDocumentPayload!
|
deleteDocument(input: DeleteDocumentInput!): DeleteDocumentPayload!
|
||||||
# Meeting mutations
|
# Meeting mutations
|
||||||
createMeeting(input: CreateMeetingInput!): CreateMeetingPayload!
|
createMeeting(input: CreateMeetingInput!): CreateMeetingPayload!
|
||||||
@@ -4472,6 +4473,7 @@ input CreateDocumentInput {
|
|||||||
organizationId: ID!
|
organizationId: ID!
|
||||||
title: String!
|
title: String!
|
||||||
content: String!
|
content: String!
|
||||||
|
approverIds: [ID!]!
|
||||||
documentType: DocumentType!
|
documentType: DocumentType!
|
||||||
classification: DocumentClassification!
|
classification: DocumentClassification!
|
||||||
trustCenterVisibility: TrustCenterVisibility
|
trustCenterVisibility: TrustCenterVisibility
|
||||||
@@ -4485,6 +4487,11 @@ input UpdateDocumentInput {
|
|||||||
trustCenterVisibility: TrustCenterVisibility
|
trustCenterVisibility: TrustCenterVisibility
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input UpdateDocumentVersionContentInput {
|
||||||
|
id: ID!
|
||||||
|
content: String!
|
||||||
|
}
|
||||||
|
|
||||||
input ExportDocumentVersionPDFInput {
|
input ExportDocumentVersionPDFInput {
|
||||||
documentVersionId: ID!
|
documentVersionId: ID!
|
||||||
withWatermark: Boolean!
|
withWatermark: Boolean!
|
||||||
@@ -5250,6 +5257,10 @@ type UnarchiveDocumentPayload {
|
|||||||
document: Document!
|
document: Document!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateDocumentVersionContentPayload {
|
||||||
|
content: String!
|
||||||
|
}
|
||||||
|
|
||||||
type DeleteDocumentPayload {
|
type DeleteDocumentPayload {
|
||||||
deletedDocumentId: ID!
|
deletedDocumentId: ID!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4629,6 +4629,7 @@ func (r *mutationResolver) CreateDocument(ctx context.Context, input types.Creat
|
|||||||
DocumentType: input.DocumentType,
|
DocumentType: input.DocumentType,
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
Content: input.Content,
|
Content: input.Content,
|
||||||
|
ApproverIDs: input.ApproverIds,
|
||||||
Classification: input.Classification,
|
Classification: input.Classification,
|
||||||
TrustCenterVisibility: input.TrustCenterVisibility,
|
TrustCenterVisibility: input.TrustCenterVisibility,
|
||||||
},
|
},
|
||||||
@@ -4729,6 +4730,36 @@ func (r *mutationResolver) UnarchiveDocument(ctx context.Context, input types.Un
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateDocumentVersionContent is the resolver for the updateDocumentVersionContent field.
|
||||||
|
func (r *mutationResolver) UpdateDocumentVersionContent(ctx context.Context, input types.UpdateDocumentVersionContentInput) (*types.UpdateDocumentVersionContentPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.ID, probo.ActionDocumentUpdate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.ID.TenantID())
|
||||||
|
|
||||||
|
content, err := prb.Documents.UpdateDocumentVersionContent(
|
||||||
|
ctx,
|
||||||
|
probo.UpdateDocumentVersionRequest{
|
||||||
|
ID: input.ID,
|
||||||
|
Content: input.Content,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if _, ok := errors.AsType[*probo.ErrDocumentVersionNotDraft](err); ok {
|
||||||
|
return nil, gqlutils.Conflict(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot update document version content", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.UpdateDocumentVersionContentPayload{
|
||||||
|
Content: content,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteDocument is the resolver for the deleteDocument field.
|
// DeleteDocument is the resolver for the deleteDocument field.
|
||||||
func (r *mutationResolver) DeleteDocument(ctx context.Context, input types.DeleteDocumentInput) (*types.DeleteDocumentPayload, error) {
|
func (r *mutationResolver) DeleteDocument(ctx context.Context, input types.DeleteDocumentInput) (*types.DeleteDocumentPayload, error) {
|
||||||
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentDelete); err != nil {
|
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentDelete); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user