diff --git a/changelog.md b/changelog.md index 4c1460d6..8b94370f 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,7 @@ - New _DataCard_ and _DataGroup_ components. - Typography docs now cover font weight, line height and letter spacing scales, with improved Storybook examples. +- `JsonFormsControls.layoutRenderers`: a JsonForms vertical layout with consistent spacing between fields. ### Changed diff --git a/dev/src/App.tsx b/dev/src/App.tsx index dd74376b..600518d1 100644 --- a/dev/src/App.tsx +++ b/dev/src/App.tsx @@ -20,6 +20,7 @@ import { } from "@mui/material"; import { Blocks as ComponentsIcon, + ClipboardList as FormsIcon, Home as HomeIcon, Layers as LayersIcon, Menu as MenuIcon, @@ -57,6 +58,8 @@ import { Breadcrumbs } from "../../src/components/navigation/Breadcrumbs"; import { Bar } from "../../src/components/controls/Bar"; import { Logo } from "../../src/components/controls/Logo"; +import { FormsPage } from "./FormsPage"; + /* TABLE */ export const SimpleMuiTableExample = () => { @@ -259,6 +262,11 @@ const navigation: Navigation = [ icon: , linkProps: { to: "/components", component: NavLink }, }, + { + label: "Forms", + icon: , + linkProps: { to: "/forms", component: NavLink }, + }, { label: "Colours", icon: , @@ -330,6 +338,7 @@ const AppLayout = () => { } /> } /> + } /> } /> } /> } /> diff --git a/dev/src/FormsPage.tsx b/dev/src/FormsPage.tsx new file mode 100644 index 00000000..7db8dee7 --- /dev/null +++ b/dev/src/FormsPage.tsx @@ -0,0 +1,487 @@ +import * as React from "react"; +import { + Autocomplete, + Box, + Button, + Checkbox, + FormControl, + FormControlLabel, + FormGroup, + FormHelperText, + FormLabel, + InputAdornment, + InputLabel, + MenuItem, + Radio, + RadioGroup, + Select, + Slider, + Stack, + Switch, + TextField, + Typography, +} from "@mui/material"; +import { JsonForms } from "@jsonforms/react"; +import { + materialCells, + materialRenderers, +} from "@jsonforms/material-renderers"; +import type { JsonSchema, UISchemaElement } from "@jsonforms/core"; + +import { JsonFormsControls } from "../../src/index"; + +const beamlines = ["I03", "I04", "I15-1", "I24", "B24", "DIAD"]; + +const Section = ({ + title, + description, + children, +}: { + title: string; + description?: React.ReactNode; + children: React.ReactNode; +}) => ( + + + {title} + + {description && ( + + {description} + + )} + {children} + +); + +const textFieldVariants = ["outlined", "filled", "standard"] as const; + +const MuiTextFields = () => ( + + {textFieldVariants.map((variant) => ( + + + {variant} + + + + + keV, + }, + }} + /> + + + + + + ))} + +); + +const MuiSelectionControls = () => { + const [beamline, setBeamline] = React.useState("I24"); + + return ( + + + + Beamline + + + + Read only + + + + Disabled + + + ( + + )} + /> + ( + + )} + /> + + + Energy (keV) + + + + + + + + Detector options + + } + label="Save raw frames" + /> + } + label="Dark-field correction" + /> + } + label="Disabled" + /> + + + + Scan type + + } label="Step" /> + } label="Fly" /> + } + label="Grid" + disabled + /> + + + + Shutter + } label="Open shutter" /> + Interlock not satisfied + + + + + + + + + ); +}; + +const jsonFormsRenderers = [ + ...materialRenderers, + ...JsonFormsControls.layoutRenderers, +]; + +const jsonFormsSchema: JsonSchema = { + type: "object", + properties: { + sampleName: { type: "string", minLength: 3 }, + proposal: { type: "string", pattern: "^[a-z]{2}[0-9]+$" }, + beamline: { type: "string", enum: beamlines }, + energy: { type: "number", minimum: 5, maximum: 25 }, + exposures: { type: "integer", minimum: 1 }, + scheduled: { type: "string", format: "date-time" }, + saveRaw: { type: "boolean" }, + notes: { type: "string" }, + positions: { + type: "array", + items: { + type: "object", + properties: { + label: { type: "string" }, + x: { type: "number" }, + y: { type: "number" }, + }, + }, + }, + }, + required: ["sampleName", "proposal", "beamline"], +}; + +const jsonFormsUiSchema: UISchemaElement = { + type: "VerticalLayout", + elements: [ + { + type: "HorizontalLayout", + elements: [ + { type: "Control", scope: "#/properties/sampleName" }, + { type: "Control", scope: "#/properties/proposal" }, + { type: "Control", scope: "#/properties/beamline" }, + ], + }, + { + type: "HorizontalLayout", + elements: [ + { type: "Control", scope: "#/properties/energy" }, + { type: "Control", scope: "#/properties/exposures" }, + { type: "Control", scope: "#/properties/scheduled" }, + ], + }, + { type: "Control", scope: "#/properties/saveRaw" }, + { + type: "Control", + scope: "#/properties/notes", + options: { multi: true }, + }, + { type: "Control", scope: "#/properties/positions" }, + ], +} as UISchemaElement; + +const initialJsonFormsData = { + sampleName: "Lysozyme", + proposal: "cm12345", + beamline: "I24", + energy: 12.4, + exposures: 10, + scheduled: "2026-10-06T09:30:00.000Z", + saveRaw: true, + positions: [{ label: "P1", x: 0.1, y: 0.2 }], +}; + +const JsonFormsExample = () => { + const [data, setData] = React.useState(initialJsonFormsData); + const [readonly, setReadonly] = React.useState(false); + + return ( + + setReadonly(event.target.checked)} + /> + } + label="Read only form (uses JsonFormsControls)" + /> + setData(data)} + /> + + {JSON.stringify(data, null, 2)} + + + ); +}; + +const comparisonSchema: JsonSchema = { + type: "object", + properties: { + sampleId: { type: "string" }, + status: { type: "string", enum: ["Queued", "Running", "Completed"] }, + energy: { type: "number" }, + scheduled: { type: "string", format: "date-time" }, + saveRaw: { type: "boolean" }, + }, +}; + +const comparisonData = { + sampleId: "SAMPLE-0042", + status: "Queued", + energy: 12.4, + scheduled: "2026-10-06T09:30:00.000Z", + saveRaw: true, +}; + +const comparisonUiSchema = (controlExtras: object = {}) => + ({ + type: "VerticalLayout", + elements: Object.keys(comparisonData).map((key) => ({ + type: "Control", + scope: `#/properties/${key}`, + ...controlExtras, + })), + }) as UISchemaElement; + +const comparisonColumns = [ + { + title: "sci-react-ui text controls", + caption: "Form-wide readonly + JsonFormsControls.rendererControls", + uischema: comparisonUiSchema(), + renderers: [...jsonFormsRenderers, ...JsonFormsControls.rendererControls], + cells: [...materialCells, ...JsonFormsControls.cellControls], + readonly: true, + }, + { + title: "Disabled", + caption: "DISABLE rule", + uischema: comparisonUiSchema({ + // An empty schema always matches, so the rule always applies. + rule: { effect: "DISABLE", condition: { scope: "#", schema: {} } }, + }), + renderers: jsonFormsRenderers, + cells: materialCells, + }, +]; + +const ReadonlyVsDisabledExample = () => ( + + {comparisonColumns.map(({ title, caption, ...formProps }) => ( + + {title} + + {caption} + + + + ))} + +); + +export const FormsPage = () => ( + + Forms + +
+ +
+ +
+ +
+ +
+ Material renderers plus JsonFormsControls, as in the + Helpers/JsonForms guide. Toggle read only to swap disabled inputs for + the accessible text controls. + + } + > + +
+ +
+ JsonForms' material renderers don't distinguish read only + from disabled: both render as disabled inputs. The sci-react-ui text + controls replace them for read-only forms. + + } + > + +
+
+); diff --git a/src/components/helpers/jsonforms/index.ts b/src/components/helpers/jsonforms/index.ts index 1b4b013f..c8c7f756 100644 --- a/src/components/helpers/jsonforms/index.ts +++ b/src/components/helpers/jsonforms/index.ts @@ -12,6 +12,7 @@ import { CellTextDateTimeControl, CellTextDateTimeControlTester, } from "./controls/CellTextDateTimeControl"; +import { VerticalLayout, VerticalLayoutTester } from "./layouts/VerticalLayout"; const rendererControls = [ { renderer: TextControl, tester: TextControlTester }, @@ -21,8 +22,12 @@ const cellControls = [ { cell: CellTextControl, tester: CellTextControlTester }, { cell: CellTextDateTimeControl, tester: CellTextDateTimeControlTester }, ]; +const layoutRenderers = [ + { renderer: VerticalLayout, tester: VerticalLayoutTester }, +]; export const JsonFormsControls = { rendererControls, cellControls, + layoutRenderers, }; diff --git a/src/components/helpers/jsonforms/layouts/VerticalLayout.test.tsx b/src/components/helpers/jsonforms/layouts/VerticalLayout.test.tsx new file mode 100644 index 00000000..c2dbdc5b --- /dev/null +++ b/src/components/helpers/jsonforms/layouts/VerticalLayout.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import { JsonForms } from "@jsonforms/react"; +import { + materialCells, + materialRenderers, +} from "@jsonforms/material-renderers"; +import type { UISchemaElement } from "@jsonforms/core"; + +import { VerticalLayout, VerticalLayoutTester } from "./VerticalLayout"; + +const schema = { + type: "object", + properties: { + name: { type: "string" }, + hidden: { type: "string" }, + }, +}; + +const uischema = { + type: "VerticalLayout", + elements: [ + { type: "Control", scope: "#/properties/name" }, + { + type: "Control", + scope: "#/properties/hidden", + rule: { effect: "HIDE", condition: { scope: "#", schema: {} } }, + }, + ], +} as UISchemaElement; + +const renderForm = () => + render( + , + ); + +describe("VerticalLayout", () => { + test("renders visible fields", () => { + renderForm(); + expect(screen.getByLabelText("Name")).toBeInTheDocument(); + expect(screen.queryByLabelText("Hidden")).not.toBeInTheDocument(); + }); + + test("leaves hidden fields' wrappers empty so they take no gap", () => { + const { container } = renderForm(); + const wrappers = container.querySelectorAll(".MuiStack-root > div"); + expect(wrappers).toHaveLength(2); + expect(wrappers[1]).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/helpers/jsonforms/layouts/VerticalLayout.tsx b/src/components/helpers/jsonforms/layouts/VerticalLayout.tsx new file mode 100644 index 00000000..f065f284 --- /dev/null +++ b/src/components/helpers/jsonforms/layouts/VerticalLayout.tsx @@ -0,0 +1,49 @@ +import { JsonFormsDispatch, withJsonFormsLayoutProps } from "@jsonforms/react"; +import { + LayoutProps, + rankWith, + uiTypeIs, + VerticalLayout as VerticalLayoutSchema, +} from "@jsonforms/core"; +import { Stack } from "@mui/material"; + +// Material's own vertical layout (rank 1) stacks fields with no gap. +const VerticalLayoutTester = rankWith(2, uiTypeIs("VerticalLayout")); + +const VerticalLayoutComponent = ({ + uischema, + schema, + path, + enabled, + visible, + renderers, + cells, +}: LayoutProps) => { + const { elements } = uischema as VerticalLayoutSchema; + if (!visible || elements.length === 0) return null; + + return ( + + ); +}; + +const VerticalLayout = withJsonFormsLayoutProps(VerticalLayoutComponent); +export { VerticalLayout, VerticalLayoutTester }; diff --git a/src/storybook/helpers/JsonForms.mdx b/src/storybook/helpers/JsonForms.mdx index ee72f18d..8d7f66a1 100644 --- a/src/storybook/helpers/JsonForms.mdx +++ b/src/storybook/helpers/JsonForms.mdx @@ -52,6 +52,11 @@ import {Box, TextField, Typography} from "@mui/material"; You can also add your own renderers, make sure they have a higher rank than SciReactUI ones which are currently set at 10, and 15 + For consistent spacing between stacked fields (material's vertical layout has none), add the layout renderer: + ```jsx + renderers={[...materialRenderers, ...JsonFormsControls.layoutRenderers]} + ``` + To style them, use these classes: ```css .data-box {}