Refactor renamed the component groups to something less obtuse:
Elements -> Groups -> Screens -> Views
This commit is contained in:
parent
ca6de91a96
commit
dd46acba87
2 changed files with 151 additions and 144 deletions
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
244
clients/web/src/components/groups/SchemaForm/SchemaForm.tsx
Normal file
244
clients/web/src/components/groups/SchemaForm/SchemaForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue