mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-23 11:36:29 +00:00
refactor(knowledge): split page into form, layout, header and field components
Extract the previously monolithic knowledge page into focused modules under features/knowledges/ to match the resources/flows convention: - knowledge-form: schema, helpers and RHF wiring - knowledge-form-layout: desktop split / mobile stacked layouts - knowledge-form-controls: meta fields and content (markdown) field - knowledge-header / knowledge-layout: shared header and loading/not-found shell Promote the unsaved-changes machinery to reusable primitives (use-unsaved-changes-guard hook + unsaved-changes-dialog) so other forms can adopt the same flow. Tighten markdown-editor for knowledge content authoring: disable transformPastedText (a leading "- " or "1. " no longer silently turns a plain paste into a list/blockquote) and reserve the editor's bounding box during tiptap initialization to avoid layout jumps on mount. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
4a1f5ba527
commit
8b1cf00ba1
@@ -159,7 +159,12 @@ const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(
|
||||
linkify: true,
|
||||
tightLists: true,
|
||||
transformCopiedText: true,
|
||||
transformPastedText: true,
|
||||
// Plain text pasted from the OS clipboard is left as-is.
|
||||
// With `transformPastedText: true`, a leading "- " (or
|
||||
// "1. ", "> ", etc.) would be parsed as markdown and
|
||||
// turn the paste into a list/blockquote — almost never
|
||||
// what the user wants for knowledge documents.
|
||||
transformPastedText: false,
|
||||
}),
|
||||
],
|
||||
immediatelyRender: false,
|
||||
@@ -243,11 +248,29 @@ const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(
|
||||
if (autoFocus && editor) {
|
||||
editor.commands.focus('end');
|
||||
}
|
||||
// `autoFocus` is intentionally omitted from the deps — it's a
|
||||
// mount-time prop, equivalent to the native `<input autoFocus>`
|
||||
// attribute. We don't want a parent flipping `autoFocus` later
|
||||
// to steal focus back into the editor.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor]);
|
||||
|
||||
// While tiptap is initializing (`useEditor` returns `null` on the
|
||||
// first render with `immediatelyRender: false`), render a placeholder
|
||||
// with the same outer classes so the bounding box is already correct
|
||||
// and the parent layout doesn't jump when the editor mounts.
|
||||
if (!editor) {
|
||||
return null;
|
||||
return (
|
||||
<div
|
||||
aria-busy="true"
|
||||
className={cn(
|
||||
'border-input dark:bg-input/30 group/markdown-editor flex w-full flex-col overflow-hidden rounded-md border shadow-2xs outline-hidden transition-[color,box-shadow]',
|
||||
disabled && 'pointer-events-none opacity-60',
|
||||
className,
|
||||
)}
|
||||
data-slot="markdown-editor"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -268,7 +291,7 @@ const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(
|
||||
) : null}
|
||||
<EditorContent
|
||||
className={cn(
|
||||
'prose prose-sm dark:prose-invert tiptap-content min-w-0 max-w-none flex-1 overflow-auto px-3 py-2',
|
||||
'prose prose-sm dark:prose-invert tiptap-content max-w-none min-w-0 flex-1 overflow-auto px-3 py-2',
|
||||
'[&_.ProseMirror]:min-h-full [&_.ProseMirror]:outline-none',
|
||||
contentClassName,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Save } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
export interface UnsavedChangesDialogProps {
|
||||
/** When `false`, the "Save & leave" button is disabled (e.g. form is invalid). */
|
||||
canSave: boolean;
|
||||
description?: string;
|
||||
discardText?: string;
|
||||
handleCancel: () => void;
|
||||
handleDiscard: () => void;
|
||||
handleOpenChange: (open: boolean) => void;
|
||||
handleSaveAndLeave: () => Promise<void> | void;
|
||||
isOpen: boolean;
|
||||
isSavingFromDialog: boolean;
|
||||
/** Override the default `<Save />` icon next to the save button. */
|
||||
saveIcon?: ReactNode;
|
||||
saveText?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const UnsavedChangesDialog = ({
|
||||
canSave,
|
||||
description = 'You have unsaved changes on this page. Would you like to save them before leaving?',
|
||||
discardText = 'Discard',
|
||||
handleCancel,
|
||||
handleDiscard,
|
||||
handleOpenChange,
|
||||
handleSaveAndLeave,
|
||||
isOpen,
|
||||
isSavingFromDialog,
|
||||
saveIcon = <Save />,
|
||||
saveText = 'Save',
|
||||
title = 'Unsaved changes',
|
||||
}: UnsavedChangesDialogProps) => (
|
||||
<Dialog
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isSavingFromDialog) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (isSavingFromDialog) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
disabled={isSavingFromDialog}
|
||||
onClick={handleCancel}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSavingFromDialog}
|
||||
onClick={handleDiscard}
|
||||
variant="destructive"
|
||||
>
|
||||
{discardText}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSavingFromDialog || !canSave}
|
||||
onClick={() => {
|
||||
void handleSaveAndLeave();
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
{isSavingFromDialog ? <Spinner variant="circle" /> : saveIcon}
|
||||
{saveText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
export { UnsavedChangesDialog };
|
||||
@@ -0,0 +1,250 @@
|
||||
import { type Control, useWatch } from 'react-hook-form';
|
||||
|
||||
import type {
|
||||
KnowledgeAnswerType as KnowledgeAnswerTypeT,
|
||||
KnowledgeGuideType as KnowledgeGuideTypeT,
|
||||
} from '@/graphql/types';
|
||||
|
||||
import { MarkdownEditor } from '@/components/shared/markdown-editor';
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputGroup, InputGroupTextareaAutosize } from '@/components/ui/input-group';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { KnowledgeAnswerType, KnowledgeDocType, KnowledgeGuideType } from '@/graphql/types';
|
||||
|
||||
import type { FormValues } from './knowledge-form';
|
||||
|
||||
// `<Select>` option lists. Co-located with the controls they feed because no
|
||||
// other module needs them.
|
||||
const docTypeValues = [KnowledgeDocType.Answer, KnowledgeDocType.Guide, KnowledgeDocType.Code] as const;
|
||||
const guideTypeValues = Object.values(KnowledgeGuideType) as KnowledgeGuideTypeT[];
|
||||
const answerTypeValues = Object.values(KnowledgeAnswerType) as KnowledgeAnswerTypeT[];
|
||||
|
||||
interface KnowledgeMetaFieldsProps {
|
||||
control: Control<FormValues>;
|
||||
isNew: boolean;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export const KnowledgeMetaFields = ({ control, isNew, isSaving }: KnowledgeMetaFieldsProps) => {
|
||||
// Targeted subscription: only this component re-renders when docType changes,
|
||||
// not the whole form. The full-form `useWatch` from the original code
|
||||
// re-rendered on every keystroke in the markdown editor.
|
||||
const docType = useWatch({ control, name: 'docType' });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={control}
|
||||
name="docType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Document type</FormLabel>
|
||||
{isNew ? (
|
||||
<Select
|
||||
disabled={isSaving}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{docTypeValues.map((value) => (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="border-input bg-muted/30 text-muted-foreground flex h-9 items-center rounded-md border px-3 text-sm">
|
||||
{field.value || '—'}
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{docType === KnowledgeDocType.Guide ? (
|
||||
<FormField
|
||||
control={control}
|
||||
name="guideType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Guide type</FormLabel>
|
||||
<Select
|
||||
disabled={isSaving}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select guide type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{guideTypeValues.map((value) => (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{docType === KnowledgeDocType.Answer ? (
|
||||
<FormField
|
||||
control={control}
|
||||
name="answerType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Answer type</FormLabel>
|
||||
<Select
|
||||
disabled={isSaving}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select answer type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{answerTypeValues.map((value) => (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{docType === KnowledgeDocType.Code ? (
|
||||
<FormField
|
||||
control={control}
|
||||
name="codeLang"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Code language</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={isSaving}
|
||||
placeholder="e.g. python, go, typescript"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="question"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Question</FormLabel>
|
||||
<FormControl>
|
||||
<InputGroup className="block">
|
||||
<InputGroupTextareaAutosize
|
||||
{...field}
|
||||
autoFocus={isNew}
|
||||
className="min-h-0"
|
||||
disabled={isSaving}
|
||||
maxRows={6}
|
||||
minRows={1}
|
||||
placeholder="Short title or question this document answers"
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<InputGroup className="block">
|
||||
<InputGroupTextareaAutosize
|
||||
{...field}
|
||||
className="min-h-0"
|
||||
disabled={isSaving}
|
||||
maxRows={8}
|
||||
minRows={1}
|
||||
placeholder="Optional short description"
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface KnowledgeContentFieldProps {
|
||||
control: Control<FormValues>;
|
||||
/** When `true`, the editor stretches to fill its parent (desktop split view). */
|
||||
fillParent?: boolean;
|
||||
isSaving: boolean;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export const KnowledgeContentField = ({
|
||||
control,
|
||||
fillParent = false,
|
||||
isSaving,
|
||||
showLabel = false,
|
||||
}: KnowledgeContentFieldProps) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem className={fillParent ? 'flex min-h-0 flex-1 flex-col' : undefined}>
|
||||
{showLabel ? <FormLabel>Content</FormLabel> : null}
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
className={fillParent ? 'min-h-0 flex-1' : 'min-h-[280px]'}
|
||||
contentClassName={fillParent ? undefined : 'min-h-[240px]'}
|
||||
disabled={isSaving}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
placeholder="Knowledge content (will be embedded into the vector store)"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Control } from 'react-hook-form';
|
||||
|
||||
import { GripVertical } from 'lucide-react';
|
||||
|
||||
import type { KnowledgeDocumentFragmentFragment } from '@/graphql/types';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
|
||||
|
||||
import type { FormValues } from './knowledge-form';
|
||||
|
||||
import { KnowledgeContentField, KnowledgeMetaFields } from './knowledge-form-controls';
|
||||
|
||||
interface KnowledgeIntroBlockProps {
|
||||
isNew: boolean;
|
||||
knowledge?: KnowledgeDocumentFragmentFragment | null;
|
||||
}
|
||||
|
||||
const KnowledgeIntroBlock = ({ isNew, knowledge }: KnowledgeIntroBlockProps) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{isNew ? 'Create a new knowledge document' : 'Edit knowledge document'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{isNew
|
||||
? 'Add an entry to the vector knowledge base'
|
||||
: 'Edits to content or metadata will trigger re-embedding'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isNew && knowledge ? (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge variant={knowledge.manual ? 'secondary' : 'outline'}>
|
||||
{knowledge.manual ? 'manual' : 'agent'}
|
||||
</Badge>
|
||||
{knowledge.flowId ? <Badge variant="outline">flow #{knowledge.flowId}</Badge> : null}
|
||||
{knowledge.taskId ? <Badge variant="outline">task #{knowledge.taskId}</Badge> : null}
|
||||
{knowledge.subtaskId ? <Badge variant="outline">subtask #{knowledge.subtaskId}</Badge> : null}
|
||||
<span>·</span>
|
||||
<span>
|
||||
chunk {knowledge.partSize} of {knowledge.totalSize}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface KnowledgeFormLayoutProps {
|
||||
control: Control<FormValues>;
|
||||
isNew: boolean;
|
||||
isSaving: boolean;
|
||||
knowledge?: KnowledgeDocumentFragmentFragment | null;
|
||||
}
|
||||
|
||||
export const KnowledgeFormLayoutDesktop = ({ control, isNew, isSaving, knowledge }: KnowledgeFormLayoutProps) => (
|
||||
<div className="flex min-h-0 w-full max-w-full flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
className="w-full"
|
||||
direction="horizontal"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={45}
|
||||
minSize={30}
|
||||
>
|
||||
<div className="h-full min-h-0 overflow-y-auto">
|
||||
<Card className="mx-auto min-h-full w-full max-w-2xl rounded-none border-0">
|
||||
<CardContent className="flex flex-col gap-6 py-6">
|
||||
<KnowledgeIntroBlock
|
||||
isNew={isNew}
|
||||
knowledge={knowledge}
|
||||
/>
|
||||
<KnowledgeMetaFields
|
||||
control={control}
|
||||
isNew={isNew}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle>
|
||||
<GripVertical className="size-4" />
|
||||
</ResizableHandle>
|
||||
<ResizablePanel
|
||||
defaultSize={55}
|
||||
minSize={30}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden p-4">
|
||||
<KnowledgeContentField
|
||||
control={control}
|
||||
fillParent
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const KnowledgeFormLayoutMobile = ({ control, isNew, isSaving, knowledge }: KnowledgeFormLayoutProps) => (
|
||||
<div className="flex min-w-0 flex-1 items-start justify-center p-4">
|
||||
<Card className="w-full max-w-3xl">
|
||||
<CardContent className="flex flex-col gap-6 pt-6">
|
||||
<KnowledgeIntroBlock
|
||||
isNew={isNew}
|
||||
knowledge={knowledge}
|
||||
/>
|
||||
<KnowledgeMetaFields
|
||||
control={control}
|
||||
isNew={isNew}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<KnowledgeContentField
|
||||
control={control}
|
||||
isSaving={isSaving}
|
||||
showLabel
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,300 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Save } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { type FieldPath, type SubmitHandler, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type {
|
||||
CreateKnowledgeDocumentInput,
|
||||
KnowledgeDocumentFragmentFragment,
|
||||
UpdateKnowledgeDocumentInput,
|
||||
} from '@/graphql/types';
|
||||
|
||||
import { UnsavedChangesDialog } from '@/components/shared/unsaved-changes-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { KnowledgeAnswerType, KnowledgeDocType, KnowledgeGuideType } from '@/graphql/types';
|
||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/use-unsaved-changes-guard';
|
||||
import { Log } from '@/lib/log';
|
||||
|
||||
import { KnowledgeFormLayoutDesktop, KnowledgeFormLayoutMobile } from './knowledge-form-layout';
|
||||
import { KnowledgeHeader } from './knowledge-header';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema, types, pure helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const formSchema = z
|
||||
.object({
|
||||
answerType: z.nativeEnum(KnowledgeAnswerType).optional(),
|
||||
codeLang: z.string().trim().optional(),
|
||||
content: z.string().trim().min(1, { message: 'Content is required' }),
|
||||
description: z.string().trim().optional(),
|
||||
docType: z.nativeEnum(KnowledgeDocType),
|
||||
guideType: z.nativeEnum(KnowledgeGuideType).optional(),
|
||||
question: z.string().trim().min(1, { message: 'Question is required' }),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const requiredByDocType: Partial<Record<KnowledgeDocType, { field: FieldPath<FormValues>; message: string }>> =
|
||||
{
|
||||
[KnowledgeDocType.Answer]: { field: 'answerType', message: 'Answer type is required' },
|
||||
[KnowledgeDocType.Code]: { field: 'codeLang', message: 'Code language is required' },
|
||||
[KnowledgeDocType.Guide]: { field: 'guideType', message: 'Guide type is required' },
|
||||
};
|
||||
|
||||
const rule = requiredByDocType[value.docType];
|
||||
|
||||
if (!rule) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldValue = value[rule.field];
|
||||
const isMissing = fieldValue === undefined || fieldValue === null || fieldValue === '';
|
||||
|
||||
if (isMissing) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: rule.message,
|
||||
path: [rule.field],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const newDocumentDefaults: FormValues = {
|
||||
answerType: undefined,
|
||||
codeLang: '',
|
||||
content: '',
|
||||
description: '',
|
||||
docType: KnowledgeDocType.Answer,
|
||||
guideType: undefined,
|
||||
question: '',
|
||||
};
|
||||
|
||||
export const documentToFormValues = (k: KnowledgeDocumentFragmentFragment): FormValues => ({
|
||||
answerType: k.answerType ?? undefined,
|
||||
codeLang: k.codeLang ?? '',
|
||||
content: k.content,
|
||||
description: k.description ?? '',
|
||||
docType: k.docType,
|
||||
guideType: k.guideType ?? undefined,
|
||||
question: k.question,
|
||||
});
|
||||
|
||||
// `description` and `codeLang` are optional and we want empty strings to map
|
||||
// to `undefined` so the backend treats them as "absent" instead of "set to ''".
|
||||
const trimmedOrUndefined = (value: null | string | undefined): string | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
return trimmed.length === 0 ? undefined : trimmed;
|
||||
};
|
||||
|
||||
// Shared field projection used by both create and update payloads. The only
|
||||
// difference between the two GraphQL inputs is that `docType` is required on
|
||||
// create and immutable on update — see `formValuesTo{Create,Update}Input`
|
||||
// below.
|
||||
const formValuesToBasePayload = (values: FormValues) => ({
|
||||
answerType: values.docType === KnowledgeDocType.Answer ? values.answerType : undefined,
|
||||
codeLang: values.docType === KnowledgeDocType.Code ? trimmedOrUndefined(values.codeLang) : undefined,
|
||||
content: values.content,
|
||||
description: trimmedOrUndefined(values.description),
|
||||
guideType: values.docType === KnowledgeDocType.Guide ? values.guideType : undefined,
|
||||
question: values.question,
|
||||
});
|
||||
|
||||
export const formValuesToCreateInput = (values: FormValues): CreateKnowledgeDocumentInput => ({
|
||||
...formValuesToBasePayload(values),
|
||||
docType: values.docType,
|
||||
});
|
||||
|
||||
export const formValuesToUpdateInput = (values: FormValues): UpdateKnowledgeDocumentInput =>
|
||||
formValuesToBasePayload(values);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SubmitResult {
|
||||
document?: KnowledgeDocumentFragmentFragment;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
interface KnowledgeFormProps {
|
||||
initialValues: FormValues;
|
||||
isNew: boolean;
|
||||
knowledge?: KnowledgeDocumentFragmentFragment | null;
|
||||
knowledgeName: null | string;
|
||||
onSubmit: (values: FormValues) => Promise<SubmitResult>;
|
||||
}
|
||||
|
||||
export const KnowledgeForm = ({ initialValues, isNew, knowledge, knowledgeName, onSubmit }: KnowledgeFormProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { isDesktop } = useBreakpoint();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: initialValues,
|
||||
// `onTouched` validates a field on its first blur and on every change
|
||||
// afterwards. With `onChange` we'd run the entire Zod schema on every
|
||||
// keystroke (including every emit from the multi-kilobyte `content`
|
||||
// markdown editor) — same UX after the first interaction, no waste
|
||||
// on initial mount or untouched fields.
|
||||
mode: 'onTouched',
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const { control, formState, handleSubmit, reset } = form;
|
||||
const { isDirty, isValid } = formState;
|
||||
|
||||
const performSave = useCallback(
|
||||
async (values: FormValues): Promise<boolean> => {
|
||||
try {
|
||||
const result = await onSubmit(values);
|
||||
|
||||
// Prefer the server's view of the document — backend may have
|
||||
// trimmed/normalized fields, attached derived data, or filled
|
||||
// optional fields. Falling back to the local `values` keeps
|
||||
// the form stable when the mutation hook can't return the
|
||||
// saved fragment for some reason.
|
||||
const resetValues = result.document ? documentToFormValues(result.document) : values;
|
||||
|
||||
// Reset BEFORE navigate so `isDirty` is false by the time the
|
||||
// blocker re-evaluates. We also `skipNextBlock` defensively
|
||||
// because reset's state propagation is async.
|
||||
reset(resetValues, { keepDefaultValues: false });
|
||||
|
||||
if (result.redirectTo) {
|
||||
skipNextBlockRef.current();
|
||||
navigate(result.redirectTo);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
Log.error('Failed to save knowledge document', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[navigate, onSubmit, reset],
|
||||
);
|
||||
|
||||
// The ref below breaks an otherwise circular hook dependency:
|
||||
//
|
||||
// performSave → skipNextBlockRef.current() (ref filled by effect below)
|
||||
// onSaveFromDialog → performSave
|
||||
// useUnsavedChangesGuard({ onSave: onSaveFromDialog }) → exposes skipNextBlock
|
||||
// useEffect → wires the exposed skipNextBlock back into the ref
|
||||
//
|
||||
// Replacing the ref with a plain dep would force `performSave` to depend
|
||||
// on `guard.skipNextBlock`, which is produced by a hook (`guard`) whose
|
||||
// own input (`onSave`) closes over `performSave` — a real cycle that
|
||||
// can't be expressed in deps without `useRef`.
|
||||
const skipNextBlockRef = useRef<() => void>(() => {});
|
||||
|
||||
const onSubmitWithGuard: SubmitHandler<FormValues> = useCallback(
|
||||
async (values) => {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await performSave(values);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[isSaving, performSave],
|
||||
);
|
||||
|
||||
const onSaveFromDialog = useCallback(async (): Promise<boolean> => {
|
||||
if (isSaving || !isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
return await performSave(form.getValues());
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [form, isSaving, isValid, performSave]);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
isDirty,
|
||||
isFormValid: isValid,
|
||||
onSave: onSaveFromDialog,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
skipNextBlockRef.current = guard.skipNextBlock;
|
||||
}, [guard.skipNextBlock]);
|
||||
|
||||
const canSubmit = !isSaving && isValid && (isNew || isDirty);
|
||||
|
||||
const saveButton = (
|
||||
<Button
|
||||
disabled={!canSubmit}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{isSaving ? <Spinner variant="circle" /> : <Save aria-hidden="true" />}
|
||||
{isNew ? 'Create' : 'Save'}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
// Desktop: lock to the viewport so the resizable panels
|
||||
// inside the body can fill the remaining space below the
|
||||
// sticky header. Mobile: allow the page to grow with its
|
||||
// content (single column, vertical scroll).
|
||||
className={isDesktop ? 'flex h-[100dvh] min-h-0 w-full flex-col' : 'flex min-h-[100dvh] flex-col'}
|
||||
onSubmit={handleSubmit(onSubmitWithGuard)}
|
||||
>
|
||||
<KnowledgeHeader
|
||||
isNew={isNew}
|
||||
knowledgeName={knowledgeName}
|
||||
saveButton={saveButton}
|
||||
/>
|
||||
{isDesktop ? (
|
||||
<KnowledgeFormLayoutDesktop
|
||||
control={control}
|
||||
isNew={isNew}
|
||||
isSaving={isSaving}
|
||||
knowledge={knowledge}
|
||||
/>
|
||||
) : (
|
||||
<KnowledgeFormLayoutMobile
|
||||
control={control}
|
||||
isNew={isNew}
|
||||
isSaving={isSaving}
|
||||
knowledge={knowledge}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
<UnsavedChangesDialog
|
||||
canSave={isValid}
|
||||
handleCancel={guard.handleCancel}
|
||||
handleDiscard={guard.handleDiscard}
|
||||
handleOpenChange={guard.handleOpenChange}
|
||||
handleSaveAndLeave={guard.handleSaveAndLeave}
|
||||
isOpen={guard.isOpen}
|
||||
isSavingFromDialog={guard.isSavingFromDialog}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { LibraryBig } from 'lucide-react';
|
||||
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
|
||||
interface KnowledgeHeaderProps {
|
||||
isNew: boolean;
|
||||
knowledgeName: null | string;
|
||||
saveButton?: ReactNode;
|
||||
}
|
||||
|
||||
export const KnowledgeHeader = ({ isNew, knowledgeName, saveButton }: KnowledgeHeaderProps) => (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
className="mr-2 h-4"
|
||||
orientation="vertical"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<LibraryBig className="size-4 shrink-0" />
|
||||
<BreadcrumbPage className="max-w-[240px] truncate">
|
||||
{isNew ? 'New knowledge' : (knowledgeName ?? 'Knowledge')}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
{saveButton ? <div className="ml-auto flex items-center gap-2">{saveButton}</div> : null}
|
||||
</header>
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { KnowledgeHeader } from './knowledge-header';
|
||||
|
||||
interface KnowledgeLayoutProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
isNew: boolean;
|
||||
knowledgeName: null | string;
|
||||
saveButton?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared layout shell for the knowledge page in non-form branches
|
||||
* (loading, not-found). `KnowledgeForm` owns its own `<form>` root and
|
||||
* renders the header inline because the form must be the parent of every
|
||||
* input.
|
||||
*/
|
||||
export const KnowledgeLayout = ({ children, className, isNew, knowledgeName, saveButton }: KnowledgeLayoutProps) => (
|
||||
<div className={cn('flex min-h-[100dvh] flex-col', className)}>
|
||||
<KnowledgeHeader
|
||||
isNew={isNew}
|
||||
knowledgeName={knowledgeName}
|
||||
saveButton={saveButton}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { type BlockerFunction, useBlocker } from 'react-router-dom';
|
||||
|
||||
export interface UnsavedChangesGuard {
|
||||
handleCancel: () => void;
|
||||
handleDiscard: () => void;
|
||||
handleOpenChange: (open: boolean) => void;
|
||||
handleSaveAndLeave: () => Promise<void>;
|
||||
isOpen: boolean;
|
||||
isSavingFromDialog: boolean;
|
||||
/**
|
||||
* Allows the next router navigation to bypass the blocker. Use this after
|
||||
* a successful save when the consumer needs to navigate to a fresh URL
|
||||
* (e.g. the new document page) without showing the dialog.
|
||||
*/
|
||||
skipNextBlock: () => void;
|
||||
}
|
||||
|
||||
export interface UseUnsavedChangesGuardArgs {
|
||||
isDirty: boolean;
|
||||
isFormValid: boolean;
|
||||
onSave: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic "are you sure you want to leave?" guard for any editing form
|
||||
* inside React Router. Combines two mechanisms:
|
||||
*
|
||||
* 1. `useBlocker` — intercepts in-app router navigations and exposes a
|
||||
* blocked state that the consumer can render as a dialog.
|
||||
* 2. `beforeunload` — covers full page reloads and tab close attempts;
|
||||
* the browser shows its native prompt while there are dirty changes.
|
||||
*
|
||||
* Designed to be UI-agnostic: pair with `<UnsavedChangesDialog>` (or any
|
||||
* custom dialog) by wiring the returned handlers.
|
||||
*/
|
||||
export const useUnsavedChangesGuard = ({
|
||||
isDirty,
|
||||
isFormValid,
|
||||
onSave,
|
||||
}: UseUnsavedChangesGuardArgs): UnsavedChangesGuard => {
|
||||
const allowNextRef = useRef(false);
|
||||
const isDirtyRef = useRef(isDirty);
|
||||
|
||||
useEffect(() => {
|
||||
isDirtyRef.current = isDirty;
|
||||
}, [isDirty]);
|
||||
|
||||
const blockerFn = useCallback<BlockerFunction>(({ currentLocation, nextLocation }) => {
|
||||
if (allowNextRef.current) {
|
||||
allowNextRef.current = false;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentLocation.pathname === nextLocation.pathname && currentLocation.search === nextLocation.search) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isDirtyRef.current;
|
||||
}, []);
|
||||
|
||||
const blocker = useBlocker(blockerFn);
|
||||
const [isSavingFromDialog, setIsSavingFromDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, [isDirty]);
|
||||
|
||||
const isOpen = blocker.state === 'blocked';
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (blocker.state === 'blocked') {
|
||||
blocker.reset();
|
||||
}
|
||||
}, [blocker]);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
if (blocker.state === 'blocked') {
|
||||
blocker.proceed();
|
||||
}
|
||||
}, [blocker]);
|
||||
|
||||
const handleSaveAndLeave = useCallback(async () => {
|
||||
if (isSavingFromDialog || !isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingFromDialog(true);
|
||||
|
||||
try {
|
||||
const success = await onSave();
|
||||
|
||||
if (success && blocker.state === 'blocked') {
|
||||
blocker.proceed();
|
||||
}
|
||||
} finally {
|
||||
setIsSavingFromDialog(false);
|
||||
}
|
||||
}, [blocker, isFormValid, isSavingFromDialog, onSave]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (isSavingFromDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open && blocker.state === 'blocked') {
|
||||
blocker.reset();
|
||||
}
|
||||
},
|
||||
[blocker, isSavingFromDialog],
|
||||
);
|
||||
|
||||
const skipNextBlock = useCallback(() => {
|
||||
allowNextRef.current = true;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleCancel,
|
||||
handleDiscard,
|
||||
handleOpenChange,
|
||||
handleSaveAndLeave,
|
||||
isOpen,
|
||||
isSavingFromDialog,
|
||||
skipNextBlock,
|
||||
};
|
||||
};
|
||||
@@ -1,873 +1,22 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { GripVertical, LibraryBig, Save } from 'lucide-react';
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { type Control, type FieldPath, type SubmitHandler, useForm, useWatch } from 'react-hook-form';
|
||||
import { type BlockerFunction, useBlocker, useNavigate, useParams } from 'react-router-dom';
|
||||
import { z } from 'zod';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import type {
|
||||
CreateKnowledgeDocumentInput,
|
||||
KnowledgeAnswerType as KnowledgeAnswerTypeT,
|
||||
KnowledgeDocumentFragmentFragment,
|
||||
KnowledgeGuideType as KnowledgeGuideTypeT,
|
||||
UpdateKnowledgeDocumentInput,
|
||||
} from '@/graphql/types';
|
||||
|
||||
import { MarkdownEditor } from '@/components/shared/markdown-editor';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputGroup, InputGroupTextareaAutosize } from '@/components/ui/input-group';
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { KnowledgeAnswerType, KnowledgeDocType, KnowledgeGuideType, useKnowledgeDocumentQuery } from '@/graphql/types';
|
||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
import { Log } from '@/lib/log';
|
||||
import {
|
||||
documentToFormValues,
|
||||
type FormValues,
|
||||
formValuesToCreateInput,
|
||||
formValuesToUpdateInput,
|
||||
KnowledgeForm,
|
||||
newDocumentDefaults,
|
||||
type SubmitResult,
|
||||
} from '@/features/knowledges/knowledge-form';
|
||||
import { KnowledgeLayout } from '@/features/knowledges/knowledge-layout';
|
||||
import { useKnowledgeDocumentQuery } from '@/graphql/types';
|
||||
import { useKnowledges } from '@/providers/knowledges-provider';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema, types, pure helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const docTypeValues = [KnowledgeDocType.Answer, KnowledgeDocType.Guide, KnowledgeDocType.Code] as const;
|
||||
const guideTypeValues = Object.values(KnowledgeGuideType) as KnowledgeGuideTypeT[];
|
||||
const answerTypeValues = Object.values(KnowledgeAnswerType) as KnowledgeAnswerTypeT[];
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
answerType: z.nativeEnum(KnowledgeAnswerType).optional(),
|
||||
codeLang: z.string().trim().optional(),
|
||||
content: z.string().trim().min(1, { message: 'Content is required' }),
|
||||
description: z.string().trim().optional(),
|
||||
docType: z.nativeEnum(KnowledgeDocType),
|
||||
guideType: z.nativeEnum(KnowledgeGuideType).optional(),
|
||||
question: z.string().trim().min(1, { message: 'Question is required' }),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const requiredByDocType: Partial<Record<KnowledgeDocType, { field: FieldPath<FormValues>; message: string }>> = {
|
||||
[KnowledgeDocType.Answer]: { field: 'answerType', message: 'Answer type is required' },
|
||||
[KnowledgeDocType.Code]: { field: 'codeLang', message: 'Code language is required' },
|
||||
[KnowledgeDocType.Guide]: { field: 'guideType', message: 'Guide type is required' },
|
||||
};
|
||||
|
||||
const rule = requiredByDocType[value.docType];
|
||||
|
||||
if (!rule) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldValue = value[rule.field];
|
||||
const isMissing = fieldValue === undefined || fieldValue === null || fieldValue === '';
|
||||
|
||||
if (isMissing) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: rule.message,
|
||||
path: [rule.field],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const newDocumentDefaults: FormValues = {
|
||||
answerType: undefined,
|
||||
codeLang: '',
|
||||
content: '',
|
||||
description: '',
|
||||
docType: KnowledgeDocType.Answer,
|
||||
guideType: undefined,
|
||||
question: '',
|
||||
};
|
||||
|
||||
const documentToFormValues = (k: KnowledgeDocumentFragmentFragment): FormValues => ({
|
||||
answerType: k.answerType ?? undefined,
|
||||
codeLang: k.codeLang ?? '',
|
||||
content: k.content,
|
||||
description: k.description ?? '',
|
||||
docType: k.docType,
|
||||
guideType: k.guideType ?? undefined,
|
||||
question: k.question,
|
||||
});
|
||||
|
||||
// `description` and `codeLang` are optional and we want empty strings to map
|
||||
// to `undefined` so the backend treats them as "absent" instead of "set to ''".
|
||||
const trimmedOrUndefined = (value: null | string | undefined): string | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
return trimmed.length === 0 ? undefined : trimmed;
|
||||
};
|
||||
|
||||
// Shared field projection used by both create and update payloads. The only
|
||||
// difference between the two GraphQL inputs is that `docType` is required on
|
||||
// create and immutable on update — see `formValuesTo{Create,Update}Input`
|
||||
// below.
|
||||
const formValuesToBasePayload = (values: FormValues) => ({
|
||||
answerType: values.docType === KnowledgeDocType.Answer ? values.answerType : undefined,
|
||||
codeLang: values.docType === KnowledgeDocType.Code ? trimmedOrUndefined(values.codeLang) : undefined,
|
||||
content: values.content,
|
||||
description: trimmedOrUndefined(values.description),
|
||||
guideType: values.docType === KnowledgeDocType.Guide ? values.guideType : undefined,
|
||||
question: values.question,
|
||||
});
|
||||
|
||||
const formValuesToCreateInput = (values: FormValues): CreateKnowledgeDocumentInput => ({
|
||||
...formValuesToBasePayload(values),
|
||||
docType: values.docType,
|
||||
});
|
||||
|
||||
const formValuesToUpdateInput = (values: FormValues): UpdateKnowledgeDocumentInput =>
|
||||
formValuesToBasePayload(values);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unsaved-changes guard hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UnsavedChangesGuard {
|
||||
handleCancel: () => void;
|
||||
handleDiscard: () => void;
|
||||
handleOpenChange: (open: boolean) => void;
|
||||
handleSaveAndLeave: () => Promise<void>;
|
||||
isOpen: boolean;
|
||||
isSavingFromDialog: boolean;
|
||||
/**
|
||||
* Allows the next router navigation to bypass the blocker. Use this after
|
||||
* a successful save when the form (or its parent) needs to navigate to
|
||||
* a fresh URL (e.g. the new document page) without showing the dialog.
|
||||
*/
|
||||
skipNextBlock: () => void;
|
||||
}
|
||||
|
||||
interface UseUnsavedChangesGuardArgs {
|
||||
isDirty: boolean;
|
||||
isFormValid: boolean;
|
||||
onSave: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const useUnsavedChangesGuard = ({
|
||||
isDirty,
|
||||
isFormValid,
|
||||
onSave,
|
||||
}: UseUnsavedChangesGuardArgs): UnsavedChangesGuard => {
|
||||
const allowNextRef = useRef(false);
|
||||
const isDirtyRef = useRef(isDirty);
|
||||
|
||||
useEffect(() => {
|
||||
isDirtyRef.current = isDirty;
|
||||
}, [isDirty]);
|
||||
|
||||
const blockerFn = useCallback<BlockerFunction>(({ currentLocation, nextLocation }) => {
|
||||
if (allowNextRef.current) {
|
||||
allowNextRef.current = false;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentLocation.pathname === nextLocation.pathname && currentLocation.search === nextLocation.search) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isDirtyRef.current;
|
||||
}, []);
|
||||
|
||||
const blocker = useBlocker(blockerFn);
|
||||
const [isSavingFromDialog, setIsSavingFromDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, [isDirty]);
|
||||
|
||||
const isOpen = blocker.state === 'blocked';
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (blocker.state === 'blocked') {
|
||||
blocker.reset();
|
||||
}
|
||||
}, [blocker]);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
if (blocker.state === 'blocked') {
|
||||
blocker.proceed();
|
||||
}
|
||||
}, [blocker]);
|
||||
|
||||
const handleSaveAndLeave = useCallback(async () => {
|
||||
if (isSavingFromDialog || !isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingFromDialog(true);
|
||||
|
||||
try {
|
||||
const success = await onSave();
|
||||
|
||||
if (success && blocker.state === 'blocked') {
|
||||
blocker.proceed();
|
||||
}
|
||||
} finally {
|
||||
setIsSavingFromDialog(false);
|
||||
}
|
||||
}, [blocker, isFormValid, isSavingFromDialog, onSave]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (isSavingFromDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open && blocker.state === 'blocked') {
|
||||
blocker.reset();
|
||||
}
|
||||
},
|
||||
[blocker, isSavingFromDialog],
|
||||
);
|
||||
|
||||
const skipNextBlock = useCallback(() => {
|
||||
allowNextRef.current = true;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleCancel,
|
||||
handleDiscard,
|
||||
handleOpenChange,
|
||||
handleSaveAndLeave,
|
||||
isOpen,
|
||||
isSavingFromDialog,
|
||||
skipNextBlock,
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface KnowledgePageHeaderProps {
|
||||
isNew: boolean;
|
||||
knowledgeName: null | string;
|
||||
saveButton?: ReactNode;
|
||||
}
|
||||
|
||||
const KnowledgePageHeader = ({ isNew, knowledgeName, saveButton }: KnowledgePageHeaderProps) => (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
className="mr-2 h-4"
|
||||
orientation="vertical"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<LibraryBig className="size-4 shrink-0" />
|
||||
<BreadcrumbPage className="max-w-[240px] truncate">
|
||||
{isNew ? 'New knowledge' : (knowledgeName ?? 'Knowledge')}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
{saveButton ? <div className="ml-auto flex items-center gap-2">{saveButton}</div> : null}
|
||||
</header>
|
||||
);
|
||||
|
||||
interface KnowledgeIntroBlockProps {
|
||||
isNew: boolean;
|
||||
knowledge?: KnowledgeDocumentFragmentFragment | null;
|
||||
}
|
||||
|
||||
const KnowledgeIntroBlock = ({ isNew, knowledge }: KnowledgeIntroBlockProps) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{isNew ? 'Create a new knowledge document' : 'Edit knowledge document'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{isNew
|
||||
? 'Add an entry to the vector knowledge base'
|
||||
: 'Edits to content or metadata will trigger re-embedding'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isNew && knowledge ? (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge variant={knowledge.manual ? 'secondary' : 'outline'}>
|
||||
{knowledge.manual ? 'manual' : 'agent'}
|
||||
</Badge>
|
||||
{knowledge.flowId ? <Badge variant="outline">flow #{knowledge.flowId}</Badge> : null}
|
||||
{knowledge.taskId ? <Badge variant="outline">task #{knowledge.taskId}</Badge> : null}
|
||||
{knowledge.subtaskId ? <Badge variant="outline">subtask #{knowledge.subtaskId}</Badge> : null}
|
||||
<span>·</span>
|
||||
<span>
|
||||
chunk {knowledge.partSize} of {knowledge.totalSize}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface KnowledgeMetaFieldsProps {
|
||||
control: Control<FormValues>;
|
||||
isNew: boolean;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const KnowledgeMetaFields = ({ control, isNew, isSaving }: KnowledgeMetaFieldsProps) => {
|
||||
// Targeted subscription: only this component re-renders when docType changes,
|
||||
// not the whole form. The full-form `useWatch` from the original code
|
||||
// re-rendered on every keystroke in the markdown editor.
|
||||
const docType = useWatch({ control, name: 'docType' });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={control}
|
||||
name="docType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Document type</FormLabel>
|
||||
{isNew ? (
|
||||
<Select
|
||||
disabled={isSaving}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{docTypeValues.map((value) => (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="border-input bg-muted/30 text-muted-foreground flex h-9 items-center rounded-md border px-3 text-sm">
|
||||
{field.value || '—'}
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{docType === KnowledgeDocType.Guide ? (
|
||||
<FormField
|
||||
control={control}
|
||||
name="guideType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Guide type</FormLabel>
|
||||
<Select
|
||||
disabled={isSaving}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select guide type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{guideTypeValues.map((value) => (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{docType === KnowledgeDocType.Answer ? (
|
||||
<FormField
|
||||
control={control}
|
||||
name="answerType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Answer type</FormLabel>
|
||||
<Select
|
||||
disabled={isSaving}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select answer type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{answerTypeValues.map((value) => (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{docType === KnowledgeDocType.Code ? (
|
||||
<FormField
|
||||
control={control}
|
||||
name="codeLang"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Code language</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={isSaving}
|
||||
placeholder="e.g. python, go, typescript"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="question"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Question</FormLabel>
|
||||
<FormControl>
|
||||
<InputGroup className="block">
|
||||
<InputGroupTextareaAutosize
|
||||
{...field}
|
||||
autoFocus={isNew}
|
||||
className="min-h-0"
|
||||
disabled={isSaving}
|
||||
maxRows={6}
|
||||
minRows={1}
|
||||
placeholder="Short title or question this document answers"
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<InputGroup className="block">
|
||||
<InputGroupTextareaAutosize
|
||||
{...field}
|
||||
className="min-h-0"
|
||||
disabled={isSaving}
|
||||
maxRows={8}
|
||||
minRows={1}
|
||||
placeholder="Optional short description"
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface KnowledgeContentFieldProps {
|
||||
control: Control<FormValues>;
|
||||
/** When `true`, the editor stretches to fill its parent (desktop split view). */
|
||||
fillParent?: boolean;
|
||||
isSaving: boolean;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
const KnowledgeContentField = ({ control, fillParent = false, isSaving, showLabel = false }: KnowledgeContentFieldProps) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem className={fillParent ? 'flex min-h-0 flex-1 flex-col' : undefined}>
|
||||
{showLabel ? <FormLabel>Content</FormLabel> : null}
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
className={fillParent ? 'min-h-0 flex-1' : 'min-h-[280px]'}
|
||||
contentClassName={fillParent ? undefined : 'min-h-[240px]'}
|
||||
disabled={isSaving}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
placeholder="Knowledge content (will be embedded into the vector store)"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
interface KnowledgeBodyProps {
|
||||
control: Control<FormValues>;
|
||||
isNew: boolean;
|
||||
isSaving: boolean;
|
||||
knowledge?: KnowledgeDocumentFragmentFragment | null;
|
||||
}
|
||||
|
||||
const KnowledgeBodyDesktop = ({ control, isNew, isSaving, knowledge }: KnowledgeBodyProps) => (
|
||||
<div className="flex h-[calc(100dvh-3rem)] min-h-0 w-full max-w-full flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
className="w-full"
|
||||
direction="horizontal"
|
||||
>
|
||||
<ResizablePanel
|
||||
className="h-[calc(100dvh-3rem)] min-h-0"
|
||||
defaultSize={45}
|
||||
minSize={30}
|
||||
>
|
||||
<div className="h-full min-h-0 overflow-y-auto">
|
||||
<Card className="mx-auto min-h-full w-full max-w-2xl rounded-none border-0">
|
||||
<CardContent className="flex flex-col gap-6 py-6">
|
||||
<KnowledgeIntroBlock
|
||||
isNew={isNew}
|
||||
knowledge={knowledge}
|
||||
/>
|
||||
<KnowledgeMetaFields
|
||||
control={control}
|
||||
isNew={isNew}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle>
|
||||
<GripVertical className="size-4" />
|
||||
</ResizableHandle>
|
||||
<ResizablePanel
|
||||
className="h-[calc(100dvh-3rem)] min-h-0"
|
||||
defaultSize={55}
|
||||
minSize={30}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden p-4">
|
||||
<KnowledgeContentField
|
||||
control={control}
|
||||
fillParent
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
const KnowledgeBodyMobile = ({ control, isNew, isSaving, knowledge }: KnowledgeBodyProps) => (
|
||||
<div className="flex min-w-0 flex-1 items-start justify-center p-4">
|
||||
<Card className="w-full max-w-3xl">
|
||||
<CardContent className="flex flex-col gap-6 pt-6">
|
||||
<KnowledgeIntroBlock
|
||||
isNew={isNew}
|
||||
knowledge={knowledge}
|
||||
/>
|
||||
<KnowledgeMetaFields
|
||||
control={control}
|
||||
isNew={isNew}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<KnowledgeContentField
|
||||
control={control}
|
||||
isSaving={isSaving}
|
||||
showLabel
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface KnowledgeLeaveDialogProps extends Pick<UnsavedChangesGuard, 'handleCancel' | 'handleDiscard' | 'handleOpenChange' | 'handleSaveAndLeave' | 'isOpen' | 'isSavingFromDialog'> {
|
||||
canSave: boolean;
|
||||
}
|
||||
|
||||
const KnowledgeLeaveDialog = ({
|
||||
canSave,
|
||||
handleCancel,
|
||||
handleDiscard,
|
||||
handleOpenChange,
|
||||
handleSaveAndLeave,
|
||||
isOpen,
|
||||
isSavingFromDialog,
|
||||
}: KnowledgeLeaveDialogProps) => (
|
||||
<Dialog
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isSavingFromDialog) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (isSavingFromDialog) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unsaved changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have unsaved changes on this page. Would you like to save them before leaving?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
disabled={isSavingFromDialog}
|
||||
onClick={handleCancel}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSavingFromDialog}
|
||||
onClick={handleDiscard}
|
||||
variant="destructive"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSavingFromDialog || !canSave}
|
||||
onClick={handleSaveAndLeave}
|
||||
variant="default"
|
||||
>
|
||||
{isSavingFromDialog ? <Spinner variant="circle" /> : <Save />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form view (uses the form, owns navigation guard)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface KnowledgeFormViewProps {
|
||||
initialValues: FormValues;
|
||||
isNew: boolean;
|
||||
knowledge?: KnowledgeDocumentFragmentFragment | null;
|
||||
knowledgeName: null | string;
|
||||
onSubmit: (values: FormValues) => Promise<SubmitResult>;
|
||||
}
|
||||
|
||||
interface SubmitResult {
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
const KnowledgeFormView = ({ initialValues, isNew, knowledge, knowledgeName, onSubmit }: KnowledgeFormViewProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { isDesktop } = useBreakpoint();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: initialValues,
|
||||
mode: 'onChange',
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const { control, formState, handleSubmit, reset } = form;
|
||||
const { isDirty, isValid } = formState;
|
||||
|
||||
const performSave = useCallback(
|
||||
async (values: FormValues): Promise<boolean> => {
|
||||
try {
|
||||
const result = await onSubmit(values);
|
||||
|
||||
// Reset BEFORE navigate so `isDirty` is false by the time the
|
||||
// blocker re-evaluates. We also `skipNextBlock` defensively
|
||||
// because reset's state propagation is async.
|
||||
reset(values, { keepDefaultValues: false });
|
||||
|
||||
if (result.redirectTo) {
|
||||
skipNextBlockRef.current();
|
||||
navigate(result.redirectTo);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
Log.error('Failed to save knowledge document', error);
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[navigate, onSubmit, reset],
|
||||
);
|
||||
|
||||
// The ref below breaks an otherwise circular hook dependency:
|
||||
//
|
||||
// performSave → skipNextBlockRef.current() (ref filled by effect below)
|
||||
// onSaveFromDialog → performSave
|
||||
// useUnsavedChangesGuard({ onSave: onSaveFromDialog }) → exposes skipNextBlock
|
||||
// useEffect → wires the exposed skipNextBlock back into the ref
|
||||
//
|
||||
// Replacing the ref with a plain dep would force `performSave` to depend
|
||||
// on `guard.skipNextBlock`, which is produced by a hook (`guard`) whose
|
||||
// own input (`onSave`) closes over `performSave` — a real cycle that
|
||||
// can't be expressed in deps without `useRef`.
|
||||
const skipNextBlockRef = useRef<() => void>(() => {});
|
||||
|
||||
const onSubmitWithGuard: SubmitHandler<FormValues> = useCallback(
|
||||
async (values) => {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await performSave(values);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[isSaving, performSave],
|
||||
);
|
||||
|
||||
const onSaveFromDialog = useCallback(async (): Promise<boolean> => {
|
||||
if (isSaving || !isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
return await performSave(form.getValues());
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [form, isSaving, isValid, performSave]);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
isDirty,
|
||||
isFormValid: isValid,
|
||||
onSave: onSaveFromDialog,
|
||||
});
|
||||
|
||||
// Wire the ref so `performSave` can call into the guard's `skipNextBlock`.
|
||||
useEffect(() => {
|
||||
skipNextBlockRef.current = guard.skipNextBlock;
|
||||
}, [guard.skipNextBlock]);
|
||||
|
||||
const canSubmit = !isSaving && isValid && (isNew || isDirty);
|
||||
|
||||
const saveButton = (
|
||||
<Button
|
||||
disabled={!canSubmit}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{isSaving ? <Spinner variant="circle" /> : <Save />}
|
||||
{isNew ? 'Create' : 'Save'}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={isDesktop ? 'flex h-full min-h-0 w-full flex-1 flex-col' : 'flex min-h-[100dvh] flex-col'}
|
||||
onSubmit={handleSubmit(onSubmitWithGuard)}
|
||||
>
|
||||
<KnowledgePageHeader
|
||||
isNew={isNew}
|
||||
knowledgeName={knowledgeName}
|
||||
saveButton={saveButton}
|
||||
/>
|
||||
{isDesktop ? (
|
||||
<KnowledgeBodyDesktop
|
||||
control={control}
|
||||
isNew={isNew}
|
||||
isSaving={isSaving}
|
||||
knowledge={knowledge}
|
||||
/>
|
||||
) : (
|
||||
<KnowledgeBodyMobile
|
||||
control={control}
|
||||
isNew={isNew}
|
||||
isSaving={isSaving}
|
||||
knowledge={knowledge}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
<KnowledgeLeaveDialog
|
||||
canSave={isValid}
|
||||
handleCancel={guard.handleCancel}
|
||||
handleDiscard={guard.handleDiscard}
|
||||
handleOpenChange={guard.handleOpenChange}
|
||||
handleSaveAndLeave={guard.handleSaveAndLeave}
|
||||
isOpen={guard.isOpen}
|
||||
isSavingFromDialog={guard.isSavingFromDialog}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page container
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Knowledge = () => {
|
||||
const navigate = useNavigate();
|
||||
const { knowledgeId } = useParams<{ knowledgeId?: string }>();
|
||||
@@ -895,6 +44,7 @@ const Knowledge = () => {
|
||||
const created = await createKnowledge(formValuesToCreateInput(values));
|
||||
|
||||
return {
|
||||
document: created ?? undefined,
|
||||
redirectTo: created?.id ? `/knowledges/${created.id}` : '/knowledges',
|
||||
};
|
||||
}
|
||||
@@ -903,35 +53,33 @@ const Knowledge = () => {
|
||||
return {};
|
||||
}
|
||||
|
||||
await updateKnowledge(knowledgeId, formValuesToUpdateInput(values));
|
||||
const updated = await updateKnowledge(knowledgeId, formValuesToUpdateInput(values));
|
||||
|
||||
return {};
|
||||
return { document: updated ?? undefined };
|
||||
},
|
||||
[createKnowledge, isNew, knowledgeId, updateKnowledge],
|
||||
);
|
||||
|
||||
if (!isNew && isLoadingKnowledge) {
|
||||
return (
|
||||
<>
|
||||
<KnowledgePageHeader
|
||||
isNew={false}
|
||||
knowledgeName={knowledgeName}
|
||||
/>
|
||||
<div className="flex min-h-[calc(100dvh-3rem)] items-center justify-center">
|
||||
<KnowledgeLayout
|
||||
isNew={false}
|
||||
knowledgeName={knowledgeName}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner variant="circle" />
|
||||
</div>
|
||||
</>
|
||||
</KnowledgeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNew && !knowledge) {
|
||||
return (
|
||||
<>
|
||||
<KnowledgePageHeader
|
||||
isNew={false}
|
||||
knowledgeName={knowledgeName}
|
||||
/>
|
||||
<div className="flex min-h-[calc(100dvh-3rem)] items-center justify-center p-4">
|
||||
<KnowledgeLayout
|
||||
isNew={false}
|
||||
knowledgeName={knowledgeName}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 pt-6 text-center">
|
||||
<h2 className="text-xl font-semibold">Knowledge not found</h2>
|
||||
@@ -942,12 +90,12 @@ const Knowledge = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
</KnowledgeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KnowledgeFormView
|
||||
<KnowledgeForm
|
||||
initialValues={initialValues}
|
||||
isNew={isNew}
|
||||
key={knowledgeId ?? 'new'}
|
||||
|
||||
Reference in New Issue
Block a user