Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,35 @@ export function ValidateButton({ schema }: { schema: JsonSchema }) {

Validation runs as the user types and reports syntax errors, schema validation errors, and line or column locations when available.

## External References

By default the editor never loads anything over the network: external `$ref` targets (anything that does not start with `#`) are preserved exactly as written, but cannot be previewed or validated against. To opt in, pass a resolver through the `resolveExternalRef` prop:

```tsx
import { fetchExternalRef, SchemaBuilder, ValidateJsonDialog } from "jsonjoy-builder";

<SchemaBuilder
value={schema}
onChange={setSchema}
resolveExternalRef={fetchExternalRef}
/>

<ValidateJsonDialog
open={open}
onOpenChange={setOpen}
schema={schema}
resolveExternalRef={fetchExternalRef}
/>
```

With a resolver configured:

- the reference editor loads external documents (e.g. `https://raw.githubusercontent.com/cloudevents/spec/refs/tags/v1.0.2/cloudevents/formats/cloudevents.json`), resolves URI fragments such as `…/schema.json#/definitions/specversion`, and shows the same read-only preview as for local references
- `ValidateJsonDialog` validates documents against schemas that reference external documents, loading them (and any documents they reference in turn) on demand
- each document is loaded at most once per session and failed loads are retried on the next attempt

`fetchExternalRef` is a plain `fetch`-based loader, so the usual browser rules apply: the remote host must allow cross-origin requests. You can also pass your own resolver — anything `(documentUri: string) => Promise<JsonSchema>` — to serve schemas from a registry, a bundle, or an authenticated API. Relative references inside externally loaded documents are not resolved against the document's base URI.

## Themes and Styling

JSONJoy Builder ships with default light and dark theme variables. All exported components include a `.jsonjoy` wrapper, so you can theme one editor instance or your whole app.
Expand Down Expand Up @@ -275,6 +304,8 @@ The visual editor covers the common schema authoring flow:
- Array item type, length, uniqueness, and contains constraints
- Boolean allowed-value constraints
- `anyOf`, `oneOf`, and `allOf` composition
- `$ref` references with a definition picker, broken-reference warnings, and a read-only preview of the referenced schema; external references can be loaded through the opt-in `resolveExternalRef` prop
- Reusable definitions (`$defs` and legacy `definitions`): create, edit, and delete them in the visual editor; renaming a definition rewrites every `$ref` that points at it
- `additionalProperties` controls

You can still edit unsupported JSON Schema keywords directly in the JSON source editor.
Expand Down
3 changes: 3 additions & 0 deletions demo/pages/Index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { exampleSchema } from "../../demo/utils/schemaExample.ts";
import { Button } from "../../src/components/ui/button.tsx";
import { en } from "../../src/i18n/locales/en.ts";
import {
fetchExternalRef,
InferSchemaDialog,
type JsonSchema,
SchemaBuilder,
Expand Down Expand Up @@ -221,6 +222,7 @@ const Index = () => {
readOnly={readOnly}
onChange={setSchema}
className="shadow-lg animate-in border-border/50 backdrop-blur-xs"
resolveExternalRef={fetchExternalRef}
/>
</div>

Expand All @@ -236,6 +238,7 @@ const Index = () => {
open={validateDialogOpen}
onOpenChange={setValidateDialogOpen}
schema={schema}
resolveExternalRef={fetchExternalRef}
/>

{/* How It Works - kept within max-w-4xl */}
Expand Down
12 changes: 11 additions & 1 deletion src/components/SchemaEditor/AddFieldButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CirclePlus } from "lucide-react";
import { type FC, type FormEvent, useId, useState } from "react";
import { type FC, type FormEvent, useContext, useId, useState } from "react";
import { Badge } from "../../components/ui/badge.tsx";
import { Button } from "../../components/ui/button.tsx";
import {
Expand All @@ -11,7 +11,9 @@ import {
DialogTitle,
} from "../../components/ui/dialog.tsx";
import { Input } from "../../components/ui/input.tsx";
import { RootSchemaContext } from "../../hooks/use-root-schema.ts";
import { useTranslation } from "../../hooks/use-translation.ts";
import { collectRefTargets } from "../../lib/refUtils.ts";
import { cn } from "../../lib/utils.ts";
import type { NewField, SchemaEditorType } from "../../types/jsonSchema.ts";
import SchemaTypeSelector from "./SchemaTypeSelector.tsx";
Expand Down Expand Up @@ -45,6 +47,7 @@ const AddFieldButton: FC<AddFieldButtonProps> = ({
const formId = useId();

const t = useTranslation();
const rootSchema = useContext(RootSchemaContext);
const regexError = (() => {
if (!useNameRegex || !fieldName.trim()) return "";
try {
Expand Down Expand Up @@ -74,6 +77,12 @@ const AddFieldButton: FC<AddFieldButtonProps> = ({
required: useNameRegex ? false : fieldRequired,
additionalProperties:
fieldType === "object" ? additionalProperties : undefined,
// New references point at the first definition in the document,
// or at the root when there is none yet
validation:
fieldType === "ref" && rootSchema !== undefined
? { $ref: collectRefTargets(rootSchema)[0]?.pointer ?? "#" }
: undefined,
};

if (useNameRegex) {
Expand Down Expand Up @@ -250,6 +259,7 @@ const AddFieldButton: FC<AddFieldButtonProps> = ({
{fieldType === "anyOf" && '{ "anyOf": [...] }'}
{fieldType === "oneOf" && '{ "oneOf": [...] }'}
{fieldType === "allOf" && '{ "allOf": [...] }'}
{fieldType === "ref" && '{ "$ref": "#/$defs/address" }'}
</code>
</div>
</div>
Expand Down
160 changes: 160 additions & 0 deletions src/components/SchemaEditor/DefinitionsEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { CirclePlus } from "lucide-react";
import { type FC, type FormEvent, useMemo, useState } from "react";
import { useTranslation } from "../../hooks/use-translation.ts";
import {
getRootDefinitions,
removeDefinition,
renameDefinition,
updateDefinition,
} from "../../lib/refUtils.ts";
import { cn } from "../../lib/utils.ts";
import type { JsonSchema, ObjectJsonSchema } from "../../types/jsonSchema.ts";
import { asObjectSchema } from "../../types/jsonSchema.ts";
import { buildValidationTree } from "../../types/validation.ts";
import { Button } from "../ui/button.tsx";
import { Input } from "../ui/input.tsx";
import { DefinitionSchemaPropertyEditor } from "./SchemaPropertyEditor.tsx";

interface DefinitionsEditorProps {
schema: JsonSchema;
readOnly: boolean;
onChange: (schema: JsonSchema) => void;
autoFocus?: boolean;
}

/**
* Edits the reusable definitions of the document ($defs and the legacy
* definitions keyword), which can be referenced elsewhere with $ref.
*/
const DefinitionsEditor: FC<DefinitionsEditorProps> = ({
schema,
readOnly = false,
onChange,
autoFocus = true,
}) => {
const t = useTranslation();
const [newName, setNewName] = useState("");
const [nameError, setNameError] = useState(false);

const definitions = getRootDefinitions(schema);
const validationTree = useMemo(
() => buildValidationTree(schema, t),
[schema, t],
);

if (readOnly && definitions.length === 0) return null;

const nameExists = (name: string) =>
definitions.some((definition) => definition.name === name);

const handleAdd = (e: FormEvent) => {
e.preventDefault();
const name = newName.trim();
if (!name) return;
if (nameExists(name)) {
setNameError(true);
return;
}

onChange(
updateDefinition(asObjectSchema(schema), "$defs", name, {
type: "object",
}),
);
setNewName("");
setNameError(false);
};

return (
<div className="mt-6 pt-4 border-t">
<h4 className="font-medium text-sm">{t.definitionsTitle}</h4>
<p className="text-xs text-muted-foreground italic mt-1 mb-3">
{t.definitionsDescription}
</p>

{definitions.map((definition) => (
<DefinitionSchemaPropertyEditor
key={`${definition.container}:${definition.name}`}
name={definition.name}
pointer={definition.pointer}
schema={definition.schema}
schemaKey={definition.pointer}
readOnly={readOnly}
autoFocus={autoFocus}
validationNode={
validationTree.children[
`${definition.container}:${definition.name}`
]
Comment on lines +85 to +87

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation tree lookup uses ${definition.container}:${definition.name} as the key, which matches how buildValidationTree writes its children. That's correct. However, buildValidationTree processes $defs and definitions as separate passes — if a document happens to define the same name in both containers (e.g. $defs.address and definitions.address), the definitions entry silently overwrites the $defs entry in the tree, and only one of them gets validation feedback. Unlikely in practice, but a latent collision.

}
onDelete={() =>
onChange(
removeDefinition(
asObjectSchema(schema),
definition.container,
definition.name,
),
)
}
onNameChange={(name) => {
if (!name || nameExists(name)) return;
onChange(
renameDefinition(
asObjectSchema(schema),
definition.container,
definition.name,
name,
),
Comment on lines +101 to +106

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user types a name that already exists, the rename is silently swallowed — no toast, no inline error, no tooltip. The user sees the field revert to its old name and may not understand why. The add flow already has a nameError state with a visible message (definitionNameExists); it would be a nice touch to surface the same feedback for renames.

);
}}
onSchemaChange={(updated: ObjectJsonSchema) =>
onChange(
updateDefinition(
asObjectSchema(schema),
definition.container,
definition.name,
updated,
),
)
}
/>
))}

{!readOnly && (
<form onSubmit={handleAdd} className="mt-3">
<div className="flex items-center gap-2">
<Input
aria-label={t.definitionsTitle}
value={newName}
onChange={(e) => {
setNewName(e.target.value);
setNameError(false);
}}
placeholder={t.definitionNamePlaceholder}
aria-invalid={nameError ? true : undefined}
className={cn(
"h-8 text-sm font-mono max-w-[260px]",
nameError && "border-destructive",
)}
/>
<Button
type="submit"
variant="outline"
size="sm"
className="flex items-center gap-1.5"
>
<CirclePlus size={14} />
{t.definitionAddButton}
</Button>
</div>
{nameError && (
<p className="text-xs text-destructive mt-1.5">
{t.definitionNameExists}
</p>
)}
</form>
)}
</div>
);
};

export default DefinitionsEditor;
14 changes: 14 additions & 0 deletions src/components/SchemaEditor/SchemaBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useControllableSchema } from "../../hooks/use-controllable-schema.ts";
import { useTranslation } from "../../hooks/use-translation.ts";
import { SchemaBuilderProvider } from "../../i18n/schema-builder-config.tsx";
import type { Translation } from "../../i18n/translation-keys.ts";
import type { ExternalRefResolver } from "../../lib/refUtils.ts";
import { cn } from "../../lib/utils.ts";
import type { JsonSchema } from "../../types/jsonSchema.ts";
import SchemaFieldsEditor from "./SchemaFieldsEditor.tsx";
Expand All @@ -24,6 +25,13 @@ export interface SchemaBuilderProps {
autoFocus?: boolean;
locale?: Translation;
messages?: Partial<Translation>;
/**
* Opt-in loader for external $ref targets, used to preview the
* referenced schemas. When omitted, external references are
* preserved but never loaded. Pass `fetchExternalRef` for plain
* HTTP(S) loading.
*/
resolveExternalRef?: ExternalRefResolver;
}

/** @public */
Expand All @@ -36,6 +44,7 @@ const SchemaBuilder: FC<SchemaBuilderProps> = ({
autoFocus = true,
locale,
messages,
resolveExternalRef,
}) => {
const [schema, setSchema] = useControllableSchema({
value,
Expand All @@ -51,6 +60,7 @@ const SchemaBuilder: FC<SchemaBuilderProps> = ({
readOnly={readOnly}
className={className}
autoFocus={autoFocus}
resolveExternalRef={resolveExternalRef}
/>
</SchemaBuilderProvider>
);
Expand All @@ -62,6 +72,7 @@ interface SchemaBuilderContentProps {
readOnly?: boolean;
className?: string;
autoFocus?: boolean;
resolveExternalRef?: ExternalRefResolver;
}

const SchemaBuilderContent: FC<SchemaBuilderContentProps> = ({
Expand All @@ -70,6 +81,7 @@ const SchemaBuilderContent: FC<SchemaBuilderContentProps> = ({
readOnly = false,
className,
autoFocus = true,
resolveExternalRef,
}) => {
const t = useTranslation();

Expand Down Expand Up @@ -174,6 +186,7 @@ const SchemaBuilderContent: FC<SchemaBuilderContentProps> = ({
value={value}
onChange={onChange}
autoFocus={autoFocus}
resolveExternalRef={resolveExternalRef}
/>
) : (
<SchemaJsonEditor
Expand Down Expand Up @@ -215,6 +228,7 @@ const SchemaBuilderContent: FC<SchemaBuilderContentProps> = ({
value={value}
onChange={onChange}
autoFocus={autoFocus}
resolveExternalRef={resolveExternalRef}
/>
</div>
{/** biome-ignore lint/a11y/noStaticElementInteractions: What exactly does this div do? */}
Expand Down
Loading