shadimage
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 · light
ajv@^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 };

More from retab

Browse all components in this library →