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:
Sergey Kozyrenko
2026-05-12 09:49:22 +07:00
co-authored by Cursor
parent 4a1f5ba527
commit 8b1cf00ba1
9 changed files with 1031 additions and 884 deletions
@@ -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,
};
};
+29 -881
View File
@@ -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'}