retab
uischema-buildersource: ok · hit
Schema Builder
Controlled JSON Schema editor for building Retab extraction schemas — fields, types, nested objects, arrays, enums, and required flags.
npx shadcn add "https://ui.retab.com/r/schema-builder.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightajv@^8.17.1ajv-errors@^3.0.0ajv-formats@^3.0.1lucide-react@^0.460.0radix-ui@^1.5.0sonner@^2.0.0
Registry dependencies
@retab/utils@retab/alert-dialog@retab/form@retab/input@retab/label@retab/switch@retab/textareadialogdropdown-menutooltip@retab/effect-key@retab/use-keyed-layout-effect@retab/use-keyed-mount-effect
Files115 · source cached 10/8/2026, 3:17:23 PM
registry/new-york-v4/ui/schema-builder.tsx
"use client";
export {
SchemaBuilder,
type ExtendedJSONSchema7,
type SchemaBuilderFeatures,
type SchemaBuilderProps,
type SchemaBuilderView,
} from "@/components/schema-editor/schema-builder";
components/schema-editor/document-array-node-editor.tsx
"use client";
import type {
DocumentSchemaNodeEditorProps,
RenderDocumentNodeEditor,
SchemaEditorMode,
} from "@/components/schema-editor/document-node-editor-types";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import type { DocumentNodeView } from "@/components/schema-editor/document/view-model";
import type { ResolvedSchemaBuilderFeatures } from "@/components/schema-editor/schema-builder-types";
interface DocumentArrayNodeEditorProps {
doc: SchemaDocument;
nodeId: string;
nodeView: DocumentNodeView;
path: string;
setDefsAccordionOpen: (open: boolean) => void;
draggedParentRef: DocumentSchemaNodeEditorProps["draggedParentRef"];
draggedPropertyRef: DocumentSchemaNodeEditorProps["draggedPropertyRef"];
mode: SchemaEditorMode;
features: ResolvedSchemaBuilderFeatures;
renderNode: RenderDocumentNodeEditor;
dispatch: DocumentSchemaNodeEditorProps["dispatch"];
}
export function DocumentArrayNodeEditor({
dispatch,
doc,
nodeId,
nodeView,
path,
setDefsAccordionOpen,
draggedParentRef,
draggedPropertyRef,
mode,
features,
renderNode,
}: DocumentArrayNodeEditorProps) {
const itemView = nodeView.items;
if (!itemView) {
return null;
}
return (
<div className="ml-4">
<div className="border-border ml-4 border-l">
{renderNode({
dispatch,
doc,
draggedParentRef,
draggedPropertyRef,
mode,
features,
name: "items",
nodeId: itemView.nodeId,
nodeView: itemView,
path: `${path}.items`,
canDelete: false,
setDefsAccordionOpen,
})}
</div>
</div>
);
}
components/schema-editor/document-definitions-editor-controller.ts
import * as React from "react";
import { toast } from "sonner";
import type {
DocumentSchemaNodeEditorProps,
SchemaEditorMode,
} from "@/components/schema-editor/document-node-editor-types";
import { nodeFromJson } from "@/components/schema-editor/document/convert";
import {
addDefinition,
removeDefinition,
renameDefinition,
} from "@/components/schema-editor/document/definition-operations";
import { isDefinitionReferenced } from "@/components/schema-editor/document/derive";
import { replaceNodeJson } from "@/components/schema-editor/document/json-node";
import { definitionRef } from "@/components/schema-editor/document/json-pointer";
import type {
DefinitionEntry,
SchemaDocument,
} from "@/components/schema-editor/document/types";
import { getDocumentNodeView } from "@/components/schema-editor/document/view-model";
import type { SchemaDispatch } from "@/components/schema-editor/schema-builder-types";
interface DocumentDefinitionsEditorControllerOptions {
dispatch: SchemaDispatch;
doc: SchemaDocument;
mode: SchemaEditorMode;
definitionsEnabled: boolean;
accordionOpen: boolean;
setAccordionOpen: (open: boolean) => void;
}
export function useDocumentDefinitionsEditorController({
dispatch,
doc,
mode,
definitionsEnabled,
accordionOpen,
setAccordionOpen,
}: DocumentDefinitionsEditorControllerOptions) {
const [newDefinitionName, setNewDefinitionName] = React.useState("");
const editable = mode === "editable";
const shouldShowClosedPrompt =
doc.defs.length === 0 && (!accordionOpen || !definitionsEnabled);
const accordionValue = accordionOpen ? "defs" : "";
const openDefinitions = React.useCallback(() => {
setAccordionOpen(true);
}, [setAccordionOpen]);
const addNewDefinition = React.useCallback(() => {
const definitionName = newDefinitionName.trim();
if (!definitionName) return;
dispatch(
(current) =>
addDefinition(current, {
name: definitionName,
node: nodeFromJson(
{ type: "object", properties: {}, required: [] },
current,
),
}).doc,
);
setNewDefinitionName("");
setAccordionOpen(true);
}, [dispatch, newDefinitionName, setAccordionOpen]);
const deleteDefinition = React.useCallback(
(definition: DefinitionEntry) => {
if (
isDefinitionReferenced(doc, definition.id, {
exceptDefId: definition.id,
})
) {
toast.error(
`Cannot delete "${definition.name}" because it is referenced by one or more $ref properties. Remove or update those references first.`,
);
return;
}
dispatch((current) => removeDefinition(current, definition.id));
if (doc.defs.length <= 1) {
setAccordionOpen(false);
}
},
[dispatch, doc, setAccordionOpen],
);
const updateDefinition = React.useCallback(
(
definition: DefinitionEntry,
newName: string,
updatedDefinition?: Parameters<
NonNullable<DocumentSchemaNodeEditorProps["onNameChange"]>
>[1],
) => {
if (newName === definition.name && !updatedDefinition) return;
dispatch((current) => {
let next = current;
if (newName !== definition.name) {
next = renameDefinition(next, definition.id, newName);
}
if (updatedDefinition) {
next = replaceNodeJson(next, definition.node.id, updatedDefinition);
}
return next;
});
},
[dispatch],
);
const definitionViews = React.useMemo(
() =>
doc.defs.map((definition) => ({
definition,
nodeView: getDocumentNodeView(doc, definition.node),
path: definitionRef("$defs", definition.name),
canDelete: !isDefinitionReferenced(doc, definition.id, {
exceptDefId: definition.id,
}),
})),
[doc],
);
return {
accordionValue,
definitionViews,
editable,
newDefinitionName,
setNewDefinitionName,
shouldShowClosedPrompt,
openDefinitions,
addNewDefinition,
deleteDefinition,
updateDefinition,
};
}
components/schema-editor/document-definitions-editor.tsx
"use client";
import * as React from "react";
import { PlusIcon } from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useDocumentDefinitionsEditorController } from "@/components/schema-editor/document-definitions-editor-controller";
import type { SchemaEditorMode } from "@/components/schema-editor/document-node-editor-types";
import {
definitionElementId,
DEFINITIONS_SECTION_ID,
} from "@/components/schema-editor/document-node-reveal";
import { DocumentSchemaNodeEditor } from "@/components/schema-editor/document-schema-node-editor";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import type {
ResolvedSchemaBuilderFeatures,
SchemaDispatch,
} from "@/components/schema-editor/schema-builder-types";
interface DocumentDefinitionsEditorProps {
dispatch: SchemaDispatch;
doc: SchemaDocument;
mode: SchemaEditorMode;
definitionsEnabled: boolean;
features: ResolvedSchemaBuilderFeatures;
accordionOpen: boolean;
setAccordionOpen: (open: boolean) => void;
draggedParentRef: React.RefObject<string | null>;
draggedPropertyRef: React.RefObject<string | null>;
}
export function DocumentDefinitionsEditor({
dispatch,
doc,
mode,
definitionsEnabled,
features,
accordionOpen,
setAccordionOpen,
draggedParentRef,
draggedPropertyRef,
}: DocumentDefinitionsEditorProps) {
const controller = useDocumentDefinitionsEditorController({
dispatch,
doc,
mode,
definitionsEnabled,
accordionOpen,
setAccordionOpen,
});
if (controller.shouldShowClosedPrompt) {
return mode === "descriptionOnly" ? null : (
<div className="mt-4 flex">
<div
className="rounded-md transition-colors duration-300"
id={DEFINITIONS_SECTION_ID}
>
{definitionsEnabled ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={controller.openDefinitions}
>
<PlusIcon className="h-4 w-4" />
<span>Add definition</span>
</Button>
) : null}
</div>
</div>
);
}
return (
<Accordion
type="single"
collapsible
id={DEFINITIONS_SECTION_ID}
className="border-border mt-6 w-full rounded-lg border px-4 pb-0"
value={controller.accordionValue}
onValueChange={(value) => setAccordionOpen(value === "defs")}
>
<AccordionItem value="defs" className="border-none bg-transparent">
<AccordionTrigger className="text-muted-foreground bg-transparent font-medium">
<div className="flex items-center">
Definitions ({doc.defs.length})
</div>
</AccordionTrigger>
<AccordionContent className="bg-transparent px-1 pt-2">
<div className="space-y-4">
{doc.defs.length === 0 && (
<p className="text-muted-foreground text-sm">
Create reusable schema components to avoid duplication with a
new definition.
</p>
)}
{controller.definitionViews.map((definitionView) => {
const { definition } = definitionView;
return (
<div
key={definition.id}
className="py-2 transition-colors duration-300"
id={definitionElementId(definition.id)}
>
<DocumentSchemaNodeEditor
dispatch={dispatch}
doc={doc}
draggedParentRef={draggedParentRef}
draggedPropertyRef={draggedPropertyRef}
mode={definitionsEnabled ? mode : "readOnly"}
features={features}
name={definition.name}
nodeId={definition.node.id}
nodeView={definitionView.nodeView}
onNameChange={(newName, updatedDefinition) =>
controller.updateDefinition(
definition,
newName,
updatedDefinition,
)
}
path={definitionView.path}
canDelete={definitionView.canDelete}
onDelete={() => controller.deleteDefinition(definition)}
setDefsAccordionOpen={setAccordionOpen}
/>
</div>
);
})}
{controller.editable && definitionsEnabled && (
<div className="flex items-center gap-3">
<Input
placeholder="New definition name"
className="w-40"
value={controller.newDefinitionName}
onChange={(event) =>
controller.setNewDefinitionName(event.target.value)
}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={!controller.newDefinitionName.trim()}
onClick={controller.addNewDefinition}
>
<PlusIcon className="h-4 w-4" />
<span>Add</span>
</Button>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
components/schema-editor/document-enum-node-editor.tsx
"use client";
import { useState } from "react";
import type {
DocumentSchemaNodeEditorProps,
SchemaEditorMode,
} from "@/components/schema-editor/document-node-editor-types";
import {
addEnumValue,
removeEnumValue,
updateEnumValue,
} from "@/components/schema-editor/document/enum-operations";
import type { EnumValue } from "@/components/schema-editor/document/types";
import { SchemaChipAddRow } from "@/components/schema-editor/primitives/schema-chip-add-row";
import { SchemaChipList } from "@/components/schema-editor/primitives/schema-chip-list";
interface DocumentEnumNodeEditorProps {
dispatch: DocumentSchemaNodeEditorProps["dispatch"];
mode: SchemaEditorMode;
nodeId: string;
enumEntries: EnumValue[];
}
export function DocumentEnumNodeEditor({
dispatch,
mode,
nodeId,
enumEntries,
}: DocumentEnumNodeEditorProps) {
const [newEnumValue, setNewEnumValue] = useState("");
const editable = mode === "editable";
const canAddEnumValue = newEnumValue.trim().length > 0;
const handleAddEnum = () => {
if (!canAddEnumValue) return;
dispatch((current) => addEnumValue(current, nodeId, newEnumValue.trim()));
setNewEnumValue("");
};
const handleRemoveEnum = (id: string) => {
dispatch((current) => removeEnumValue(current, nodeId, id));
};
const handleEditEnum = (id: string, newValue: string) => {
dispatch((current) =>
updateEnumValue(current, nodeId, id, { value: newValue }),
);
};
const addInput = {
focusAfterSubmit: true,
inputLabel: "New choice",
placeholder: "New choice",
submitLabel: "Add",
value: newEnumValue,
onChange: setNewEnumValue,
onSubmit: handleAddEnum,
};
return (
<div className="ml-6">
<div className="mt-1 mb-2">
<SchemaChipList
editable={editable}
items={enumEntries.map((entry, index) => {
const value = String(entry.value);
return {
id: entry.id,
inputLabel: `Option ${index + 1}: ${value || "empty"}`,
removeLabel: `Remove option ${value}`,
value,
};
})}
onRemove={handleRemoveEnum}
onReplace={handleEditEnum}
/>
{editable && (
<SchemaChipAddRow addInput={addInput} editable={editable} />
)}
</div>
{enumEntries.length === 0 && !editable && (
<div className="text-muted-foreground mb-2 text-sm">
No enum values defined.
</div>
)}
</div>
);
}
components/schema-editor/document-node-actions.tsx
"use client";
import { SchemaRowActions } from "@/components/schema-editor/primitives/schema-row-actions";
interface DocumentNodeActionsProps {
canDelete: boolean;
mode: "descriptionOnly" | "readOnly" | "editable";
editable: boolean;
hidePencilButton: boolean;
onDelete?: () => void;
onOpenMetadata: () => void;
}
export function DocumentNodeActions(props: DocumentNodeActionsProps) {
const details =
props.hidePencilButton || !props.onOpenMetadata
? undefined
: props.mode === "readOnly"
? {
label: "View field properties",
mode: "view" as const,
onOpen: props.onOpenMetadata,
}
: {
label: "Edit field properties",
mode: "edit" as const,
onOpen: props.onOpenMetadata,
};
return (
<SchemaRowActions
canDelete={props.canDelete}
editable={props.editable}
details={details}
onDelete={props.onDelete}
/>
);
}
components/schema-editor/document-node-description-control.tsx
"use client";
import { SchemaInlineDescription } from "@/components/schema-editor/primitives/schema-inline-description";
interface DocumentNodeDescriptionControlProps {
description: string;
mode: "descriptionOnly" | "readOnly" | "editable";
onOpenMetadata: () => void;
onSubmitDescription: (description: string) => void;
}
export function DocumentNodeDescriptionControl({
description,
mode,
onOpenMetadata,
onSubmitDescription,
}: DocumentNodeDescriptionControlProps) {
return (
<SchemaInlineDescription
ariaLabel="Field description"
value={description}
editable={mode !== "readOnly"}
onOpenDetails={onOpenMetadata}
onCommit={onSubmitDescription}
/>
);
}
components/schema-editor/document-node-editor-types.ts
import type * as React from "react";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import type { DocumentNodeView } from "@/components/schema-editor/document/view-model";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import type {
ResolvedSchemaBuilderFeatures,
SchemaDispatch,
} from "@/components/schema-editor/schema-builder-types";
import type { SchemaEditorMode } from "@/components/schema-editor/schema-editor-mode";
export type { SchemaEditorMode };
export interface DocumentSchemaNodeEditorProps {
dispatch: SchemaDispatch;
doc: SchemaDocument;
name: string;
nodeId: string;
nodeView: DocumentNodeView;
path: string;
canDelete?: boolean;
onDelete?: () => void;
onNameChange?: (newName: string, updatedNode?: ExtendedJSONSchema7) => void;
setDefsAccordionOpen: (open: boolean) => void;
draggedParentRef: React.RefObject<string | null>;
draggedPropertyRef: React.RefObject<string | null>;
mode?: SchemaEditorMode;
hidePencilButton?: boolean;
isRequired?: boolean;
onRequiredChange?: (required: boolean) => void;
siblingNames?: string[];
features?: ResolvedSchemaBuilderFeatures;
}
export type RenderDocumentNodeEditor = (
props: DocumentSchemaNodeEditorProps,
) => React.ReactNode;
components/schema-editor/document-node-header-controller.ts
import * as React from "react";
import type { JSONSchema7Definition } from "json-schema";
import {
revealDefinitionElement,
revealDefinitionsSection,
} from "@/components/schema-editor/document-node-reveal";
import { projectNode } from "@/components/schema-editor/document/convert";
import { setRefByName } from "@/components/schema-editor/document/definition-operations";
import { setEnumValues } from "@/components/schema-editor/document/enum-operations";
import { setNodeDescription } from "@/components/schema-editor/document/node-metadata";
import {
setNodeEditorType,
type SchemaEditorType,
} from "@/components/schema-editor/document/type-operations";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import type { DocumentNodeView } from "@/components/schema-editor/document/view-model";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import type { PropertyFormCommand } from "@/components/schema-editor/property-form/types";
import type { SchemaDispatch } from "@/components/schema-editor/schema-builder-types";
interface DocumentNodeHeaderControllerOptions {
dispatch: SchemaDispatch;
doc: SchemaDocument;
nodeId: string;
nodeView: DocumentNodeView;
setDefsAccordionOpen: (open: boolean) => void;
}
export function useDocumentNodeHeaderController({
dispatch,
doc,
nodeId,
nodeView,
setDefsAccordionOpen,
}: DocumentNodeHeaderControllerOptions) {
const schemaNode = projectNode(doc, nodeView.docNode) as ExtendedJSONSchema7;
const defs = React.useMemo(() => {
const next: Record<string, JSONSchema7Definition> = {};
for (const definition of doc.defs) {
setRecordValue(next, definition.name, projectNode(doc, definition.node));
}
return next;
}, [doc]);
const localType = nodeView.type;
const description = nodeView.description || "";
const refName = localType === "$ref" ? nodeView.refName : undefined;
const [metadataDialogOpen, setMetadataDialogOpen] = React.useState(false);
const [enumCreationDialogOpen, setEnumCreationDialogOpen] =
React.useState(false);
const showDefinition = React.useCallback(
(definitionName: string) => {
setDefsAccordionOpen(true);
const definition = doc.defs.find((def) => def.name === definitionName);
if (definition) {
revealDefinitionElement(definition.id);
}
},
[doc.defs, setDefsAccordionOpen],
);
const showDefinitionsSection = React.useCallback(() => {
setDefsAccordionOpen(true);
revealDefinitionsSection();
}, [setDefsAccordionOpen]);
const selectType = React.useCallback(
(newType: SchemaEditorType | "enum") => {
if (newType === "enum") {
if (localType !== "enum") {
setEnumCreationDialogOpen(true);
}
return;
}
dispatch((current) =>
setNodeEditorType(current, nodeId, newType as SchemaEditorType),
);
},
[dispatch, localType, nodeId],
);
const confirmEnumValues = React.useCallback(
(enumValues: string[]) => {
dispatch((current) => setEnumValues(current, nodeId, enumValues));
},
[dispatch, nodeId],
);
const submitDescription = React.useCallback(
(nextDescription: string) => {
dispatch((current) =>
setNodeDescription(current, nodeId, nextDescription || undefined),
);
},
[dispatch, nodeId],
);
const selectDefinition = React.useCallback(
(definitionName: string) => {
dispatch((current) => setRefByName(current, nodeId, definitionName));
},
[dispatch, nodeId],
);
const selectObjectTemplate = React.useCallback(
(templateName: string) => {
void import("./optional/object-templates/object-template-reference").then(
({ applyObjectTemplateReferenceToDocument }) => {
dispatch((current) =>
applyObjectTemplateReferenceToDocument(
current,
nodeId,
templateName,
),
);
},
);
},
[dispatch, nodeId],
);
const handlePropertyFormCommand = React.useCallback(
async (command: PropertyFormCommand) => {
if (command.type === "createDefinition") {
showDefinitionsSection();
return;
}
if (command.type === "installObjectTemplate") {
const { addObjectTemplateDefinitionsToDocument } = await import(
"./optional/object-templates/object-template-reference"
);
dispatch((current) =>
addObjectTemplateDefinitionsToDocument(current, command.templateName),
);
}
},
[dispatch, showDefinitionsSection],
);
return {
schemaNode,
defs,
localType,
description,
refName,
metadataDialogOpen,
setMetadataDialogOpen,
enumCreationDialogOpen,
setEnumCreationDialogOpen,
showDefinition,
showDefinitionsSection,
selectType,
confirmEnumValues,
submitDescription,
selectDefinition,
selectObjectTemplate,
handlePropertyFormCommand,
};
}
function setRecordValue<T>(record: Record<string, T>, key: string, value: T) {
Object.defineProperty(record, key, {
value,
enumerable: true,
configurable: true,
writable: true,
});
}
components/schema-editor/document-node-header.tsx
"use client";
import * as React from "react";
import { DocumentNodeActions } from "@/components/schema-editor/document-node-actions";
import { DocumentNodeDescriptionControl } from "@/components/schema-editor/document-node-description-control";
import type {
DocumentSchemaNodeEditorProps,
SchemaEditorMode,
} from "@/components/schema-editor/document-node-editor-types";
import { useDocumentNodeHeaderController } from "@/components/schema-editor/document-node-header-controller";
import { DocumentNodeNameControl } from "@/components/schema-editor/document-node-name-control";
import { DocumentNodeTypeMenu } from "@/components/schema-editor/document-node-type-menu";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import type { DocumentNodeView } from "@/components/schema-editor/document/view-model";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { NodeDialog } from "@/components/schema-editor/node-dialog";
import { SchemaFieldRow } from "@/components/schema-editor/primitives/schema-field-row";
import { EnumCreationDialog } from "./enum-creation-dialog";
interface DocumentNodeHeaderProps
extends Omit<
DocumentSchemaNodeEditorProps,
"draggedParentRef" | "draggedPropertyRef"
> {
doc: SchemaDocument;
nodeView: DocumentNodeView;
mode: SchemaEditorMode;
features: NonNullable<DocumentSchemaNodeEditorProps["features"]>;
onChange: (newNode: ExtendedJSONSchema7) => void;
}
export function DocumentNodeHeader({
dispatch,
doc,
name,
nodeView,
nodeId,
path,
canDelete = false,
onDelete,
onNameChange,
setDefsAccordionOpen,
mode,
hidePencilButton = false,
siblingNames = [],
features,
onChange,
}: DocumentNodeHeaderProps) {
const editable = mode === "editable";
const controller = useDocumentNodeHeaderController({
dispatch,
doc,
nodeId,
nodeView,
setDefsAccordionOpen,
});
return (
<>
{path !== "#" && (
<SchemaFieldRow
id={`schema-field-${path.replace(/^#\.?/, "").split(".").join("-")}`}
grip={editable ? "drag" : "empty"}
name={
<DocumentNodeNameControl
editable={editable}
name={name}
siblingNames={siblingNames}
canRename={Boolean(onNameChange)}
isReference={controller.localType === "$ref"}
refName={controller.refName}
onNameChange={onNameChange}
onShowDefinition={controller.showDefinition}
/>
}
description={
<DocumentNodeDescriptionControl
description={controller.description}
mode={mode}
onOpenMetadata={() => controller.setMetadataDialogOpen(true)}
onSubmitDescription={controller.submitDescription}
/>
}
actions={
<DocumentNodeActions
canDelete={canDelete}
editable={editable}
mode={mode}
hidePencilButton={hidePencilButton}
onDelete={onDelete}
onOpenMetadata={() => controller.setMetadataDialogOpen(true)}
/>
}
type={
<DocumentNodeTypeMenu
defs={controller.defs}
editable={editable}
features={features}
localType={controller.localType}
refName={controller.refName}
onCreateDefinition={controller.showDefinitionsSection}
onSelectDefinition={controller.selectDefinition}
onSelectObjectTemplate={controller.selectObjectTemplate}
onSelectType={controller.selectType}
/>
}
/>
)}
{path !== "#" && controller.metadataDialogOpen ? (
<NodeDialog
isOpen={controller.metadataDialogOpen}
onClose={() => controller.setMetadataDialogOpen(false)}
onChange={onChange}
onNameChange={onNameChange}
onDelete={
editable && canDelete
? () => {
if (onDelete) {
onDelete();
controller.setMetadataDialogOpen(false);
}
}
: undefined
}
node={controller.schemaNode}
name={name}
mode={mode}
siblingNames={siblingNames}
formContext={{
schemaDefinitions: controller.defs || {},
fieldPath: path,
objectTemplatesEnabled: features.objectTemplates,
onCommand: controller.handlePropertyFormCommand,
}}
/>
) : null}
<EnumCreationDialog
isOpen={controller.enumCreationDialogOpen}
onClose={() => controller.setEnumCreationDialogOpen(false)}
onConfirm={controller.confirmEnumValues}
onCancel={() => undefined}
/>
</>
);
}
components/schema-editor/document-node-name-control.tsx
"use client";
import { validateName } from "@/components/schema-editor/lib/json-schema-utils";
import { SchemaInlineName } from "@/components/schema-editor/primitives/schema-inline-name";
interface DocumentNodeNameControlProps {
editable: boolean;
name: string;
siblingNames: string[];
canRename: boolean;
isReference: boolean;
refName?: string;
onNameChange?: (newName: string) => void;
onShowDefinition: (definitionName: string) => void;
}
export function DocumentNodeNameControl({
editable,
name,
siblingNames,
canRename,
isReference,
refName,
onNameChange,
onShowDefinition,
}: DocumentNodeNameControlProps) {
return (
<SchemaInlineName
ariaLabel={`Field name ${name}`}
value={name}
editable={editable}
canRename={canRename}
validate={(value) => validateName(value, siblingNames, name, "property")}
reference={
isReference && refName
? {
label: refName,
onReveal: () => onShowDefinition(refName),
}
: undefined
}
onCommit={onNameChange}
/>
);
}
components/schema-editor/document-node-reveal.ts
export const DEFINITIONS_SECTION_ID = "schema-definitions-section";
const REVEAL_AFTER_RENDER_DELAY_MS = 0;
const HIGHLIGHT_DURATION_MS = 2500;
export function definitionElementId(definitionId: string) {
return `schema-definition-${definitionId}`;
}
export function revealDefinitionElement(definitionId: string) {
revealElementAfterRender(definitionElementId(definitionId));
}
export function revealDefinitionsSection() {
revealElementAfterRender(DEFINITIONS_SECTION_ID);
}
function revealElementAfterRender(elementId: string) {
window.setTimeout(() => {
const element = document.getElementById(elementId);
if (!element) return;
element.scrollIntoView({ behavior: "smooth", block: "center" });
element.classList.add("bg-accent");
window.setTimeout(() => {
element.classList.remove("bg-accent");
}, HIGHLIGHT_DURATION_MS);
}, REVEAL_AFTER_RENDER_DELAY_MS);
}
components/schema-editor/document-node-type-menu.tsx
"use client";
import type { JSONSchema7Definition } from "json-schema";
import type { SchemaEditorType } from "@/components/schema-editor/document/type-operations";
import { createObjectTemplateTypeTrailingContent } from "@/components/schema-editor/object-template-type-section";
import {
SchemaTypeMenu,
type SchemaTypeMenuSection,
} from "@/components/schema-editor/primitives/schema-type-menu";
import type { ResolvedSchemaBuilderFeatures } from "@/components/schema-editor/schema-builder-types";
import {
createDefinitionTypeSubmenu,
createPrimitiveTypeItems,
createTypeMenuValue,
} from "@/components/schema-editor/schema-type-menu-sections";
interface DocumentNodeTypeMenuProps {
defs: Record<string, JSONSchema7Definition>;
editable: boolean;
features: ResolvedSchemaBuilderFeatures;
localType: string;
refName?: string;
onCreateDefinition: () => void;
onSelectDefinition: (definitionName: string) => void;
onSelectObjectTemplate: (templateName: string) => void;
onSelectType: (type: SchemaEditorType | "enum") => void;
}
export function DocumentNodeTypeMenu({
defs,
editable,
features,
localType,
refName,
onCreateDefinition,
onSelectDefinition,
onSelectObjectTemplate,
onSelectType,
}: DocumentNodeTypeMenuProps) {
const definitionNames = Object.keys(defs);
const sections: SchemaTypeMenuSection[] = [
{
id: "types",
kind: "items",
items: createPrimitiveTypeItems({
onSelectType: (type) => onSelectType(type as SchemaEditorType | "enum"),
}),
},
];
if (features.definitions) {
sections.push(
createDefinitionTypeSubmenu({
createDefinitionLabel: "Create a new definition to get started",
definitionNames,
onCreateDefinition,
onSelectDefinition,
}),
);
}
const trailingContent = features.objectTemplates
? createObjectTemplateTypeTrailingContent({
onSelectTemplate: onSelectObjectTemplate,
})
: undefined;
return (
<SchemaTypeMenu
variant="row"
editable={editable}
sections={sections}
trailingContent={trailingContent}
value={createTypeMenuValue({ type: localType, refName })}
/>
);
}
components/schema-editor/document-object-node-editor-controller.ts
import type * as React from "react";
import type { DocumentSchemaNodeEditorProps } from "@/components/schema-editor/document-node-editor-types";
import {
beginSchemaRowDrag,
leaveSchemaRowDragTarget,
resolveSchemaRowDrop,
updateSchemaRowDragTarget,
} from "@/components/schema-editor/primitives/schema-row-drag";
import { replaceNodeJson } from "@/components/schema-editor/document/json-node";
import {
addProperty,
moveProperty,
removeProperty,
renameProperty,
setRequired,
} from "@/components/schema-editor/document/property-operations";
import { createNode } from "@/components/schema-editor/document/type-operations";
import type { DocumentPropertyView } from "@/components/schema-editor/document/view-model";
import { formatTitle } from "@/components/schema-editor/schema-title";
interface DocumentObjectNodeEditorControllerOptions {
dispatch: DocumentSchemaNodeEditorProps["dispatch"];
objectNodeId: string;
properties: DocumentPropertyView[];
draggedPropertyRef: DocumentSchemaNodeEditorProps["draggedPropertyRef"];
}
export function useDocumentObjectNodeEditorController({
dispatch,
objectNodeId,
properties,
draggedPropertyRef,
}: DocumentObjectNodeEditorControllerOptions) {
const propertyNames = properties.map((property) => property.propertyName);
const propertyIds = properties.map((property) => property.propertyId);
const addNewProperty = (propertyName: string) => {
dispatch((current) =>
addProperty(current, objectNodeId, {
key: propertyName,
required: true,
node: { ...createNode("string"), title: formatTitle(propertyName) },
}),
);
};
const setPropertyRequired = (
property: DocumentPropertyView,
required: boolean,
) => {
dispatch((current) => setRequired(current, property.propertyId, required));
};
const updateProperty = (
property: DocumentPropertyView,
newName: string,
updatedNode?: Parameters<
NonNullable<DocumentSchemaNodeEditorProps["onNameChange"]>
>[1],
) => {
dispatch((current) => {
let next = current;
if (newName !== property.propertyName) {
next = renameProperty(next, property.propertyId, newName);
}
if (updatedNode) {
next = replaceNodeJson(next, property.nodeView.nodeId, updatedNode);
}
return next;
});
};
const deleteProperty = (property: DocumentPropertyView) => {
dispatch((current) => removeProperty(current, property.propertyId));
};
const startDrag = (
event: React.DragEvent<HTMLDivElement>,
property: DocumentPropertyView,
) => {
beginSchemaRowDrag({
event,
item: {
id: property.propertyId,
label: property.propertyName,
},
draggedRowIdRef: draggedPropertyRef,
});
};
const dragOver = (
event: React.DragEvent<HTMLDivElement>,
property: DocumentPropertyView,
) => {
updateSchemaRowDragTarget({
event,
rowIds: propertyIds,
targetRowId: property.propertyId,
draggedRowIdRef: draggedPropertyRef,
});
};
const drop = (
event: React.DragEvent<HTMLDivElement>,
property: DocumentPropertyView,
) => {
const move = resolveSchemaRowDrop({
event,
rowIds: propertyIds,
targetRowId: property.propertyId,
draggedRowIdRef: draggedPropertyRef,
});
if (!move) return;
dispatch((current) =>
moveProperty(current, move.sourceRowId, objectNodeId, move.targetIndex),
);
};
return {
propertyNames,
addNewProperty,
setPropertyRequired,
updateProperty,
deleteProperty,
startDrag,
dragOver,
leaveDragTarget: leaveSchemaRowDragTarget,
drop,
};
}
components/schema-editor/document-object-node-editor.tsx
"use client";
import * as React from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import type {
DocumentSchemaNodeEditorProps,
RenderDocumentNodeEditor,
SchemaEditorMode,
} from "@/components/schema-editor/document-node-editor-types";
import { useDocumentObjectNodeEditorController } from "@/components/schema-editor/document-object-node-editor-controller";
import { DocumentPropertyAddRow } from "@/components/schema-editor/document-property-add-row";
import { DocumentPropertyRow } from "@/components/schema-editor/document-property-row";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import type {
DocumentNodeView,
DocumentPropertyView,
} from "@/components/schema-editor/document/view-model";
import type { ResolvedSchemaBuilderFeatures } from "@/components/schema-editor/schema-builder-types";
interface DocumentObjectNodeEditorProps {
dispatch: DocumentSchemaNodeEditorProps["dispatch"];
doc: SchemaDocument;
nodeId: string;
nodeView: DocumentNodeView;
path: string;
setDefsAccordionOpen: (open: boolean) => void;
draggedParentRef: DocumentSchemaNodeEditorProps["draggedParentRef"];
draggedPropertyRef: DocumentSchemaNodeEditorProps["draggedPropertyRef"];
mode: SchemaEditorMode;
features: ResolvedSchemaBuilderFeatures;
renderNode: RenderDocumentNodeEditor;
}
export function DocumentObjectNodeEditor({
dispatch,
doc,
nodeId,
nodeView,
path,
setDefsAccordionOpen,
draggedParentRef,
draggedPropertyRef,
mode,
features,
renderNode,
}: DocumentObjectNodeEditorProps) {
const editable = mode === "editable";
const objectNodeId = nodeView.effectiveNode.id ?? nodeId;
const properties = nodeView.properties;
const controller = useDocumentObjectNodeEditorController({
dispatch,
objectNodeId,
properties,
draggedPropertyRef,
});
const renderProperty = (
property: DocumentPropertyView,
rootLayout: boolean,
) => {
return (
<DocumentPropertyRow
key={property.propertyId}
propertyId={property.propertyId}
dispatch={dispatch}
doc={doc}
propertyName={property.propertyName}
nodeView={property.nodeView}
rootLayout={rootLayout}
path={path}
setDefsAccordionOpen={setDefsAccordionOpen}
draggedParentRef={draggedParentRef}
draggedPropertyRef={draggedPropertyRef}
mode={mode}
features={features}
editable={editable}
isRequired={property.isRequired}
siblingNames={controller.propertyNames}
renderNode={renderNode}
onRequiredChange={(required) =>
controller.setPropertyRequired(property, required)
}
onNameChange={(newName, updatedNode) =>
controller.updateProperty(property, newName, updatedNode)
}
onDelete={() => controller.deleteProperty(property)}
onDragStart={(event) => controller.startDrag(event, property)}
onDragOver={(event) => controller.dragOver(event, property)}
onDragLeave={controller.leaveDragTarget}
onDrop={(event) => controller.drop(event, property)}
/>
);
};
const addPropertyControl = (rootLayout: boolean) =>
editable ? (
<DocumentPropertyAddRow
rootLayout={rootLayout}
siblingNames={controller.propertyNames}
onAddProperty={controller.addNewProperty}
/>
) : null;
if (path === "#") {
return (
<div>
<Accordion
type="single"
collapsible
defaultValue="properties"
className="border-border w-full rounded-lg border px-4 pb-0"
>
<AccordionItem value="properties" className="border-none">
<AccordionTrigger className="text-muted-foreground text-sm font-medium">
Properties ({properties.length})
</AccordionTrigger>
<AccordionContent className="px-1 pt-2">
<div>
{properties.length === 0 && (
<p className="text-muted-foreground py-2 text-sm">
Define the data structure for this object.
</p>
)}
{properties.map((property) => renderProperty(property, true))}
{addPropertyControl(true)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
return (
<div className="pl-2">
<div>
{properties.map((property) => renderProperty(property, false))}
{addPropertyControl(false)}
</div>
</div>
);
}
components/schema-editor/document-property-add-row.tsx
"use client";
import * as React from "react";
import { validateName } from "@/components/schema-editor/lib/json-schema-utils";
import { SchemaAddRow } from "@/components/schema-editor/primitives/schema-add-row";
interface DocumentPropertyAddRowProps {
rootLayout: boolean;
siblingNames: string[];
onAddProperty: (propertyName: string) => void;
}
export function DocumentPropertyAddRow({
rootLayout,
siblingNames,
onAddProperty,
}: DocumentPropertyAddRowProps) {
const [propertyName, setPropertyName] = React.useState("");
const [propertyNameError, setPropertyNameError] = React.useState<
string | null
>(null);
const validatePropertyName = React.useCallback(
(value: string) => validateName(value, siblingNames, undefined, "property"),
[siblingNames],
);
const addProperty = () => {
const key = propertyName.trim();
const error = validatePropertyName(key);
if (error) {
setPropertyNameError(error);
return;
}
if (!key) return;
onAddProperty(key);
setPropertyName("");
setPropertyNameError(null);
};
return (
<SchemaAddRow
className={
rootLayout ? "mt-3 ml-4" : "border-border mt-2 ml-4 border-l pl-4"
}
disabled={false}
error={propertyNameError}
inputLabel="New property name"
placeholder="New property name"
submitLabel="Add"
value={propertyName}
onChange={(value) => {
setPropertyName(value);
setPropertyNameError(value ? validatePropertyName(value) : null);
}}
onSubmit={addProperty}
/>
);
}
components/schema-editor/document-property-row.tsx
"use client";
import type * as React from "react";
import { cn } from "@/lib/utils";
import type {
DocumentSchemaNodeEditorProps,
RenderDocumentNodeEditor,
SchemaEditorMode,
} from "@/components/schema-editor/document-node-editor-types";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import type { DocumentNodeView } from "@/components/schema-editor/document/view-model";
import type { ResolvedSchemaBuilderFeatures } from "@/components/schema-editor/schema-builder-types";
interface DocumentPropertyRowProps {
propertyId: string;
dispatch: DocumentSchemaNodeEditorProps["dispatch"];
doc: SchemaDocument;
propertyName: string;
nodeView: DocumentNodeView;
rootLayout: boolean;
path: string;
setDefsAccordionOpen: (open: boolean) => void;
draggedParentRef: DocumentSchemaNodeEditorProps["draggedParentRef"];
draggedPropertyRef: DocumentSchemaNodeEditorProps["draggedPropertyRef"];
mode: SchemaEditorMode;
features: ResolvedSchemaBuilderFeatures;
editable: boolean;
isRequired: boolean;
siblingNames: string[];
renderNode: RenderDocumentNodeEditor;
onRequiredChange: (required: boolean) => void;
onNameChange: DocumentSchemaNodeEditorProps["onNameChange"];
onDelete: () => void;
onDragStart: React.DragEventHandler<HTMLDivElement>;
onDragOver: React.DragEventHandler<HTMLDivElement>;
onDragLeave: React.DragEventHandler<HTMLDivElement>;
onDrop: React.DragEventHandler<HTMLDivElement>;
}
export function DocumentPropertyRow({
propertyId,
dispatch,
doc,
propertyName,
nodeView,
rootLayout,
path,
setDefsAccordionOpen,
draggedParentRef,
draggedPropertyRef,
mode,
features,
editable,
isRequired,
siblingNames,
renderNode,
onRequiredChange,
onNameChange,
onDelete,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
}: DocumentPropertyRowProps) {
return (
<div
className={cn(
rootLayout ? "" : "border-border ml-4 border-l",
editable && "cursor-grab",
)}
draggable={editable}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
data-property-id={propertyId}
data-property-name={propertyName}
>
{renderNode({
dispatch,
doc,
draggedParentRef,
draggedPropertyRef,
mode,
features,
isRequired,
siblingNames,
onRequiredChange,
name: propertyName,
nodeId: nodeView.nodeId,
nodeView,
onNameChange,
path: `${path}.${propertyName}`,
canDelete: true,
onDelete,
setDefsAccordionOpen,
})}
</div>
);
}
components/schema-editor/document-schema-editor-controller.ts
import * as React from "react";
import { replaceNodeJson } from "@/components/schema-editor/document/json-node";
import {
setNodeDescription,
setNodeTitle,
stripDescriptions,
} from "@/components/schema-editor/document/node-metadata";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import { getDocumentNodeView } from "@/components/schema-editor/document/view-model";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import type {
ResolvedSchemaBuilderFeatures,
SchemaDispatch,
SchemaValidationResult,
} from "@/components/schema-editor/schema-builder-types";
import { resolveSchemaBuilderFeatures } from "@/components/schema-editor/schema-builder-types";
import { validationErrorsText } from "@/components/schema-editor/validation";
interface DocumentSchemaEditorControllerOptions {
doc: SchemaDocument;
validation: SchemaValidationResult;
dispatch: SchemaDispatch;
features?: ResolvedSchemaBuilderFeatures;
}
export function useDocumentSchemaEditorController({
doc,
validation,
dispatch,
features: featuresProp,
}: DocumentSchemaEditorControllerOptions) {
const features = featuresProp ?? resolveSchemaBuilderFeatures();
const [defsAccordionOpen, setDefsAccordionOpen] = React.useState(
() => doc.defs.length > 0,
);
const draggedParentRef = React.useRef<string | null>(null);
const draggedPropertyRef = React.useRef<string | null>(null);
const validationErrors = React.useMemo(
() => validationErrorsText(validation),
[validation],
);
const rootNodeView = React.useMemo(
() => getDocumentNodeView(doc, doc.root),
[doc],
);
const setRootTitle = React.useCallback(
(title: string) => {
dispatch((current) => setNodeTitle(current, current.root.id, title));
},
[dispatch],
);
const setRootDescription = React.useCallback(
(description: string) => {
dispatch((current) =>
setNodeDescription(current, current.root.id, description),
);
},
[dispatch],
);
const eraseRootSchema = React.useCallback(() => {
dispatch((current) =>
replaceNodeJson(current, current.root.id, {
title: "",
type: "object",
properties: {},
}),
);
}, [dispatch]);
const eraseDescriptions = React.useCallback(() => {
dispatch((current) => stripDescriptions(current));
}, [dispatch]);
const replaceRoot = React.useCallback(
(newNode: ExtendedJSONSchema7) => {
dispatch((current) => replaceNodeJson(current, current.root.id, newNode));
},
[dispatch],
);
return {
features,
defsAccordionOpen,
setDefsAccordionOpen,
draggedParentRef,
draggedPropertyRef,
validationErrors,
rootNodeView,
setRootTitle,
setRootDescription,
eraseRootSchema,
eraseDescriptions,
replaceRoot,
};
}
components/schema-editor/document-schema-editor.tsx
"use client";
import * as React from "react";
import { DocumentDefinitionsEditor } from "@/components/schema-editor/document-definitions-editor";
import type { SchemaEditorMode } from "@/components/schema-editor/document-node-editor-types";
import { useDocumentSchemaEditorController } from "@/components/schema-editor/document-schema-editor-controller";
import { DocumentSchemaNodeEditor } from "@/components/schema-editor/document-schema-node-editor";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import type {
ResolvedSchemaBuilderFeatures,
SchemaDispatch,
SchemaValidationResult,
} from "@/components/schema-editor/schema-builder-types";
import { TopLevelEditor } from "@/components/schema-editor/top-level-editor";
import { ValidationErrorDisplay } from "@/components/schema-editor/validation-error-display";
interface DocumentSchemaEditorProps {
doc: SchemaDocument;
schema: ExtendedJSONSchema7;
validation: SchemaValidationResult;
dispatch: SchemaDispatch;
mode?: SchemaEditorMode;
features?: ResolvedSchemaBuilderFeatures;
}
export function DocumentSchemaEditor({
doc,
schema,
validation,
dispatch,
mode = "editable",
features: featuresProp,
}: DocumentSchemaEditorProps) {
const controller = useDocumentSchemaEditorController({
doc,
validation,
dispatch,
features: featuresProp,
});
return (
<div className="flex min-h-0 w-full flex-1 flex-col overflow-y-auto">
<div className="group flex w-full flex-col">
<ValidationErrorDisplay
validationErrors={controller.validationErrors}
variant="full"
/>
<TopLevelEditor
node={schema}
mode={mode}
showImportExportActions={controller.features.importExport}
onTitleChange={controller.setRootTitle}
onDescriptionChange={controller.setRootDescription}
onEraseAll={controller.eraseRootSchema}
onEraseDescriptions={controller.eraseDescriptions}
onReplaceRoot={controller.replaceRoot}
/>
</div>
<div className="flex min-h-0 flex-1 flex-col">
<DocumentSchemaNodeEditor
dispatch={dispatch}
doc={doc}
name="root"
nodeId={doc.root.id}
nodeView={controller.rootNodeView}
path="#"
mode={mode}
features={controller.features}
canDelete={false}
setDefsAccordionOpen={controller.setDefsAccordionOpen}
draggedParentRef={controller.draggedParentRef}
draggedPropertyRef={controller.draggedPropertyRef}
/>
<DocumentDefinitionsEditor
dispatch={dispatch}
doc={doc}
mode={mode}
definitionsEnabled={controller.features.definitions}
features={controller.features}
accordionOpen={controller.defsAccordionOpen}
setAccordionOpen={controller.setDefsAccordionOpen}
draggedParentRef={controller.draggedParentRef}
draggedPropertyRef={controller.draggedPropertyRef}
/>
</div>
</div>
);
}
components/schema-editor/document-schema-node-editor.tsx
"use client";
import * as React from "react";
import { DocumentArrayNodeEditor } from "@/components/schema-editor/document-array-node-editor";
import { DocumentEnumNodeEditor } from "@/components/schema-editor/document-enum-node-editor";
import type {
DocumentSchemaNodeEditorProps,
RenderDocumentNodeEditor,
} from "@/components/schema-editor/document-node-editor-types";
import { DocumentNodeHeader } from "@/components/schema-editor/document-node-header";
import { DocumentObjectNodeEditor } from "@/components/schema-editor/document-object-node-editor";
import { replaceNodeJson } from "@/components/schema-editor/document/json-node";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { resolveSchemaBuilderFeatures } from "@/components/schema-editor/schema-builder-types";
function changeNodeJson(
doc: SchemaDocument,
nodeId: string,
nextNode: ExtendedJSONSchema7,
) {
return replaceNodeJson(doc, nodeId, nextNode);
}
export function DocumentSchemaNodeEditor({
dispatch,
doc,
name,
nodeId,
nodeView,
path,
canDelete = false,
onDelete,
onNameChange,
setDefsAccordionOpen,
draggedParentRef,
draggedPropertyRef,
mode = "editable",
hidePencilButton = false,
isRequired,
onRequiredChange,
siblingNames = [],
features: featuresProp,
}: DocumentSchemaNodeEditorProps) {
const features =
featuresProp ??
resolveSchemaBuilderFeatures({
definitions: true,
objectTemplates: true,
jsonMode: true,
importExport: true,
});
const onChange = React.useCallback(
(newNode: ExtendedJSONSchema7) => {
dispatch((current) => changeNodeJson(current, nodeId, newNode));
},
[dispatch, nodeId],
);
const renderNode = React.useCallback<RenderDocumentNodeEditor>(
(props) => <DocumentSchemaNodeEditor {...props} />,
[],
);
const localType = nodeView.type;
return (
<div>
<DocumentNodeHeader
dispatch={dispatch}
doc={doc}
name={name}
nodeView={nodeView}
nodeId={nodeId}
path={path}
canDelete={canDelete}
onDelete={onDelete}
onNameChange={onNameChange}
setDefsAccordionOpen={setDefsAccordionOpen}
mode={mode}
hidePencilButton={hidePencilButton}
isRequired={isRequired}
onRequiredChange={onRequiredChange}
siblingNames={siblingNames}
features={features}
onChange={onChange}
/>
{localType === "object" && (
<DocumentObjectNodeEditor
dispatch={dispatch}
doc={doc}
nodeId={nodeId}
nodeView={nodeView}
path={path}
setDefsAccordionOpen={setDefsAccordionOpen}
draggedParentRef={draggedParentRef}
draggedPropertyRef={draggedPropertyRef}
mode={mode}
features={features}
renderNode={renderNode}
/>
)}
{localType === "array" && (
<DocumentArrayNodeEditor
dispatch={dispatch}
doc={doc}
nodeId={nodeId}
nodeView={nodeView}
path={path}
setDefsAccordionOpen={setDefsAccordionOpen}
draggedParentRef={draggedParentRef}
draggedPropertyRef={draggedPropertyRef}
mode={mode}
features={features}
renderNode={renderNode}
/>
)}
{localType === "enum" && (
<DocumentEnumNodeEditor
dispatch={dispatch}
mode={mode}
nodeId={nodeId}
enumEntries={nodeView.enumEntries}
/>
)}
</div>
);
}
components/schema-editor/document/array.ts
export function mapPreserve<T>(
items: T[],
fn: (item: T, index: number) => T,
): T[] {
let changed = false;
const next = items.map((item, index) => {
const result = fn(item, index);
if (result !== item) changed = true;
return result;
});
return changed ? next : items;
}
components/schema-editor/document/convert.ts
import type { JSONSchema7, JSONSchema7Definition } from "json-schema";
import { createId } from "./id";
import {
definitionRefAliases,
definitionRef,
type DefinitionsKeyword,
} from "./json-pointer";
import type {
DefinitionEntry,
DocumentNode,
EnumValue,
JsonValue,
PropertyEntry,
SchemaDocument,
} from "./types";
/**
* Boundary conversions between vanilla JSON Schema (the wire format) and the
* editor Document (the in-memory source of truth).
*
* - `fromJsonSchema` is TOTAL: it mints fresh ids, distributes `required`, and
* carries every unmodeled keyword into `rest`. Run it ONCE when a new external
* schema arrives — not on every render.
* - `toJsonSchema` is a PURE PROJECTION: it rebuilds `required[]`, re-projects
* `$ref` from the target definition's current name, and drops transient-invalid
* artifacts (empty/duplicate property keys). Run it on demand for
* `onSchemaChange` and the JSON preview.
*
* Together they round-trip losslessly for everything the editor surfaces, and
* carry everything it doesn't.
*/
/** Keys consumed structurally by a node; everything else falls into `rest`. */
const MODELED_NODE_KEYS = new Set<string>([
"type",
"title",
"description",
"properties",
"required",
"items",
"enum",
"$ref",
"anyOf",
"oneOf",
"allOf",
]);
type RefMap = Map<string, string>; // json-pointer string -> definition NodeId
type CreateDocumentId = (prefix?: string) => string;
// ---------------------------------------------------------------------------
// Import: JSON Schema -> Document
// ---------------------------------------------------------------------------
export function fromJsonSchema(schema: JSONSchema7): SchemaDocument {
const createImportId = createDeterministicImportIdFactory();
const hasDefsKeyword = schema.$defs !== undefined;
const hasDefinitionsKeyword = schema.definitions !== undefined;
const defsKeyword = schema.$defs
? "$defs"
: schema.definitions
? "definitions"
: "$defs";
const rawDefs = (schema.$defs ?? schema.definitions ?? {}) as Record<
string,
JSONSchema7Definition
>;
// First pass: give every top-level definition an id so refs can resolve to it.
const defEntries: DefinitionEntry[] = Object.keys(rawDefs).map((name) => ({
id: createImportId("def"),
name,
node: { id: createImportId(), rest: {} }, // placeholder, filled in second pass
}));
const refMap: RefMap = new Map();
for (const def of defEntries) {
addDefinitionRefMapEntries(refMap, def, {
defsKeyword,
hasOtherDefsKeyword:
defsKeyword === "$defs" ? hasDefinitionsKeyword : hasDefsKeyword,
});
}
// Second pass: build each definition's node now that the ref map exists.
for (const def of defEntries) {
def.node = nodeFromSchema(rawDefs[def.name], refMap, {
createDocumentId: createImportId,
});
}
// Strip only the PRIMARY defs keyword from the root; if the (unusual) other
// keyword is also present, it's carried verbatim in `rest` so it isn't lost.
const root = nodeFromSchema(schema, refMap, {
stripKeyword: defsKeyword,
createDocumentId: createImportId,
});
return {
root,
defs: defEntries,
rest: { defsKeyword },
};
}
function nodeFromSchema(
schema: JSONSchema7Definition,
refMap: RefMap,
options: {
createDocumentId?: CreateDocumentId;
stripKeyword?: string;
} = {},
): DocumentNode {
const createDocumentId = options.createDocumentId ?? createId;
// A boolean schema (`true` / `false`) has no structure to model — preserve it.
if (typeof schema === "boolean") {
return { id: createDocumentId(), rest: {}, booleanSchema: schema };
}
const node: DocumentNode = { id: createDocumentId(), rest: {} };
// Record the source key order so the projection can replay it exactly,
// keeping round-trips byte-faithful (no $defs/keyword reshuffling on edit).
node.order = Object.keys(schema);
if (typeof schema.$ref === "string") {
const defId = refMap.get(schema.$ref);
if (defId) node.ref = defId;
else {
// unresolved pointer — keep verbatim
setRecordValue(node.rest, "$ref", schema.$ref);
}
}
if (schema.type !== undefined) node.type = schema.type;
if (schema.title !== undefined) node.title = schema.title;
if (schema.description !== undefined) node.description = schema.description;
if (Array.isArray(schema.enum)) {
node.enum = enumFromSchema(schema, createDocumentId);
}
const required = Array.isArray(schema.required) ? schema.required : [];
if (required.length > 0 || node.order?.includes("required")) {
node.requiredOrder = required;
}
if (schema.properties) {
node.properties = Object.entries(schema.properties).map(
([key, child]): PropertyEntry => ({
id: createDocumentId("prop"),
key,
required: required.includes(key),
node: nodeFromSchema(child, refMap, { createDocumentId }),
}),
);
}
if (required.length > 0) {
const propertyKeys = new Set(
node.properties?.map((property) => property.key),
);
const extraRequired = required.filter((key) => !propertyKeys.has(key));
if (extraRequired.length > 0) node.extraRequired = extraRequired;
}
if (schema.items !== undefined) {
if (Array.isArray(schema.items)) {
// Tuple `items` (array form) is rare and not UI-editable; carry it
// verbatim in `rest` so it survives the round-trip losslessly.
setRecordValue(node.rest, "items", schema.items);
} else {
node.items = nodeFromSchema(schema.items, refMap, { createDocumentId });
}
}
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
const value = schema[key];
if (Array.isArray(value)) {
node[key] = value.map((sub) =>
nodeFromSchema(sub, refMap, { createDocumentId }),
);
}
}
// Carry every keyword we don't model.
for (const [key, value] of Object.entries(schema)) {
if (MODELED_NODE_KEYS.has(key)) continue;
if (options.stripKeyword && key === options.stripKeyword) continue;
setRecordValue(node.rest, key, value);
}
return node;
}
function enumFromSchema(
schema: JSONSchema7,
createDocumentId: CreateDocumentId = createId,
): EnumValue[] {
return (schema.enum ?? []).map(
(value): EnumValue => ({
id: createDocumentId("enum"),
value: value as JsonValue,
}),
);
}
function createDeterministicImportIdFactory(): CreateDocumentId {
let nextId = 0;
return (prefix = "node") => {
nextId += 1;
return `${prefix}-import-${nextId}`;
};
}
// ---------------------------------------------------------------------------
// Export: Document -> JSON Schema
// ---------------------------------------------------------------------------
export function toJsonSchema(doc: SchemaDocument): JSONSchema7 {
const defsKeyword =
doc.rest.defsKeyword === "definitions" ? "definitions" : "$defs";
const defNameById = new Map<string, string>();
for (const def of doc.defs) defNameById.set(def.id, def.name);
const out = nodeToSchema(doc.root, defNameById, defsKeyword) as JSONSchema7;
if (doc.defs.length > 0) {
const bag: Record<string, JSONSchema7Definition> = {};
for (const def of doc.defs) {
setRecordValue(
bag,
def.name,
nodeToSchema(def.node, defNameById, defsKeyword),
);
}
setRecordValue(out as Record<string, unknown>, defsKeyword, bag);
}
// Replay the root key order (so $defs lands back where the source had it).
return applyKeyOrder(
out as Record<string, unknown>,
doc.root.order,
) as JSONSchema7;
}
/**
* Project a single node to JSON Schema (using the document's definition names for
* any `$ref`s). The inverse of `nodeFromJson` — together they let a component read
* and rewrite one node's JSON by id while the Document stays the source of truth.
*/
export function projectNode(
doc: SchemaDocument,
node: DocumentNode,
): JSONSchema7Definition {
const defsKeyword =
doc.rest.defsKeyword === "definitions" ? "definitions" : "$defs";
const defNameById = new Map<string, string>();
for (const def of doc.defs) defNameById.set(def.id, def.name);
return nodeToSchema(node, defNameById, defsKeyword);
}
/** Convert a JSON Schema subtree into a Document node, resolving `$ref`s against
* the document's existing definitions (so refs survive the round-trip). */
export function nodeFromJson(
schema: JSONSchema7Definition,
doc: SchemaDocument,
): DocumentNode {
const refMap: RefMap = new Map();
const defsKeyword =
doc.rest.defsKeyword === "definitions" ? "definitions" : "$defs";
const otherDefsKeyword = defsKeyword === "$defs" ? "definitions" : "$defs";
const hasOtherDefsKeyword = Object.prototype.hasOwnProperty.call(
doc.root.rest,
otherDefsKeyword,
);
for (const def of doc.defs) {
addDefinitionRefMapEntries(refMap, def, {
defsKeyword,
hasOtherDefsKeyword,
});
}
// Strip the document's primary defs keyword — definitions live at the document
// level, not on a node; this keeps a root-level edit (whose JSON still carries
// `$defs`) from duplicating them into the root node's `rest`.
return nodeFromSchema(schema, refMap, { stripKeyword: defsKeyword });
}
function addDefinitionRefMapEntries(
refMap: RefMap,
def: DefinitionEntry,
options: {
defsKeyword: "$defs" | "definitions";
hasOtherDefsKeyword: boolean;
},
) {
for (const ref of definitionRefAliases(options.defsKeyword, def.name)) {
refMap.set(ref, def.id);
}
if (!options.hasOtherDefsKeyword) {
const otherKeyword =
options.defsKeyword === "$defs" ? "definitions" : "$defs";
for (const ref of definitionRefAliases(otherKeyword, def.name)) {
refMap.set(ref, def.id);
}
}
}
function nodeToSchema(
node: DocumentNode,
defNameById: Map<string, string>,
defsKeyword: DefinitionsKeyword,
): JSONSchema7Definition {
if (node.booleanSchema !== undefined) {
return node.booleanSchema;
}
// Emit modeled keys first in a natural reading order ($ref, type, title, …),
// then any unmodeled keywords. Export is a projection, so it normalizes key
// order the way a formatter would — semantics are preserved, not byte layout.
const out: Record<string, unknown> = {};
if (node.ref) {
const name = defNameById.get(node.ref);
if (name) setRecordValue(out, "$ref", definitionRef(defsKeyword, name));
}
if (node.type !== undefined) setRecordValue(out, "type", node.type);
if (node.title !== undefined) setRecordValue(out, "title", node.title);
if (node.description !== undefined)
setRecordValue(out, "description", node.description);
if (node.enum) {
setRecordValue(
out,
"enum",
node.enum.map((entry) => entry.value),
);
}
if (node.properties) {
const properties: Record<string, JSONSchema7Definition> = {};
const required: string[] = [];
const seen = new Set<string>();
for (const entry of node.properties) {
const key = entry.key;
// Transient-invalid states live in the Document, not the projection:
// drop empty and duplicate keys at the boundary.
if ((entry.isTransient && !key) || seen.has(key)) continue;
seen.add(key);
setRecordValue(
properties,
key,
nodeToSchema(entry.node, defNameById, defsKeyword),
);
if (entry.required) required.push(key);
}
setRecordValue(out, "properties", properties);
// Emit `required` when it has entries, or when the source had an explicit
// (possibly empty) `required` key — so `required: []` round-trips faithfully.
const hadRequiredKey = node.order?.includes("required") ?? false;
const projectedRequired = orderRequiredNames(
[...(node.extraRequired ?? []), ...required],
node.requiredOrder,
);
if (projectedRequired.length > 0 || hadRequiredKey) {
setRecordValue(out, "required", projectedRequired);
}
} else if (node.extraRequired?.length) {
setRecordValue(
out,
"required",
orderRequiredNames(node.extraRequired, node.requiredOrder),
);
}
if (node.items) {
setRecordValue(
out,
"items",
nodeToSchema(node.items, defNameById, defsKeyword),
);
}
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
const value = node[key];
if (value) {
setRecordValue(
out,
key,
value.map((sub) => nodeToSchema(sub, defNameById, defsKeyword)),
);
}
}
// Trailing unmodeled keywords (const, default, format, pattern, x-*, …).
for (const [key, value] of Object.entries(node.rest)) {
if (hasOwn(out, key)) continue; // modeled field already won this key
setRecordValue(out, key, value);
}
return applyKeyOrder(out, node.order) as JSONSchema7;
}
/**
* Reorder an object's keys to match a recorded source order. Keys present in
* `order` come first (in that order); any keys not in it (newly added by edits)
* are appended in their current order. Returns a new object.
*/
function applyKeyOrder(
obj: Record<string, unknown>,
order: unknown,
): Record<string, unknown> {
if (!Array.isArray(order)) return obj;
const result: Record<string, unknown> = {};
for (const key of order as string[]) {
if (hasOwn(obj, key)) setRecordValue(result, key, obj[key]);
}
for (const key of Object.keys(obj)) {
if (!hasOwn(result, key)) setRecordValue(result, key, obj[key]);
}
return result;
}
function hasOwn(record: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(record, key);
}
function setRecordValue<T>(record: Record<string, T>, key: string, value: T) {
Object.defineProperty(record, key, {
value,
enumerable: true,
configurable: true,
writable: true,
});
}
function orderRequiredNames(names: string[], sourceOrder: unknown): string[] {
const uniqueNames = [...new Set(names)];
if (!Array.isArray(sourceOrder)) return uniqueNames;
const remaining = new Set(uniqueNames);
const ordered: string[] = [];
for (const name of sourceOrder) {
if (typeof name !== "string" || !remaining.has(name)) continue;
ordered.push(name);
remaining.delete(name);
}
ordered.push(...remaining);
return ordered;
}
components/schema-editor/document/definition-operations.ts
import { mapPreserve } from "@/components/schema-editor/document/array";
import { createId } from "@/components/schema-editor/document/id";
import {
definitionRef,
definitionRefAliases,
} from "@/components/schema-editor/document/json-pointer";
import { updateNode } from "@/components/schema-editor/document/node-update";
import {
createNode,
stripSchemaTypeSpecificRest,
updateEffectiveNodeShape,
} from "@/components/schema-editor/document/type-operations";
import type {
DefinitionEntry,
DocumentNode,
SchemaDocument,
} from "@/components/schema-editor/document/types";
const SCHEMA_VALUE_REST_KEYS = new Set([
"additionalItems",
"additionalProperties",
"allOf",
"anyOf",
"contains",
"else",
"if",
"items",
"not",
"oneOf",
"prefixItems",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties",
]);
const SCHEMA_MAP_REST_KEYS = new Set([
"$defs",
"definitions",
"dependentSchemas",
"dependencies",
"patternProperties",
"properties",
]);
export function addDefinition(
doc: SchemaDocument,
init: Partial<DefinitionEntry> = {},
): { doc: SchemaDocument; defId: string } {
const baseName = init.name?.trim() || "Definition";
const entry: DefinitionEntry = {
id: init.id ?? createId("def"),
name: uniqueDefinitionName(doc, baseName),
node: init.node ?? createNode("object"),
};
return { doc: { ...doc, defs: [...doc.defs, entry] }, defId: entry.id };
}
export function renameDefinition(
doc: SchemaDocument,
defId: string,
name: string,
): SchemaDocument {
const currentDefinition = doc.defs.find(
(definition) => definition.id === defId,
);
if (!currentDefinition) return doc;
const nextName = name.trim();
if (!nextName) return doc;
const taken = new Set(
doc.defs
.filter((definition) => definition.id !== defId)
.map((definition) => definition.name),
);
let finalName = nextName;
if (taken.has(finalName)) {
let index = 2;
while (taken.has(`${nextName}${index}`)) index += 1;
finalName = `${nextName}${index}`;
}
const defs = mapPreserve(doc.defs, (definition) =>
definition.id === defId ? { ...definition, name: finalName } : definition,
);
if (defs === doc.defs) return doc;
return rewriteRawDefinitionRefs(
{ ...doc, defs },
currentDefinition.name,
finalName,
);
}
export function removeDefinition(
doc: SchemaDocument,
defId: string,
): SchemaDocument {
const defs = doc.defs.filter((definition) => definition.id !== defId);
if (defs.length === doc.defs.length) return doc;
return { ...doc, defs };
}
export function setRef(
doc: SchemaDocument,
id: string,
defId: string,
): SchemaDocument {
return updateNode(doc, id, (node) => {
if (isTypeArrayNullable(node)) {
return {
...node,
type: undefined,
properties: undefined,
items: undefined,
enum: undefined,
ref: undefined,
rest: stripSchemaTypeSpecificRest(node.rest),
order: undefined,
anyOf: [createRefNode(defId), createNode("null")],
};
}
return updateEffectiveNodeShape(node, (effective) => ({
id: effective.id,
ref: defId,
title: effective.title,
description: effective.description,
rest: stripSchemaTypeSpecificRest(effective.rest),
order: effective.order,
}));
});
}
export function setRefByName(
doc: SchemaDocument,
id: string,
name: string,
): SchemaDocument {
const definition = doc.defs.find((def) => def.name === name);
return definition ? setRef(doc, id, definition.id) : doc;
}
function uniqueDefinitionName(doc: SchemaDocument, base: string): string {
const taken = new Set(doc.defs.map((definition) => definition.name));
if (!taken.has(base)) return base;
let index = 2;
while (taken.has(`${base}${index}`)) index += 1;
return `${base}${index}`;
}
function isTypeArrayNullable(node: DocumentNode): boolean {
return Array.isArray(node.type) && node.type.includes("null");
}
function createRefNode(defId: string): DocumentNode {
return {
id: createId(),
ref: defId,
rest: {},
};
}
function rewriteRawDefinitionRefs(
doc: SchemaDocument,
oldName: string,
newName: string,
): SchemaDocument {
if (oldName === newName) return doc;
const refs = getRewriteRefOptions(doc);
const root = rewriteRawDefinitionRefsInNode(doc.root, oldName, newName, refs);
const defs = mapPreserve(doc.defs, (definition) => {
const node = rewriteRawDefinitionRefsInNode(
definition.node,
oldName,
newName,
refs,
);
return node === definition.node ? definition : { ...definition, node };
});
if (root === doc.root && defs === doc.defs) return doc;
return { ...doc, root, defs };
}
function rewriteRawDefinitionRefsInNode(
node: DocumentNode,
oldName: string,
newName: string,
refs: RewriteRefOptions,
): DocumentNode {
let next = node;
const rest = rewriteRawDefinitionRefsInRest(
node.rest,
oldName,
newName,
refs,
);
if (rest !== node.rest) next = { ...next, rest };
if (node.properties) {
const properties = mapPreserve(node.properties, (property) => {
const child = rewriteRawDefinitionRefsInNode(
property.node,
oldName,
newName,
refs,
);
return child === property.node ? property : { ...property, node: child };
});
if (properties !== node.properties) next = { ...next, properties };
}
if (node.items) {
const items = rewriteRawDefinitionRefsInNode(
node.items,
oldName,
newName,
refs,
);
if (items !== node.items) next = { ...next, items };
}
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
const children = node[key];
if (!children) continue;
const mapped = mapPreserve(children, (child) =>
rewriteRawDefinitionRefsInNode(child, oldName, newName, refs),
);
if (mapped !== children) next = { ...next, [key]: mapped };
}
return next;
}
function rewriteRawDefinitionRefsInRest(
rest: Record<string, unknown>,
oldName: string,
newName: string,
refs: RewriteRefOptions,
): Record<string, unknown> {
let next = rest;
for (const [key, value] of Object.entries(rest)) {
const rewritten = SCHEMA_MAP_REST_KEYS.has(key)
? rewriteRefsInSchemaMap(value, oldName, newName, refs)
: SCHEMA_VALUE_REST_KEYS.has(key)
? rewriteRefsInSchemaValue(value, oldName, newName, refs)
: value;
if (rewritten !== value) {
if (next === rest) next = { ...rest };
setRecordValue(next, key, rewritten);
}
}
return next;
}
function rewriteRefsInSchemaMap(
value: unknown,
oldName: string,
newName: string,
refs: RewriteRefOptions,
): unknown {
if (!isPlainObject(value)) return value;
let next: Record<string, unknown> = value;
for (const [key, child] of Object.entries(value)) {
const rewritten = rewriteRefsInSchemaValue(child, oldName, newName, refs);
if (rewritten !== child) {
if (next === value) next = { ...value };
setRecordValue(next, key, rewritten);
}
}
return next;
}
function rewriteRefsInSchemaValue(
value: unknown,
oldName: string,
newName: string,
refs: RewriteRefOptions,
): unknown {
if (Array.isArray(value)) {
return mapPreserve(value, (child) =>
rewriteRefsInSchemaValue(child, oldName, newName, refs),
);
}
if (!isPlainObject(value)) return value;
let next: Record<string, unknown> = value;
const rewrittenRef = rewriteDefinitionRef(value.$ref, oldName, newName, refs);
if (rewrittenRef !== value.$ref) {
next = { ...value, $ref: rewrittenRef };
}
for (const [key, child] of Object.entries(next)) {
let rewritten = child;
if (SCHEMA_MAP_REST_KEYS.has(key)) {
rewritten = rewriteRefsInSchemaMap(child, oldName, newName, refs);
} else if (SCHEMA_VALUE_REST_KEYS.has(key)) {
rewritten = rewriteRefsInSchemaValue(child, oldName, newName, refs);
}
if (rewritten !== child) {
if (next === value) next = { ...value };
setRecordValue(next, key, rewritten);
}
}
return next;
}
function rewriteDefinitionRef(
value: unknown,
oldName: string,
newName: string,
refs: RewriteRefOptions,
): unknown {
if (
definitionRefAliases(refs.primaryKeyword, oldName).includes(value as string)
)
return definitionRef(refs.primaryKeyword, newName);
if (
refs.allowOtherKeywordAlias &&
definitionRefAliases(refs.otherKeyword, oldName).includes(value as string)
)
return definitionRef(refs.otherKeyword, newName);
return value;
}
interface RewriteRefOptions {
primaryKeyword: "$defs" | "definitions";
otherKeyword: "$defs" | "definitions";
allowOtherKeywordAlias: boolean;
}
function getRewriteRefOptions(doc: SchemaDocument): RewriteRefOptions {
const primaryKeyword =
doc.rest.defsKeyword === "definitions" ? "definitions" : "$defs";
const otherKeyword = primaryKeyword === "$defs" ? "definitions" : "$defs";
return {
primaryKeyword,
otherKeyword,
allowOtherKeywordAlias: !Object.prototype.hasOwnProperty.call(
doc.root.rest,
otherKeyword,
),
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function setRecordValue<T>(record: Record<string, T>, key: string, value: T) {
Object.defineProperty(record, key, {
value,
enumerable: true,
configurable: true,
writable: true,
});
}
components/schema-editor/document/derive.ts
import type { JSONSchema7TypeName } from "json-schema";
import { definitionRefAliases } from "@/components/schema-editor/document/json-pointer";
import type {
DefinitionEntry,
DocumentNode,
SchemaDocument,
SchemaKind,
} from "./types";
const SCHEMA_VALUE_REST_KEYS = new Set([
"additionalItems",
"additionalProperties",
"allOf",
"anyOf",
"contains",
"else",
"if",
"items",
"not",
"oneOf",
"prefixItems",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties",
]);
const SCHEMA_MAP_REST_KEYS = new Set([
"$defs",
"definitions",
"dependentSchemas",
"dependencies",
"patternProperties",
"properties",
]);
/**
* Pure projections of a node for rendering. NONE of these are stored — there is
* one source of truth (the Document) and these are computed on each render. This
* is what lets child components stay stateless the way extend's do, without a
* second representation to keep in sync.
*/
/** The effective UI "kind" of a node. */
export function getEffectiveKind(node: DocumentNode): SchemaKind {
if (node.ref) return "ref";
if (node.enum) return "enum";
if (node.anyOf || node.oneOf) return "union";
if (Array.isArray(node.type)) {
const real = node.type.filter((t) => t !== "null");
return real.length === 1 ? (real[0] as SchemaKind) : "union";
}
if (node.type) return node.type;
return "any";
}
export function isNullable(node: DocumentNode): boolean {
if (Array.isArray(node.type)) return node.type.includes("null");
if (node.type === "null") return true;
if (node.anyOf) return node.anyOf.some((sub) => sub.type === "null");
return false;
}
/** Base scalar type backing an enum (enums are `{ type, enum: [...] }`). */
export function getEnumBaseType(node: DocumentNode): JSONSchema7TypeName {
const type = Array.isArray(node.type)
? node.type.find((t) => t !== "null")
: node.type;
return (type as JSONSchema7TypeName) ?? "string";
}
/** Resolve a `$ref` node to the definition it points at (by id). */
export function resolveRef(
doc: SchemaDocument,
node: DocumentNode,
): DefinitionEntry | null {
if (!node.ref) return null;
return doc.defs.find((def) => def.id === node.ref) ?? null;
}
/** True when a `$ref` points at a definition that no longer exists. */
export function isDanglingRef(
doc: SchemaDocument,
node: DocumentNode,
): boolean {
return Boolean(node.ref) && !resolveRef(doc, node);
}
export function isDefinitionReferenced(
doc: SchemaDocument,
defId: string,
options: { exceptDefId?: string } = {},
): boolean {
const referenced = doc.defs.find((definition) => definition.id === defId);
if (!referenced) return false;
const refs = getReferenceRefOptions(doc);
if (nodeReferencesDefinition(doc.root, referenced, refs)) return true;
for (const definition of doc.defs) {
if (definition.id === options.exceptDefId) continue;
if (nodeReferencesDefinition(definition.node, referenced, refs))
return true;
}
return false;
}
function nodeReferencesDefinition(
node: DocumentNode,
definition: DefinitionEntry,
refs: ReferenceRefOptions,
): boolean {
if (node.ref === definition.id) return true;
if (restReferencesDefinition(node.rest, definition, refs)) return true;
if (node.properties) {
for (const property of node.properties) {
if (nodeReferencesDefinition(property.node, definition, refs))
return true;
}
}
if (node.items && nodeReferencesDefinition(node.items, definition, refs))
return true;
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
const children = node[key];
if (!children) continue;
for (const child of children) {
if (nodeReferencesDefinition(child, definition, refs)) return true;
}
}
return false;
}
function restReferencesDefinition(
rest: Record<string, unknown>,
definition: DefinitionEntry,
refs: ReferenceRefOptions,
): boolean {
for (const [key, value] of Object.entries(rest)) {
if (SCHEMA_MAP_REST_KEYS.has(key)) {
if (schemaMapReferencesDefinition(value, definition, refs)) return true;
} else if (SCHEMA_VALUE_REST_KEYS.has(key)) {
if (schemaReferencesDefinition(value, definition, refs)) return true;
}
}
return false;
}
function schemaMapReferencesDefinition(
value: unknown,
definition: DefinitionEntry,
refs: ReferenceRefOptions,
): boolean {
if (!isPlainObject(value)) return false;
return Object.values(value).some((child) =>
schemaReferencesDefinition(child, definition, refs),
);
}
function schemaReferencesDefinition(
value: unknown,
definition: DefinitionEntry,
refs: ReferenceRefOptions,
): boolean {
if (Array.isArray(value)) {
return value.some((child) =>
schemaReferencesDefinition(child, definition, refs),
);
}
if (!isPlainObject(value)) return false;
const ref = value.$ref;
if (
definitionRefAliases(refs.primaryKeyword, definition.name).includes(
ref as string,
)
)
return true;
if (
refs.allowOtherKeywordAlias &&
definitionRefAliases(refs.otherKeyword, definition.name).includes(
ref as string,
)
)
return true;
for (const [key, child] of Object.entries(value)) {
if (SCHEMA_MAP_REST_KEYS.has(key)) {
if (schemaMapReferencesDefinition(child, definition, refs)) return true;
} else if (SCHEMA_VALUE_REST_KEYS.has(key)) {
if (schemaReferencesDefinition(child, definition, refs)) return true;
}
}
return false;
}
interface ReferenceRefOptions {
primaryKeyword: "$defs" | "definitions";
otherKeyword: "$defs" | "definitions";
allowOtherKeywordAlias: boolean;
}
function getReferenceRefOptions(doc: SchemaDocument): ReferenceRefOptions {
const primaryKeyword =
doc.rest.defsKeyword === "definitions" ? "definitions" : "$defs";
const otherKeyword = primaryKeyword === "$defs" ? "definitions" : "$defs";
return {
primaryKeyword,
otherKeyword,
allowOtherKeywordAlias: !Object.prototype.hasOwnProperty.call(
doc.root.rest,
otherKeyword,
),
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
components/schema-editor/document/enum-operations.ts
import { mapPreserve } from "@/components/schema-editor/document/array";
import { getEffectiveDocNode } from "@/components/schema-editor/document/node-selectors";
import { updateNode } from "@/components/schema-editor/document/node-update";
import { getNode } from "@/components/schema-editor/document/traversal";
import {
createNode,
createEnumValue,
stripSchemaTypeSpecificRest,
updateEffectiveNodeShape,
} from "@/components/schema-editor/document/type-operations";
import type {
DocumentNode,
EnumValue,
JsonValue,
SchemaDocument,
} from "@/components/schema-editor/document/types";
const ENUM_DESCRIPTIONS_KEY = "x-enumDescriptions";
export function addEnumValue(
doc: SchemaDocument,
id: string,
value: JsonValue = "",
): SchemaDocument {
return updateNode(doc, id, (node) => {
if (isTypeArrayNullable(node) && node.enum) {
const nextEnum = [...node.enum, { ...createEnumValue(), value }];
return createNullableEnumWrapper(
node,
nextEnum,
remapEnumDescriptions(node.rest, node.enum, nextEnum),
);
}
return updateEffectiveNodeShape(node, (effective) => {
const previousEnum = effective.enum ?? [];
const nextEnum = [...previousEnum, { ...createEnumValue(), value }];
return {
...effective,
enum: nextEnum,
rest: remapEnumDescriptions(effective.rest, previousEnum, nextEnum),
};
});
});
}
export function updateEnumValue(
doc: SchemaDocument,
id: string,
enumId: string,
patch: Partial<Omit<EnumValue, "id">>,
): SchemaDocument {
return updateNode(doc, id, (node) => {
if (isTypeArrayNullable(node) && node.enum) {
const nextEnum = mapPreserve(node.enum, (value) =>
value.id === enumId ? { ...value, ...patch } : value,
);
return createNullableEnumWrapper(
node,
nextEnum,
remapEnumDescriptions(node.rest, node.enum, nextEnum),
);
}
return updateEffectiveNodeShape(node, (effective) => {
const previousEnum = effective.enum ?? [];
const nextEnum = mapPreserve(previousEnum, (value) =>
value.id === enumId ? { ...value, ...patch } : value,
);
return {
...effective,
enum: nextEnum,
rest: remapEnumDescriptions(effective.rest, previousEnum, nextEnum),
};
});
});
}
export function removeEnumValue(
doc: SchemaDocument,
id: string,
enumId: string,
): SchemaDocument {
return updateNode(doc, id, (node) => {
if (isTypeArrayNullable(node) && node.enum) {
const nextEnum = node.enum.filter((value) => value.id !== enumId);
return createNullableEnumWrapper(
node,
nextEnum,
remapEnumDescriptions(node.rest, node.enum, nextEnum),
);
}
return updateEffectiveNodeShape(node, (effective) => {
const previousEnum = effective.enum ?? [];
const nextEnum = previousEnum.filter((value) => value.id !== enumId);
return {
...effective,
enum: nextEnum,
rest: remapEnumDescriptions(effective.rest, previousEnum, nextEnum),
};
});
});
}
export function setEnumValues(
doc: SchemaDocument,
id: string,
values: JsonValue[],
): SchemaDocument {
return updateNode(doc, id, (node) => {
if (isTypeArrayNullable(node)) {
const nextEnum = buildEnumValues(values, node.enum);
return createNullableEnumWrapper(
node,
nextEnum,
remapEnumDescriptions(node.rest, node.enum, nextEnum),
);
}
return updateEffectiveNodeShape(node, (effective) => {
const nextEnum = buildEnumValues(values, effective.enum);
const rest = stripSchemaRestForEnum(
remapEnumDescriptions(effective.rest, effective.enum, nextEnum),
);
return {
...effective,
type: "string",
enum: nextEnum,
rest,
booleanSchema: undefined,
};
});
});
}
export function updateEnumValueAtIndex(
doc: SchemaDocument,
id: string,
index: number,
value: JsonValue,
): SchemaDocument {
const node = getNode(doc, id);
if (!node) return doc;
const enumId = getEffectiveDocNode(node).enum?.[index]?.id;
return enumId ? updateEnumValue(doc, id, enumId, { value }) : doc;
}
export function removeEnumValueAtIndex(
doc: SchemaDocument,
id: string,
index: number,
): SchemaDocument {
const node = getNode(doc, id);
if (!node) return doc;
const enumId = getEffectiveDocNode(node).enum?.[index]?.id;
return enumId ? removeEnumValue(doc, id, enumId) : doc;
}
function buildEnumValues(
values: JsonValue[],
existing?: EnumValue[],
): EnumValue[] {
return values.map((value, index) => ({
...(existing?.[index] ?? createEnumValue()),
value,
}));
}
function isTypeArrayNullable(node: DocumentNode): boolean {
return Array.isArray(node.type) && node.type.includes("null");
}
function createEnumNode(
values: JsonValue[],
existing?: EnumValue[],
): DocumentNode {
return createEnumNodeFromEntries(buildEnumValues(values, existing));
}
function createEnumNodeFromEntries(enumEntries: EnumValue[]): DocumentNode {
const node = createNode("string");
return {
...node,
type: "string",
enum: enumEntries,
};
}
function createNullableEnumWrapper(
node: DocumentNode,
enumEntries: EnumValue[],
rest: Record<string, unknown> = node.rest,
): DocumentNode {
return {
...node,
type: undefined,
properties: undefined,
items: undefined,
enum: undefined,
ref: undefined,
rest: stripSchemaRestForEnum(rest),
order: undefined,
booleanSchema: undefined,
anyOf: [createEnumNodeFromEntries(enumEntries), { ...createNode("null") }],
};
}
function stripSchemaRestForEnum(
rest: Record<string, unknown>,
): Record<string, unknown> {
const descriptions = rest[ENUM_DESCRIPTIONS_KEY];
const next = { ...stripSchemaTypeSpecificRest(rest) };
if (isPlainRecord(descriptions)) {
next[ENUM_DESCRIPTIONS_KEY] = descriptions;
}
return next;
}
function remapEnumDescriptions(
rest: Record<string, unknown>,
previousEntries: EnumValue[] | undefined,
nextEntries: EnumValue[],
): Record<string, unknown> {
const descriptions = rest[ENUM_DESCRIPTIONS_KEY];
if (!isPlainRecord(descriptions)) return rest;
const previousById = new Map(
previousEntries?.map((entry) => [entry.id, entry]) ?? [],
);
const nextDescriptions: Record<string, unknown> = {};
for (let index = 0; index < nextEntries.length; index += 1) {
const nextEntry = nextEntries[index];
const previous = previousById.get(nextEntry.id) ?? previousEntries?.[index];
if (!previous) continue;
const previousKey = enumDescriptionKey(previous.value);
if (!hasOwn(descriptions, previousKey)) continue;
nextDescriptions[enumDescriptionKey(nextEntry.value)] =
descriptions[previousKey];
}
const next = { ...rest };
if (Object.keys(nextDescriptions).length > 0) {
next[ENUM_DESCRIPTIONS_KEY] = nextDescriptions;
} else {
delete next[ENUM_DESCRIPTIONS_KEY];
}
return next;
}
function enumDescriptionKey(value: JsonValue): string {
return String(value);
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasOwn(record: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(record, key);
}
components/schema-editor/document/id.ts
import type { NodeId } from "./types";
/**
* Monotonic id minting. Ids are minted exactly once per node — at creation, or at
* the import boundary when an external JSON Schema first becomes a Document — and
* are stable for the node's lifetime thereafter.
*
* A module-level counter (rather than a random uuid) keeps ids short and stable
* for snapshot tests; it is process-local, which is all the editor needs since
* ids never have to be reconstructed from a serialized form (JSON Schema carries
* none — that asymmetry is the point).
*/
let counter = 0;
export function createId(prefix = "node"): NodeId {
counter += 1;
return `${prefix}-${counter}`;
}
components/schema-editor/document/json-node.ts
import type { JSONSchema7Definition } from "json-schema";
import {
nodeFromJson,
projectNode,
} from "@/components/schema-editor/document/convert";
import { updateNode } from "@/components/schema-editor/document/node-update";
import { getNode } from "@/components/schema-editor/document/traversal";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
export function getNodeJson(
doc: SchemaDocument,
id: string,
): JSONSchema7Definition | null {
const node = getNode(doc, id);
return node ? projectNode(doc, node) : null;
}
export function replaceNodeJson(
doc: SchemaDocument,
id: string,
jsonNode: JSONSchema7Definition,
): SchemaDocument {
const converted = nodeFromJson(jsonNode, doc);
return updateNode(doc, id, (node) => ({
...converted,
id: node.id,
order: node.order ?? converted.order,
}));
}
export function updateNodeJson(
doc: SchemaDocument,
id: string,
transform: (json: JSONSchema7Definition) => JSONSchema7Definition,
): SchemaDocument {
const json = getNodeJson(doc, id);
if (json === null) return doc;
return replaceNodeJson(doc, id, transform(json));
}
components/schema-editor/document/json-pointer.ts
export type DefinitionsKeyword = "$defs" | "definitions";
export function escapeJsonPointerSegment(segment: string): string {
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
}
export function unescapeJsonPointerSegment(segment: string): string {
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}
export function decodeJsonPointerSegment(segment: string): string {
return unescapeJsonPointerSegment(decodeUriComponentSafe(segment));
}
export function definitionRef(
keyword: DefinitionsKeyword,
name: string,
): string {
return `#/${keyword}/${escapeJsonPointerSegment(name)}`;
}
export function definitionRefAliases(
keyword: DefinitionsKeyword,
name: string,
): string[] {
const pointerSegment = escapeJsonPointerSegment(name);
const encodedSegment = encodeURIComponent(pointerSegment);
const encodedNameSegment = encodeURIComponent(name);
const encodedNameSegmentWithTilde = encodedNameSegment.replace(/~/g, "%7E");
const lowerHexEncodedSegment = encodedSegment.replace(
/%[0-9A-F]{2}/g,
(escapeSequence) => escapeSequence.toLowerCase(),
);
const lowerHexEncodedNameSegment = encodedNameSegmentWithTilde.replace(
/%[0-9A-F]{2}/g,
(escapeSequence) => escapeSequence.toLowerCase(),
);
return [
...new Set([
`#/${keyword}/${pointerSegment}`,
`#/${keyword}/${encodedSegment}`,
`#/${keyword}/${lowerHexEncodedSegment}`,
...encodedOriginalNameAliases(keyword, encodedNameSegment),
...encodedOriginalNameAliases(keyword, encodedNameSegmentWithTilde),
...encodedOriginalNameAliases(keyword, lowerHexEncodedNameSegment),
]),
];
}
function encodedOriginalNameAliases(
keyword: DefinitionsKeyword,
encodedSegment: string,
): string[] {
return encodedSegment.includes("%") ? [`#/${keyword}/${encodedSegment}`] : [];
}
export function definitionNameFromRef(ref: string): string {
const prefix = ref.startsWith("#/$defs/")
? "#/$defs/"
: ref.startsWith("#/definitions/")
? "#/definitions/"
: undefined;
return prefix ? decodeJsonPointerSegment(ref.slice(prefix.length)) : ref;
}
function decodeUriComponentSafe(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
components/schema-editor/document/node-metadata.ts
import { mapPreserve } from "@/components/schema-editor/document/array";
import { updateNode } from "@/components/schema-editor/document/node-update";
import type {
DocumentNode,
SchemaDocument,
} from "@/components/schema-editor/document/types";
const SCHEMA_VALUE_REST_KEYS = new Set([
"additionalItems",
"additionalProperties",
"allOf",
"anyOf",
"contains",
"else",
"if",
"items",
"not",
"oneOf",
"prefixItems",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties",
]);
const SCHEMA_MAP_REST_KEYS = new Set([
"$defs",
"definitions",
"dependentSchemas",
"dependencies",
"patternProperties",
"properties",
]);
export function setNodeDescription(
doc: SchemaDocument,
id: string,
description: string | undefined,
): SchemaDocument {
return updateNode(doc, id, (node) => {
const nextDescription = description?.trim() ? description : undefined;
return node.description === nextDescription
? node
: { ...node, description: nextDescription };
});
}
export function setNodeTitle(
doc: SchemaDocument,
id: string,
title: string | undefined,
): SchemaDocument {
return updateNode(doc, id, (node) => {
const nextTitle = title?.trim() ? title : undefined;
return node.title === nextTitle ? node : { ...node, title: nextTitle };
});
}
export function stripDescriptions(doc: SchemaDocument): SchemaDocument {
const root = stripNodeDescription(doc.root);
const defs = mapPreserve(doc.defs, (definition) => {
const node = stripNodeDescription(definition.node);
return node === definition.node ? definition : { ...definition, node };
});
if (root === doc.root && defs === doc.defs) return doc;
return { ...doc, root, defs };
}
function stripNodeDescription(node: DocumentNode): DocumentNode {
let next =
node.description === undefined ? node : { ...node, description: undefined };
if (next.properties) {
const properties = mapPreserve(next.properties, (property) => {
const childNode = stripNodeDescription(property.node);
return childNode === property.node
? property
: { ...property, node: childNode };
});
if (properties !== next.properties) next = { ...next, properties };
}
const rest = stripRestDescriptionKeywords(next.rest);
if (rest !== next.rest) next = { ...next, rest };
if (next.items) {
const items = stripNodeDescription(next.items);
if (items !== next.items) next = { ...next, items };
}
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
const children = next[key];
if (!children) continue;
const mapped = mapPreserve(children, stripNodeDescription);
if (mapped !== children) next = { ...next, [key]: mapped };
}
return next;
}
function stripRestDescriptionKeywords(
rest: Record<string, unknown>,
): Record<string, unknown> {
let next = rest;
for (const [key, value] of Object.entries(rest)) {
const stripped = SCHEMA_MAP_REST_KEYS.has(key)
? stripSchemaMapDescriptions(value)
: SCHEMA_VALUE_REST_KEYS.has(key)
? stripSchemaDescription(value)
: value;
if (stripped !== value) {
if (next === rest) next = { ...rest };
setRecordValue(next, key, stripped);
}
}
return next;
}
function stripSchemaMapDescriptions(value: unknown): unknown {
if (!isPlainObject(value)) return value;
let next: Record<string, unknown> = value;
for (const [key, child] of Object.entries(value)) {
const stripped = stripSchemaDescription(child);
if (stripped !== child) {
if (next === value) next = { ...value };
setRecordValue(next, key, stripped);
}
}
return next;
}
function stripSchemaDescription(value: unknown): unknown {
if (Array.isArray(value)) {
return mapPreserve(value, stripSchemaDescription);
}
if (!isPlainObject(value)) return value;
let next: Record<string, unknown> = value;
if (Object.prototype.hasOwnProperty.call(value, "description")) {
const { description: _description, ...rest } = value;
next = rest;
}
for (const [key, child] of Object.entries(next)) {
let stripped = child;
if (SCHEMA_MAP_REST_KEYS.has(key)) {
stripped = stripSchemaMapDescriptions(child);
} else if (SCHEMA_VALUE_REST_KEYS.has(key)) {
stripped = stripSchemaDescription(child);
}
if (stripped !== child) {
if (next === value) next = { ...value };
setRecordValue(next, key, stripped);
}
}
return next;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function setRecordValue<T>(record: Record<string, T>, key: string, value: T) {
Object.defineProperty(record, key, {
value,
enumerable: true,
configurable: true,
writable: true,
});
}
components/schema-editor/document/node-selectors.ts
import { getNode } from "@/components/schema-editor/document/traversal";
import type {
DocumentNode,
PropertyEntry,
SchemaDocument,
} from "@/components/schema-editor/document/types";
export function getEffectiveDocNode(node: DocumentNode): DocumentNode {
if (node.anyOf) {
const nonNull = node.anyOf.find(
(branch) => branch.type !== "null" || branch.ref,
);
if (nonNull) return nonNull;
}
return node;
}
export function getChildPropertyId(
doc: SchemaDocument,
parentId: string,
key: string,
): string | undefined {
const parent = getNode(doc, parentId);
if (!parent) return undefined;
return getEffectiveDocNode(parent).properties?.find(
(entry) => entry.key === key,
)?.id;
}
export function getChildNodeId(
doc: SchemaDocument,
parentId: string,
key: string,
): string | undefined {
const parent = getNode(doc, parentId);
if (!parent) return undefined;
return getEffectiveDocNode(parent).properties?.find(
(entry) => entry.key === key,
)?.node.id;
}
export function getItemsNodeId(
doc: SchemaDocument,
parentId: string,
): string | undefined {
const parent = getNode(doc, parentId);
if (!parent) return undefined;
return getEffectiveDocNode(parent).items?.id;
}
export function getOwnProperty(
doc: SchemaDocument,
parentId: string,
index: number,
): PropertyEntry | null {
const parent = getNode(doc, parentId);
return parent
? (getEffectiveDocNode(parent).properties?.[index] ?? null)
: null;
}
components/schema-editor/document/node-update.ts
import { mapPreserve } from "@/components/schema-editor/document/array";
import type {
DocumentNode,
SchemaDocument,
} from "@/components/schema-editor/document/types";
export function updateNode(
doc: SchemaDocument,
id: string,
fn: (node: DocumentNode) => DocumentNode,
): SchemaDocument {
const root = replaceInNode(doc.root, id, fn);
const defs = mapPreserve(doc.defs, (definition) => {
const node = replaceInNode(definition.node, id, fn);
return node === definition.node ? definition : { ...definition, node };
});
if (root === doc.root && defs === doc.defs) return doc;
return { ...doc, root, defs };
}
function replaceInNode(
node: DocumentNode,
id: string,
fn: (node: DocumentNode) => DocumentNode,
): DocumentNode {
if (node.id === id) return fn(node);
let next = node;
if (node.properties) {
const properties = mapPreserve(node.properties, (entry) => {
const child = replaceInNode(entry.node, id, fn);
return child === entry.node ? entry : { ...entry, node: child };
});
if (properties !== node.properties) next = { ...next, properties };
}
if (node.items) {
const items = replaceInNode(node.items, id, fn);
if (items !== node.items) next = { ...next, items };
}
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
const list = node[key];
if (!list) continue;
const mapped = mapPreserve(list, (child) => replaceInNode(child, id, fn));
if (mapped !== list) next = { ...next, [key]: mapped };
}
return next;
}
export function updateNodeRest(
doc: SchemaDocument,
id: string,
patch: Record<string, unknown>,
): SchemaDocument {
return updateNode(doc, id, (node) => ({
...node,
rest: { ...node.rest, ...patch },
}));
}
components/schema-editor/document/property-operations.ts
import { mapPreserve } from "@/components/schema-editor/document/array";
import { createId } from "@/components/schema-editor/document/id";
import {
getEffectiveDocNode,
getOwnProperty,
} from "@/components/schema-editor/document/node-selectors";
import { updateNode } from "@/components/schema-editor/document/node-update";
import {
childNodes,
getNode,
} from "@/components/schema-editor/document/traversal";
import { createNode } from "@/components/schema-editor/document/type-operations";
import type {
DocumentNode,
PropertyEntry,
SchemaDocument,
} from "@/components/schema-editor/document/types";
function updateObjectProperties(
doc: SchemaDocument,
parentId: string,
fn: (properties: PropertyEntry[]) => PropertyEntry[],
): SchemaDocument {
return updateNode(doc, parentId, (node) => {
if (isSingleNullableBranchContainer(node)) {
const branch = getEffectiveDocNode(node);
if (branch.type !== "object" && !branch.properties) return node;
return {
...node,
anyOf: node.anyOf!.map((child) =>
child.id === branch.id
? updateNodeProperties(child, fn(child.properties ?? []))
: child,
),
};
}
if (node.type !== "object" && !node.properties) return node;
return updateNodeProperties(node, fn(node.properties ?? []));
});
}
function updateNodeProperties(
node: DocumentNode,
properties: PropertyEntry[],
): DocumentNode {
return {
...node,
properties,
requiredOrder: getRequiredOrder(node, properties),
};
}
function getRequiredOrder(
node: DocumentNode,
properties: PropertyEntry[],
): string[] | undefined {
const requiredProperties = properties
.filter((property) => property.required && isProjectableProperty(property))
.map((property) => property.key);
const extraRequired = node.extraRequired ?? [];
if (requiredProperties.length === 0 && extraRequired.length === 0) {
return node.requiredOrder?.length ? [] : undefined;
}
const orderedExtraRequired = node.requiredOrder
? node.requiredOrder.filter((name) => extraRequired.includes(name))
: extraRequired;
return [...orderedExtraRequired, ...requiredProperties];
}
export function findOwningProperty(
doc: SchemaDocument,
propertyId: string,
): { parentId: string; index: number } | null {
let result: { parentId: string; index: number } | null = null;
const visit = (node: DocumentNode) => {
if (result) return;
if (node.properties) {
const index = node.properties.findIndex(
(property) => property.id === propertyId,
);
if (index >= 0) {
result = { parentId: node.id, index };
return;
}
}
for (const child of childNodes(node)) visit(child);
};
visit(doc.root);
for (const definition of doc.defs) visit(definition.node);
return result;
}
export function addProperty(
doc: SchemaDocument,
parentId: string,
init: Partial<PropertyEntry> = {},
): SchemaDocument {
const entry: PropertyEntry = {
id: init.id ?? createId("prop"),
key: init.key ?? "",
isTransient: init.isTransient ?? (init.key ? undefined : true),
required: init.required ?? false,
node: init.node ?? createNode("string"),
};
return updateObjectProperties(doc, parentId, (properties) => [
...properties,
entry,
]);
}
export function removeProperty(
doc: SchemaDocument,
propertyId: string,
): SchemaDocument {
const owner = findOwningProperty(doc, propertyId);
if (!owner) return doc;
return updateObjectProperties(doc, owner.parentId, (properties) =>
properties.filter((property) => property.id !== propertyId),
);
}
export function renameProperty(
doc: SchemaDocument,
propertyId: string,
key: string,
): SchemaDocument {
return updateOwningEntry(doc, propertyId, (entry) =>
entry.key === key
? entry
: { ...entry, key, isTransient: key === "" ? true : undefined },
);
}
export function setRequired(
doc: SchemaDocument,
propertyId: string,
required: boolean,
): SchemaDocument {
return updateOwningEntry(doc, propertyId, (entry) =>
entry.required === required ? entry : { ...entry, required },
);
}
function updateOwningEntry(
doc: SchemaDocument,
propertyId: string,
fn: (entry: PropertyEntry) => PropertyEntry,
): SchemaDocument {
const owner = findOwningProperty(doc, propertyId);
if (!owner) return doc;
return updateObjectProperties(doc, owner.parentId, (properties) =>
mapPreserve(properties, (entry) =>
entry.id === propertyId ? fn(entry) : entry,
),
);
}
export function moveProperty(
doc: SchemaDocument,
propertyId: string,
targetParentId: string,
index: number,
): SchemaDocument {
const owner = findOwningProperty(doc, propertyId);
if (!owner) return doc;
const moved = getOwnProperty(doc, owner.parentId, owner.index);
if (!moved) return doc;
const targetParent = getNode(doc, targetParentId);
if (!targetParent) return doc;
const targetEffectiveParent = getEffectiveDocNode(targetParent);
if (
targetEffectiveParent.type !== "object" &&
!targetEffectiveParent.properties
)
return doc;
if (isAncestor(doc, moved.node.id, targetParentId)) return doc;
let next = updateObjectProperties(doc, owner.parentId, (properties) =>
properties.filter((property) => property.id !== propertyId),
);
next = updateObjectProperties(next, targetParentId, (properties) => {
const clamped = Math.max(0, Math.min(index, properties.length));
const out = properties.slice();
out.splice(clamped, 0, moved);
return out;
});
return next;
}
function isSingleNullableBranchContainer(node: DocumentNode): boolean {
if (!node.anyOf) return false;
const nonNullBranches = node.anyOf.filter(
(branch) => branch.type !== "null" || branch.ref,
);
return (
nonNullBranches.length === 1 &&
node.anyOf.length === 2 &&
node.anyOf.some((branch) => branch.type === "null" && !branch.ref)
);
}
function isProjectableProperty(property: PropertyEntry): boolean {
return property.key !== "" || !property.isTransient;
}
function isAncestor(
doc: SchemaDocument,
nodeId: string,
maybeDescendantId: string,
): boolean {
const node = getNode(doc, nodeId);
if (!node) return false;
if (node.id === maybeDescendantId) return true;
return childNodes(node).some((child) =>
isAncestor(doc, child.id, maybeDescendantId),
);
}
components/schema-editor/document/traversal.ts
import type {
DefinitionEntry,
DocumentNode,
SchemaDocument,
} from "@/components/schema-editor/document/types";
export function childNodes(node: DocumentNode): DocumentNode[] {
const out: DocumentNode[] = [];
if (node.properties)
for (const property of node.properties) out.push(property.node);
if (node.items) out.push(node.items);
if (node.anyOf) out.push(...node.anyOf);
if (node.oneOf) out.push(...node.oneOf);
if (node.allOf) out.push(...node.allOf);
return out;
}
export function getNode(doc: SchemaDocument, id: string): DocumentNode | null {
return findInNode(doc.root, id) ?? findInDefinitions(doc.defs, id);
}
function findInDefinitions(
definitions: DefinitionEntry[],
id: string,
): DocumentNode | null {
for (const definition of definitions) {
const found = findInNode(definition.node, id);
if (found) return found;
}
return null;
}
function findInNode(node: DocumentNode, id: string): DocumentNode | null {
if (node.id === id) return node;
for (const child of childNodes(node)) {
const found = findInNode(child, id);
if (found) return found;
}
return null;
}
export function findNodeByPath(
doc: SchemaDocument,
path: string | readonly string[],
): string | null {
const segments =
typeof path === "string" ? path.split(".").filter(Boolean) : path;
let node: DocumentNode | undefined = doc.root;
for (const segment of segments) {
node = unwrapContainer(doc, node);
const entry = node?.properties?.find(
(property) => property.key === segment,
);
if (!entry) return null;
node = entry.node;
}
return node?.id ?? null;
}
function unwrapContainer(
doc: SchemaDocument,
node: DocumentNode | undefined,
): DocumentNode | undefined {
let current = node;
const visited = new Set<string>();
while (current) {
current = getEffectiveContainerNode(current);
if (visited.has(current.id)) return undefined;
visited.add(current.id);
if (current.ref) {
current = doc.defs.find(
(definition) => definition.id === current!.ref,
)?.node;
continue;
}
if (current.items && !current.properties) {
current = current.items;
continue;
}
break;
}
return current;
}
function getEffectiveContainerNode(node: DocumentNode): DocumentNode {
if (node.anyOf) {
const nonNull = node.anyOf.find(
(branch) => branch.type !== "null" || branch.ref,
);
if (nonNull) return nonNull;
}
return node;
}
components/schema-editor/document/type-operations.ts
import type { JSONSchema7TypeName } from "json-schema";
import { mapPreserve } from "@/components/schema-editor/document/array";
import { createId } from "@/components/schema-editor/document/id";
import { updateNode } from "@/components/schema-editor/document/node-update";
import type {
DocumentNode,
EnumValue,
SchemaDocument,
} from "@/components/schema-editor/document/types";
export type SchemaEditorType =
| JSONSchema7TypeName
| "enum"
| "date"
| "time"
| "datetime";
const STRING_REST_KEYS = new Set([
"contentEncoding",
"contentMediaType",
"contentSchema",
"format",
"maxLength",
"minLength",
"pattern",
]);
const NUMBER_REST_KEYS = new Set([
"exclusiveMaximum",
"exclusiveMinimum",
"maximum",
"minimum",
"multipleOf",
]);
const ARRAY_REST_KEYS = new Set([
"additionalItems",
"contains",
"items",
"maxContains",
"maxItems",
"minContains",
"minItems",
"prefixItems",
"unevaluatedItems",
"uniqueItems",
]);
const OBJECT_REST_KEYS = new Set([
"additionalProperties",
"dependentRequired",
"dependentSchemas",
"dependencies",
"maxProperties",
"minProperties",
"patternProperties",
"propertyNames",
"unevaluatedProperties",
]);
const ENUM_REST_KEYS = new Set(["x-enumDescriptions"]);
const ENUM_ALLOWED_REST_KEYS = new Set([
...STRING_REST_KEYS,
...ENUM_REST_KEYS,
]);
const TYPE_SPECIFIC_REST_KEYS = new Set([
...STRING_REST_KEYS,
...NUMBER_REST_KEYS,
...ARRAY_REST_KEYS,
...OBJECT_REST_KEYS,
...ENUM_REST_KEYS,
]);
export function setNodeType(
doc: SchemaDocument,
id: string,
type: JSONSchema7TypeName | "enum",
): SchemaDocument {
return updateNode(doc, id, (node) => normalizeNodeForType(node, type));
}
export function setNodeEditorType(
doc: SchemaDocument,
id: string,
type: SchemaEditorType,
): SchemaDocument {
return updateNode(doc, id, (node) =>
updateEffectiveNodeShape(node, (effective) =>
normalizeNodeForEditorType(effective, type),
),
);
}
export function normalizeNodeForType(
node: DocumentNode,
type: JSONSchema7TypeName | "enum",
): DocumentNode {
const nullable = isNodeNullable(node);
const base: DocumentNode = {
...node,
rest: stripSchemaRestForType(node.rest, type),
ref: undefined,
anyOf: undefined,
oneOf: undefined,
allOf: undefined,
properties: undefined,
extraRequired: undefined,
requiredOrder: undefined,
items: undefined,
enum: undefined,
booleanSchema: undefined,
};
if (type === "enum") {
base.type = "string";
base.enum = node.enum?.length ? node.enum : [createEnumValue()];
} else if (type === "object") {
base.type = "object";
base.extraRequired = node.extraRequired;
base.requiredOrder = node.requiredOrder;
base.properties = node.properties?.length
? node.properties
: [
{
id: createId("prop"),
key: "",
// Starter row the user has yet to name: mark it transient so the
// empty key is dropped at the projection boundary (matching
// `addProperty`) instead of leaking as a property named "".
isTransient: true,
required: false,
node: createNode("string"),
},
];
} else if (type === "array") {
base.type = "array";
base.items = node.items ?? createNode("string");
} else {
base.type = type;
}
return nullable ? setNodeNullable(base, true) : base;
}
function normalizeNodeForEditorType(
node: DocumentNode,
type: SchemaEditorType,
): DocumentNode {
const format =
type === "date"
? "date"
: type === "time"
? "time"
: type === "datetime"
? "date-time"
: undefined;
const schemaType: JSONSchema7TypeName | "enum" = format
? "string"
: (type as JSONSchema7TypeName | "enum");
const normalized = normalizeNodeForType(node, schemaType);
return {
...normalized,
rest: format
? { ...stripSchemaFormat(normalized.rest), format }
: stripSchemaFormat(normalized.rest),
};
}
export function stripSchemaFormat(
rest: Record<string, unknown>,
): Record<string, unknown> {
if (!Object.prototype.hasOwnProperty.call(rest, "format")) return rest;
const { format: _format, ...withoutFormat } = rest;
return withoutFormat;
}
export function stripSchemaTypeSpecificRest(
rest: Record<string, unknown>,
): Record<string, unknown> {
return filterSchemaRest(rest, undefined);
}
function stripSchemaRestForType(
rest: Record<string, unknown>,
type: JSONSchema7TypeName | "enum",
): Record<string, unknown> {
return filterSchemaRest(rest, getRestKeysForType(type));
}
function getRestKeysForType(
type: JSONSchema7TypeName | "enum",
): Set<string> | undefined {
if (type === "string") return STRING_REST_KEYS;
if (type === "enum") return ENUM_ALLOWED_REST_KEYS;
if (type === "number" || type === "integer") return NUMBER_REST_KEYS;
if (type === "array") return ARRAY_REST_KEYS;
if (type === "object") return OBJECT_REST_KEYS;
return undefined;
}
function filterSchemaRest(
rest: Record<string, unknown>,
allowedTypeSpecificKeys: Set<string> | undefined,
): Record<string, unknown> {
let next = rest;
for (const key of Object.keys(rest)) {
if (
TYPE_SPECIFIC_REST_KEYS.has(key) &&
!allowedTypeSpecificKeys?.has(key)
) {
if (next === rest) next = { ...rest };
delete next[key];
}
}
return next;
}
export function setNullable(
doc: SchemaDocument,
id: string,
nullable: boolean,
): SchemaDocument {
return updateNode(doc, id, (node) => setNodeNullable(node, nullable));
}
function setNodeNullable(node: DocumentNode, nullable: boolean): DocumentNode {
if (node.anyOf) {
return setAnyOfNodeNullable(node, nullable);
}
const current = node.type;
const names = Array.isArray(current)
? current.filter((type) => type !== "null")
: current
? [current]
: [];
if (nullable) {
if (node.enum) return wrapNodeInNullableAnyOf(node);
if (names.length === 0) {
return node.ref ? wrapNodeInNullableAnyOf(node) : node;
}
return { ...node, type: [...names, "null"] };
}
if (names.length <= 1) return { ...node, type: names[0] };
return { ...node, type: names };
}
function isNodeNullable(node: DocumentNode): boolean {
if (Array.isArray(node.type)) return node.type.includes("null");
if (node.anyOf) return node.anyOf.some((branch) => branch.type === "null");
return node.type === "null";
}
function setAnyOfNodeNullable(
node: DocumentNode,
nullable: boolean,
): DocumentNode {
const branches = node.anyOf ?? [];
const nonNullBranches = branches.filter(
(branch) => branch.type !== "null" || branch.ref,
);
if (nullable) {
if (nonNullBranches.length !== branches.length) return node;
return { ...node, anyOf: [...branches, createNode("null")] };
}
if (nonNullBranches.length === branches.length) return node;
if (nonNullBranches.length !== 1) {
return { ...node, anyOf: nonNullBranches };
}
return mergeNullableWrapperIntoBranch(node, nonNullBranches[0]);
}
function wrapNodeInNullableAnyOf(node: DocumentNode): DocumentNode {
return {
id: node.id,
title: node.title,
description: node.description,
rest: node.rest,
order: node.order,
anyOf: [cloneNodeAsAnyOfBranch(node), createNode("null")],
};
}
function cloneNodeAsAnyOfBranch(node: DocumentNode): DocumentNode {
return {
...node,
id: createId(),
type: nonNullType(node.type),
title: undefined,
description: undefined,
rest: {},
order: undefined,
};
}
function nonNullType(type: DocumentNode["type"]): DocumentNode["type"] {
if (!Array.isArray(type)) return type;
const types = type.filter((entry) => entry !== "null");
if (types.length === 0) return undefined;
return types.length === 1 ? types[0] : types;
}
function mergeNullableWrapperIntoBranch(
wrapper: DocumentNode,
branch: DocumentNode,
): DocumentNode {
return {
...branch,
id: wrapper.id,
title: wrapper.title ?? branch.title,
description: wrapper.description ?? branch.description,
rest: { ...branch.rest, ...wrapper.rest },
order: wrapper.order ?? branch.order,
};
}
export function createNode(
type: JSONSchema7TypeName | "enum" = "string",
): DocumentNode {
return normalizeNodeForType({ id: createId(), rest: {} }, type);
}
export function createEnumValue(): EnumValue {
return { id: createId("enum"), value: "" };
}
export function updateEffectiveNodeShape(
node: DocumentNode,
fn: (node: DocumentNode) => DocumentNode,
): DocumentNode {
if (node.anyOf) {
return {
...node,
anyOf: mapPreserve(node.anyOf, (branch) =>
branch.type === "null" && !branch.ref ? branch : fn(branch),
),
};
}
return fn(node);
}
components/schema-editor/document/types.ts
import type { JSONSchema7TypeName } from "json-schema";
/**
* Stable, intrinsic node identity.
*
* Born when a node is created, stable across renames / reorders / retypes, dies
* with the node. NEVER derived from a mutable key or a positional path — that is
* the whole reason this Document model exists instead of editing JSON Schema in
* place.
*/
export type NodeId = string;
export type PropertyId = string;
/** A JSON Schema literal (enum entries, const, default, …). */
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
/**
* The editor's source of truth: a Document — a total, identity-bearing,
* order-explicit representation of a JSON Schema.
*
* Design contract (see ./DESIGN.md):
*
* 1. Identity is intrinsic. Every node carries an `id` field; we never key a
* node by its property name or its path.
*
* 2. Order is explicit. Children JSON Schema keys by name (`properties`, `$defs`)
* are stored as ORDERED arrays of entries, so reordering is `arrayMove` and
* never depends on JS object key-order.
*
* 3. Losslessness by construction. Every keyword we don't model structurally is
* carried verbatim in `rest` and projected straight back out. Editing a node
* spreads it (`{ ...node, description }`), so unknown keywords ride along.
*
* 4. References are by id, not by name. A `$ref` resolves to a Definition's
* `id` on import, so renaming a definition can never break a reference; the
* `#/$defs/Name` pointer is re-projected from the def's current name on export.
*
* 5. The Document can hold transient-invalid states (empty/duplicate keys while
* typing). Cleanup (dropping empty keys, etc.) happens at the EXPORT boundary,
* never in the model — which is what lets the component be fully controlled.
*/
export interface DocumentNode {
id: NodeId;
/**
* Mirrors JSON Schema `type`. A single name is the common case; an array
* encodes a type-union such as `["string", "null"]` (nullable). Absent = "any".
*/
type?: JSONSchema7TypeName | JSONSchema7TypeName[];
title?: string;
description?: string;
/** object: ordered, identity-bearing property entries. */
properties?: PropertyEntry[];
/** Required names from the source that are not modeled as property entries. */
extraRequired?: string[];
/** Source order for `required[]`, used to preserve external required names. */
requiredOrder?: string[];
/** array: the item schema. (Tuple `items` arrays are carried in `rest` for v1.) */
items?: DocumentNode;
/** enum: ordered, identity-bearing values. Base type comes from `type`. */
enum?: EnumValue[];
/** $ref: id of the Definition this node points at, resolved on import. */
ref?: NodeId;
/** Composition keywords, recursively modeled. */
anyOf?: DocumentNode[];
oneOf?: DocumentNode[];
allOf?: DocumentNode[];
/**
* Every keyword we don't model structurally — `const`, `default`, `format`,
* `pattern`, `minLength`, `examples`, `additionalProperties`, `x-*`, … — kept
* verbatim and projected straight back out. This is what makes the round-trip
* lossless.
*/
rest: Record<string, unknown>;
/**
* Internal: source key order, replayed on export for byte-faithful round-trips.
* Kept off `rest` so a real keyword named `__order` can't collide with it.
*/
order?: string[];
/**
* Internal: a boolean schema (`true` / `false`) has no structure to model; its
* literal value is carried here.
*/
booleanSchema?: boolean;
}
/**
* A named child of an object. The `key` is mutable and freely editable; the
* `required` flag lives on the parent/child EDGE here (in JSON Schema it lives in
* the parent's `required[]` array, which we distribute onto entries on import and
* rebuild on export).
*/
export interface PropertyEntry {
id: PropertyId;
key: string;
/** Empty key created by the editor while typing; omitted from projection. */
isTransient?: boolean;
required: boolean;
node: DocumentNode;
}
/** A single enum option. `id` keeps the row stable while its value is edited. */
export interface EnumValue {
id: NodeId;
value: JsonValue;
}
/** A named definition under `$defs`. `name` is mutable; references point at `id`. */
export interface DefinitionEntry {
id: NodeId;
name: string;
node: DocumentNode;
}
/** The whole editor document — the single source of truth held in one useState. */
export interface SchemaDocument {
root: DocumentNode;
/** Named definitions ($defs / legacy definitions), ordered. */
defs: DefinitionEntry[];
/**
* Document-level round-trip metadata we don't surface structurally — which defs
* keyword the source used, top-level `$schema`/`$id`, etc. Carried verbatim.
*/
rest: Record<string, unknown>;
}
/**
* The effective "kind" of a node for the UI. Derived (see ./derive.ts), never
* stored — there is exactly one source of truth and this is a projection of it.
*/
export type SchemaKind =
| JSONSchema7TypeName // string | number | integer | boolean | object | array | null
| "enum"
| "ref"
| "union"
| "any";
components/schema-editor/document/view-model.ts
import type { JSONSchema7Definition } from "json-schema";
import { projectNode } from "@/components/schema-editor/document/convert";
import {
getEffectiveKind,
isNullable,
resolveRef,
} from "@/components/schema-editor/document/derive";
import type { SchemaEditorType } from "@/components/schema-editor/document/type-operations";
import type {
DocumentNode,
EnumValue,
PropertyEntry,
SchemaDocument,
} from "@/components/schema-editor/document/types";
export interface DocumentDefinitionView {
definitionId: string;
definitionName: string;
schema: JSONSchema7Definition;
}
export interface DocumentPropertyView {
propertyId: string;
propertyName: string;
isRequired: boolean;
nodeView: DocumentNodeView;
}
export interface DocumentNodeView {
nodeId: string;
docNode: DocumentNode;
effectiveNode: DocumentNode;
type: SchemaEditorType | "$ref" | "any" | "union" | "null";
title?: string;
description?: string;
refName?: string;
isNullable: boolean;
properties: DocumentPropertyView[];
items?: DocumentNodeView;
enumEntries: EnumValue[];
}
export interface SchemaDocumentView {
root: DocumentNodeView;
definitions: DocumentDefinitionView[];
}
export function getSchemaDocumentView(doc: SchemaDocument): SchemaDocumentView {
return {
root: getDocumentNodeView(doc, doc.root),
definitions: doc.defs.map((definition) => ({
definitionId: definition.id,
definitionName: definition.name,
schema: projectNode(doc, definition.node),
})),
};
}
export function getDocumentNodeView(
doc: SchemaDocument,
node: DocumentNode,
): DocumentNodeView {
const effectiveNode = getViewEffectiveNode(node);
return {
nodeId: node.id,
docNode: node,
effectiveNode,
type: getDocumentEditorType(doc, effectiveNode),
title: node.title,
description: node.description,
refName: resolveRef(doc, effectiveNode)?.name,
isNullable: isNullable(node),
properties: (effectiveNode.properties ?? []).map((property) =>
getDocumentPropertyView(doc, property),
),
items: effectiveNode.items
? getDocumentNodeView(doc, effectiveNode.items)
: undefined,
enumEntries: effectiveNode.enum ?? [],
};
}
function getDocumentPropertyView(
doc: SchemaDocument,
property: PropertyEntry,
): DocumentPropertyView {
return {
propertyId: property.id,
propertyName: property.key,
isRequired: property.required,
nodeView: getDocumentNodeView(doc, property.node),
};
}
function getViewEffectiveNode(node: DocumentNode): DocumentNode {
if (node.anyOf) {
return (
node.anyOf.find((branch) => branch.type !== "null" || branch.ref) ?? node
);
}
return node;
}
function getDocumentEditorType(
doc: SchemaDocument,
node: DocumentNode,
): DocumentNodeView["type"] {
if (node.ref) return "$ref";
if (node.enum) return "enum";
const kind = getEffectiveKind(node);
if (kind === "ref") return "$ref";
if (kind !== "string") return kind;
const format = node.rest.format;
if (format === "date") return "date";
if (format === "time") return "time";
if (format === "date-time") return "datetime";
return resolveRef(doc, node) ? "$ref" : "string";
}
components/schema-editor/draft/draft-node-edits.ts
import type { JSONSchema7Definition } from "json-schema";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { getEffectiveNode } from "@/components/schema-editor/lib/json-schema-utils";
import { formatTitle } from "../schema-title";
export function getEffectiveType(node: ExtendedJSONSchema7): {
type: string;
isNullable: boolean;
} {
if (node.anyOf && Array.isArray(node.anyOf)) {
const nonNull = node.anyOf.find(
(branch: JSONSchema7Definition) =>
typeof branch === "object" &&
(branch.type !== "null" || branch.$ref || branch.enum),
);
const isNullable = node.anyOf.some(
(branch: JSONSchema7Definition) =>
typeof branch === "object" && branch.type === "null",
);
if (nonNull && typeof nonNull === "object") {
if (nonNull.$ref) return { type: "$ref", isNullable };
if (nonNull.enum) return { type: "enum", isNullable };
if (nonNull.type === "string" && nonNull.format === "date") {
return { type: "date", isNullable };
}
if (nonNull.type === "string" && nonNull.format === "time") {
return { type: "time", isNullable };
}
if (nonNull.type === "string" && nonNull.format === "date-time") {
return { type: "datetime", isNullable };
}
return { type: nonNull.type?.toString() || "string", isNullable };
}
return { type: "string", isNullable };
}
// A nullable scalar can also be encoded as a type union `["string", "null"]`
// (the standard JSON Schema form, and what the document-model builder emits).
// Treat that `null` member as nullability, not as part of the type name.
const isTypeArrayNullable =
Array.isArray(node.type) && node.type.includes("null");
const effectiveTypeName = isTypeArrayNullable
? (node.type as string[]).find((member) => member !== "null")
: node.type;
if (node.enum) return { type: "enum", isNullable: isTypeArrayNullable };
if (node.$ref) return { type: "$ref", isNullable: isTypeArrayNullable };
if (effectiveTypeName === "string" && node.format === "date") {
return { type: "date", isNullable: isTypeArrayNullable };
}
if (effectiveTypeName === "string" && node.format === "time") {
return { type: "time", isNullable: isTypeArrayNullable };
}
if (effectiveTypeName === "string" && node.format === "date-time") {
return { type: "datetime", isNullable: isTypeArrayNullable };
}
return {
type: effectiveTypeName?.toString() || "string",
isNullable: isTypeArrayNullable,
};
}
export function defaultSchemaForType(type: string): ExtendedJSONSchema7 {
switch (type) {
case "string":
return { type: "string" };
case "number":
return { type: "number" };
case "integer":
return { type: "integer" };
case "boolean":
return { type: "boolean" };
case "object":
return { type: "object", properties: {}, required: [] };
case "array":
return { type: "array", items: { type: "string" } };
case "$ref":
return {} as ExtendedJSONSchema7;
case "enum":
return { enum: [], type: "string" };
case "date":
return { type: "string", format: "date" };
case "time":
return { type: "string", format: "time" };
case "datetime":
return { type: "string", format: "date-time" };
default:
return { type: "string" };
}
}
export function updateType(
newType: string,
nullable: boolean,
oldNode: ExtendedJSONSchema7,
): ExtendedJSONSchema7 {
const effectiveOldNode = getEffectiveNode(oldNode);
const metadata: Partial<ExtendedJSONSchema7> = {};
if (oldNode.title) metadata.title = oldNode.title;
if (oldNode.description) metadata.description = oldNode.description;
let baseSchema = defaultSchemaForType(newType);
if (newType === "array") {
baseSchema = {
type: "array",
items:
effectiveOldNode.type === "object"
? effectiveOldNode
: { type: "string" },
};
} else if (newType === "enum") {
baseSchema = {
type: "string",
enum: effectiveOldNode.enum || [],
};
}
if (nullable) {
return {
anyOf: [baseSchema, { type: "null" }],
...metadata,
} as ExtendedJSONSchema7;
}
return { ...baseSchema, ...metadata };
}
export function setNullable(
node: ExtendedJSONSchema7,
nullable: boolean,
): ExtendedJSONSchema7 {
const { title, description, ...rest } = node;
if (nullable) {
if (node.anyOf && Array.isArray(node.anyOf)) {
const nonNullBranch = node.anyOf.find(
(branch: JSONSchema7Definition) =>
typeof branch === "object" && (branch.type !== "null" || branch.$ref),
);
const nonNullObject =
typeof nonNullBranch === "object" ? nonNullBranch : {};
return {
anyOf: [{ ...nonNullObject }, { type: "null" }],
...(title ? { title } : {}),
...(description ? { description } : {}),
};
}
// Strip any pre-existing `null` from a type union so the wrapped branch
// isn't doubly-nullable (e.g. `["string","null"]` -> `"string"`).
return {
anyOf: [stripNullType({ ...rest }), { type: "null" }],
...(title ? { title } : {}),
...(description ? { description } : {}),
};
}
if (node.anyOf && Array.isArray(node.anyOf)) {
const nonNullBranch = node.anyOf.find(
(branch: JSONSchema7Definition) =>
typeof branch === "object" && (branch.type !== "null" || branch.$ref),
);
const nonNullObject =
typeof nonNullBranch === "object" ? nonNullBranch : {};
return {
...nonNullObject,
...(title ? { title } : {}),
...(description ? { description } : {}),
};
}
// Plain node: also handle the type-union nullable form `["string","null"]`,
// which carries no `anyOf` — drop the `null` member to make it non-nullable.
return stripNullType(node);
}
/** Remove a `null` member from a `type` union, collapsing to a single name. */
function stripNullType(node: ExtendedJSONSchema7): ExtendedJSONSchema7 {
if (!Array.isArray(node.type)) return node;
const nonNull = node.type.filter((member) => member !== "null");
return {
...node,
type: (nonNull.length === 0
? undefined
: nonNull.length === 1
? nonNull[0]
: nonNull) as ExtendedJSONSchema7["type"],
};
}
export function updateEffectiveNode(
node: ExtendedJSONSchema7,
updatedEffectiveNode: ExtendedJSONSchema7,
): ExtendedJSONSchema7 {
if (node.anyOf && Array.isArray(node.anyOf)) {
const anyOf = node.anyOf.map((branch: JSONSchema7Definition) =>
typeof branch === "object" && branch.type === "null"
? branch
: updatedEffectiveNode,
);
return { ...node, anyOf };
}
return updatedEffectiveNode;
}
export function updateSchemaProperty(
schemaNode: ExtendedJSONSchema7,
propertyKey: string,
newPropertyName: string,
updatedProperty: ExtendedJSONSchema7,
): ExtendedJSONSchema7 {
// Refuse to rename onto an existing sibling: merging would drop the renamed
// property's schema and leave a duplicate in `required`. Callers validate
// name collisions upstream, so this is a defensive no-op.
if (
newPropertyName !== propertyKey &&
schemaNode.properties &&
Object.prototype.hasOwnProperty.call(schemaNode.properties, newPropertyName)
) {
return schemaNode;
}
const effectiveNode = getEffectiveNode(updatedProperty);
const cleanProperty = {
...effectiveNode,
title: formatTitle(newPropertyName),
...(Array.isArray(effectiveNode.enum) && { type: "string" }),
} as ExtendedJSONSchema7;
const finalProperty = updateEffectiveNode(updatedProperty, cleanProperty);
const oldProperties = schemaNode.properties || {};
const newProperties: Record<string, ExtendedJSONSchema7> = {};
let found = false;
Object.keys(oldProperties).forEach((key) => {
if (key === propertyKey && newPropertyName !== propertyKey) {
setRecordValue(newProperties, newPropertyName, finalProperty);
found = true;
} else if (isJSONSchema(oldProperties[key])) {
setRecordValue(
newProperties,
key,
oldProperties[key] as ExtendedJSONSchema7,
);
}
});
if (!found && newPropertyName !== propertyKey) {
setRecordValue(newProperties, newPropertyName, finalProperty);
delete newProperties[propertyKey];
}
if (newPropertyName !== propertyKey) {
const required = Array.isArray(schemaNode.required)
? schemaNode.required.map((key: string) =>
key === propertyKey ? newPropertyName : key,
)
: [];
return {
...schemaNode,
properties: newProperties,
required,
};
}
return {
...schemaNode,
properties: {
...newProperties,
[propertyKey]: finalProperty,
},
};
}
function isJSONSchema(
value: JSONSchema7Definition,
): value is ExtendedJSONSchema7 {
return typeof value === "object" && value !== null;
}
function setRecordValue<T>(record: Record<string, T>, key: string, value: T) {
Object.defineProperty(record, key, {
value,
enumerable: true,
configurable: true,
writable: true,
});
}
components/schema-editor/enum-creation-dialog.tsx
import * as React from "react";
import { useState } from "react";
import { PlusIcon, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface EnumCreationDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (enumValues: string[]) => void;
onCancel: () => void;
}
interface EnumCreationDialogContentProps {
onClose: () => void;
onConfirm: (enumValues: string[]) => void;
onCancel: () => void;
}
function EnumCreationDialogContent({
onClose,
onConfirm,
onCancel,
}: EnumCreationDialogContentProps) {
const [enumValues, setEnumValues] = useState<string[]>([]); // Start with no values
const [newValue, setNewValue] = useState("");
const enumInputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
const pendingFocusIndexRef = React.useRef<number | null>(null);
const setEnumInputRef = React.useCallback(
(index: number, input: HTMLInputElement | null) => {
enumInputRefs.current[index] = input;
if (input && pendingFocusIndexRef.current === index) {
pendingFocusIndexRef.current = null;
input.focus();
}
},
[],
);
const handleAddValue = () => {
if (newValue.trim()) {
setEnumValues((prev) => [...prev, newValue.trim()]);
setNewValue("");
}
};
const handleAddFromEmpty = () => {
pendingFocusIndexRef.current = enumValues.length;
setEnumValues((prev) => [...prev, ""]);
};
const handleRemoveValue = (index: number) => {
setEnumValues((prev) => prev.filter((_, i) => i !== index));
};
const handleEditValue = (index: number, value: string) => {
setEnumValues((prev) => prev.map((val, i) => (i === index ? value : val)));
};
const handleConfirm = () => {
// Filter out empty values
const validValues = enumValues.filter((val) => val.trim() !== "");
if (validValues.length > 0) {
onConfirm(validValues);
onClose();
}
};
const handleCancel = () => {
onCancel();
onClose();
};
const validValuesCount = enumValues.filter((val) => val.trim() !== "").length;
return (
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Update Field</DialogTitle>
<DialogDescription>
Define the allowed values for this field.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="mb-2">Enabled Options</Label>
{enumValues.length > 0 && (
<div className="mb-4 space-y-2">
{enumValues.map((value, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={value}
ref={(input) => {
setEnumInputRef(index, input);
}}
onChange={(e) => handleEditValue(index, e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) {
if (index === enumValues.length - 1) {
handleAddFromEmpty();
} else {
enumInputRefs.current[index + 1]?.focus();
}
}
}}
className="flex-1"
placeholder={`Value ${index + 1}`}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-1"
onClick={() => handleRemoveValue(index)}
disabled={enumValues.length === 1}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<Input
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newValue.trim()) {
handleAddValue();
}
}}
className="flex-1"
placeholder="Add another value"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddValue}
disabled={!newValue.trim()}
>
<PlusIcon className="mr-1 h-4 w-4" />
Add
</Button>
</div>
<p className="text-muted-foreground mt-2 text-xs">
{validValuesCount} value{validValuesCount !== 1 ? "s" : ""} defined.
</p>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button
type="button"
variant="default"
onClick={handleConfirm}
disabled={validValuesCount === 0}
>
Update Field ({validValuesCount} value
{validValuesCount !== 1 ? "s" : ""})
</Button>
</DialogFooter>
</DialogContent>
);
}
export function EnumCreationDialog({
isOpen,
onClose,
onConfirm,
onCancel,
}: EnumCreationDialogProps) {
const handleOpenChange = (open: boolean) => {
if (!open) {
onCancel();
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
{isOpen ? (
<EnumCreationDialogContent
key="enum-creation-dialog"
onClose={onClose}
onConfirm={onConfirm}
onCancel={onCancel}
/>
) : null}
</Dialog>
);
}
components/schema-editor/lib/configure-ajv.ts
import type Ajv from "ajv";
import ajvErrors from "ajv-errors";
import addFormats from "ajv-formats";
type AjvFormatsInstance = Parameters<typeof addFormats>[0];
type AjvErrorsInstance = Parameters<typeof ajvErrors>[0];
export function addJsonSchemaFormats(ajv: Ajv) {
addFormats(ajv as unknown as AjvFormatsInstance);
}
export function addJsonSchemaErrors(ajv: Ajv) {
ajvErrors(ajv as unknown as AjvErrorsInstance);
}
components/schema-editor/lib/json-schema-types.ts
import type { JSONSchema7 } from "json-schema";
/**
* The schema editor works with standard JSON Schema (Draft 7). This is a plain
* alias for `JSONSchema7` — no custom extensions — kept under a single shared
* name so the editor's many imports stay stable.
*/
export type ExtendedJSONSchema7 = JSONSchema7;
components/schema-editor/lib/json-schema-utils.ts
import type { JSONSchema7Definition } from "json-schema";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
// Pydantic reserved names that actually break model creation
const PYDANTIC_RESERVED = [
"__root__",
"model_config",
"model_post_init",
"model_validate",
"model_dump",
];
// Generic name validator for Pydantic compatible field and definition names
export function validateName(
name: string,
existingNames: string[] = [],
currentName?: string,
entityType: string = "name",
): string | null {
if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/.test(name)) {
return "Name must start with a letter or underscore, contain only letters, numbers, or underscores, and be at most 64 characters long";
}
if (PYDANTIC_RESERVED.includes(name)) {
return `"${name}" is a Pydantic reserved name`;
}
const namesToCheck = currentName
? existingNames.filter((n) => n.toLowerCase() !== currentName.toLowerCase())
: existingNames;
if (namesToCheck.some((n) => n.toLowerCase() === name.toLowerCase())) {
return `A ${entityType} with the name "${name}" already exists (names are case-insensitive)`;
}
return null;
}
/**
* Unwrap a nullable `anyOf` node (e.g. `{ anyOf: [<schema>, { type: "null" }] }`)
* down to its underlying non-null schema so it can be inspected directly.
*/
export function getEffectiveNode(
node: ExtendedJSONSchema7,
): ExtendedJSONSchema7 {
if (node.anyOf && Array.isArray(node.anyOf)) {
const nonNull = node.anyOf.find(
(b: JSONSchema7Definition) =>
typeof b === "object" && (b.type !== "null" || b.$ref),
);
return nonNull && typeof nonNull === "object" ? nonNull : node;
}
return node;
}
components/schema-editor/node-dialog.tsx
import * as React from "react";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { resolvePropertyCapabilities } from "@/components/schema-editor/property-form/model/property-capabilities";
import { PropertyForm } from "@/components/schema-editor/property-form/property-form";
import type {
PropertyDraft,
PropertyFormMode,
PropertyFormSchemaContext,
} from "@/components/schema-editor/property-form/types";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface NodeDialogProps {
isOpen: boolean;
onClose: () => void;
onChange: (newNode: ExtendedJSONSchema7) => void;
onNameChange?: (newName: string, updatedNode?: ExtendedJSONSchema7) => void;
onDelete?: () => void;
node: ExtendedJSONSchema7;
name: string;
mode: PropertyFormMode;
siblingNames: string[];
formContext: Omit<PropertyFormSchemaContext, "siblingNames" | "originalName">;
}
export function NodeDialog({
isOpen,
onClose,
onChange,
onNameChange,
onDelete,
node,
name,
mode,
siblingNames,
formContext,
}: NodeDialogProps) {
const handleCommit = async (next: PropertyDraft) => {
if (next.name !== name && onNameChange) {
onNameChange(next.name, next.schemaNode);
} else {
onChange(next.schemaNode);
}
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
{/* [&>*]:min-w-0 lets grid items shrink with classic scrollbars instead of clipping */}
<DialogContent className="max-h-[90vh] gap-2 overflow-y-auto p-0 sm:max-w-xl [&>*]:min-w-0">
<DialogHeader className="px-4 pt-4">
<DialogTitle>
{mode === "readOnly" ? "View Property" : "Edit Property"}
</DialogTitle>
<DialogDescription>
{mode === "readOnly"
? "View property name, type, description, and other characteristics."
: "Modify property name, type, description, and other characteristics."}
</DialogDescription>
</DialogHeader>
<PropertyForm
propertyDraft={{ name, schemaNode: node }}
capabilities={
onNameChange
? undefined
: {
...resolvePropertyCapabilities({
mode,
canDelete: Boolean(onDelete),
}),
// fixed-name nodes (an array's "items") cannot be renamed
canEditName: false,
}
}
schemaContext={{
...formContext,
siblingNames,
originalName: name,
}}
onCommitPropertyDraft={handleCommit}
onCancel={onClose}
onDelete={onDelete}
submitLabel="Save"
mode={mode}
/>
</DialogContent>
</Dialog>
);
}
components/schema-editor/object-template-type-section.tsx
"use client";
import * as React from "react";
import type { SchemaTypeMenuTrailingContent } from "@/components/schema-editor/primitives/schema-type-menu";
const LazyObjectTemplateSubmenu = React.lazy(() =>
import(
"@/components/schema-editor/optional/object-templates/object-template-menu"
).then((module) => ({
default: module.ObjectTemplateSubmenu,
})),
);
export function createObjectTemplateTypeTrailingContent({
onSelectTemplate,
}: {
onSelectTemplate: (templateName: string) => void;
}): SchemaTypeMenuTrailingContent {
function ObjectTemplateTypeTrailingContent({
editable,
}: {
editable: boolean;
}) {
return (
<React.Suspense fallback={null}>
<LazyObjectTemplateSubmenu
onSelectTemplate={(templateName) => {
if (!editable) return;
onSelectTemplate(templateName);
}}
/>
</React.Suspense>
);
}
ObjectTemplateTypeTrailingContent.displayName =
"ObjectTemplateTypeTrailingContent";
return ObjectTemplateTypeTrailingContent;
}
components/schema-editor/optional/import-export/import-export-menu-items.tsx
"use client";
import { CloudUpload, Copy, Download } from "lucide-react";
import { toast } from "sonner";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
export function ImportExportMenuItems({
node,
onReplaceRoot,
}: {
node: ExtendedJSONSchema7;
onReplaceRoot: (node: ExtendedJSONSchema7) => void;
}) {
const handleDownloadSchema = () => {
try {
const schemaBlob = new Blob([JSON.stringify(node, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(schemaBlob);
const link = document.createElement("a");
link.href = url;
link.download = node.title
? `${node.title.toLowerCase().replace(/\s+/g, "-")}.json`
: "schema.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Download Started", {
description: "Your schema has been downloaded successfully.",
});
} catch (error) {
console.error("Error downloading schema:", error);
toast.error("Download Failed", {
description: "There was an error downloading your schema.",
});
}
};
const handleUploadSchema = () => {
try {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".json";
fileInput.style.display = "none";
fileInput.addEventListener("change", (event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
const file = target.files[0];
const reader = new FileReader();
reader.onload = async (loadEvent) => {
try {
const content = loadEvent.target?.result as string;
onReplaceRoot(JSON.parse(content));
toast.success("Schema Uploaded", {
description:
"Your schema has been uploaded and applied successfully.",
});
} catch (error) {
console.error("Error parsing uploaded schema:", error);
toast.error("Upload Failed", {
description: "The uploaded file is not a valid JSON schema.",
});
}
};
reader.readAsText(file);
}
document.body.removeChild(fileInput);
});
document.body.appendChild(fileInput);
fileInput.click();
} catch (error) {
console.error("Error uploading schema:", error);
toast.error("Upload Failed", {
description: "There was an error uploading your schema.",
});
}
};
const handleCopy = () => {
try {
const formattedSchema = JSON.stringify(node, null, 2);
navigator.clipboard
.writeText(formattedSchema)
.then(() => {
toast.success("Copied to Clipboard", {
description: "Your schema has been copied to the clipboard.",
});
})
.catch((error) => {
console.error("Error copying to clipboard:", error);
const textArea = document.createElement("textarea");
textArea.value = formattedSchema;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
textArea.remove();
toast.success("Copied to Clipboard", {
description: "Your schema has been copied to the clipboard.",
});
} catch (copyError) {
console.error("Unable to copy schema", copyError);
toast.error("Copy Failed", {
description:
"There was an error copying your schema to the clipboard.",
});
textArea.remove();
}
});
} catch (error) {
console.error("Error preparing schema for copy:", error);
toast.error("Copy Failed", {
description: "There was an error preparing your schema for copying.",
});
}
};
return (
<>
<DropdownMenuItem onClick={handleDownloadSchema}>
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
<DropdownMenuItem onClick={handleUploadSchema}>
<CloudUpload className="mr-2 h-4 w-4" />
Upload
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopy}>
<Copy className="mr-2 h-4 w-4" />
Copy to clipboard
</DropdownMenuItem>
</>
);
}
components/schema-editor/optional/json-mode/json-mode-editor.tsx
"use client";
import * as React from "react";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/schema-builder-types";
import { Button } from "@/components/ui/button";
type JsonModeDraftState =
| { status: "dirty-valid"; text: string; parsed: ExtendedJSONSchema7 }
| { status: "dirty-invalid"; text: string; error: string };
type JsonModeState = { status: "synced"; text: string } | JsonModeDraftState;
export function JsonModeEditor({
schema,
readOnly,
replaceSchema,
}: {
schema: ExtendedJSONSchema7;
readOnly: boolean;
replaceSchema: (schema: ExtendedJSONSchema7) => void | Promise<void>;
}) {
const schemaText = React.useMemo(
() => JSON.stringify(schema, null, 2),
[schema],
);
const [jsonDraft, setJsonDraft] = React.useState<JsonModeDraftState | null>(
null,
);
const jsonState = React.useMemo<JsonModeState>(
() =>
jsonDraft ?? {
status: "synced",
text: schemaText,
},
[jsonDraft, schemaText],
);
const handleJsonChange = React.useCallback((text: string) => {
try {
const parsed = JSON.parse(text) as ExtendedJSONSchema7;
setJsonDraft({ status: "dirty-valid", text, parsed });
} catch (error) {
setJsonDraft({
status: "dirty-invalid",
text,
error: error instanceof Error ? error.message : "Invalid JSON",
});
}
}, []);
const applyJson = React.useCallback(() => {
if (readOnly || jsonState.status !== "dirty-valid") return;
void replaceSchema(jsonState.parsed);
setJsonDraft(null);
}, [jsonState, readOnly, replaceSchema]);
const discardJson = React.useCallback(() => {
setJsonDraft(null);
}, []);
return (
<div className="flex min-h-[420px] flex-col gap-3">
<textarea
spellCheck={false}
readOnly={readOnly}
className="bg-muted/30 focus-visible:ring-ring min-h-0 flex-1 resize-none overflow-auto rounded-lg border p-3 font-mono text-xs outline-none focus-visible:ring-2"
value={jsonState.text}
onChange={(event) => handleJsonChange(event.target.value)}
/>
<div className="flex items-center justify-between gap-3">
<p className="text-destructive min-h-5 text-sm">
{jsonState.status === "dirty-invalid" ? jsonState.error : null}
</p>
{!readOnly && (
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={discardJson}
disabled={jsonState.status === "synced"}
>
Discard
</Button>
<Button
type="button"
size="sm"
onClick={applyJson}
disabled={jsonState.status !== "dirty-valid"}
>
Apply
</Button>
</div>
)}
</div>
</div>
);
}
components/schema-editor/optional/object-templates/object-template-menu.tsx
import { Shapes } from "lucide-react";
import {
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "@/components/ui/dropdown-menu";
import { getTemplateIcon } from "@/components/schema-editor/type-icons";
import { templateObjects } from "./template-objects";
export function ObjectTemplateSubmenu({
onSelectTemplate,
}: {
onSelectTemplate: (name: string) => void;
}) {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Shapes className="mr-4 h-4 w-4" />
object template
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{Object.entries(templateObjects).map(([name]) => (
<DropdownMenuItem
key={name}
className="flex items-center gap-2"
onClick={() => onSelectTemplate(name)}
>
{getTemplateIcon(name)}
{name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
}
components/schema-editor/optional/object-templates/object-template-reference.ts
import { nodeFromJson } from "@/components/schema-editor/document/convert";
import {
addDefinition,
setRef,
} from "@/components/schema-editor/document/definition-operations";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import {
objectTemplateDependencies,
templateObjects,
} from "./template-objects";
export function applyObjectTemplateReferenceToDocument(
doc: SchemaDocument,
nodeId: string,
templateName: string,
): SchemaDocument {
const next = addObjectTemplateDefinitionsToDocument(doc, templateName);
const targetDefinition = next.defs.find(
(definition) => definition.name === templateName,
);
return targetDefinition ? setRef(next, nodeId, targetDefinition.id) : next;
}
export function addObjectTemplateDefinitionsToDocument(
doc: SchemaDocument,
templateName: string,
): SchemaDocument {
const template = templateObjects[templateName];
if (!template) return doc;
// Dependencies must be added BEFORE the template that references them, so
// `nodeFromJson` can resolve the template's `$ref`s to a real definition id.
// Otherwise the reference is kept as a raw, unlinked `$ref` in `rest` and the
// field renders as "any" in the editor (the exported JSON stays correct, but
// the in-editor model is broken).
const defsToAdd = [
...(objectTemplateDependencies[templateName] ?? []),
templateName,
];
let next = doc;
for (const defName of defsToAdd) {
if (next.defs.some((definition) => definition.name === defName)) continue;
const templateNode = templateObjects[defName];
if (!templateNode) continue;
next = addDefinition(next, {
name: defName,
node: nodeFromJson(templateNode, next),
}).doc;
}
return next;
}
components/schema-editor/optional/object-templates/template-objects.ts
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
export const objectTemplateDependencies: Record<string, string[]> = {
Company: ["Address"],
};
export const templateObjects: Record<string, ExtendedJSONSchema7> = {
Address: {
description:
"A normalized postal address (Schema.org / UBL / FHIR compatible).",
properties: {
street_address: {
description:
"Full address line: house number, street name, apt/suite, building, floor, company, ...",
examples: ["221B Baker Street, Flat B, 10th Floor, Acme Inc."],
title: "Street Address",
type: "string",
},
city: {
examples: ["London"],
title: "City",
type: "string",
},
region: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
default: null,
description: "State / province / region.",
examples: ["Greater London"],
title: "Region",
},
postal_code: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
default: null,
description: "ZIP / postal code.",
examples: ["NW1 6XE"],
title: "Postal Code",
},
country: {
examples: ["GB"],
maxLength: 2,
minLength: 2,
pattern: "^[A-Z]{2}$",
title: "Country",
type: "string",
},
},
required: ["street_address", "city", "country"],
title: "Address",
type: "object",
},
Price: {
description:
"Monetary value tied to a specific ISO-4217 currency.\n\n`amount` is stored in the major unit of the currency (e.g. dollars, euros).",
properties: {
amount: {
anyOf: [
{
type: "number",
},
{
type: "string",
},
],
examples: ["19.99"],
title: "Amount",
},
currency: {
examples: ["USD"],
maxLength: 3,
minLength: 3,
pattern: "^[A-Z]{3}$",
title: "Currency",
type: "string",
},
},
required: ["amount", "currency"],
title: "Price",
type: "object",
},
Person: {
description: "A natural person with contact information.",
properties: {
first_name: {
examples: ["Ada"],
title: "First Name",
type: "string",
},
last_name: {
examples: ["Lovelace"],
title: "Last Name",
type: "string",
},
middle_name: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
default: null,
examples: ["King"],
title: "Middle Name",
},
email: {
anyOf: [
{
pattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$",
type: "string",
},
{
type: "null",
},
],
default: null,
examples: ["ada@example.com"],
title: "Email",
},
phone: {
anyOf: [
{
pattern: "^\\+?[0-9 .\\-()]{7,25}$",
type: "string",
},
{
type: "null",
},
],
default: null,
examples: ["+1-555-0100"],
title: "Phone",
},
},
required: ["first_name", "last_name"],
title: "Person",
type: "object",
},
Company: {
$defs: {
Address: {
description:
"A normalized postal address (Schema.org / UBL / FHIR compatible).",
properties: {
street_address: {
description:
"Full address line: house number, street name, apt/suite, building, floor, company, ...",
examples: ["221B Baker Street, Flat B, 10th Floor, Acme Inc."],
title: "Street Address",
type: "string",
},
city: {
examples: ["London"],
title: "City",
type: "string",
},
region: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
default: null,
description: "State / province / region.",
examples: ["Greater London"],
title: "Region",
},
postal_code: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
default: null,
description: "ZIP / postal code.",
examples: ["NW1 6XE"],
title: "Postal Code",
},
country: {
examples: ["GB"],
maxLength: 2,
minLength: 2,
pattern: "^[A-Z]{2}$",
title: "Country",
type: "string",
},
},
required: ["street_address", "city", "country"],
title: "Address",
type: "object",
},
},
description: "A legal entity or organization.",
properties: {
name: {
description: "Registered legal name.",
examples: ["Example Corp."],
title: "Name",
type: "string",
},
address: {
$ref: "#/$defs/Address",
},
phone: {
anyOf: [
{
pattern: "^\\+?[0-9 .\\-()]{7,25}$",
type: "string",
},
{
type: "null",
},
],
default: null,
examples: ["+1 415 555 0199"],
title: "Phone",
},
email: {
anyOf: [
{
pattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$",
type: "string",
},
{
type: "null",
},
],
default: null,
examples: ["info@example.com"],
title: "Email",
},
website: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
default: null,
description: "Company website URL.",
examples: ["https://example.com"],
title: "Website",
},
},
required: ["name", "address"],
title: "Company",
type: "object",
},
Event: {
description:
"An occurrence at a certain date and place, lasting from a start time to an end time.",
properties: {
title: {
examples: ["Board Meeting"],
title: "Title",
type: "string",
},
date: {
examples: ["2025-08-15"],
format: "date",
title: "Date",
type: "string",
},
start_time: {
examples: ["09:00"],
format: "time",
title: "Start Time",
type: "string",
},
end_time: {
examples: ["11:30"],
format: "time",
title: "End Time",
type: "string",
},
description: {
examples: ["Quarterly results call for the company"],
title: "Description",
type: "string",
},
},
required: ["title", "date", "start_time", "end_time", "description"],
title: "Event",
type: "object",
},
};
components/schema-editor/primitives/schema-add-input-model.ts
export interface SchemaAddInputModel {
error?: string | null;
focusAfterSubmit?: boolean;
inputLabel: string;
placeholder: string;
submitLabel: string;
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
}
components/schema-editor/primitives/schema-add-row.tsx
"use client";
import { AlertCircle, PlusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { SchemaAddInputModel } from "@/components/schema-editor/primitives/schema-add-input-model";
interface SchemaAddRowProps extends SchemaAddInputModel {
className?: string;
disabled: boolean;
}
export function SchemaAddRow({
className,
disabled,
error,
inputLabel,
placeholder,
submitLabel,
value,
onChange,
onSubmit,
}: SchemaAddRowProps) {
const isSubmitDisabled = disabled || !value.trim() || Boolean(error);
return (
<div
data-slot="schema-add-row"
className={cn("flex flex-col gap-1", className)}
>
<div className="flex items-center gap-3">
<Input
aria-label={inputLabel}
placeholder={placeholder}
disabled={disabled}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (!isSubmitDisabled) onSubmit();
}
}}
className={`h-8 w-40 ${error ? "border-destructive" : ""}`}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={isSubmitDisabled}
className={isSubmitDisabled ? "cursor-not-allowed" : ""}
onClick={onSubmit}
>
<PlusIcon className="h-4 w-4" />
<span>{submitLabel}</span>
</Button>
</div>
{error && (
<p className="text-destructive flex items-center gap-1 text-xs">
<AlertCircle className="h-3 w-3" /> {error}
</p>
)}
</div>
);
}
components/schema-editor/primitives/schema-chip-add-row.tsx
"use client";
import * as React from "react";
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { SchemaAddInputModel } from "@/components/schema-editor/primitives/schema-add-input-model";
export interface SchemaChipAddRowProps {
addInput: SchemaAddInputModel;
editable: boolean;
}
export function SchemaChipAddRow({
addInput,
editable,
}: SchemaChipAddRowProps) {
const addInputRef = React.useRef<HTMLInputElement>(null);
const submitAddRow = () => {
if (!addInput.value.trim()) return;
addInput.onSubmit();
if (addInput.focusAfterSubmit) {
addInputRef.current?.focus();
}
};
return (
<div className="flex items-center gap-2">
<Input
aria-label={addInput.inputLabel}
ref={addInputRef}
disabled={!editable}
placeholder={addInput.placeholder}
value={addInput.value}
onChange={(event) => addInput.onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
submitAddRow();
}
}}
className="w-40"
/>
<Button
disabled={!editable || !addInput.value.trim()}
type="button"
variant="outline"
size="sm"
onClick={submitAddRow}
>
<PlusIcon className="mr-1 h-4 w-4" />
{addInput.submitLabel}
</Button>
</div>
);
}
components/schema-editor/primitives/schema-chip-list.tsx
"use client";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
export interface SchemaChipItem {
id: string;
inputLabel: string;
removeLabel: string;
value: string;
}
export interface SchemaChipListProps {
editable: boolean;
items: SchemaChipItem[];
onRemove: (id: string) => void;
onReplace: (id: string, value: string) => void;
}
export function SchemaChipList({
editable,
items,
onRemove,
onReplace,
}: SchemaChipListProps) {
return (
<div data-slot="schema-chip-list" className="space-y-2">
{items.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-2">
{items.map((item) => {
return (
<div
key={item.id}
data-slot="schema-chip"
className="border-border bg-muted flex items-center gap-1 rounded-md border px-1 shadow-none"
>
<input
aria-label={item.inputLabel}
data-slot="schema-chip-input"
disabled={!editable}
value={item.value}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onReplace(item.id, event.target.value);
}}
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.stopPropagation();
}
}}
className="h-6 w-24 min-w-0 rounded-[inherit] border-0 bg-transparent px-1 text-sm leading-6 shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-64"
/>
<Button
type="button"
disabled={!editable}
variant="ghost"
size="icon"
className="h-6 w-6 rounded-sm border-0 bg-transparent p-0 shadow-none hover:bg-transparent data-pressed:bg-transparent"
aria-label={item.removeLabel}
onClick={() => onRemove(item.id)}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
) : null}
</div>
);
}
components/schema-editor/primitives/schema-field-row.tsx
"use client";
import type * as React from "react";
import { cn } from "@/lib/utils";
import {
SchemaRowGrip,
type SchemaRowGripMode,
} from "@/components/schema-editor/primitives/schema-row-grip";
interface SchemaFieldRowProps {
id?: string;
grip: SchemaRowGripMode;
name: React.ReactNode;
description: React.ReactNode;
actions?: React.ReactNode;
type: React.ReactNode;
children?: React.ReactNode;
className?: string;
bodyClassName?: string;
}
export function SchemaFieldRow({
id,
grip,
name,
description,
actions,
type,
children,
className,
bodyClassName,
}: SchemaFieldRowProps) {
return (
<div
id={id}
data-slot="schema-field-row"
className={cn("group/row", className)}
>
<div className="hover:bg-accent flex min-h-12 flex-col items-start justify-between py-0 sm:flex-row sm:items-center">
<SchemaRowGrip mode={grip} />
<div className="flex min-w-0 flex-1 items-center space-x-2">
{name}
<div className="flex min-w-0 flex-1 items-center gap-1">
{description}
</div>
</div>
<div className="flex items-center gap-2 pr-1">
{actions}
{type}
</div>
</div>
{children ? <div className={bodyClassName}>{children}</div> : null}
</div>
);
}
components/schema-editor/primitives/schema-inline-description.tsx
"use client";
import * as React from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { SchemaInlineText } from "@/components/schema-editor/primitives/schema-inline-text";
interface SchemaInlineDescriptionProps {
ariaLabel: string;
editable: boolean;
value: string;
placeholder?: string;
onOpenDetails?: () => void;
onCommit: (value: string) => void;
}
export function SchemaInlineDescription({
ariaLabel,
editable,
value,
placeholder = "Add description",
onOpenDetails,
onCommit,
}: SchemaInlineDescriptionProps) {
const [tooltipOpen, setTooltipOpen] = React.useState(false);
const [inputFocused, setInputFocused] = React.useState(false);
const description = (
<SchemaInlineText
ariaLabel={ariaLabel}
editable={editable}
value={value}
placeholder={placeholder}
className="text-muted-foreground placeholder:text-muted-foreground/70 hover:bg-accent hover:text-foreground focus:text-foreground m-0 h-6 min-w-[140px] flex-1 cursor-text rounded-sm border-none bg-transparent px-1 !text-xs leading-6 shadow-none outline-none focus-visible:ring-0"
readOnlyClassName="flex h-6 min-w-[140px] flex-1 items-center truncate rounded-sm px-1 !text-xs text-muted-foreground"
onOpenReadOnly={onOpenDetails}
onCommit={onCommit}
/>
);
if (!value) return description;
return (
<Tooltip open={tooltipOpen && !inputFocused} onOpenChange={setTooltipOpen}>
{/* SchemaInlineText drops unknown props, so the trigger needs a real DOM element */}
<TooltipTrigger
asChild
onFocusCapture={() => setInputFocused(true)}
onBlurCapture={() => setInputFocused(false)}
>
<span className="flex min-w-0 flex-1 items-center">{description}</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<div className="text-muted-foreground mb-1 text-xs">Description:</div>
<div className="text-xs">{value}</div>
</TooltipContent>
</Tooltip>
);
}
components/schema-editor/primitives/schema-inline-name.tsx
"use client";
import { EyeIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SchemaInlineText } from "@/components/schema-editor/primitives/schema-inline-text";
interface SchemaInlineNameProps {
ariaLabel: string;
editable: boolean;
value: string;
canRename?: boolean;
reference?: {
label: string;
onReveal: () => void;
};
validate?: (value: string) => string | null;
onCommit?: (value: string) => void;
}
export function SchemaInlineName({
ariaLabel,
editable,
value,
canRename = true,
reference,
validate = () => null,
onCommit,
}: SchemaInlineNameProps) {
const canEditName = editable && canRename;
return (
<span className="flex min-w-0 items-center">
<SchemaInlineText
ariaLabel={ariaLabel}
editable={canEditName}
value={value}
className="text-foreground m-0 h-6 w-36 rounded-sm border-none bg-transparent px-1 text-sm font-medium shadow-none outline-none focus-visible:ring-0"
readOnlyClassName="mr-1 truncate text-sm font-medium whitespace-nowrap text-foreground"
validate={validate}
onCommit={(nextValue) => {
if (nextValue) onCommit?.(nextValue);
}}
/>
{reference && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-4 w-4 p-0"
aria-label={`Show ${reference.label} definition`}
onClick={reference.onReveal}
>
<EyeIcon className="text-muted-foreground h-4 w-4" />
</Button>
)}
</span>
);
}
components/schema-editor/primitives/schema-inline-text.tsx
"use client";
import * as React from "react";
import { AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { useKeyedMountEffect } from "@/hooks/use-keyed-mount-effect";
import { joinEffectKey } from "@/lib/effect-key";
interface SchemaInlineTextProps {
ariaLabel: string;
editable: boolean;
value: string;
className?: string;
errorClassName?: string;
placeholder?: string;
readOnlyClassName?: string;
trimOnCommit?: boolean;
validate?: (value: string) => string | null;
onCommit: (value: string) => void;
onOpenReadOnly?: () => void;
}
export function SchemaInlineText({
ariaLabel,
editable,
value,
className,
errorClassName,
placeholder,
readOnlyClassName,
trimOnCommit = true,
validate = () => null,
onCommit,
onOpenReadOnly,
}: SchemaInlineTextProps) {
const isFocusedRef = React.useRef(false);
const draftValueRef = React.useRef(value);
const [inputResetVersion, setInputResetVersion] = React.useState(0);
const [error, setError] = React.useState<string | null>(null);
useKeyedMountEffect(joinEffectKey([value]), () => {
if (isFocusedRef.current || draftValueRef.current === value) return;
draftValueRef.current = value;
setInputResetVersion((version) => version + 1);
});
const normalizeValue = (nextValue: string) =>
trimOnCommit ? nextValue.trim() : nextValue;
const commitValue = (input: HTMLInputElement) => {
const nextValue = normalizeValue(input.value);
const nextError = validate(nextValue);
if (nextError) {
setError(nextError);
return false;
}
setError(null);
isFocusedRef.current = false;
draftValueRef.current = nextValue;
input.value = nextValue;
if (nextValue !== normalizeValue(value)) {
onCommit(nextValue);
}
return true;
};
if (!editable) {
return (
<span className={readOnlyClassName} onClick={onOpenReadOnly}>
{value || (
<span className="text-muted-foreground/70">{placeholder}</span>
)}
</span>
);
}
return (
<span className="relative flex min-w-0 items-center">
<input
key={inputResetVersion}
aria-label={ariaLabel}
aria-invalid={Boolean(error)}
className={className}
data-slot="schema-inline-input"
placeholder={placeholder}
defaultValue={value}
onFocus={() => {
isFocusedRef.current = true;
}}
onChange={(event) => {
const nextValue = event.target.value;
draftValueRef.current = nextValue;
setError(nextValue ? validate(normalizeValue(nextValue)) : null);
}}
onBlur={(event) => {
commitValue(event.currentTarget);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (commitValue(event.currentTarget)) event.currentTarget.blur();
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
draftValueRef.current = value;
event.currentTarget.value = value;
setError(null);
isFocusedRef.current = false;
event.currentTarget.blur();
}
}}
/>
{error && (
<p
className={cn(
"bg-background text-destructive absolute top-7 left-1 z-10 flex min-w-56 items-center gap-1 rounded-sm border px-2 py-1 text-xs shadow-sm",
errorClassName,
)}
>
<AlertCircle className="h-3 w-3" /> {error}
</p>
)}
</span>
);
}
components/schema-editor/primitives/schema-order.ts
export function moveOrderedItem<T>({
items,
sourceIndex,
targetIndex,
}: {
items: readonly T[];
sourceIndex: number;
targetIndex: number;
}): T[] {
const nextItems = items.slice();
if (sourceIndex < 0 || sourceIndex >= nextItems.length) return nextItems;
const [movedItem] = nextItems.splice(sourceIndex, 1);
const clampedTargetIndex = Math.max(
0,
Math.min(targetIndex, nextItems.length),
);
nextItems.splice(clampedTargetIndex, 0, movedItem);
return nextItems;
}
components/schema-editor/primitives/schema-row-actions.tsx
"use client";
import { Eye, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface SchemaRowActionsProps {
canDelete: boolean;
deleteLabel?: string;
details?: {
label: string;
mode: "edit" | "view";
onOpen: () => void;
};
editable: boolean;
onDelete?: () => void;
}
export function SchemaRowActions({
canDelete,
deleteLabel = "Delete field",
details,
editable,
onDelete,
}: SchemaRowActionsProps) {
return (
<>
{editable && canDelete && (
<Button
type="button"
variant="ghost"
size="iconSm"
className="m-0 shrink-0 p-0"
aria-label={deleteLabel}
onClick={onDelete}
>
<Trash2 className="text-primary-foreground group-hover/row:text-muted-foreground size-4" />
</Button>
)}
{details && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="iconSm"
className="m-0 shrink-0 p-0"
aria-label={details.label}
onClick={details.onOpen}
>
{details.mode === "edit" ? (
<Pencil className="text-muted-foreground size-4 opacity-0 group-hover/row:opacity-100" />
) : (
<Eye className="text-muted-foreground size-4 opacity-0 group-hover/row:opacity-100" />
)}
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>{details.label}</p>
</TooltipContent>
</Tooltip>
)}
</>
);
}
components/schema-editor/primitives/schema-row-drag.ts
import type * as React from "react";
export interface SchemaRowDragItem {
id: string;
label: string;
}
export type SchemaRowDropPlacement = "before" | "after";
export type SchemaRowDropIndicator = SchemaRowDropPlacement | null;
interface SchemaRowDropTargetRect {
height: number;
top: number;
}
const ROW_DRAG_FORMAT = "text/plain";
const DROP_CLASSES = [
"border-t-2",
"border-b-2",
"border-grey-700",
"border-dashed",
] as const;
export function getSchemaRowDropPlacement({
clientY,
targetRect,
}: {
clientY: number;
targetRect: SchemaRowDropTargetRect;
}): SchemaRowDropPlacement {
return clientY < targetRect.top + targetRect.height / 2 ? "before" : "after";
}
export function getSchemaRowDropClasses(
indicator: SchemaRowDropIndicator,
): string[] {
if (!indicator) return [];
return [
indicator === "before" ? "border-t-2" : "border-b-2",
"border-grey-700",
"border-dashed",
];
}
export function clearSchemaRowDropClasses(element: HTMLElement) {
element.classList.remove(...DROP_CLASSES);
}
export function applySchemaRowDropClasses(
element: HTMLElement,
indicator: SchemaRowDropIndicator,
) {
// Native dragover fires continuously; keep the drop indicator out of React state.
clearSchemaRowDropClasses(element);
const classes = getSchemaRowDropClasses(indicator);
if (classes.length) element.classList.add(...classes);
}
export function getSchemaRowDropTargetIndex({
placement,
rowIds,
sourceRowId,
targetRowId,
}: {
placement: SchemaRowDropPlacement;
rowIds: string[];
sourceRowId: string;
targetRowId: string;
}) {
const sourceIndex = rowIds.indexOf(sourceRowId);
if (sourceIndex < 0 || sourceRowId === targetRowId) return -1;
const remainingRowIds = rowIds.filter((rowId) => rowId !== sourceRowId);
const targetIndex = remainingRowIds.indexOf(targetRowId);
if (targetIndex < 0) return -1;
return placement === "after" ? targetIndex + 1 : targetIndex;
}
export function beginSchemaRowDrag({
event,
item,
draggedRowIdRef,
}: {
event: React.DragEvent<HTMLElement>;
item: SchemaRowDragItem;
draggedRowIdRef: React.RefObject<string | null>;
}) {
event.stopPropagation();
event.dataTransfer.setData(ROW_DRAG_FORMAT, item.id);
event.dataTransfer.effectAllowed = "move";
draggedRowIdRef.current = item.id;
const dragElement = createSchemaRowDragPreview({
sourceElement: event.currentTarget,
label: item.label,
});
event.dataTransfer.setDragImage(dragElement, 10, 10);
removeSchemaRowDragPreviewAfterFrame(dragElement);
}
export function updateSchemaRowDragTarget({
event,
rowIds,
targetRowId,
draggedRowIdRef,
}: {
event: React.DragEvent<HTMLElement>;
rowIds: string[];
targetRowId: string;
draggedRowIdRef: React.RefObject<string | null>;
}) {
event.preventDefault();
const indicator =
draggedRowIdRef.current &&
draggedRowIdRef.current !== targetRowId &&
rowIds.includes(draggedRowIdRef.current) &&
rowIds.includes(targetRowId)
? getSchemaRowDropPlacement({
clientY: event.clientY,
targetRect: event.currentTarget.getBoundingClientRect(),
})
: null;
event.dataTransfer.dropEffect = "move";
applySchemaRowDropClasses(event.currentTarget, indicator);
}
export function leaveSchemaRowDragTarget(
event: Pick<
React.DragEvent<HTMLElement>,
"currentTarget" | "stopPropagation"
>,
) {
event.stopPropagation();
clearSchemaRowDropClasses(event.currentTarget);
}
export function resolveSchemaRowDrop({
event,
rowIds,
targetRowId,
draggedRowIdRef,
}: {
event: React.DragEvent<HTMLElement>;
rowIds: string[];
targetRowId: string;
draggedRowIdRef: React.RefObject<string | null>;
}): {
placement: SchemaRowDropPlacement;
sourceRowId: string;
targetRowId: string;
targetIndex: number;
} | null {
event.stopPropagation();
event.preventDefault();
const sourceRowId = event.dataTransfer.getData(ROW_DRAG_FORMAT);
clearSchemaRowDropClasses(event.currentTarget);
if (
!sourceRowId ||
sourceRowId === targetRowId ||
draggedRowIdRef.current !== sourceRowId ||
!rowIds.includes(sourceRowId) ||
!rowIds.includes(targetRowId)
) {
return null;
}
const placement = getSchemaRowDropPlacement({
clientY: event.clientY,
targetRect: event.currentTarget.getBoundingClientRect(),
});
const targetIndex = getSchemaRowDropTargetIndex({
placement,
rowIds,
sourceRowId,
targetRowId,
});
if (targetIndex < 0) return null;
return {
placement,
sourceRowId,
targetRowId,
targetIndex,
};
}
function createSchemaRowDragPreview({
sourceElement,
label,
}: {
sourceElement: HTMLElement;
label: string;
}) {
const dragElement = document.createElement("div");
const rect = sourceElement.getBoundingClientRect();
Object.assign(dragElement.style, {
width: `${rect.width}px`,
padding: "8px",
border: "1px solid var(--ring)",
borderRadius: "4px",
backgroundColor: "var(--background)",
boxShadow: "0 2px 5px rgba(0,0,0,0.1)",
opacity: "0.8",
position: "fixed",
zIndex: "9999",
pointerEvents: "none",
});
dragElement.textContent = label;
document.body.appendChild(dragElement);
return dragElement;
}
function removeSchemaRowDragPreviewAfterFrame(dragElement: HTMLElement) {
window.requestAnimationFrame(() => {
dragElement.remove();
});
}
components/schema-editor/primitives/schema-row-grip.tsx
"use client";
import { GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
export type SchemaRowGripMode = "drag" | "static" | "empty";
interface SchemaRowGripProps {
mode: SchemaRowGripMode;
className?: string;
}
export function SchemaRowGrip({ mode, className }: SchemaRowGripProps) {
if (mode === "empty") {
return <div className={cn("h-12 w-6 px-1 py-4", className)} />;
}
return (
<GripVertical
aria-hidden="true"
className={cn(
"h-12 w-6 px-1 py-4",
mode === "drag"
? "group-hover/row:text-muted-foreground cursor-grab text-transparent"
: "text-muted-foreground",
className,
)}
/>
);
}
components/schema-editor/primitives/schema-type-menu.tsx
"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export type SchemaTypeMenuVariant = "row" | "form";
export interface SchemaTypeMenuValue {
id: string;
label: string;
icon: React.ReactNode;
}
export interface SchemaTypeMenuItem {
id: string;
label: string;
icon?: React.ReactNode;
closeOnSelect?: boolean;
onSelect: () => void;
}
export type SchemaTypeMenuSection =
| {
id: string;
kind: "items";
items: SchemaTypeMenuItem[];
}
| {
id: string;
kind: "submenu";
label: string;
icon?: React.ReactNode;
items: SchemaTypeMenuItem[];
};
export type SchemaTypeMenuTrailingContent = (context: {
editable: boolean;
}) => React.ReactNode;
interface SchemaTypeMenuProps {
ariaLabel?: string;
editable: boolean;
sections: SchemaTypeMenuSection[];
trailingContent?: SchemaTypeMenuTrailingContent;
value: SchemaTypeMenuValue;
variant: SchemaTypeMenuVariant;
}
export function SchemaTypeMenu({
ariaLabel,
editable,
sections,
trailingContent,
value,
variant,
}: SchemaTypeMenuProps) {
const runMenuAction = (event: Event, item: SchemaTypeMenuItem) => {
if (!editable) {
event.preventDefault();
return;
}
if (item.closeOnSelect === false) {
event.preventDefault();
}
item.onSelect();
};
if (!editable && variant === "row") {
return <div className="ml-4 w-40 text-xs">{value.label || "string"}</div>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={ariaLabel ?? (variant === "row" ? value.id : undefined)}
disabled={!editable}
variant={variant === "row" ? "ghost" : "outline"}
className={
variant === "row"
? "text-muted-foreground w-40 justify-between pr-1 pl-2 text-xs font-normal"
: "mt-2 w-full justify-between pr-1 pl-2"
}
>
<div
className={
variant === "row"
? "flex min-w-0 items-center gap-2"
: "flex items-center gap-2"
}
>
{value.icon}
<span className={variant === "row" ? "truncate" : ""}>
{value.label}
</span>
</div>
<ChevronDown
className={
variant === "row"
? "text-muted-foreground mr-1! h-4 w-4 shrink-0 opacity-0 group-hover/row:opacity-100"
: "mr-1! h-4 w-4"
}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className={variant === "form" ? "w-full" : ""}>
{sections.map((section) => {
if (section.kind === "submenu") {
return (
<DropdownMenuSub key={section.id}>
<DropdownMenuSubTrigger>
{section.icon}
{section.label}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{section.items.map((item) => (
<SchemaTypeMenuDropdownItem
key={item.id}
editable={editable}
item={item}
onSelect={runMenuAction}
/>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
}
return section.items.map((item) => (
<SchemaTypeMenuDropdownItem
key={item.id}
editable={editable}
item={item}
onSelect={runMenuAction}
/>
));
})}
{trailingContent?.({ editable })}
</DropdownMenuContent>
</DropdownMenu>
);
}
function SchemaTypeMenuDropdownItem({
editable,
item,
onSelect,
}: {
editable: boolean;
item: SchemaTypeMenuItem;
onSelect: (event: Event, item: SchemaTypeMenuItem) => void;
}) {
return (
<DropdownMenuItem
disabled={!editable}
onSelect={(event) => onSelect(event, item)}
>
{item.icon}
{item.label}
</DropdownMenuItem>
);
}
components/schema-editor/primitives/schema-type-options.ts
"use client";
import type * as React from "react";
import {
getTemplateIcon,
getTypeIcon,
} from "@/components/schema-editor/type-icons";
export type SchemaTypeOptionId =
| "string"
| "number"
| "integer"
| "boolean"
| "enum"
| "object"
| "array"
| "date"
| "time"
| "datetime";
export interface SchemaTypeOption {
id: SchemaTypeOptionId;
label: string;
icon: React.ReactNode;
}
const schemaTypeOptionLabels: Array<[SchemaTypeOptionId, string]> = [
["string", "string"],
["number", "number"],
["integer", "integer"],
["boolean", "true/false"],
["enum", "multiple choice"],
["object", "object"],
["array", "list"],
["date", "date"],
["time", "time"],
["datetime", "timestamp"],
];
export const schemaTypeOptions: SchemaTypeOption[] = schemaTypeOptionLabels.map(
([id, label]) => ({
id,
label,
icon: getTypeIcon(id),
}),
);
export function schemaTypeLabel(type: string, refName?: string) {
if (type === "$ref" && refName) return refName;
if (type === "boolean") return "true/false";
if (type === "enum") return "multiple choice";
if (type === "array") return "list";
if (type === "datetime") return "timestamp";
return type || "Select type";
}
export function schemaTypeIcon(
type: string,
refName?: string,
): React.ReactNode {
if (type === "$ref" && refName) return getTemplateIcon(refName);
return getTypeIcon(type);
}
components/schema-editor/property-form/fields/array-items-field.tsx
"use client";
import type * as React from "react";
import { Label } from "@/components/ui/label";
export function ArrayItemsField({ children }: { children: React.ReactNode }) {
return (
<div className="space-y-3 rounded-md border p-3">
<Label className="text-muted-foreground text-xs">List item type</Label>
{children}
</div>
);
}
components/schema-editor/property-form/fields/description-field.tsx
"use client";
import { FormItem } from "@/components/ui/form";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export function DescriptionField({
value,
disabled,
onChange,
}: {
value: string;
disabled: boolean;
onChange: (description: string) => void;
}) {
return (
<FormItem className="group">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
className={disabled ? "disabled:opacity-100" : ""}
/>
</FormItem>
);
}
components/schema-editor/property-form/fields/enum-value-identity.ts
"use client";
import * as React from "react";
import type { JSONSchema7Type } from "json-schema";
export function useEnumValueIdentity({
resetKey,
values,
}: {
resetKey: string;
values: JSONSchema7Type[];
}) {
const resetKeyRef = React.useRef(resetKey);
const nextIdRef = React.useRef(0);
const idsRef = React.useRef<string[]>([]);
if (resetKeyRef.current !== resetKey) {
resetKeyRef.current = resetKey;
nextIdRef.current = 0;
idsRef.current = [];
}
while (idsRef.current.length < values.length) {
idsRef.current.push(createEnumValueId(nextIdRef.current));
nextIdRef.current += 1;
}
if (idsRef.current.length > values.length) {
idsRef.current.length = values.length;
}
return {
ids: idsRef.current,
removeId: (id: string) => {
const index = idsRef.current.indexOf(id);
if (index >= 0) {
idsRef.current.splice(index, 1);
}
},
};
}
function createEnumValueId(sequence: number) {
return `enum-value-${sequence}`;
}
components/schema-editor/property-form/fields/enum-values-field.tsx
"use client";
import * as React from "react";
import type { JSONSchema7Type } from "json-schema";
import { Label } from "@/components/ui/label";
import { SchemaChipAddRow } from "@/components/schema-editor/primitives/schema-chip-add-row";
import { SchemaChipList } from "@/components/schema-editor/primitives/schema-chip-list";
import {
formatEnumValueInput,
parseEnumValueInput,
} from "@/components/schema-editor/property-form/model/enum-values";
import { useEnumValueIdentity } from "./enum-value-identity";
export function EnumValuesField({
values,
resetKey,
disabled,
onChange,
}: {
values: JSONSchema7Type[];
resetKey: string;
disabled: boolean;
onChange: (values: JSONSchema7Type[]) => void;
}) {
const [nextValueState, setNextValueState] = React.useState({
resetKey,
value: "",
});
const nextValue =
nextValueState.resetKey === resetKey ? nextValueState.value : "";
const setNextValue = React.useCallback(
(value: string) => setNextValueState({ resetKey, value }),
[resetKey],
);
const valueIdentity = useEnumValueIdentity({ resetKey, values });
const items = values.map((value, index) => {
const inputValue = formatEnumValueInput(value);
return {
id: valueIdentity.ids[index],
inputLabel: `Option ${index + 1}: ${inputValue || "empty"}`,
removeLabel: `Remove option ${inputValue}`,
value: inputValue,
};
});
const indexFromId = (id: string) => valueIdentity.ids.indexOf(id);
const addInput = {
inputLabel: "Add new value",
placeholder: "Add new value",
submitLabel: "Add",
value: nextValue,
onChange: setNextValue,
onSubmit: () => {
onChange([...values, parseEnumValueInput(nextValue)]);
setNextValueState({ resetKey, value: "" });
},
};
return (
<div className="space-y-2">
<Label className="text-muted-foreground text-xs">Enabled options</Label>
<SchemaChipList
editable={!disabled}
items={items}
onRemove={(id) => {
const index = indexFromId(id);
valueIdentity.removeId(id);
onChange(values.filter((_value, current) => current !== index));
}}
onReplace={(id, value) => {
const index = indexFromId(id);
const nextValues = values.slice();
nextValues[index] = parseEnumValueInput(value);
onChange(nextValues);
}}
/>
<SchemaChipAddRow addInput={addInput} editable={!disabled} />
</div>
);
}
components/schema-editor/property-form/fields/name-field.tsx
"use client";
import { AlertCircle } from "lucide-react";
import type { FieldValidation } from "@/components/schema-editor/property-form/types";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function NameField({
value,
disabled,
validation,
onChange,
}: {
value: string;
disabled: boolean;
validation: FieldValidation;
onChange: (name: string) => void;
}) {
const message =
validation.status === "invalid" ? validation.message : undefined;
return (
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
disabled={disabled}
value={value}
onChange={(event) => onChange(event.target.value)}
className={`${message ? "border-destructive" : ""} ${disabled ? "disabled:opacity-100" : ""}`}
placeholder="e.g. first_name or firstName"
required
aria-invalid={Boolean(message)}
/>
{message && (
<p className="text-destructive mt-1 flex items-center gap-1 text-sm font-medium">
<AlertCircle className="h-3 w-3" />
{message}
</p>
)}
</div>
);
}
components/schema-editor/property-form/fields/nullable-field.tsx
"use client";
import { FormItem } from "@/components/ui/form";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function NullableField({
checked,
disabled,
onChange,
}: {
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<FormItem className="flex flex-row items-center space-y-0 space-x-2">
<Switch
id="nullable"
disabled={disabled}
checked={checked}
onCheckedChange={onChange}
className={disabled ? "disabled:opacity-100" : ""}
/>
<Tooltip>
<TooltipTrigger asChild>
<Label htmlFor="nullable" className="cursor-pointer">
Nullable
</Label>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>
Nullable fields allow <code>null</code> as a value (the type is
widened to include <code>null</code>).
</p>
</TooltipContent>
</Tooltip>
</FormItem>
);
}
components/schema-editor/property-form/fields/object-properties-drag.ts
"use client";
import * as React from "react";
import {
beginSchemaRowDrag,
leaveSchemaRowDragTarget,
resolveSchemaRowDrop,
updateSchemaRowDragTarget,
} from "@/components/schema-editor/primitives/schema-row-drag";
import type { ObjectPropertyRowModel } from "@/components/schema-editor/property-form/model/object-properties-view";
export function useObjectPropertiesRowDrag({
rows,
editable,
}: {
rows: ObjectPropertyRowModel[];
editable: boolean;
}) {
const draggedRowIdRef = React.useRef<string | null>(null);
const rowIds = rows.map((row) => row.id);
const getRowDragProps = (row: ObjectPropertyRowModel) => {
if (!editable) {
return {
draggable: false,
onDragStart: noopDragHandler,
onDragOver: noopDragHandler,
onDragLeave: noopDragHandler,
onDrop: noopDragHandler,
};
}
return {
draggable: true,
onDragStart: (event: React.DragEvent<HTMLDivElement>) => {
beginSchemaRowDrag({
event,
item: {
id: row.id,
label: row.name,
},
draggedRowIdRef,
});
},
onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
updateSchemaRowDragTarget({
event,
rowIds,
targetRowId: row.id,
draggedRowIdRef,
});
},
onDragLeave: leaveSchemaRowDragTarget,
onDrop: (event: React.DragEvent<HTMLDivElement>) => {
const move = resolveSchemaRowDrop({
event,
rowIds,
targetRowId: row.id,
draggedRowIdRef,
});
if (!move) return;
const sourceRow = rows.find(
(candidate) => candidate.id === move.sourceRowId,
);
sourceRow?.reorder.move(move.targetIndex);
},
};
};
return {
rowIds,
getRowDragProps,
};
}
function noopDragHandler() {}
components/schema-editor/property-form/fields/object-properties-field.tsx
"use client";
import type * as React from "react";
import { SchemaAddRow } from "@/components/schema-editor/primitives/schema-add-row";
import type { PropertyObjectPropertiesFieldModel } from "@/components/schema-editor/property-form/model/object-properties-view";
import type { PropertySchemaPlan } from "@/components/schema-editor/property-form/types";
import { ObjectPropertyRows } from "./object-property-row";
export function ObjectPropertiesField({
model,
renderPlan,
}: {
model: PropertyObjectPropertiesFieldModel;
renderPlan: (plan: PropertySchemaPlan) => React.ReactNode;
}) {
return (
<>
<ObjectPropertyRows model={model} renderPlan={renderPlan} />
<SchemaAddRow
{...model.addInput}
className="border-border ml-4 border-l pl-4"
disabled={!model.editable}
/>
</>
);
}
components/schema-editor/property-form/fields/object-properties-model.ts
"use client";
import type { PropertyObjectPropertiesFieldModel } from "@/components/schema-editor/property-form/model/object-properties-view";
import type { PropertyObjectPropertiesPlan } from "@/components/schema-editor/property-form/types";
import { createObjectPropertyAddInput } from "./object-property-add-input";
import { createObjectPropertyOperations } from "./object-properties-operations";
import { createObjectPropertyRows } from "./object-properties-rows";
import { useObjectPropertiesState } from "./object-properties-state";
export function useObjectPropertiesModel(
plan: PropertyObjectPropertiesPlan,
): PropertyObjectPropertiesFieldModel {
const state = useObjectPropertiesState(plan);
const operations = createObjectPropertyOperations({
plan,
state,
});
const rows = createObjectPropertyRows({ operations, plan, state });
return {
addInput: createObjectPropertyAddInput({
onSubmit: operations.addProperty,
state: {
propertyNames: state.propertyNames,
value: state.addInputValue,
setValue: state.setAddInputValue,
},
}),
editable: plan.editable,
rows,
};
}
components/schema-editor/property-form/fields/object-properties-operations.ts
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { moveOrderedItem } from "@/components/schema-editor/primitives/schema-order";
import {
createObjectPropertySchema,
moveObjectProperty,
removeObjectProperty,
renameObjectProperty,
replaceObjectProperty,
} from "@/components/schema-editor/property-form/model/object-property-edits";
import type { PropertyObjectPropertiesPlan } from "@/components/schema-editor/property-form/types";
import type { ObjectPropertiesState } from "./object-properties-state";
export interface ObjectPropertyOperations {
addProperty: (propertyName: string) => void;
moveProperty: (input: {
propertyName: string;
sourceIndex: number;
targetIndex: number;
}) => void;
removeProperty: (propertyName: string) => void;
renameProperty: (oldName: string, newName: string) => void;
replacePropertySchemaNode: (
propertyName: string,
propertySchema: ExtendedJSONSchema7,
) => void;
}
export function createObjectPropertyOperations({
plan,
state,
}: {
plan: PropertyObjectPropertiesPlan;
state: ObjectPropertiesState;
}): ObjectPropertyOperations {
const replacePropertySchemaNode = (
propertyName: string,
propertySchema: ExtendedJSONSchema7,
) => {
plan.onChange(
replaceObjectProperty({
schemaNode: plan.schemaNode,
propertyName,
propertySchema,
}),
);
};
return {
addProperty: (propertyName) => {
state.rowIdentity.preserveAddRowForLocalPropertyNames([
...state.propertyNames,
propertyName,
]);
state.rowIdentity.addRowId(propertyName);
replacePropertySchemaNode(
propertyName,
createObjectPropertySchema(propertyName),
);
state.setAddInputValue("");
},
moveProperty: ({ propertyName, sourceIndex, targetIndex }) => {
state.rowIdentity.preserveAddRowForLocalPropertyNames(
moveOrderedItem({
items: state.propertyNames,
sourceIndex,
targetIndex,
}),
);
plan.onChange(
moveObjectProperty({
schemaNode: plan.schemaNode,
propertyName,
targetIndex,
}),
);
},
removeProperty: (propertyName) => {
state.rowIdentity.preserveAddRowForLocalPropertyNames(
state.propertyNames.filter((name) => name !== propertyName),
);
state.rowIdentity.removeRowId(propertyName);
plan.onChange(
removeObjectProperty({
schemaNode: plan.schemaNode,
propertyName,
}),
);
},
renameProperty: (oldName, newName) => {
state.rowIdentity.preserveAddRowForLocalPropertyNames(
state.propertyNames.map((propertyName) =>
propertyName === oldName ? newName : propertyName,
),
);
state.rowIdentity.renameRowId(oldName, newName);
plan.onChange(
renameObjectProperty({
schemaNode: plan.schemaNode,
oldName,
newName,
}),
);
},
replacePropertySchemaNode,
};
}
components/schema-editor/property-form/fields/object-properties-plan-field.tsx
"use client";
import type * as React from "react";
import { ObjectPropertiesField } from "@/components/schema-editor/property-form/fields/object-properties-field";
import { useObjectPropertiesModel } from "@/components/schema-editor/property-form/fields/object-properties-model";
import type {
PropertyObjectPropertiesPlan,
PropertySchemaPlan,
} from "@/components/schema-editor/property-form/types";
export function ObjectPropertiesPlanField({
plan,
renderPlan,
}: {
plan: PropertyObjectPropertiesPlan;
renderPlan: (plan: PropertySchemaPlan) => React.ReactNode;
}) {
const objectProperties = useObjectPropertiesModel(plan);
return (
<ObjectPropertiesField model={objectProperties} renderPlan={renderPlan} />
);
}
components/schema-editor/property-form/fields/object-properties-rows.ts
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { isSchemaNode } from "@/components/schema-editor/property-form/model/object-property-selectors";
import type { ObjectPropertyRowModel } from "@/components/schema-editor/property-form/model/object-properties-view";
import type { PropertyObjectPropertiesPlan } from "@/components/schema-editor/property-form/types";
import { validatePropertyFormName } from "@/components/schema-editor/property-form/validation";
import { createObjectPropertyRowSchemaPlan } from "./object-property-row-schema-plan";
import type { ObjectPropertyOperations } from "./object-properties-operations";
import type { ObjectPropertiesState } from "./object-properties-state";
import { createPropertyTypeFieldWithObjectTemplates } from "./property-object-template-type-field";
export function createObjectPropertyRows({
operations,
plan,
state,
}: {
operations: ObjectPropertyOperations;
plan: PropertyObjectPropertiesPlan;
state: ObjectPropertiesState;
}): ObjectPropertyRowModel[] {
return state.propertyNames.flatMap((name, index) => {
const propertySchema = plan.schemaNode.properties?.[name];
if (!isSchemaNode(propertySchema)) return [];
const id = state.rowIdentity.getRowId(name);
const replaceSchemaNode = (nextSchemaNode: ExtendedJSONSchema7) => {
operations.replacePropertySchemaNode(name, nextSchemaNode);
};
const rowSchemaContext = {
...plan.schemaContext,
siblingNames: state.propertyNames,
originalName: name,
fieldPath: [
plan.schemaContext.fieldPath ?? plan.schemaContext.originalName,
id,
].join("."),
resetKey: [
plan.schemaContext.resetKey ??
plan.schemaContext.fieldPath ??
plan.schemaContext.originalName,
id,
].join("."),
};
return [
{
id,
schemaPlan: createObjectPropertyRowSchemaPlan({
access: plan.access,
editable: plan.editable,
mode: plan.mode,
schemaNode: propertySchema,
schemaContext: rowSchemaContext,
onChange: replaceSchemaNode,
}),
name,
nameField: {
ariaLabel: `Field name ${name}`,
value: name,
editable: plan.editable,
validate: (value: string) =>
validatePropertyFormName({
name: value,
siblingNames: state.propertyNames,
originalName: name,
}),
onCommit: (nextName: string) => {
operations.renameProperty(name, nextName);
},
},
descriptionField: {
ariaLabel: `Description for ${name}`,
value: propertySchema.description || "",
editable: plan.editable,
onCommit: (description: string) => {
replaceSchemaNode({
...propertySchema,
description: description || undefined,
});
},
},
reorder: {
move: (targetIndex: number) => {
operations.moveProperty({
propertyName: name,
sourceIndex: index,
targetIndex,
});
},
},
typeField: createPropertyTypeFieldWithObjectTemplates({
schemaNode: propertySchema,
schemaContext: rowSchemaContext,
editable: plan.editable && plan.access.type,
onChange: replaceSchemaNode,
}),
deleteAction: {
label: `Remove field ${name}`,
onDelete: () => operations.removeProperty(name),
},
},
];
});
}
components/schema-editor/property-form/fields/object-properties-state.ts
"use client";
import * as React from "react";
import { getObjectPropertyNames } from "@/components/schema-editor/property-form/model/object-property-selectors";
import type { PropertyObjectPropertiesPlan } from "@/components/schema-editor/property-form/types";
import {
type ObjectPropertyRowIdentity,
useObjectPropertyRowIdentity,
} from "./object-property-row-identity";
export interface ObjectPropertiesState {
addInputValue: string;
propertyNames: string[];
rowIdentity: ObjectPropertyRowIdentity;
setAddInputValue: (value: string) => void;
}
export function useObjectPropertiesState(
plan: PropertyObjectPropertiesPlan,
): ObjectPropertiesState {
const [addInputValue, setAddInputValue] = React.useState("");
const propertyNames = getObjectPropertyNames(plan.schemaNode);
const resetAddInputValue = React.useCallback(() => {
setAddInputValue("");
}, []);
const rowIdentity = useObjectPropertyRowIdentity({
onExternalPropertyNamesChange: resetAddInputValue,
propertyNames,
resetKey: plan.schemaContext.resetKey ?? plan.schemaContext.originalName,
});
return {
addInputValue,
propertyNames,
rowIdentity,
setAddInputValue,
};
}
components/schema-editor/property-form/fields/object-property-add-input.ts
import type { SchemaAddInputModel } from "@/components/schema-editor/primitives/schema-add-input-model";
import { validatePropertyFormName } from "@/components/schema-editor/property-form/validation";
export interface ObjectPropertyAddInputState {
propertyNames: string[];
value: string;
setValue: (value: string) => void;
}
export function createObjectPropertyAddInput({
onSubmit,
state,
}: {
onSubmit: (propertyName: string) => void;
state: ObjectPropertyAddInputState;
}): SchemaAddInputModel {
const trimmedValue = state.value.trim();
const error = trimmedValue
? validatePropertyFormName({
name: trimmedValue,
siblingNames: state.propertyNames,
originalName: "",
})
: null;
return {
error,
inputLabel: "New object field",
placeholder: "New property name",
submitLabel: "Add",
value: state.value,
onChange: state.setValue,
onSubmit: () => {
if (!trimmedValue || error) return;
onSubmit(trimmedValue);
},
};
}
components/schema-editor/property-form/fields/object-property-row-identity.ts
"use client";
import * as React from "react";
import { useKeyedMountEffect } from "@/hooks/use-keyed-mount-effect";
import { joinEffectKey } from "@/lib/effect-key";
export interface ObjectPropertyRowIdentity {
addRowId: (propertyName: string) => void;
getRowId: (propertyName: string) => string;
preserveAddRowForLocalPropertyNames: (propertyNames: string[]) => void;
removeRowId: (propertyName: string) => void;
renameRowId: (oldName: string, name: string) => void;
}
export function useObjectPropertyRowIdentity({
onExternalPropertyNamesChange,
propertyNames,
resetKey,
}: {
onExternalPropertyNamesChange: () => void;
propertyNames: string[];
resetKey: string;
}): ObjectPropertyRowIdentity {
const propertyNamesKey = getPropertyNamesKey(propertyNames);
const localPropertyNamesKeyRef = React.useRef<string | null>(null);
const [rowIdsByName, setRowIdsByName] = React.useState(() =>
createRowIdsByName(propertyNames),
);
const nextRowIdRef = React.useRef(propertyNames.length);
useKeyedMountEffect(
joinEffectKey([onExternalPropertyNamesChange, propertyNamesKey, resetKey]),
() => {
if (localPropertyNamesKeyRef.current === propertyNamesKey) {
localPropertyNamesKeyRef.current = null;
return;
}
onExternalPropertyNamesChange();
},
);
const createRowId = React.useCallback(() => {
const rowId = `draft-property-${nextRowIdRef.current}`;
nextRowIdRef.current += 1;
return rowId;
}, []);
return {
addRowId: (propertyName) => {
setRowIdsByName((current) => {
const next = { ...current };
setRecordValue(next, propertyName, createRowId());
return next;
});
},
getRowId: (propertyName) =>
rowIdsByName[propertyName] ?? `external-property-${propertyName}`,
preserveAddRowForLocalPropertyNames: (nextPropertyNames) => {
localPropertyNamesKeyRef.current = getPropertyNamesKey(nextPropertyNames);
},
removeRowId: (propertyName) => {
setRowIdsByName((current) => {
const next = { ...current };
delete next[propertyName];
return next;
});
},
renameRowId: (oldName, name) => {
setRowIdsByName((current) => {
const rowId = current[oldName] ?? createRowId();
const next = { ...current };
delete next[oldName];
setRecordValue(next, name, rowId);
return next;
});
},
};
}
function getPropertyNamesKey(propertyNames: string[]) {
return propertyNames.join("\0");
}
function createRowIdsByName(propertyNames: string[]) {
const rowIdsByName: Record<string, string> = {};
propertyNames.forEach((propertyName, index) => {
setRecordValue(rowIdsByName, propertyName, `draft-property-${index}`);
});
return rowIdsByName;
}
function setRecordValue<T>(record: Record<string, T>, key: string, value: T) {
Object.defineProperty(record, key, {
value,
enumerable: true,
configurable: true,
writable: true,
});
}
components/schema-editor/property-form/fields/object-property-row-schema-plan.ts
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { createPropertySchemaPlan } from "@/components/schema-editor/property-form/model/property-schema-plan";
import type {
PropertyFormMode,
PropertyFormSchemaContext,
PropertySchemaPlanAccess,
PropertySchemaPlan,
} from "@/components/schema-editor/property-form/types";
export function createObjectPropertyRowSchemaPlan({
access,
editable,
mode,
schemaContext,
schemaNode,
onChange,
}: {
access: PropertySchemaPlanAccess;
editable: boolean;
mode: PropertyFormMode;
schemaContext: PropertyFormSchemaContext;
schemaNode: ExtendedJSONSchema7;
onChange: (schemaNode: ExtendedJSONSchema7) => void;
}): PropertySchemaPlan {
return createPropertySchemaPlan({
schemaNode,
schemaContext,
mode,
access,
editable,
showTypeSelector: false,
onChange,
});
}
components/schema-editor/property-form/fields/object-property-row.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { SchemaFieldRow } from "@/components/schema-editor/primitives/schema-field-row";
import { SchemaInlineDescription } from "@/components/schema-editor/primitives/schema-inline-description";
import { SchemaInlineName } from "@/components/schema-editor/primitives/schema-inline-name";
import { SchemaRowActions } from "@/components/schema-editor/primitives/schema-row-actions";
import type {
ObjectPropertyRowModel,
PropertyObjectPropertiesFieldModel,
} from "@/components/schema-editor/property-form/model/object-properties-view";
import type { PropertySchemaPlan } from "@/components/schema-editor/property-form/types";
import { useObjectPropertiesRowDrag } from "./object-properties-drag";
import { TypeField } from "./type-field";
interface ObjectPropertyRowsProps {
model: PropertyObjectPropertiesFieldModel;
renderPlan: (plan: PropertySchemaPlan) => React.ReactNode;
}
interface ObjectPropertyRowProps {
editable: boolean;
row: ObjectPropertyRowModel;
rowDragProps: React.HTMLAttributes<HTMLDivElement>;
renderPlan: (plan: PropertySchemaPlan) => React.ReactNode;
}
export function ObjectPropertyRows({
model,
renderPlan,
}: ObjectPropertyRowsProps) {
const rowDrag = useObjectPropertiesRowDrag({
rows: model.rows,
editable: model.editable,
});
return (
<div className="space-y-2">
{model.rows.map((row) => (
<ObjectPropertyRow
key={row.id}
editable={model.editable}
row={row}
rowDragProps={rowDrag.getRowDragProps(row)}
renderPlan={renderPlan}
/>
))}
</div>
);
}
export function ObjectPropertyRow({
editable,
row,
rowDragProps,
renderPlan,
}: ObjectPropertyRowProps) {
return (
<div
className={cn("border-border ml-4 border-l", editable && "cursor-grab")}
data-property-form-row-id={row.id}
data-property-form-property-name={row.name}
{...rowDragProps}
>
<SchemaFieldRow
grip={editable ? "drag" : "empty"}
name={
<SchemaInlineName
ariaLabel={row.nameField.ariaLabel}
value={row.nameField.value}
editable={row.nameField.editable}
validate={row.nameField.validate}
onCommit={row.nameField.onCommit}
/>
}
description={
<SchemaInlineDescription
ariaLabel={row.descriptionField.ariaLabel}
value={row.descriptionField.value}
editable={row.descriptionField.editable}
onCommit={row.descriptionField.onCommit}
/>
}
actions={
<SchemaRowActions
canDelete={true}
editable={editable}
deleteLabel={row.deleteAction.label}
onDelete={row.deleteAction.onDelete}
/>
}
type={<TypeField field={row.typeField} variant="row" />}
/>
{renderPlan(row.schemaPlan)}
</div>
);
}
components/schema-editor/property-form/fields/property-object-template-type-field.tsx
"use client";
import { definitionRef } from "@/components/schema-editor/document/json-pointer";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { createObjectTemplateTypeTrailingContent } from "@/components/schema-editor/object-template-type-section";
import type {
PropertyFormSchemaContext,
PropertyTypeFieldModel,
} from "@/components/schema-editor/property-form/types";
import {
createPropertyTypeField,
replacePropertyTypeSchemaNode,
} from "./property-type-field-model";
interface ObjectTemplatePropertyTypeFieldInput {
editable: boolean;
schemaContext: PropertyFormSchemaContext;
schemaNode: ExtendedJSONSchema7;
onChange: (schemaNode: ExtendedJSONSchema7) => void;
}
export function createPropertyTypeFieldWithObjectTemplates({
editable,
schemaContext,
schemaNode,
onChange,
}: ObjectTemplatePropertyTypeFieldInput): PropertyTypeFieldModel {
const selectObjectTemplate = (templateName: string) => {
if (!editable) return;
void schemaContext.onCommand?.({
type: "installObjectTemplate",
templateName,
});
replacePropertyTypeSchemaNode({
schemaNode,
replacement: {
$ref: definitionRef("$defs", templateName),
},
onChange,
});
};
return createPropertyTypeField({
editable,
schemaContext,
schemaNode,
trailingContent: schemaContext.objectTemplatesEnabled
? createObjectTemplateTypeTrailingContent({
onSelectTemplate: selectObjectTemplate,
})
: undefined,
onChange,
});
}
components/schema-editor/property-form/fields/property-schema-plan-field.tsx
"use client";
import { ArrayItemsField } from "@/components/schema-editor/property-form/fields/array-items-field";
import { EnumValuesField } from "@/components/schema-editor/property-form/fields/enum-values-field";
import { ObjectPropertiesPlanField } from "@/components/schema-editor/property-form/fields/object-properties-plan-field";
import { TypeField } from "@/components/schema-editor/property-form/fields/type-field";
import type { PropertySchemaPlan } from "@/components/schema-editor/property-form/types";
export function PropertySchemaPlanField({
plan,
}: {
plan: PropertySchemaPlan;
}) {
const renderPlan = (schemaPlan: PropertySchemaPlan) => (
<PropertySchemaPlanField plan={schemaPlan} />
);
return (
<div className="space-y-3">
{plan.items.map((item) => {
switch (item.kind) {
case "type":
return <TypeField key={item.kind} field={item.field} />;
case "enumValues":
return (
<EnumValuesField
key={item.kind}
values={item.field.values}
resetKey={item.field.resetKey}
disabled={item.field.disabled}
onChange={item.field.onChange}
/>
);
case "objectProperties":
return (
<ObjectPropertiesPlanField
key={item.kind}
plan={item.plan}
renderPlan={renderPlan}
/>
);
case "arrayItems":
return (
<ArrayItemsField key={item.kind}>
<PropertySchemaPlanField plan={item.itemSchemaPlan} />
</ArrayItemsField>
);
}
})}
</div>
);
}
components/schema-editor/property-form/fields/property-type-field-model.ts
"use client";
import {
definitionNameFromRef,
definitionRef,
} from "@/components/schema-editor/document/json-pointer";
import {
getEffectiveType,
updateEffectiveNode,
updateType,
} from "@/components/schema-editor/draft/draft-node-edits";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { getEffectiveNode } from "@/components/schema-editor/lib/json-schema-utils";
import type {
SchemaTypeMenuSection,
SchemaTypeMenuTrailingContent,
} from "@/components/schema-editor/primitives/schema-type-menu";
import type { SchemaTypeOptionId } from "@/components/schema-editor/primitives/schema-type-options";
import type {
PropertyFormSchemaContext,
PropertyTypeFieldModel,
} from "@/components/schema-editor/property-form/types";
import {
createDefinitionTypeSubmenu,
createPrimitiveTypeItems,
createTypeMenuValue,
} from "@/components/schema-editor/schema-type-menu-sections";
interface PropertyTypeFieldModelInput {
editable: boolean;
schemaContext: PropertyFormSchemaContext;
schemaNode: ExtendedJSONSchema7;
trailingContent?: SchemaTypeMenuTrailingContent;
onChange: (schemaNode: ExtendedJSONSchema7) => void;
}
export function createPropertyTypeField({
editable,
schemaContext,
schemaNode,
trailingContent,
onChange,
}: PropertyTypeFieldModelInput): PropertyTypeFieldModel {
const effectiveType = getEffectiveType(schemaNode);
const effectiveSchemaNode = getEffectiveNode(schemaNode);
const selectType = (type: SchemaTypeOptionId) => {
if (!editable) return;
onChange(updateType(type, effectiveType.isNullable, schemaNode));
};
const selectDefinition = (definitionName: string) => {
if (!editable) return;
void schemaContext.onCommand?.({
type: "selectDefinition",
definitionName,
});
replacePropertyTypeSchemaNode({
schemaNode,
replacement: { $ref: definitionRef("$defs", definitionName) },
onChange,
});
};
const refName =
effectiveType.type === "$ref" && effectiveSchemaNode.$ref
? definitionNameFromRef(effectiveSchemaNode.$ref)
: undefined;
return {
ariaLabel: `Data type${
schemaContext.fieldPath ? ` for ${schemaContext.fieldPath}` : ""
}`,
editable,
sections: [
{
id: "types",
kind: "items",
items: createPrimitiveTypeItems({ onSelectType: selectType }),
},
createDefinitionSection({
definitionNames: Object.keys(schemaContext.schemaDefinitions),
onCreateDefinition: () => {
if (!editable) return;
void schemaContext.onCommand?.({ type: "createDefinition" });
},
onSelectDefinition: selectDefinition,
}),
],
trailingContent,
value: createTypeMenuValue({ type: effectiveType.type, refName }),
};
}
export function replacePropertyTypeSchemaNode({
schemaNode,
replacement,
onChange,
}: {
schemaNode: ExtendedJSONSchema7;
replacement: ExtendedJSONSchema7;
onChange: (schemaNode: ExtendedJSONSchema7) => void;
}) {
onChange(
updateEffectiveNode(schemaNode, preserveMetadata(schemaNode, replacement)),
);
}
function createDefinitionSection({
definitionNames,
onCreateDefinition,
onSelectDefinition,
}: {
definitionNames: string[];
onCreateDefinition: () => void;
onSelectDefinition: (definitionName: string) => void;
}): SchemaTypeMenuSection {
return createDefinitionTypeSubmenu({
createDefinitionLabel: "Create new definition",
definitionNames,
onCreateDefinition,
onSelectDefinition,
});
}
function preserveMetadata(
schemaNode: ExtendedJSONSchema7,
replacement: ExtendedJSONSchema7,
): ExtendedJSONSchema7 {
const { anyOf, description, title } = schemaNode;
if (anyOf && Array.isArray(anyOf)) return replacement;
return {
...replacement,
...(title ? { title } : {}),
...(description ? { description } : {}),
};
}
components/schema-editor/property-form/fields/type-field.tsx
"use client";
import {
SchemaTypeMenu,
type SchemaTypeMenuVariant,
} from "@/components/schema-editor/primitives/schema-type-menu";
import type { PropertyTypeFieldModel } from "@/components/schema-editor/property-form/types";
export function TypeField({
field,
variant = "form",
}: {
field: PropertyTypeFieldModel;
variant?: SchemaTypeMenuVariant;
}) {
return (
<SchemaTypeMenu
ariaLabel={field.ariaLabel}
editable={field.editable}
sections={field.sections}
trailingContent={field.trailingContent}
value={field.value}
variant={variant}
/>
);
}
components/schema-editor/property-form/model/effective-node-edits.ts
import type { JSONSchema7Definition } from "json-schema";
import { setNullable } from "@/components/schema-editor/draft/draft-node-edits";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { getEffectiveNode } from "@/components/schema-editor/lib/json-schema-utils";
import type { PropertyDraft } from "@/components/schema-editor/property-form/types";
export function isObjectSchema(
value: JSONSchema7Definition | undefined,
): value is ExtendedJSONSchema7 {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function setDraftNullable(
propertyDraft: PropertyDraft,
isNullable: boolean,
): PropertyDraft {
return {
...propertyDraft,
schemaNode: setNullable(propertyDraft.schemaNode, isNullable),
};
}
export function getArrayItemsForDraft(
schemaNode: ExtendedJSONSchema7,
): ExtendedJSONSchema7 {
const effectiveSchemaNode = getEffectiveNode(schemaNode);
const effectiveItems = Array.isArray(effectiveSchemaNode.items)
? undefined
: effectiveSchemaNode.items;
return isObjectSchema(effectiveItems) ? effectiveItems : { type: "string" };
}
components/schema-editor/property-form/model/enum-values.ts
import type { JSONSchema7Type } from "json-schema";
export function formatEnumValueInput(value: JSONSchema7Type): string {
return typeof value === "string" ? value : JSON.stringify(value);
}
export function parseEnumValueInput(value: string): JSONSchema7Type {
const trimmedValue = value.trim();
if (!trimmedValue) return "";
try {
const parsedValue = JSON.parse(trimmedValue);
if (isJsonSchemaValue(parsedValue)) return parsedValue;
} catch {
return trimmedValue;
}
return trimmedValue;
}
function isJsonSchemaValue(value: unknown): value is JSONSchema7Type {
if (value === null) return true;
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return true;
}
if (Array.isArray(value)) return value.every(isJsonSchemaValue);
if (typeof value !== "object") return false;
return Object.values(value).every(isJsonSchemaValue);
}
components/schema-editor/property-form/model/object-properties-view.ts
import type { SchemaAddInputModel } from "@/components/schema-editor/primitives/schema-add-input-model";
import type {
PropertySchemaPlan,
PropertyTypeFieldModel,
} from "@/components/schema-editor/property-form/types";
export interface PropertyObjectPropertiesFieldModel {
addInput: SchemaAddInputModel;
editable: boolean;
rows: ObjectPropertyRowModel[];
}
export interface ObjectPropertyRowModel {
id: string;
name: string;
schemaPlan: PropertySchemaPlan;
nameField: ObjectPropertyNameFieldModel;
descriptionField: ObjectPropertyDescriptionFieldModel;
reorder: ObjectPropertyRowReorderModel;
typeField: PropertyTypeFieldModel;
deleteAction: {
label: string;
onDelete: () => void;
};
}
export interface ObjectPropertyNameFieldModel {
ariaLabel: string;
value: string;
editable: boolean;
validate: (value: string) => string | null;
onCommit: (name: string) => void;
}
export interface ObjectPropertyDescriptionFieldModel {
ariaLabel: string;
value: string;
editable: boolean;
onCommit: (description: string) => void;
}
export interface ObjectPropertyRowReorderModel {
move: (targetIndex: number) => void;
}
components/schema-editor/property-form/model/object-property-edits.ts
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { moveOrderedItem } from "@/components/schema-editor/primitives/schema-order";
import { getObjectPropertyNames } from "@/components/schema-editor/property-form/model/object-property-selectors";
import { formatTitle } from "@/components/schema-editor/schema-title";
export function createObjectPropertySchema(
propertyName: string,
): ExtendedJSONSchema7 {
return {
type: "string",
title: formatTitle(propertyName),
};
}
export function replaceObjectProperty({
schemaNode,
propertyName,
propertySchema,
}: {
schemaNode: ExtendedJSONSchema7;
propertyName: string;
propertySchema: ExtendedJSONSchema7;
}): ExtendedJSONSchema7 {
return {
...schemaNode,
properties: {
...(schemaNode.properties || {}),
[propertyName]: propertySchema,
},
required: getObjectPropertyNames(schemaNode).includes(propertyName)
? schemaNode.required
: [...(schemaNode.required || []), propertyName],
};
}
export function renameObjectProperty({
schemaNode,
oldName,
newName,
}: {
schemaNode: ExtendedJSONSchema7;
oldName: string;
newName: string;
}): ExtendedJSONSchema7 {
if (!newName || oldName === newName) return schemaNode;
if (getObjectPropertyNames(schemaNode).includes(newName)) return schemaNode;
const properties = schemaNode.properties || {};
const nextProperties: NonNullable<ExtendedJSONSchema7["properties"]> = {};
for (const [currentName, propertySchema] of Object.entries(properties)) {
setRecordValue(
nextProperties,
currentName === oldName ? newName : currentName,
propertySchema,
);
}
return {
...schemaNode,
properties: nextProperties,
required: (schemaNode.required || []).map((name) =>
name === oldName ? newName : name,
),
};
}
export function removeObjectProperty({
schemaNode,
propertyName,
}: {
schemaNode: ExtendedJSONSchema7;
propertyName: string;
}): ExtendedJSONSchema7 {
const { [propertyName]: _removed, ...nextProperties } =
schemaNode.properties || {};
return {
...schemaNode,
properties: nextProperties,
required: (schemaNode.required || []).filter(
(name) => name !== propertyName,
),
};
}
export function moveObjectProperty({
schemaNode,
propertyName,
targetIndex,
}: {
schemaNode: ExtendedJSONSchema7;
propertyName: string;
targetIndex: number;
}): ExtendedJSONSchema7 {
const properties = schemaNode.properties || {};
const propertyEntries = Object.entries(properties);
const sourceIndex = propertyEntries.findIndex(
([name]) => name === propertyName,
);
if (sourceIndex < 0) return schemaNode;
const nextProperties: NonNullable<ExtendedJSONSchema7["properties"]> = {};
for (const [name, propertySchema] of moveOrderedItem({
items: propertyEntries,
sourceIndex,
targetIndex,
})) {
setRecordValue(nextProperties, name, propertySchema);
}
return {
...schemaNode,
properties: nextProperties,
};
}
function setRecordValue<T>(record: Record<string, T>, key: string, value: T) {
Object.defineProperty(record, key, {
value,
enumerable: true,
configurable: true,
writable: true,
});
}
components/schema-editor/property-form/model/object-property-selectors.ts
import type { JSONSchema7Definition } from "json-schema";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
export function isSchemaNode(
value: JSONSchema7Definition | undefined,
): value is ExtendedJSONSchema7 {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function getObjectPropertyNames(schemaNode: ExtendedJSONSchema7) {
return Object.keys(schemaNode.properties || {});
}
components/schema-editor/property-form/model/property-capabilities.ts
import type {
PropertyCapabilities,
PropertyFormMode,
} from "@/components/schema-editor/property-form/types";
export function resolvePropertyCapabilities({
mode,
canDelete,
}: {
mode: PropertyFormMode;
canDelete: boolean;
}): PropertyCapabilities {
const editable = mode === "editable";
const descriptionOnly = mode === "descriptionOnly";
return {
mode,
canEditName: editable,
canEditType: editable,
canEditNullable: editable,
canEditDescription: editable || descriptionOnly,
canEditNestedObject: editable,
canEditArrayItems: editable,
canEditEnumValues: editable,
canDelete: editable && canDelete,
};
}
components/schema-editor/property-form/model/property-draft-commit.ts
import { formatTitle } from "@/components/schema-editor/schema-title";
import type { PropertyDraft } from "@/components/schema-editor/property-form/types";
export function buildCommittedDraft(
propertyDraft: PropertyDraft,
): PropertyDraft {
return {
...propertyDraft,
schemaNode: {
...propertyDraft.schemaNode,
title: formatTitle(propertyDraft.name),
},
};
}
components/schema-editor/property-form/model/property-schema-plan.ts
"use client";
import { updateEffectiveNode } from "@/components/schema-editor/draft/draft-node-edits";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { getEffectiveNode } from "@/components/schema-editor/lib/json-schema-utils";
import { createPropertyTypeFieldWithObjectTemplates } from "@/components/schema-editor/property-form/fields/property-object-template-type-field";
import { getArrayItemsForDraft } from "@/components/schema-editor/property-form/model/effective-node-edits";
import type {
PropertyFormMode,
PropertyFormSchemaContext,
PropertySchemaPlanAccess,
PropertySchemaPlan,
PropertySchemaPlanItem,
} from "@/components/schema-editor/property-form/types";
interface CreatePropertySchemaPlanInput {
schemaNode: ExtendedJSONSchema7;
schemaContext: PropertyFormSchemaContext;
mode: PropertyFormMode;
access: PropertySchemaPlanAccess;
editable: boolean;
showTypeSelector?: boolean;
onChange: (schemaNode: ExtendedJSONSchema7) => void;
}
export function createPropertySchemaPlan({
schemaNode,
schemaContext,
mode,
access,
editable,
showTypeSelector = true,
onChange,
}: CreatePropertySchemaPlanInput): PropertySchemaPlan {
const effectiveSchemaNode = getEffectiveNode(schemaNode);
const resetKey =
schemaContext.resetKey ??
schemaContext.fieldPath ??
schemaContext.originalName;
const items: PropertySchemaPlanItem[] = [];
const updateEffectiveSchemaNode = (nextSchemaNode: ExtendedJSONSchema7) => {
onChange(updateEffectiveNode(schemaNode, nextSchemaNode));
};
if (showTypeSelector) {
items.push({
kind: "type",
field: createPropertyTypeFieldWithObjectTemplates({
schemaNode,
schemaContext,
editable: editable && access.type,
onChange,
}),
});
}
if (access.enumValues && Array.isArray(effectiveSchemaNode.enum)) {
items.push({
kind: "enumValues",
field: {
values: effectiveSchemaNode.enum,
resetKey,
disabled: !editable || !access.enumValues,
onChange: (values) => {
updateEffectiveSchemaNode({
...effectiveSchemaNode,
enum: values,
});
},
},
});
}
if (
access.objectProperties &&
effectiveSchemaNode.type === "object" &&
!effectiveSchemaNode.$ref
) {
items.push({
kind: "objectProperties",
plan: {
schemaNode: effectiveSchemaNode,
schemaContext,
mode,
access,
editable: editable && access.objectProperties,
onChange: updateEffectiveSchemaNode,
},
});
}
if (access.arrayItems && effectiveSchemaNode.type === "array") {
items.push({
kind: "arrayItems",
itemSchemaPlan: createPropertySchemaPlan({
schemaNode: getArrayItemsForDraft(schemaNode),
schemaContext,
mode,
access,
editable,
onChange: (items) => {
updateEffectiveSchemaNode({
...effectiveSchemaNode,
items,
});
},
}),
});
}
return { items };
}
components/schema-editor/property-form/model/property-validation.ts
import type {
PropertyCapabilities,
PropertyValidation,
} from "@/components/schema-editor/property-form/types";
export function normalizeValidationForCapabilities({
validation,
capabilities,
}: {
validation: PropertyValidation;
capabilities: PropertyCapabilities;
}): PropertyValidation {
const isEnumValueValidation =
validation.schemaNode.code === "enum_empty" ||
validation.schemaNode.code === "enum_blank" ||
validation.schemaNode.code === "enum_duplicate";
const canEditSchemaValidation =
validation.schemaNode.status !== "invalid" ||
(isEnumValueValidation
? capabilities.canEditType || capabilities.canEditEnumValues
: capabilities.canEditType ||
capabilities.canEditNullable ||
capabilities.canEditNestedObject ||
capabilities.canEditArrayItems ||
capabilities.canEditEnumValues);
const name =
capabilities.canEditName || validation.name.status !== "invalid"
? validation.name
: { status: "valid" as const };
const schemaNode = canEditSchemaValidation
? validation.schemaNode
: { status: "valid" as const };
return {
...validation,
name,
schemaNode,
canCommit: name.status !== "invalid" && schemaNode.status !== "invalid",
};
}
components/schema-editor/property-form/property-form-controller.ts
"use client";
import * as React from "react";
import { getEffectiveType } from "@/components/schema-editor/draft/draft-node-edits";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import { createPropertyTypeFieldWithObjectTemplates } from "@/components/schema-editor/property-form/fields/property-object-template-type-field";
import { resolvePropertyCapabilities } from "@/components/schema-editor/property-form/model/property-capabilities";
import { createPropertySchemaPlan } from "@/components/schema-editor/property-form/model/property-schema-plan";
import { normalizeValidationForCapabilities } from "@/components/schema-editor/property-form/model/property-validation";
import { propertyDraftReducer } from "@/components/schema-editor/property-form/reducer";
import { usePropertyFormSubmit } from "@/components/schema-editor/property-form/property-form-submit";
import type {
PropertyDraftOperation,
PropertyFormMode,
PropertyFormProps,
PropertyFormViewModel,
} from "@/components/schema-editor/property-form/types";
import { validatePropertyDraft } from "@/components/schema-editor/property-form/validation";
import { useKeyedMountEffect } from "@/hooks/use-keyed-mount-effect";
import { joinEffectKey } from "@/lib/effect-key";
type PropertyFormControllerInput = Omit<
PropertyFormProps,
"mode" | "submitLabel"
> & {
mode: PropertyFormMode;
submitLabel: string;
canDelete: boolean;
};
export function usePropertyFormController({
propertyDraft: initialPropertyDraft,
schemaContext,
capabilities: capabilitiesProp,
validation: validationProp,
mode,
submitLabel,
canDelete,
onPropertyDraftChange,
onCommitPropertyDraft,
onCancel,
onDelete,
}: PropertyFormControllerInput): PropertyFormViewModel {
const [propertyDraft, setPropertyDraft] =
React.useState(initialPropertyDraft);
const propertyDraftRef = React.useRef(initialPropertyDraft);
const [draftResetVersion, setDraftResetVersion] = React.useState(0);
useKeyedMountEffect(joinEffectKey([initialPropertyDraft]), () => {
propertyDraftRef.current = initialPropertyDraft;
setPropertyDraft(initialPropertyDraft);
setDraftResetVersion((version) => version + 1);
});
const capabilities = React.useMemo(() => {
if (mode !== "editable") {
return resolvePropertyCapabilities({
mode,
canDelete,
});
}
const nextCapabilities =
capabilitiesProp ??
resolvePropertyCapabilities({
mode,
canDelete,
});
return {
...nextCapabilities,
mode,
};
}, [canDelete, capabilitiesProp, mode]);
const validation = normalizeValidationForCapabilities({
validation:
validationProp ??
validatePropertyDraft({
propertyDraft,
schemaContext,
}),
capabilities,
});
const effectiveType = getEffectiveType(propertyDraft.schemaNode);
const schemaPlanContext = React.useMemo(
() => ({
...schemaContext,
resetKey: [
schemaContext.resetKey ??
schemaContext.fieldPath ??
schemaContext.originalName,
draftResetVersion,
].join(":"),
}),
[draftResetVersion, schemaContext],
);
const updatePropertyDraft = React.useCallback(
(operation: PropertyDraftOperation) => {
const nextPropertyDraft = propertyDraftReducer(
propertyDraftRef.current,
operation,
);
propertyDraftRef.current = nextPropertyDraft;
setPropertyDraft(nextPropertyDraft);
onPropertyDraftChange?.(nextPropertyDraft);
},
[onPropertyDraftChange],
);
const { commitPropertyDraft, isSubmitting } = usePropertyFormSubmit({
capabilities,
propertyDraftRef,
schemaContext,
validation: validationProp,
onCommitPropertyDraft,
});
const keyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (event.key !== "Enter" || event.shiftKey) return;
if (event.nativeEvent.isComposing) return;
if (event.target instanceof HTMLButtonElement) return;
if (event.target instanceof HTMLTextAreaElement) {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
void commitPropertyDraft();
}
return;
}
event.preventDefault();
void commitPropertyDraft();
},
[commitPropertyDraft],
);
const schemaPlan = createPropertySchemaPlan({
schemaNode: propertyDraft.schemaNode,
schemaContext: schemaPlanContext,
mode: capabilities.mode,
access: {
arrayItems: capabilities.canEditArrayItems,
enumValues: capabilities.canEditEnumValues,
objectProperties: capabilities.canEditNestedObject,
type: capabilities.canEditType,
},
editable:
capabilities.canEditEnumValues ||
capabilities.canEditNestedObject ||
capabilities.canEditArrayItems,
showTypeSelector: false,
onChange: (schemaNode) =>
updatePropertyDraft({
type: "replacePropertySchemaNode",
schemaNode,
}),
});
const hasSchemaPlan = schemaPlan.items.length > 0;
const { description } = propertyDraft.schemaNode;
return {
validation,
capabilities,
fields: {
name: {
value: propertyDraft.name,
validation: validation.name,
disabled: !capabilities.canEditName,
onChange: (name) =>
updatePropertyDraft({
type: "renameProperty",
name,
}),
},
type: createPropertyTypeFieldWithObjectTemplates({
schemaNode: propertyDraft.schemaNode,
schemaContext,
editable: capabilities.canEditType,
onChange: (schemaNode: ExtendedJSONSchema7) =>
updatePropertyDraft({
type: "replacePropertySchemaNode",
schemaNode,
}),
}),
nullable: {
isNullable: effectiveType.isNullable,
disabled: !capabilities.canEditNullable,
onChange: (isNullable) =>
updatePropertyDraft({
type: "setPropertyNullable",
isNullable,
}),
},
description: {
value: description || "",
disabled: !capabilities.canEditDescription,
onChange: (description) =>
updatePropertyDraft({
type: "setPropertyDescription",
description,
}),
},
schemaPlan: hasSchemaPlan ? schemaPlan : undefined,
},
footer: {
canDelete: capabilities.canDelete,
isSubmitting,
isSubmitDisabled: !validation.canCommit,
submitLabel,
onCancel,
onDelete,
},
events: {
submit: commitPropertyDraft,
keyDown,
},
};
}
components/schema-editor/property-form/property-form-footer.tsx
"use client";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
export function PropertyFormFooter({
canDelete,
isSubmitting,
isSubmitDisabled,
submitLabel,
onCancel,
onDelete,
}: {
canDelete: boolean;
isSubmitting: boolean;
isSubmitDisabled: boolean;
submitLabel: string;
onCancel?: () => void;
onDelete?: () => void;
}) {
return (
<DialogFooter className="mx-0 mb-0 flex-row justify-between sm:justify-between">
<div>
{onDelete && canDelete && (
<Button
type="button"
variant="destructive"
size="sm"
disabled={isSubmitting}
onClick={onDelete}
>
Delete Property
</Button>
)}
</div>
<div className="flex space-x-2">
{onCancel && (
<Button
type="button"
variant="outline"
size="sm"
disabled={isSubmitting}
onClick={onCancel}
>
Cancel
</Button>
)}
<Button
type="submit"
size="sm"
className="px-3"
disabled={isSubmitting || isSubmitDisabled}
>
{submitLabel}
</Button>
</div>
</DialogFooter>
);
}
components/schema-editor/property-form/property-form-shell.tsx
"use client";
import { AlertCircle } from "lucide-react";
import { DescriptionField } from "@/components/schema-editor/property-form/fields/description-field";
import { NameField } from "@/components/schema-editor/property-form/fields/name-field";
import { NullableField } from "@/components/schema-editor/property-form/fields/nullable-field";
import { PropertySchemaPlanField } from "@/components/schema-editor/property-form/fields/property-schema-plan-field";
import { TypeField } from "@/components/schema-editor/property-form/fields/type-field";
import { PropertyFormFooter } from "@/components/schema-editor/property-form/property-form-footer";
import type { PropertyFormViewModel } from "@/components/schema-editor/property-form/types";
import { Label } from "@/components/ui/label";
export function PropertyFormShell({
viewModel,
}: {
viewModel: PropertyFormViewModel;
}) {
const { fields, footer, capabilities, events, validation } = viewModel;
const isReadOnly = capabilities.mode === "readOnly";
return (
<form
onSubmit={(event) => {
event.preventDefault();
void events.submit();
}}
onKeyDown={events.keyDown}
className="flex h-full flex-col"
>
<div className="max-h-[60vh] flex-1 overflow-y-auto">
<div className="border-border space-y-4 border-b p-4">
<NameField {...fields.name} />
<div>
<div className="mb-1.5 flex items-center justify-between">
<Label htmlFor="type">Data type</Label>
<NullableField
checked={fields.nullable.isNullable}
disabled={fields.nullable.disabled}
onChange={fields.nullable.onChange}
/>
</div>
<TypeField field={fields.type} />
{validation.schemaNode.message && (
<p className="text-destructive mt-2 flex items-center gap-1 text-sm font-medium">
<AlertCircle className="h-3 w-3" />
{validation.schemaNode.message}
</p>
)}
</div>
{fields.schemaPlan && (
<PropertySchemaPlanField plan={fields.schemaPlan} />
)}
</div>
<div className="space-y-4 p-4">
<DescriptionField {...fields.description} />
</div>
</div>
{!isReadOnly && (
<PropertyFormFooter
canDelete={footer.canDelete}
isSubmitting={footer.isSubmitting}
isSubmitDisabled={footer.isSubmitDisabled}
submitLabel={footer.submitLabel}
onCancel={footer.onCancel}
onDelete={footer.onDelete}
/>
)}
</form>
);
}
components/schema-editor/property-form/property-form-submit.ts
"use client";
import * as React from "react";
import { buildCommittedDraft } from "@/components/schema-editor/property-form/model/property-draft-commit";
import { normalizeValidationForCapabilities } from "@/components/schema-editor/property-form/model/property-validation";
import type {
PropertyCapabilities,
PropertyDraft,
PropertyFormProps,
PropertyFormSchemaContext,
PropertyValidation,
} from "@/components/schema-editor/property-form/types";
import { validatePropertyDraft } from "@/components/schema-editor/property-form/validation";
interface UsePropertyFormSubmitInput {
capabilities: PropertyCapabilities;
propertyDraftRef: React.MutableRefObject<PropertyDraft>;
schemaContext: PropertyFormSchemaContext;
validation?: PropertyValidation;
onCommitPropertyDraft: PropertyFormProps["onCommitPropertyDraft"];
}
export function usePropertyFormSubmit({
capabilities,
propertyDraftRef,
schemaContext,
validation,
onCommitPropertyDraft,
}: UsePropertyFormSubmitInput) {
const [isSubmitting, setIsSubmitting] = React.useState(false);
const isSubmittingRef = React.useRef(false);
const commitPropertyDraft = React.useCallback(async () => {
if (isSubmittingRef.current) return false;
if (capabilities.mode === "readOnly") return false;
const currentPropertyDraft = propertyDraftRef.current;
const currentValidation = normalizeValidationForCapabilities({
validation:
validation ??
validatePropertyDraft({
propertyDraft: currentPropertyDraft,
schemaContext,
}),
capabilities,
});
if (!currentValidation.canCommit) return false;
isSubmittingRef.current = true;
setIsSubmitting(true);
try {
await onCommitPropertyDraft(buildCommittedDraft(currentPropertyDraft));
return true;
} catch {
return false;
} finally {
isSubmittingRef.current = false;
setIsSubmitting(false);
}
}, [
capabilities,
onCommitPropertyDraft,
propertyDraftRef,
schemaContext,
validation,
]);
return {
commitPropertyDraft,
isSubmitting,
};
}
components/schema-editor/property-form/property-form.tsx
"use client";
import { usePropertyFormController } from "@/components/schema-editor/property-form/property-form-controller";
import { PropertyFormShell } from "@/components/schema-editor/property-form/property-form-shell";
import type { PropertyFormProps } from "@/components/schema-editor/property-form/types";
export function PropertyForm(props: PropertyFormProps) {
const mode = props.mode ?? "editable";
const viewModel = usePropertyFormController({
propertyDraft: props.propertyDraft,
schemaContext: props.schemaContext,
capabilities: props.capabilities,
validation: props.validation,
mode,
submitLabel: props.submitLabel ?? "Save Changes",
canDelete: Boolean(props.onDelete),
onPropertyDraftChange: props.onPropertyDraftChange,
onCommitPropertyDraft: props.onCommitPropertyDraft,
onCancel: props.onCancel,
onDelete: props.onDelete,
});
return <PropertyFormShell viewModel={viewModel} />;
}
components/schema-editor/property-form/reducer.ts
import { setDraftNullable } from "@/components/schema-editor/property-form/model/effective-node-edits";
import type {
PropertyDraft,
PropertyDraftOperation,
} from "@/components/schema-editor/property-form/types";
export function propertyDraftReducer(
propertyDraft: PropertyDraft,
operation: PropertyDraftOperation,
): PropertyDraft {
switch (operation.type) {
case "renameProperty":
return { ...propertyDraft, name: operation.name };
case "setPropertyDescription":
return {
...propertyDraft,
schemaNode: {
...propertyDraft.schemaNode,
description: operation.description,
},
};
case "setPropertyNullable":
return setDraftNullable(propertyDraft, operation.isNullable);
case "replacePropertySchemaNode":
return {
...propertyDraft,
schemaNode: operation.schemaNode,
};
}
}
components/schema-editor/property-form/types.ts
import type * as React from "react";
import type { JSONSchema7Definition, JSONSchema7Type } from "json-schema";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import type {
SchemaTypeMenuSection,
SchemaTypeMenuTrailingContent,
SchemaTypeMenuValue,
} from "@/components/schema-editor/primitives/schema-type-menu";
export type PropertyFormMode = "descriptionOnly" | "readOnly" | "editable";
export interface PropertyDraft {
name: string;
schemaNode: ExtendedJSONSchema7;
}
export interface FieldValidation {
status: "valid" | "invalid" | "warning";
message?: string;
code?: string;
}
export interface NodeValidation {
status: "valid" | "invalid" | "warning";
message?: string;
code?: string;
}
export interface PropertyValidation {
name: FieldValidation;
schemaNode: NodeValidation;
canCommit: boolean;
}
export interface PropertyCapabilities {
mode: PropertyFormMode;
canEditName: boolean;
canEditType: boolean;
canEditNullable: boolean;
canEditDescription: boolean;
canEditNestedObject: boolean;
canEditArrayItems: boolean;
canEditEnumValues: boolean;
canDelete: boolean;
}
export type PropertyFormCommand =
| { type: "createDefinition" }
| { type: "selectDefinition"; definitionName: string }
| { type: "installObjectTemplate"; templateName: string };
export interface PropertyFormSchemaContext {
siblingNames: string[];
originalName: string;
schemaDefinitions: Record<string, JSONSchema7Definition>;
fieldPath?: string;
resetKey?: string;
objectTemplatesEnabled?: boolean;
onCommand?: (command: PropertyFormCommand) => void | Promise<void>;
}
export interface PropertyFormProps {
propertyDraft: PropertyDraft;
schemaContext: PropertyFormSchemaContext;
capabilities?: PropertyCapabilities;
validation?: PropertyValidation;
mode?: PropertyFormMode;
submitLabel?: string;
onPropertyDraftChange?: (propertyDraft: PropertyDraft) => void;
onCommitPropertyDraft: (propertyDraft: PropertyDraft) => void | Promise<void>;
onCancel?: () => void;
onDelete?: () => void;
}
export type PropertyDraftOperation =
| { type: "renameProperty"; name: string }
| { type: "setPropertyDescription"; description: string }
| { type: "setPropertyNullable"; isNullable: boolean }
| { type: "replacePropertySchemaNode"; schemaNode: ExtendedJSONSchema7 };
export interface PropertyFormFooterModel {
canDelete: boolean;
isSubmitting: boolean;
isSubmitDisabled: boolean;
submitLabel: string;
onCancel?: () => void;
onDelete?: () => void;
}
export interface PropertyTypeFieldModel {
ariaLabel: string;
editable: boolean;
sections: SchemaTypeMenuSection[];
trailingContent?: SchemaTypeMenuTrailingContent;
value: SchemaTypeMenuValue;
}
export interface PropertyEnumValuesFieldModel {
values: JSONSchema7Type[];
resetKey: string;
disabled: boolean;
onChange: (values: JSONSchema7Type[]) => void;
}
export interface PropertySchemaPlanAccess {
arrayItems: boolean;
enumValues: boolean;
objectProperties: boolean;
type: boolean;
}
export interface PropertyObjectPropertiesPlan {
schemaNode: ExtendedJSONSchema7;
schemaContext: PropertyFormSchemaContext;
mode: PropertyFormMode;
access: PropertySchemaPlanAccess;
editable: boolean;
onChange: (schemaNode: ExtendedJSONSchema7) => void;
}
export interface PropertySchemaPlan {
items: PropertySchemaPlanItem[];
}
export type PropertySchemaPlanItem =
| PropertyTypePlanItem
| PropertyEnumPlanItem
| PropertyObjectPropertiesPlanItem
| PropertyArrayItemsPlanItem;
export interface PropertyTypePlanItem {
kind: "type";
field: PropertyTypeFieldModel;
}
export interface PropertyEnumPlanItem {
kind: "enumValues";
field: PropertyEnumValuesFieldModel;
}
export interface PropertyObjectPropertiesPlanItem {
kind: "objectProperties";
plan: PropertyObjectPropertiesPlan;
}
export interface PropertyArrayItemsPlanItem {
kind: "arrayItems";
itemSchemaPlan: PropertySchemaPlan;
}
export interface PropertyFormViewModel {
validation: PropertyValidation;
capabilities: PropertyCapabilities;
fields: {
name: {
value: string;
validation: FieldValidation;
disabled: boolean;
onChange: (name: string) => void;
};
type: PropertyTypeFieldModel;
nullable: {
isNullable: boolean;
disabled: boolean;
onChange: (isNullable: boolean) => void;
};
description: {
value: string;
disabled: boolean;
onChange: (description: string) => void;
};
schemaPlan?: PropertySchemaPlan;
};
footer: PropertyFormFooterModel;
events: {
submit: () => Promise<boolean>;
keyDown: (event: React.KeyboardEvent) => void;
};
}
components/schema-editor/property-form/validation.ts
import { getEffectiveType } from "@/components/schema-editor/draft/draft-node-edits";
import {
getEffectiveNode,
validateName,
} from "@/components/schema-editor/lib/json-schema-utils";
import type {
FieldValidation,
NodeValidation,
PropertyDraft,
PropertyFormSchemaContext,
PropertyValidation,
} from "@/components/schema-editor/property-form/types";
export const PROPERTY_NAME_ERROR =
"Name must start with a letter or underscore, contain only letters, numbers, or underscores, and be at most 64 characters long";
export function validatePropertyFormName({
name,
siblingNames,
originalName,
}: {
name: string;
siblingNames: string[];
originalName: string;
}): string | null {
return validateName(name, siblingNames, originalName, "property");
}
function validField(): FieldValidation {
return { status: "valid" };
}
function invalidField(message: string, code: string): FieldValidation {
return { status: "invalid", message, code };
}
function validNode(): NodeValidation {
return { status: "valid" };
}
function invalidNode(message: string, code: string): NodeValidation {
return { status: "invalid", message, code };
}
function getStableValueKey(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(getStableValueKey).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(
([key, currentValue]) =>
`${JSON.stringify(key)}:${getStableValueKey(currentValue)}`,
)
.join(",")}}`;
}
return JSON.stringify(value);
}
function hasDuplicateEnumValues(values: unknown[]) {
const seen = new Set<string>();
for (const value of values) {
const key = getStableValueKey(value);
if (seen.has(key)) return true;
seen.add(key);
}
return false;
}
function hasBlankEnumValues(values: unknown[]) {
return values.some((value) => typeof value === "string" && !value.trim());
}
export function validatePropertyDraft({
propertyDraft,
schemaContext,
}: {
propertyDraft: PropertyDraft;
schemaContext: PropertyFormSchemaContext;
}): PropertyValidation {
const nameError = validatePropertyFormName({
name: propertyDraft.name,
siblingNames: schemaContext.siblingNames,
originalName: schemaContext.originalName,
});
const name = nameError
? invalidField(nameError, "property_name_invalid")
: validField();
const effectiveSchemaNode = getEffectiveNode(propertyDraft.schemaNode);
const effectiveType = getEffectiveType(propertyDraft.schemaNode);
const enumValues = Array.isArray(effectiveSchemaNode.enum)
? effectiveSchemaNode.enum
: [];
const schemaNode =
effectiveType.type === "enum" && enumValues.length === 0
? invalidNode(
"Multiple choice fields need at least one option",
"enum_empty",
)
: effectiveType.type === "enum" && hasBlankEnumValues(enumValues)
? invalidNode("Multiple choice options cannot be blank", "enum_blank")
: effectiveType.type === "enum" && hasDuplicateEnumValues(enumValues)
? invalidNode(
"Multiple choice options must be unique",
"enum_duplicate",
)
: validNode();
return {
name,
schemaNode,
canCommit: name.status !== "invalid" && schemaNode.status !== "invalid",
};
}
components/schema-editor/root-dialog.tsx
import * as React from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
type RootMetadataValues = {
title: string;
description: string;
};
interface RootDialogProps {
isOpen: boolean;
onClose: () => void;
path: string;
schemaTitle: string;
setSchemaTitle: (name: string) => void;
metadataValues: RootMetadataValues;
setMetadataValues: (values: RootMetadataValues) => void;
onSave: (values: RootMetadataValues) => void;
mode?: "descriptionOnly" | "readOnly" | "editable";
}
export function RootDialog({
isOpen,
onClose,
path,
schemaTitle,
setSchemaTitle,
metadataValues,
setMetadataValues,
onSave,
mode = "editable",
}: RootDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{mode === "readOnly" ? "View Schema" : "Edit Schema"}
</DialogTitle>
<DialogDescription>
{mode === "readOnly"
? "View schema title and description."
: "Modify schema title and description."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<div className="flex items-center gap-2">
<Label htmlFor={`${path}-title`}>Schema Title</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="text-muted-foreground size-4" />
</TooltipTrigger>
<TooltipContent>
This title is used by the model to understand what is being
extracted.
</TooltipContent>
</Tooltip>
</div>
<Input
id={`${path}-title`}
value={schemaTitle}
onChange={(e) => setSchemaTitle(e.target.value)}
placeholder="Enter schema title"
disabled={mode === "readOnly" || mode === "descriptionOnly"}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${path}-description`}>Description</Label>
<Textarea
id={`${path}-description`}
value={metadataValues.description}
onChange={(e) =>
setMetadataValues({
...metadataValues,
description: e.target.value,
})
}
placeholder="Add a description to your schema"
disabled={mode === "readOnly"}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
type="button"
onClick={() => {
onSave({
...metadataValues,
title: schemaTitle,
});
onClose();
}}
disabled={mode === "readOnly"}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
components/schema-editor/schema-builder-types.ts
import type * as React from "react";
import type { ErrorObject } from "ajv";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
export type { ExtendedJSONSchema7 };
export type SchemaBuilderView = "fields" | "json";
export interface SchemaBuilderFeatures {
definitions?: boolean;
objectTemplates?: boolean;
jsonMode?: boolean;
importExport?: boolean;
}
export interface ResolvedSchemaBuilderFeatures {
definitions: boolean;
objectTemplates: boolean;
jsonMode: boolean;
importExport: boolean;
}
export interface SchemaBuilderProps {
/** The JSON Schema being edited (controlled). */
value: ExtendedJSONSchema7;
/** Called with the next valid schema whenever the user commits an edit. */
onValueChange: (schema: ExtendedJSONSchema7) => void;
className?: string;
readOnly?: boolean;
view?: SchemaBuilderView;
onViewChange?: (view: SchemaBuilderView) => void;
features?: SchemaBuilderFeatures;
}
export interface SchemaValidationResult {
isValid: boolean;
errors: SchemaValidationIssue[];
propertyCount: number;
isPropertyLimitExceeded: boolean;
}
export interface SchemaValidationIssue {
code:
| "invalid_schema"
| "numeric_property_name"
| "additional_properties_not_false"
| "property_limit_exceeded";
path: string;
message: string;
source?: ErrorObject;
}
export type SchemaDispatch = (
op: (doc: SchemaDocument) => SchemaDocument,
persist?: boolean,
) => void;
export interface SchemaBuilderState {
doc: SchemaDocument;
schema: ExtendedJSONSchema7;
validation: SchemaValidationResult;
dispatch: SchemaDispatch;
replaceSchema: (
value: React.SetStateAction<ExtendedJSONSchema7>,
persist?: boolean,
) => Promise<void>;
}
export function resolveSchemaBuilderFeatures(
features?: SchemaBuilderFeatures,
): ResolvedSchemaBuilderFeatures {
return {
definitions: features?.definitions ?? true,
objectTemplates: features?.objectTemplates ?? false,
jsonMode: features?.jsonMode ?? false,
importExport: features?.importExport ?? false,
};
}
components/schema-editor/schema-builder.tsx
"use client";
import * as React from "react";
import { DocumentSchemaEditor } from "@/components/schema-editor/document-schema-editor";
import type {
ExtendedJSONSchema7,
SchemaBuilderProps,
SchemaBuilderView,
} from "@/components/schema-editor/schema-builder-types";
import { resolveSchemaBuilderFeatures } from "@/components/schema-editor/schema-builder-types";
import { useSchemaBuilderState } from "@/components/schema-editor/use-schema-builder-state";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const LazyJsonModeEditor = React.lazy(() =>
import("@/components/schema-editor/optional/json-mode/json-mode-editor").then(
(module) => ({
default: module.JsonModeEditor,
}),
),
);
export function SchemaBuilder({
value,
onValueChange,
className,
readOnly = false,
view,
onViewChange,
features,
}: SchemaBuilderProps) {
const resolvedFeatures = React.useMemo(
() => resolveSchemaBuilderFeatures(features),
[features],
);
const state = useSchemaBuilderState({
value,
onValueChange,
readOnly,
});
const [internalView, setInternalView] =
React.useState<SchemaBuilderView>("fields");
const activeView = resolvedFeatures.jsonMode
? (view ?? internalView)
: "fields";
const setView = React.useCallback(
(nextView: SchemaBuilderView) => {
if (nextView === "json" && !resolvedFeatures.jsonMode) return;
setInternalView(nextView);
onViewChange?.(nextView);
},
[onViewChange, resolvedFeatures.jsonMode],
);
return (
<div data-slot="schema-builder" className={cn("w-full", className)}>
{resolvedFeatures.jsonMode && (
<div className="mb-3 flex items-center gap-2">
<Button
type="button"
size="sm"
variant={activeView === "fields" ? "secondary" : "ghost"}
onClick={() => setView("fields")}
>
Fields
</Button>
<Button
type="button"
size="sm"
variant={activeView === "json" ? "secondary" : "ghost"}
onClick={() => setView("json")}
>
JSON
</Button>
</div>
)}
{activeView === "json" ? (
<React.Suspense fallback={null}>
<LazyJsonModeEditor
schema={state.schema}
readOnly={readOnly}
replaceSchema={state.replaceSchema}
/>
</React.Suspense>
) : (
<DocumentSchemaEditor
doc={state.doc}
schema={state.schema}
validation={state.validation}
dispatch={state.dispatch}
mode={readOnly ? "readOnly" : "editable"}
features={resolvedFeatures}
/>
)}
</div>
);
}
export type {
ExtendedJSONSchema7,
SchemaBuilderFeatures,
SchemaBuilderProps,
SchemaBuilderView,
} from "@/components/schema-editor/schema-builder-types";
components/schema-editor/schema-editor-mode.ts
export type SchemaEditorMode = "descriptionOnly" | "readOnly" | "editable";
components/schema-editor/schema-required-policy.ts
import type { JSONSchema7Definition } from "json-schema";
const SCHEMA_VALUE_KEYS = [
"additionalItems",
"additionalProperties",
"contains",
"else",
"if",
"not",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties",
] as const;
const SCHEMA_MAP_KEYS = [
"$defs",
"definitions",
"dependentSchemas",
"dependencies",
"patternProperties",
] as const;
/**
* Editor policy: every object property is required. Recursively sets each
* object's `required` to all of its property keys. Nullability is orthogonal:
* a field can be both required and nullable.
*/
export function requireAllProperties(
schema: JSONSchema7Definition,
): JSONSchema7Definition {
if (typeof schema !== "object" || schema === null) return schema;
const out: Record<string, unknown> = { ...schema };
if (out.properties && typeof out.properties === "object") {
const properties = out.properties as Record<string, JSONSchema7Definition>;
const nextProperties: Record<string, JSONSchema7Definition> = {};
for (const [key, value] of Object.entries(properties)) {
setRecordValue(nextProperties, key, requireAllProperties(value));
}
out.properties = nextProperties;
out.required = mergeRequiredNames(
out.required,
Object.keys(nextProperties),
);
}
if (out.items) {
out.items = Array.isArray(out.items)
? out.items.map((item) =>
requireAllProperties(item as JSONSchema7Definition),
)
: requireAllProperties(out.items as JSONSchema7Definition);
}
if (Array.isArray(out.prefixItems)) {
out.prefixItems = (out.prefixItems as JSONSchema7Definition[]).map(
requireAllProperties,
);
}
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
if (Array.isArray(out[key])) {
out[key] = (out[key] as JSONSchema7Definition[]).map(
requireAllProperties,
);
}
}
for (const key of SCHEMA_VALUE_KEYS) {
if (isSchemaObject(out[key])) {
out[key] = requireAllProperties(out[key] as JSONSchema7Definition);
}
}
for (const key of SCHEMA_MAP_KEYS) {
if (out[key] && typeof out[key] === "object") {
const definitions = out[key] as Record<string, unknown>;
const nextDefinitions: Record<string, unknown> = {};
for (const [name, value] of Object.entries(definitions)) {
setRecordValue(
nextDefinitions,
name,
key === "dependencies" && Array.isArray(value)
? value
: isJsonSchemaDefinition(value)
? requireAllProperties(value)
: value,
);
}
out[key] = nextDefinitions;
}
}
return out as JSONSchema7Definition;
}
function isSchemaObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isJsonSchemaDefinition(
value: unknown,
): value is JSONSchema7Definition {
return typeof value === "boolean" || isSchemaObject(value);
}
function mergeRequiredNames(
existing: unknown,
propertyNames: string[],
): string[] {
const required = Array.isArray(existing)
? existing.filter((name): name is string => typeof name === "string")
: [];
const seen = new Set(required);
for (const propertyName of propertyNames) {
if (seen.has(propertyName)) continue;
required.push(propertyName);
seen.add(propertyName);
}
return required;
}
function setRecordValue<T>(record: Record<string, T>, key: string, value: T) {
Object.defineProperty(record, key, {
value,
enumerable: true,
configurable: true,
writable: true,
});
}
components/schema-editor/schema-title.ts
export function formatTitle(rawName: string): string {
return rawName
.replace(/([a-z\d])([A-Z])/g, "$1 $2")
.replace(/[^a-zA-Z0-9]+/g, " ")
.split(" ")
.filter(Boolean)
.map(
(chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1).toLowerCase(),
)
.join(" ");
}
components/schema-editor/schema-type-menu-sections.tsx
"use client";
import { PlusIcon } from "lucide-react";
import type {
SchemaTypeMenuItem,
SchemaTypeMenuSection,
SchemaTypeMenuValue,
} from "@/components/schema-editor/primitives/schema-type-menu";
import {
schemaTypeIcon,
schemaTypeLabel,
schemaTypeOptions,
type SchemaTypeOptionId,
} from "@/components/schema-editor/primitives/schema-type-options";
import {
getTemplateIcon,
getTypeIcon,
} from "@/components/schema-editor/type-icons";
export function createTypeMenuValue({
type,
refName,
}: {
type: string;
refName?: string;
}): SchemaTypeMenuValue {
return {
id: type,
label: schemaTypeLabel(type, refName),
icon: schemaTypeIcon(type, refName),
};
}
export function createPrimitiveTypeItems({
onSelectType,
}: {
onSelectType: (type: SchemaTypeOptionId) => void;
}): SchemaTypeMenuItem[] {
return schemaTypeOptions.map((option) => ({
id: option.id,
label: option.label,
icon: option.icon,
onSelect: () => onSelectType(option.id),
}));
}
export function createDefinitionTypeSubmenu({
createDefinitionLabel,
definitionNames,
onCreateDefinition,
onSelectDefinition,
}: {
createDefinitionLabel: string;
definitionNames: string[];
onCreateDefinition: () => void;
onSelectDefinition: (definitionName: string) => void;
}): SchemaTypeMenuSection {
return {
id: "definitions",
kind: "submenu",
label: "definition",
icon: getTypeIcon("$ref"),
items:
definitionNames.length === 0
? [
{
id: "create-definition",
label: createDefinitionLabel,
icon: <PlusIcon className="h-4 w-4" />,
onSelect: onCreateDefinition,
},
]
: definitionNames.map((definitionName) => ({
id: definitionName,
label: definitionName,
icon: getTemplateIcon(definitionName),
onSelect: () => onSelectDefinition(definitionName),
})),
};
}
components/schema-editor/schema-validation.ts
"use client";
import Ajv from "ajv";
import type { ErrorObject, ValidateFunction } from "ajv";
import draft7MetaSchema from "ajv/dist/refs/json-schema-draft-07.json";
import {
addJsonSchemaErrors,
addJsonSchemaFormats,
} from "@/components/schema-editor/lib/configure-ajv";
let ajvSingleton: Ajv | null = null;
let validateJsonSchemaSingleton: ValidateFunction | null = null;
function isMutableObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function injectConstraints(schema: unknown): void {
if (!isMutableObject(schema)) return;
if (schema.type === "object") {
schema.propertyNames = {
type: "string",
pattern: "^(?![-+]?(\\d+(\\.\\d*)?|\\.\\d+)$).+",
};
}
for (const value of Object.values(schema)) {
if (isMutableObject(value)) {
injectConstraints(value);
}
}
}
function buildEnrichedDraft7MetaSchema(): Record<string, unknown> {
const schema = structuredClone(draft7MetaSchema) as Record<string, unknown>;
schema.$id = "http://retab-json-schema.org/draft-07/enriched-schema#";
injectConstraints(schema);
if (!isMutableObject(schema.properties)) {
schema.properties = {};
}
const properties = schema.properties;
if (!isMutableObject(properties)) return schema;
properties.additionalProperties = {
const: false,
};
return schema;
}
function getAjv(): Ajv {
if (!ajvSingleton) {
ajvSingleton = new Ajv({
allErrors: true,
allowUnionTypes: true,
});
addJsonSchemaFormats(ajvSingleton);
addJsonSchemaErrors(ajvSingleton);
}
return ajvSingleton;
}
function getValidator(): ValidateFunction {
if (!validateJsonSchemaSingleton) {
validateJsonSchemaSingleton = getAjv().compile(
buildEnrichedDraft7MetaSchema(),
);
}
return validateJsonSchemaSingleton;
}
export function validateJsonSchema(schema: unknown): boolean {
return !!getValidator()(schema);
}
export function getJsonSchemaValidationErrors(): ErrorObject[] | null {
return getValidator().errors ?? null;
}
export function errorsText(
errors?: ErrorObject[] | null,
options?: { separator?: string; dataVar?: string },
): string {
return getAjv().errorsText(errors ?? undefined, options);
}
components/schema-editor/top-level-editor-controller.ts
import * as React from "react";
import type { ExtendedJSONSchema7 } from "@/components/schema-editor/lib/json-schema-types";
import type { SchemaEditorMode } from "@/components/schema-editor/schema-editor-mode";
export type TopLevelEditorProps = {
node: ExtendedJSONSchema7;
mode: SchemaEditorMode;
showImportExportActions?: boolean;
onTitleChange: (title: string) => void;
onDescriptionChange: (description: string) => void;
onEraseAll: () => void;
onEraseDescriptions: () => void;
onReplaceRoot: (node: ExtendedJSONSchema7) => void;
};
export type TopLevelConfirmAction = "eraseAll" | "eraseDescriptions";
export function buildTopLevelMetadataValues(node: ExtendedJSONSchema7) {
return {
title: node.title || "",
description: node.description || "",
};
}
export function useTopLevelEditorController({
node,
onTitleChange,
onDescriptionChange,
onEraseAll,
onEraseDescriptions,
}: Pick<
TopLevelEditorProps,
| "node"
| "onTitleChange"
| "onDescriptionChange"
| "onEraseAll"
| "onEraseDescriptions"
>) {
const [metadataDialogOpen, setMetadataDialogOpen] = React.useState(false);
const [confirmAction, setConfirmAction] =
React.useState<TopLevelConfirmAction | null>(null);
const [metadataValues, setMetadataValues] = React.useState(() =>
buildTopLevelMetadataValues(node),
);
const [draftTitle, setDraftTitle] = React.useState(node.title || "");
const [isTitleDirty, setIsTitleDirty] = React.useState(false);
const [draftDescription, setDraftDescription] = React.useState(
node.description || "",
);
const [isDescriptionDirty, setIsDescriptionDirty] = React.useState(false);
const [dialogPropertyName, setDialogPropertyName] = React.useState(
node.title || "",
);
const currentTitle = isTitleDirty ? draftTitle : node.title || "";
const currentDescription = isDescriptionDirty
? draftDescription
: node.description || "";
const commitTitle = React.useCallback(() => {
if (currentTitle !== (node.title || "")) {
onTitleChange(currentTitle || "");
}
setIsTitleDirty(false);
setDraftTitle(node.title || "");
}, [currentTitle, node.title, onTitleChange]);
const commitDescription = React.useCallback(() => {
if (currentDescription !== (node.description || "")) {
onDescriptionChange(currentDescription);
}
setIsDescriptionDirty(false);
setDraftDescription(node.description || "");
}, [currentDescription, node.description, onDescriptionChange]);
const openMetadataDialog = React.useCallback(() => {
setMetadataValues(buildTopLevelMetadataValues(node));
setDialogPropertyName(node.title || "");
setMetadataDialogOpen(true);
}, [node]);
const confirmDestructiveAction = React.useCallback(() => {
if (confirmAction === "eraseAll") onEraseAll();
if (confirmAction === "eraseDescriptions") onEraseDescriptions();
setConfirmAction(null);
}, [confirmAction, onEraseAll, onEraseDescriptions]);
return {
metadataDialogOpen,
setMetadataDialogOpen,
confirmAction,
setConfirmAction,
metadataValues,
setMetadataValues,
draftTitle,
setDraftTitle,
setIsTitleDirty,
draftDescription,
setDraftDescription,
setIsDescriptionDirty,
dialogPropertyName,
setDialogPropertyName,
currentTitle,
currentDescription,
commitTitle,
commitDescription,
openMetadataDialog,
confirmDestructiveAction,
};
}
components/schema-editor/top-level-editor.tsx
"use client";
import * as React from "react";
import {
EllipsisVertical,
Eye,
MessageCircleOff,
Pencil,
Trash2,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { RootDialog } from "@/components/schema-editor/root-dialog";
import {
useTopLevelEditorController,
type TopLevelEditorProps,
} from "@/components/schema-editor/top-level-editor-controller";
const LazyImportExportMenuItems = React.lazy(() =>
import("@/components/schema-editor/optional/import-export/import-export-menu-items").then(
(module) => ({
default: module.ImportExportMenuItems,
}),
),
);
export function TopLevelEditor({
node,
mode,
showImportExportActions = true,
onTitleChange,
onDescriptionChange,
onEraseAll,
onEraseDescriptions,
onReplaceRoot,
}: TopLevelEditorProps) {
const controller = useTopLevelEditorController({
node,
onTitleChange,
onDescriptionChange,
onEraseAll,
onEraseDescriptions,
});
return (
<div className="pb-4">
<div className="group flex flex-col items-start justify-between pl-0 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-1 items-center space-x-2">
<input
className="text-foreground placeholder:text-muted-foreground/72 m-0 h-5 w-full min-w-0 rounded-none border-none bg-transparent p-0 text-lg font-medium shadow-none outline-none focus-visible:ring-0 disabled:opacity-64 md:text-lg"
value={controller.currentTitle}
placeholder="Add a title to your schema"
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
controller.setDraftTitle(event.target.value);
controller.setIsTitleDirty(true);
}}
onBlur={controller.commitTitle}
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") controller.commitTitle();
}}
disabled={mode === "readOnly" || mode === "descriptionOnly"}
/>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="iconSm"
aria-label="Open schema actions"
className="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100"
>
<EllipsisVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{mode === "editable" && (
<DropdownMenuItem
onClick={() => controller.setConfirmAction("eraseAll")}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Schema
</DropdownMenuItem>
)}
{(mode === "editable" || mode === "descriptionOnly") && (
<DropdownMenuItem
onClick={() =>
controller.setConfirmAction("eraseDescriptions")
}
>
<MessageCircleOff className="mr-2 h-4 w-4" />
Delete all descriptions
</DropdownMenuItem>
)}
{showImportExportActions && (
<React.Suspense fallback={null}>
<LazyImportExportMenuItems
node={node}
onReplaceRoot={onReplaceRoot}
/>
</React.Suspense>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="pb-2">
<div className="flex items-start justify-between">
<Textarea
className="text-muted-foreground placeholder:text-muted-foreground/70 m-0 max-h-64 min-h-6 resize-none rounded-none border-none bg-transparent p-0 text-sm font-normal shadow-none outline-none focus-visible:ring-0 md:text-sm dark:bg-transparent"
value={controller.currentDescription}
placeholder="Add a description to your schema"
onChange={(event) => {
controller.setDraftDescription(event.target.value);
controller.setIsDescriptionDirty(true);
}}
onBlur={controller.commitDescription}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
controller.commitDescription();
}
}}
disabled={mode === "readOnly"}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={
mode === "readOnly"
? "View schema properties"
: "Edit schema properties"
}
className="m-0 p-0"
onClick={controller.openMetadataDialog}
>
{mode === "readOnly" ? (
<Eye className="text-muted-foreground h-1 w-1 opacity-0 group-hover:opacity-100" />
) : (
<Pencil className="text-muted-foreground h-1 w-1 opacity-0 group-hover:opacity-100" />
)}
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>
{mode === "readOnly"
? "View schema properties"
: "Edit schema properties"}
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<RootDialog
isOpen={controller.metadataDialogOpen}
onClose={() => controller.setMetadataDialogOpen(false)}
path="#"
schemaTitle={controller.dialogPropertyName}
setSchemaTitle={controller.setDialogPropertyName}
metadataValues={controller.metadataValues}
setMetadataValues={controller.setMetadataValues}
onSave={(metadata) => {
onTitleChange(metadata.title);
onDescriptionChange(metadata.description);
}}
mode={mode}
/>
<AlertDialog
open={controller.confirmAction !== null}
onOpenChange={(open) => !open && controller.setConfirmAction(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{controller.confirmAction === "eraseAll"
? "Delete schema?"
: "Delete all descriptions?"}
</AlertDialogTitle>
<AlertDialogDescription>
{controller.confirmAction === "eraseAll"
? "This clears every field in the current schema. This action cannot be undone."
: "This removes the description from every field in the schema. This action cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={controller.confirmDestructiveAction}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
components/schema-editor/type-icons.tsx
"use client";
import {
Type,
Hash,
ToggleLeft,
List,
Braces,
Brackets,
Calendar,
Clock,
CalendarClock,
Link,
Shapes,
MapPin,
DollarSign,
User,
Building2,
CalendarDays,
} from "lucide-react";
// Get icon for data type
export const getTypeIcon = (type: string) => {
switch (type) {
case "string":
return <Type className="h-4 w-4" />;
case "number":
case "integer":
return <Hash className="h-4 w-4" />;
case "boolean":
return <ToggleLeft className="h-4 w-4" />;
case "enum":
return <List className="h-4 w-4" />;
case "object":
return <Braces className="h-4 w-4" />;
case "array":
return <Brackets className="h-4 w-4" />;
case "date":
return <Calendar className="h-4 w-4" />;
case "time":
return <Clock className="h-4 w-4" />;
case "datetime":
return <CalendarClock className="h-4 w-4" />;
case "$ref":
return <Link className="h-4 w-4" />;
default:
return <Shapes className="h-4 w-4" />;
}
};
// Get icon for template objects
export const getTemplateIcon = (templateName: string) => {
switch (templateName) {
case "Address":
return <MapPin className="h-4 w-4" />;
case "Price":
return <DollarSign className="h-4 w-4" />;
case "Person":
return <User className="h-4 w-4" />;
case "Company":
return <Building2 className="h-4 w-4" />;
case "Event":
return <CalendarDays className="h-4 w-4" />;
default:
return <Link className="h-4 w-4" />;
}
};
components/schema-editor/use-schema-builder-state.ts
"use client";
import * as React from "react";
import {
fromJsonSchema,
toJsonSchema,
} from "@/components/schema-editor/document/convert";
import type { SchemaDocument } from "@/components/schema-editor/document/types";
import type {
ExtendedJSONSchema7,
SchemaBuilderState,
} from "@/components/schema-editor/schema-builder-types";
import { requireAllProperties } from "@/components/schema-editor/schema-required-policy";
import { validateProjectedSchema } from "@/components/schema-editor/validation";
import { useKeyedLayoutEffect } from "@/hooks/use-keyed-layout-effect";
import { joinEffectKey } from "@/lib/effect-key";
export interface UseSchemaBuilderStateOptions {
value: ExtendedJSONSchema7;
onValueChange: (schema: ExtendedJSONSchema7) => void;
readOnly?: boolean;
onPersist?: (schema: ExtendedJSONSchema7) => Promise<void>;
}
export function schemaSignature(value: unknown): string {
return JSON.stringify(value);
}
export function projectSchemaDocument(
doc: SchemaDocument,
): ExtendedJSONSchema7 {
return requireAllProperties(toJsonSchema(doc)) as ExtendedJSONSchema7;
}
export function useSchemaBuilderState({
value,
onValueChange,
readOnly = false,
onPersist,
}: UseSchemaBuilderStateOptions): SchemaBuilderState {
const [initialSignature] = React.useState(() => schemaSignature(value));
const [doc, setDoc] = React.useState<SchemaDocument>(() =>
fromJsonSchema(value),
);
const lastImportedSignatureRef = React.useRef(initialSignature);
const lastEmittedSignatureRef = React.useRef<string | null>(null);
const propSignature = React.useMemo(() => schemaSignature(value), [value]);
useKeyedLayoutEffect(joinEffectKey([propSignature, value]), () => {
if (propSignature === lastEmittedSignatureRef.current) {
lastImportedSignatureRef.current = propSignature;
return;
}
if (propSignature !== lastImportedSignatureRef.current) {
lastImportedSignatureRef.current = propSignature;
setDoc(fromJsonSchema(value));
}
});
const schema = React.useMemo(() => projectSchemaDocument(doc), [doc]);
const validation = React.useMemo(
() => validateProjectedSchema(schema),
[schema],
);
const docRef = React.useRef(doc);
useKeyedLayoutEffect(joinEffectKey([doc]), () => {
docRef.current = doc;
});
const schemaRef = React.useRef(schema);
useKeyedLayoutEffect(joinEffectKey([schema]), () => {
schemaRef.current = schema;
});
const emitSchema = React.useCallback(
(nextDoc: SchemaDocument, persist: boolean | undefined) => {
const nextSchema = projectSchemaDocument(nextDoc);
const nextSignature = schemaSignature(nextSchema);
lastEmittedSignatureRef.current = nextSignature;
lastImportedSignatureRef.current = nextSignature;
schemaRef.current = nextSchema;
onValueChange(nextSchema);
if ((persist ?? true) && onPersist) {
void onPersist(nextSchema);
}
},
[onPersist, onValueChange],
);
const dispatch = React.useCallback(
(op: (doc: SchemaDocument) => SchemaDocument, persist?: boolean) => {
if (readOnly) return;
const previous = docRef.current;
const next = op(previous);
if (next === previous) return;
docRef.current = next;
setDoc(next);
emitSchema(next, persist);
},
[emitSchema, readOnly],
);
const replaceSchema = React.useCallback(
async (
nextValue: React.SetStateAction<ExtendedJSONSchema7>,
persist?: boolean,
) => {
if (readOnly) return;
const resolved = requireAllProperties(
typeof nextValue === "function"
? nextValue(schemaRef.current)
: nextValue,
) as ExtendedJSONSchema7;
const nextSignature = schemaSignature(resolved);
if (nextSignature === schemaSignature(schemaRef.current)) {
if ((persist ?? true) && onPersist) {
await onPersist(resolved);
}
return;
}
const nextDoc = fromJsonSchema(resolved);
docRef.current = nextDoc;
setDoc(nextDoc);
lastEmittedSignatureRef.current = nextSignature;
lastImportedSignatureRef.current = nextSignature;
schemaRef.current = resolved;
onValueChange(resolved);
if ((persist ?? true) && onPersist) {
await onPersist(resolved);
}
},
[onPersist, onValueChange, readOnly],
);
return {
doc,
schema,
validation,
dispatch,
replaceSchema,
};
}
components/schema-editor/validation-error-display.tsx
"use client";
import { useState } from "react";
import { AlertCircle, ChevronDown, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
interface ValidationErrorDisplayProps {
validationErrors?: string | null;
className?: string;
/** Whether to show the full error panel or just an inline indicator. */
variant?: "full" | "inline" | "compact";
}
export function ValidationErrorDisplay({
validationErrors,
className,
variant = "full",
}: ValidationErrorDisplayProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (!validationErrors) {
return null;
}
const errorItems = validationErrors.split("\n\n").filter(Boolean);
const toggle = (
<Button
type="button"
variant="ghost"
onClick={() => setIsExpanded(!isExpanded)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-auto w-full justify-start gap-2 px-2 py-1.5"
title={isExpanded ? "Collapse error details" : "Expand error details"}
>
{isExpanded ? (
<ChevronDown className="size-4 shrink-0" />
) : (
<ChevronRight className="size-4 shrink-0" />
)}
<AlertCircle className="size-4 shrink-0" />
<span className="text-sm font-medium">
{variant === "compact"
? `${errorItems.length} validation error${errorItems.length !== 1 ? "s" : ""}`
: `Schema validation errors (${errorItems.length})`}
</span>
</Button>
);
const errorList = isExpanded ? (
<div className="border-destructive/30 space-y-2 border-t px-3 py-2">
{errorItems.map((error, index) => (
<p
key={index}
className="text-destructive text-sm leading-relaxed break-words whitespace-pre-wrap"
>
{error}
</p>
))}
</div>
) : null;
if (variant === "compact") {
return (
<div className={cn("border-destructive border-l-2", className)}>
{toggle}
{errorList}
</div>
);
}
return (
<div
className={cn(
"border-destructive bg-destructive/10 rounded-md border",
className,
)}
>
{toggle}
{errorList}
</div>
);
}
components/schema-editor/validation.ts
"use client";
import type { ErrorObject } from "ajv";
import type { JSONSchema7Definition } from "json-schema";
import {
errorsText,
getJsonSchemaValidationErrors,
validateJsonSchema,
} from "@/components/schema-editor/schema-validation";
import type {
ExtendedJSONSchema7,
SchemaValidationIssue,
SchemaValidationResult,
} from "@/components/schema-editor/schema-builder-types";
import { decodeJsonPointerSegment } from "@/components/schema-editor/document/json-pointer";
const PROPERTY_LIMIT = 500;
const SCHEMA_VALUE_KEYS = [
"additionalItems",
"additionalProperties",
"contains",
"else",
"if",
"not",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties",
] as const;
const SCHEMA_MAP_KEYS = [
"$defs",
"definitions",
"dependentSchemas",
"dependencies",
"patternProperties",
] as const;
export function validateProjectedSchema(
schema: ExtendedJSONSchema7,
): SchemaValidationResult {
const propertyCount = countSchemaProperties(schema);
const isPropertyLimitExceeded = propertyCount > PROPERTY_LIMIT;
const ajvValid = validateJsonSchema(schema);
const ajvErrors = processValidationErrors(getJsonSchemaValidationErrors());
const errors = ajvErrors.map(errorToIssue);
if (isPropertyLimitExceeded) {
errors.unshift({
code: "property_limit_exceeded",
path: "",
message: `Schema has too many properties: ${propertyCount}. Maximum accepted is ${PROPERTY_LIMIT}. Please reduce the number of properties.`,
});
}
return {
isValid: ajvValid && !isPropertyLimitExceeded,
errors,
propertyCount,
isPropertyLimitExceeded,
};
}
export function validationErrorsText(
validation: SchemaValidationResult,
): string | undefined {
if (validation.isValid) return undefined;
return validation.errors.map((issue) => issue.message).join("\n\n");
}
export function countSchemaProperties(schema?: ExtendedJSONSchema7): number {
if (!schema || typeof schema !== "object") return 0;
const resolveLocalRef = (ref: string): ExtendedJSONSchema7 | undefined => {
if (!ref.startsWith("#/")) return undefined;
const segments = ref.substring(2).split("/").map(decodeJsonPointerSegment);
let refSchema: unknown = schema;
for (const segment of segments) {
if (refSchema && typeof refSchema === "object") {
const record = refSchema as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(record, segment)) {
return undefined;
}
refSchema = record[segment];
} else {
return undefined;
}
}
return refSchema && typeof refSchema === "object"
? (refSchema as ExtendedJSONSchema7)
: undefined;
};
const countedSchemas = new Set<ExtendedJSONSchema7>();
const countProperties = (
node: ExtendedJSONSchema7,
path: string = "",
activeSchemas: Set<ExtendedJSONSchema7> = new Set(),
): number => {
if (!node || typeof node !== "object") return 0;
if (countedSchemas.has(node)) return 0;
countedSchemas.add(node);
let count = 0;
if (node.$ref && typeof node.$ref === "string") {
const refSchema = resolveLocalRef(node.$ref);
if (refSchema && !activeSchemas.has(refSchema)) {
count += countProperties(
refSchema,
`${path}>${node.$ref}`,
new Set([...activeSchemas, refSchema]),
);
}
}
if (node.properties && typeof node.properties === "object") {
count += Object.keys(node.properties).length;
Object.entries(node.properties).forEach(([propertyName, propSchema]) => {
count += countProperties(
propSchema as ExtendedJSONSchema7,
`${path}/properties/${propertyName}`,
activeSchemas,
);
});
}
if (node.items) {
if (Array.isArray(node.items)) {
node.items.forEach((item, index) => {
count += countProperties(
item as ExtendedJSONSchema7,
`${path}/items/${index}`,
activeSchemas,
);
});
} else {
count += countProperties(
node.items as ExtendedJSONSchema7,
`${path}/items`,
activeSchemas,
);
}
}
const record = node as Record<string, unknown>;
if (Array.isArray(record.prefixItems)) {
record.prefixItems.forEach((item, index) => {
count += countProperties(
item as ExtendedJSONSchema7,
`${path}/prefixItems/${index}`,
activeSchemas,
);
});
}
for (const keyword of ["allOf", "anyOf", "oneOf"] as const) {
const compositionArray = record[keyword];
if (Array.isArray(compositionArray)) {
compositionArray.forEach((subSchema, index) => {
count += countProperties(
subSchema as ExtendedJSONSchema7,
`${path}/${keyword}/${index}`,
activeSchemas,
);
});
}
}
for (const keyword of SCHEMA_VALUE_KEYS) {
const value = record[keyword];
if (isSchemaObject(value)) {
count += countProperties(
value as ExtendedJSONSchema7,
`${path}/${keyword}`,
activeSchemas,
);
}
}
for (const keyword of SCHEMA_MAP_KEYS) {
const value = record[keyword];
if (!isSchemaObject(value)) continue;
for (const [name, child] of Object.entries(value)) {
count += countProperties(
child as ExtendedJSONSchema7,
`${path}/${keyword}/${name}`,
activeSchemas,
);
}
}
return count;
};
return countProperties(schema);
}
function isSchemaObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function processValidationErrors(
errors: ErrorObject[] | null | undefined,
): ErrorObject[] {
if (!errors || errors.length === 0) return [];
const processedErrors: ErrorObject[] = [];
const processedPropertyNames = new Set<string>();
for (const error of errors) {
if (
error.keyword === "pattern" &&
error.schemaPath?.includes("/propertyNames/pattern") &&
error.data &&
typeof error.data === "string"
) {
const propertyName = error.data;
if (!processedPropertyNames.has(propertyName)) {
processedErrors.push({
keyword: "propertyNames",
instancePath: error.instancePath,
schemaPath: error.schemaPath,
params: { propertyName },
message: `Property name "${propertyName}" must not be purely numeric`,
data: error.data,
schema: error.schema,
});
processedPropertyNames.add(propertyName);
}
} else if (
error.keyword === "propertyNames" &&
error.params?.propertyName
) {
const propertyName = error.params.propertyName;
if (!processedPropertyNames.has(propertyName)) {
processedErrors.push({
keyword: "propertyNames",
instancePath: error.instancePath,
schemaPath: error.schemaPath,
params: { propertyName },
message: `Property name "${propertyName}" must not be purely numeric`,
data: error.data,
schema: error.schema,
});
processedPropertyNames.add(propertyName);
}
} else if (
error.keyword === "const" &&
error.schemaPath?.includes("/additionalProperties/const")
) {
processedErrors.push({
keyword: "additionalProperties",
instancePath: error.instancePath,
schemaPath: error.schemaPath,
params: error.params,
message:
"`additionalProperties` are forbidden, if present they MUST be false.",
data: error.data,
schema: error.schema,
});
} else if (
error.keyword !== "propertyNames" &&
!(
error.keyword === "pattern" &&
error.schemaPath?.includes("/propertyNames/pattern")
) &&
!(
error.keyword === "const" &&
error.schemaPath?.includes("/additionalProperties/const")
)
) {
processedErrors.push(error);
}
}
return processedErrors;
}
function errorToIssue(error: ErrorObject): SchemaValidationIssue {
if (error.keyword === "propertyNames") {
return {
code: "numeric_property_name",
path: error.instancePath,
message:
error.message ??
`Property name "${String(error.params?.propertyName ?? "")}" must not be purely numeric`,
source: error,
};
}
if (error.keyword === "additionalProperties") {
return {
code: "additional_properties_not_false",
path: error.instancePath,
message:
error.message ??
"`additionalProperties` are forbidden, if present they MUST be false.",
source: error,
};
}
return {
code: "invalid_schema",
path: error.instancePath,
message:
error.message ??
errorsText([error], {
separator: "\n\n",
}),
source: error,
};
}
export function isJsonSchemaDefinition(
value: unknown,
): value is JSONSchema7Definition {
return (
typeof value === "boolean" || (typeof value === "object" && value !== null)
);
}
registry/new-york-v4/ui/accordion.tsx
"use client";
import * as React from "react";
import { ChevronDownIcon } from "lucide-react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };