Refactor renamed the component groups to something less obtuse:

Elements -> Groups -> Screens -> Views
This commit is contained in:
cliffhall 2026-03-28 12:58:47 -04:00
commit dd46acba87
2 changed files with 151 additions and 144 deletions

View file

@ -0,0 +1,182 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { SchemaForm } from "./SchemaForm";
const meta: Meta<typeof SchemaForm> = {
title: "Groups/SchemaForm",
component: SchemaForm,
args: {
onChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof SchemaForm>;
export const StringFields: Story = {
args: {
schema: {
type: "object",
properties: {
name: { type: "string", title: "Name" },
description: { type: "string", title: "Description" },
},
required: ["name"],
},
values: {},
},
};
export const NumberFields: Story = {
args: {
schema: {
type: "object",
properties: {
age: { type: "integer", title: "Age", minimum: 0, maximum: 150 },
price: { type: "number", title: "Price", minimum: 0 },
},
},
values: {},
},
};
export const BooleanFields: Story = {
args: {
schema: {
type: "object",
properties: {
enabled: { type: "boolean", title: "Enabled" },
verbose: { type: "boolean", title: "Verbose" },
},
},
values: {},
},
};
export const EnumDropdown: Story = {
args: {
schema: {
type: "object",
properties: {
format: {
type: "string",
title: "Format",
enum: ["json", "csv", "xml"],
},
},
},
values: {},
},
};
export const TitledEnum: Story = {
args: {
schema: {
type: "object",
properties: {
size: {
type: "string",
title: "Size",
oneOf: [
{ const: "small", title: "Small (1-10)" },
{ const: "medium", title: "Medium (11-100)" },
{ const: "large", title: "Large (100+)" },
],
},
},
},
values: {},
},
};
export const MixedTypes: Story = {
args: {
schema: {
type: "object",
properties: {
name: { type: "string", title: "Name" },
count: { type: "number", title: "Count" },
active: { type: "boolean", title: "Active" },
color: {
type: "string",
title: "Color",
enum: ["red", "green", "blue"],
},
},
},
values: {},
},
};
export const WithDefaults: Story = {
args: {
schema: {
type: "object",
properties: {
name: { type: "string", title: "Name", default: "John Doe" },
count: { type: "number", title: "Count", default: 42 },
active: { type: "boolean", title: "Active", default: true },
format: {
type: "string",
title: "Format",
enum: ["json", "csv", "xml"],
default: "json",
},
},
},
values: {},
},
};
export const WithDescriptions: Story = {
args: {
schema: {
type: "object",
properties: {
name: {
type: "string",
title: "Name",
description: "Enter your full name as it appears on your ID",
},
email: {
type: "string",
title: "Email",
description: "A valid email address for notifications",
},
age: {
type: "integer",
title: "Age",
description: "Your age in years",
minimum: 0,
maximum: 150,
},
},
},
values: {},
},
};
export const Disabled: Story = {
args: {
schema: {
type: "object",
properties: {
name: { type: "string", title: "Name" },
count: { type: "number", title: "Count" },
active: { type: "boolean", title: "Active" },
color: {
type: "string",
title: "Color",
enum: ["red", "green", "blue"],
},
},
},
values: {
name: "Example",
count: 10,
active: true,
color: "green",
},
disabled: true,
},
};

View file

@ -0,0 +1,244 @@
import {
Checkbox,
JsonInput,
MultiSelect,
NumberInput,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
export interface JsonSchema {
type?: string;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
enum?: string[];
oneOf?: Array<{ const: string; title?: string }>;
anyOf?: Array<{ const: string; title?: string }>;
default?: unknown;
description?: string;
title?: string;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
format?: string;
}
const FieldLabel = Text.withProps({
fw: 500,
size: "sm",
});
const FieldDescription = Text.withProps({
size: "xs",
c: "dimmed",
});
function serializeJson(value: unknown): string {
return JSON.stringify(value, null, 2);
}
export interface SchemaFormProps {
schema: JsonSchema;
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
disabled?: boolean;
}
function getDefaultValue(fieldSchema: JsonSchema): unknown {
if (fieldSchema.default !== undefined) {
return fieldSchema.default;
}
return undefined;
}
function resolveValue(value: unknown, fieldSchema: JsonSchema): unknown {
if (value !== undefined) {
return value;
}
return getDefaultValue(fieldSchema);
}
export function SchemaForm({
schema,
values,
onChange,
disabled = false,
}: SchemaFormProps) {
const properties = schema.properties ?? {};
const requiredFields = schema.required ?? [];
function handleFieldChange(fieldName: string, fieldValue: unknown) {
onChange({ ...values, [fieldName]: fieldValue });
}
function renderField(fieldName: string, fieldSchema: JsonSchema) {
const isRequired = requiredFields.includes(fieldName);
const label = fieldSchema.title ?? fieldName;
const description = fieldSchema.description;
const rawValue = resolveValue(values[fieldName], fieldSchema);
// string with enum
if (fieldSchema.type === "string" && fieldSchema.enum) {
return (
<Select
key={fieldName}
label={label}
description={description}
withAsterisk={isRequired}
disabled={disabled}
data={fieldSchema.enum}
value={(rawValue as string) ?? null}
onChange={(val) => handleFieldChange(fieldName, val)}
/>
);
}
// string with oneOf
if (fieldSchema.type === "string" && fieldSchema.oneOf) {
const data = fieldSchema.oneOf.map((item) => ({
value: item.const,
label: item.title ?? item.const,
}));
return (
<Select
key={fieldName}
label={label}
description={description}
withAsterisk={isRequired}
disabled={disabled}
data={data}
value={(rawValue as string) ?? null}
onChange={(val) => handleFieldChange(fieldName, val)}
/>
);
}
// plain string
if (fieldSchema.type === "string") {
return (
<TextInput
key={fieldName}
label={label}
description={description}
withAsterisk={isRequired}
disabled={disabled}
value={(rawValue as string) ?? ""}
minLength={fieldSchema.minLength}
maxLength={fieldSchema.maxLength}
onChange={(event) =>
handleFieldChange(fieldName, event.currentTarget.value)
}
/>
);
}
// number or integer
if (fieldSchema.type === "number" || fieldSchema.type === "integer") {
return (
<NumberInput
key={fieldName}
label={label}
description={description}
withAsterisk={isRequired}
disabled={disabled}
value={(rawValue as number) ?? ""}
min={fieldSchema.minimum}
max={fieldSchema.maximum}
onChange={(val) => {
const numericValue = typeof val === "string" ? undefined : val;
handleFieldChange(fieldName, numericValue);
}}
/>
);
}
// boolean
if (fieldSchema.type === "boolean") {
return (
<Checkbox
key={fieldName}
label={label}
description={description}
disabled={disabled}
checked={(rawValue as boolean) ?? false}
onChange={(event) =>
handleFieldChange(fieldName, event.currentTarget.checked)
}
/>
);
}
// array with items having anyOf
if (fieldSchema.type === "array" && fieldSchema.items?.anyOf) {
const data = fieldSchema.items.anyOf.map((item) => ({
value: item.const,
label: item.title ?? item.const,
}));
return (
<MultiSelect
key={fieldName}
label={label}
description={description}
withAsterisk={isRequired}
disabled={disabled}
data={data}
value={(rawValue as string[]) ?? []}
onChange={(val) => handleFieldChange(fieldName, val)}
/>
);
}
// nested object
if (fieldSchema.type === "object" && fieldSchema.properties) {
return (
<Stack key={fieldName} gap="sm">
<FieldLabel>{label}</FieldLabel>
{description && <FieldDescription>{description}</FieldDescription>}
<Stack gap="sm" pl="md">
<SchemaForm
schema={fieldSchema}
values={(rawValue as Record<string, unknown>) ?? {}}
onChange={(nestedValues) =>
handleFieldChange(fieldName, nestedValues)
}
disabled={disabled}
/>
</Stack>
</Stack>
);
}
// fallback: JsonInput for complex schemas
return (
<JsonInput
key={fieldName}
label={label}
description={description}
withAsterisk={isRequired}
disabled={disabled}
formatOnBlur
autosize
value={rawValue !== undefined ? serializeJson(rawValue) : ""}
onChange={(val) => {
try {
handleFieldChange(fieldName, JSON.parse(val));
} catch {
handleFieldChange(fieldName, val);
}
}}
/>
);
}
return (
<Stack gap="sm">
{Object.entries(properties).map(([fieldName, fieldSchema]) =>
renderField(fieldName, fieldSchema),
)}
</Stack>
);
}