diff --git a/src/__testing__/RJSFFormModal.test.tsx b/src/__testing__/RJSFFormModal.test.tsx new file mode 100644 index 000000000..3b799f28e --- /dev/null +++ b/src/__testing__/RJSFFormModal.test.tsx @@ -0,0 +1,237 @@ +/** + * Tests for RJSFFormModal lifecycle, error handling, and form submission. + */ + +import type { RJSFSchema } from '@rjsf/utils'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { RJSFFormModal } from '../custom/RJSFFormWrapper/RJSFFormModal'; +import { SistentThemeProvider } from '../theme'; + +jest.mock('react-markdown', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => {children} +})); +jest.mock('remark-gfm', () => ({ + __esModule: true, + default: () => () => {} +})); +jest.mock('rehype-raw', () => ({ + __esModule: true, + default: () => () => {} +})); + +function Wrap({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe('RJSFFormModal lifecycle and validation behavior', () => { + const modalSchema: RJSFSchema = { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', title: 'Name' }, + email: { type: 'string', title: 'Email' } + } + }; + + it('renders modal title and form fields when open is true', () => { + render( + + {}} + onSubmit={() => {}} + schema={modalSchema} + title="Create Workspace" + buttonTitle="Create" + /> + + ); + + expect(screen.getByText('Create Workspace')).toBeDefined(); + expect(screen.getByLabelText(/Name/i)).toBeDefined(); + expect(screen.getByRole('button', { name: 'Create' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined(); + }); + + it('clears previous validation errors when modal closes and reopens', () => { + const onValidationError = jest.fn(); + const onSubmit = jest.fn(); + + const { rerender } = render( + + {}} + onSubmit={onSubmit} + onValidationError={onValidationError} + schema={modalSchema} + title="Create Workspace" + buttonTitle="Create" + /> + + ); + + // Attempt submit on empty required field to trigger validation failure + const createBtn = screen.getByRole('button', { name: 'Create' }); + fireEvent.click(createBtn); + expect(onValidationError).toHaveBeenCalledTimes(1); + + // Errors should be present in the modal body (alert list and field helper text) + expect(screen.getAllByText(/must have required property 'name'/i).length).toBeGreaterThan(0); + + // Close modal + rerender( + + {}} + onSubmit={onSubmit} + onValidationError={onValidationError} + schema={modalSchema} + title="Create Workspace" + buttonTitle="Create" + /> + + ); + + // Reopen modal + rerender( + + {}} + onSubmit={onSubmit} + onValidationError={onValidationError} + schema={modalSchema} + title="Create Workspace" + buttonTitle="Create" + /> + + ); + + // Lingering validation errors from previous submit attempt must be cleared + expect(screen.queryByText(/must have required property 'name'/i)).toBeNull(); + }); + + it('submits valid form data and triggers onClose on successful submission', () => { + const onSubmit = jest.fn(); + const onClose = jest.fn(); + + render( + + + + ); + + const createBtn = screen.getByRole('button', { name: 'Create' }); + fireEvent.click(createBtn); + + expect(onSubmit).toHaveBeenCalledWith({ name: 'Engineering' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('excludes unrecognized extra data when omitExtraData is enabled', () => { + const onSubmit = jest.fn(); + const onClose = jest.fn(); + + render( + + + + ); + + const createBtn = screen.getByRole('button', { name: 'Create' }); + fireEvent.click(createBtn); + + expect(onSubmit).toHaveBeenCalledWith({ name: 'Engineering' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('submits invalid form without validation errors when noValidate is enabled', () => { + const onSubmit = jest.fn(); + const onClose = jest.fn(); + const onValidationError = jest.fn(); + + render( + + + + ); + + const createBtn = screen.getByRole('button', { name: 'Create' }); + fireEvent.click(createBtn); + + expect(onValidationError).not.toHaveBeenCalled(); + expect(onSubmit).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('preserves user-entered form data when parent re-renders with a new initialData object reference while open', () => { + const { rerender } = render( + + {}} + onSubmit={() => {}} + initialData={{ name: 'Initial Name' }} + schema={modalSchema} + title="Create Workspace" + buttonTitle="Create" + /> + + ); + + const input = screen.getByRole('textbox', { name: /Name/i }); + expect((input as HTMLInputElement).value).toBe('Initial Name'); + + // Simulate user editing the field + fireEvent.change(input, { target: { value: 'User Edited Name' } }); + expect((input as HTMLInputElement).value).toBe('User Edited Name'); + + // Parent re-renders with a new object reference for initialData + rerender( + + {}} + onSubmit={() => {}} + initialData={{ name: 'Initial Name' }} + schema={modalSchema} + title="Create Workspace" + buttonTitle="Create" + /> + + ); + + // User's edited data must NOT be overwritten + expect((input as HTMLInputElement).value).toBe('User Edited Name'); + }); +}); diff --git a/src/__testing__/RJSFFormWrapper.test.tsx b/src/__testing__/RJSFFormWrapper.test.tsx index c106c6574..3adf5fda7 100644 --- a/src/__testing__/RJSFFormWrapper.test.tsx +++ b/src/__testing__/RJSFFormWrapper.test.tsx @@ -2,7 +2,7 @@ * Surface-level smoke test for the RJSFFormWrapper / RJSFFormModal * exports added in layer5io/sistent#1533. * - * Two assertions: + * Three assertions: * * 1. The wrapper module loads cleanly with the @rjsf/* peer-deps * installed (deep-path import). @@ -18,11 +18,28 @@ * importing them at runtime, because the runtime barrel pulls * in sistent's `Markdown` -> `react-markdown` ESM chain that * would need a widened jest `transformIgnorePatterns`. + * The same constraint applies to `src/custom/RJSFFormWrapper/index.ts` + * which re-exports RJSFFormModal (and thus the same Modal chain), + * so RJSFFormModal coverage also stays as a static text check. + * + * 3. Runtime imports from the theme sub-barrel validate that all + * theme generators and theme objects are defined. The theme + * sub-barrel (templates/widgets/generateTheme) does not pull in + * the Modal -> react-markdown chain, so it is safe to import. */ +import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { RJSFFormWrapper } from '../custom/RJSFFormWrapper/RJSFFormWrapper'; +import { + sistentTheme, + sistentTemplates, + sistentWidgets, + generateTheme, + generateTemplates, + generateWidgets +} from '../custom/RJSFFormWrapper/theme'; describe('RJSFFormWrapper (sistent#1533)', () => { it('exports a function with stable displayName from the deep path', () => { @@ -44,4 +61,132 @@ describe('RJSFFormWrapper (sistent#1533)', () => { expect(source).toMatch(/export\s+\*\s+from\s+['"]\.\/RJSFFormWrapper['"]/); } ); + + it('src/custom/RJSFFormWrapper/index.ts re-exports the theme module', () => { + const full = path.resolve(__dirname, '..', 'custom', 'RJSFFormWrapper', 'index.ts'); + expect(fs.existsSync(full)).toBe(true); + const source = fs.readFileSync(full, 'utf8'); + expect(source).toMatch(/export\s+\*\s+from\s+['"]\.\/theme['"]/); + }); + + it('theme generators and theme objects are defined at the theme sub-barrel', () => { + expect(typeof generateTheme).toBe('function'); + expect(typeof generateTemplates).toBe('function'); + expect(typeof generateWidgets).toBe('function'); + expect(sistentTheme).toBeDefined(); + expect(sistentTemplates).toBeDefined(); + expect(sistentWidgets).toBeDefined(); + }); + + it('has dist artifacts to inspect when running in CI', () => { + if (!process.env.CI) return; + const distJs = path.resolve(__dirname, '..', '..', 'dist', 'index.js'); + const distDts = path.resolve(__dirname, '..', '..', 'dist', 'index.d.ts'); + const distMjs = path.resolve(__dirname, '..', '..', 'dist', 'index.mjs'); + expect(fs.existsSync(distJs)).toBe(true); + expect(fs.existsSync(distDts)).toBe(true); + expect(fs.existsSync(distMjs)).toBe(true); + }); + + it('published entrypoint bundle dist/index.js exports RJSF and theme symbols at runtime when built', () => { + const distPath = path.resolve(__dirname, '..', '..', 'dist', 'index.js'); + if (process.env.CI) { + expect(fs.existsSync(distPath)).toBe(true); + } + if (!fs.existsSync(distPath)) { + return; + } + const output = execSync( + `node -e 'const pkg = require("${distPath}"); + const symbols = [ + "RJSFFormModal", + "RJSFFormWrapper", + "hideRootObjectTitle", + "sistentTheme", + "sistentTemplates", + "sistentWidgets", + "generateTheme", + "generateTemplates", + "generateWidgets" + ]; + for (const s of symbols) { + if (typeof pkg[s] === "undefined") throw new Error("Missing: " + s); + } + console.log("OK");'`, + { encoding: 'utf8' } + ); + expect(output.trim()).toBe('OK'); + }); + + it('published declaration bundle dist/index.d.ts exports RJSF and theme symbols when built', () => { + const dtsPath = path.resolve(__dirname, '..', '..', 'dist', 'index.d.ts'); + if (process.env.CI) { + expect(fs.existsSync(dtsPath)).toBe(true); + } + if (!fs.existsSync(dtsPath)) { + // Local jest run before build; skip gracefully + return; + } + const dts = fs.readFileSync(dtsPath, 'utf8'); + const dtsExportedSymbols = new Set(); + for (const match of dts.matchAll(/export\s*\{([^{}]*)\}\s*;?/g)) { + const items = match[1].split(',').map((x) => x.trim()).filter(Boolean); + for (const item of items) { + const exportedName = item.replace(/^type\s+/, '').split(/\s+as\s+/).pop()!.trim(); + dtsExportedSymbols.add(exportedName); + } + } + + const expectedDtsSymbols = [ + 'RJSFFormModal', + 'RJSFFormWrapper', + 'hideRootObjectTitle', + 'sistentTheme', + 'sistentTemplates', + 'sistentWidgets', + 'generateTheme', + 'generateTemplates', + 'generateWidgets', + 'RJSFFormModalProps', + 'RJSFFormWrapperProps', + 'RJSFValidationError' + ]; + for (const sym of expectedDtsSymbols) { + expect(dtsExportedSymbols.has(sym)).toBe(true); + } + }); + + it('published runtime bundle dist/index.mjs exports RJSF and theme symbols when built', () => { + const mjsPath = path.resolve(__dirname, '..', '..', 'dist', 'index.mjs'); + if (process.env.CI) { + expect(fs.existsSync(mjsPath)).toBe(true); + } + if (!fs.existsSync(mjsPath)) { + return; + } + const mjs = fs.readFileSync(mjsPath, 'utf8'); + const mjsExportedSymbols = new Set(); + for (const match of mjs.matchAll(/export\s*\{([^{}]*)\}\s*;?/g)) { + const items = match[1].split(',').map((x) => x.trim()).filter(Boolean); + for (const item of items) { + const exportedName = item.replace(/^type\s+/, '').split(/\s+as\s+/).pop()!.trim(); + mjsExportedSymbols.add(exportedName); + } + } + + const expectedRuntimeSymbols = [ + 'RJSFFormModal', + 'RJSFFormWrapper', + 'hideRootObjectTitle', + 'sistentTheme', + 'sistentTemplates', + 'sistentWidgets', + 'generateTheme', + 'generateTemplates', + 'generateWidgets' + ]; + for (const sym of expectedRuntimeSymbols) { + expect(mjsExportedSymbols.has(sym)).toBe(true); + } + }); }); diff --git a/src/__testing__/RJSFTheme.test.tsx b/src/__testing__/RJSFTheme.test.tsx new file mode 100644 index 000000000..d6cf30988 --- /dev/null +++ b/src/__testing__/RJSFTheme.test.tsx @@ -0,0 +1,1396 @@ +import type Form from '@rjsf/core'; +import type { ErrorSchema, RJSFSchema } from '@rjsf/utils'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { RJSFFormWrapper } from '../custom/RJSFFormWrapper/RJSFFormWrapper'; +import { + ArrayFieldItemTemplate, + ArrayFieldTemplate, + BaseInputTemplate, + ButtonTemplates, + CheckboxWidget, + CheckboxesWidget, + DescriptionFieldTemplate, + ErrorListTemplate, + FieldErrorTemplate, + FieldHelpTemplate, + FieldTemplate, + FileWidget, + ObjectFieldTemplate, + RadioWidget, + RangeWidget, + SelectWidget, + SwitchWidget, + TextWidget, + TextareaWidget, + TitleFieldTemplate, + ToggleWidget, + WrapIfAdditionalTemplate, + generateTemplates, + generateTheme, + generateWidgets, + sistentRJSFTheme, + sistentTheme +} from '../custom/RJSFFormWrapper/theme'; +import { computeSxProps } from '../custom/RJSFFormWrapper/theme/util'; +import { SistentThemeProvider, useTheme } from '../theme'; + +function Wrap({ children }: { children: React.ReactNode }) { + return {children}; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Theme exports and factories +// ───────────────────────────────────────────────────────────────────────────── + +describe('Sistent RJSF Theme Registry (Issue #418)', () => { + describe('Theme exports and factories', () => { + it('exports sistentTheme with templates and widgets', () => { + expect(sistentTheme).toBeDefined(); + expect(typeof sistentTheme.templates).toBe('object'); + expect(typeof sistentTheme.widgets).toBe('object'); + }); + + it('generateTheme returns a fresh ThemeProps object', () => { + const generated = generateTheme(); + expect(generated).toBeDefined(); + expect(typeof generated.templates).toBe('object'); + expect(typeof generated.widgets).toBe('object'); + expect(generated.templates?.FieldTemplate).toBeDefined(); + expect(generated.widgets?.TextWidget).toBeDefined(); + }); + + it('sistentTheme matches default export from generateTheme module', () => { + expect(sistentTheme).toBe(sistentRJSFTheme); + }); + + it('exports all expected templates in sistentTemplates registry', () => { + const templates = generateTemplates(); + expect(templates.ArrayFieldItemTemplate).toBe(ArrayFieldItemTemplate); + expect(templates.ArrayFieldTemplate).toBe(ArrayFieldTemplate); + expect(templates.BaseInputTemplate).toBe(BaseInputTemplate); + expect(templates.ButtonTemplates).toBe(ButtonTemplates); + expect(templates.DescriptionFieldTemplate).toBe(DescriptionFieldTemplate); + expect(templates.ErrorListTemplate).toBe(ErrorListTemplate); + expect(templates.FieldErrorTemplate).toBe(FieldErrorTemplate); + expect(templates.FieldHelpTemplate).toBe(FieldHelpTemplate); + expect(templates.FieldTemplate).toBe(FieldTemplate); + expect(templates.ObjectFieldTemplate).toBe(ObjectFieldTemplate); + expect(templates.TitleFieldTemplate).toBe(TitleFieldTemplate); + expect(templates.WrapIfAdditionalTemplate).toBe(WrapIfAdditionalTemplate); + }); + + it('exports all expected widgets in sistentWidgets registry', () => { + const widgets = generateWidgets(); + expect(widgets.TextWidget).toBe(TextWidget); + expect(widgets.TextareaWidget).toBe(TextareaWidget); + expect(widgets.SelectWidget).toBe(SelectWidget); + expect(widgets.CheckboxWidget).toBe(CheckboxWidget); + expect(widgets.CheckboxesWidget).toBe(CheckboxesWidget); + expect(widgets.RadioWidget).toBe(RadioWidget); + expect(widgets.RangeWidget).toBe(RangeWidget); + expect(widgets.ToggleWidget).toBe(ToggleWidget); + expect(widgets.SwitchWidget).toBe(SwitchWidget); + expect(widgets.FileWidget).toBe(FileWidget); + expect(widgets.switch).toBe(SwitchWidget); + expect(widgets.toggle).toBe(ToggleWidget); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 2. RJSFFormWrapper core integration + // ───────────────────────────────────────────────────────────────────────── + + describe('RJSFFormWrapper core integration', () => { + it('renders basic schema and fires onChange when field changes', () => { + const onChange = jest.fn(); + const schema: RJSFSchema = { + type: 'object', + properties: { name: { type: 'string', title: 'Full Name' } } + }; + render( + + + + ); + fireEvent.change(screen.getByLabelText(/Full Name/i), { target: { value: 'Alice' } }); + expect(onChange).toHaveBeenCalled(); + // onChange is invoked with the RJSF state object; verify formData key exists + expect(onChange.mock.calls[0][0]).toHaveProperty('formData'); + }); + + it('fires onSubmit with formData when the form element is submitted', () => { + const onSubmit = jest.fn(); + const schema: RJSFSchema = { + type: 'object', + properties: { city: { type: 'string', title: 'City' } } + }; + const { container } = render( + + + + ); + // Submit the form element directly — most reliable in jsdom + const form = container.querySelector('form'); + if (form) fireEvent.submit(form); + expect(onSubmit).toHaveBeenCalled(); + expect(onSubmit.mock.calls[0][0].formData).toEqual({ city: 'Tokyo' }); + }); + + it('pre-populates fields from formData prop', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { email: { type: 'string', title: 'Email' } } + }; + render( + + + + ); + expect(screen.getByDisplayValue('test@example.com')).toBeDefined(); + }); + + it('shows validation errors with liveValidate + extraErrors', () => { + const schema: RJSFSchema = { + type: 'object', + required: ['email'], + properties: { email: { type: 'string', title: 'Email Address' } } + }; + render( + + + + ); + expect(screen.getAllByText(/Email is required/i).length).toBeGreaterThanOrEqual(1); + }); + + it('inherits and preserves parent theme palette mode (e.g. dark mode)', () => { + let observedMode: string | undefined; + function ModeSpy() { + const theme = useTheme(); + observedMode = theme.palette.mode; + return
{theme.palette.mode}
; + } + const schema: RJSFSchema = { + type: 'object', + properties: { name: { type: 'string', title: 'Name' } } + }; + render( + + + + + + ); + expect(observedMode).toBe('dark'); + expect(screen.getByTestId('mode-spy').textContent).toBe('dark'); + }); + + it('inherits and preserves custom parent theme overrides', () => { + let observedPrimary: string | undefined; + function ThemeSpy() { + const theme = useTheme(); + observedPrimary = theme.palette.primary.main; + return
{theme.palette.primary.main}
; + } + const schema: RJSFSchema = { + type: 'object', + properties: { name: { type: 'string', title: 'Name' } } + }; + render( + + + + + + ); + expect(observedPrimary).toBe('#123456'); + expect(screen.getByTestId('theme-spy').textContent).toBe('#123456'); + }); + + it('renders array fields and shows item values', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + tags: { type: 'array', title: 'Tags', items: { type: 'string' } } + } + }; + render( + + + + ); + expect(screen.getByDisplayValue('alpha')).toBeDefined(); + expect(screen.getByDisplayValue('beta')).toBeDefined(); + }); + + it('sets readOnly attribute and keeps input enabled when field is readonly', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { token: { type: 'string', title: 'Token', readOnly: true } } + }; + render( + + + + ); + const input = screen.getByLabelText('Token') as HTMLInputElement; + expect(input.readOnly).toBe(true); + expect(input.disabled).toBe(false); + }); + + it('sets readOnly attribute and keeps select enabled when widget is readonly', () => { + const selectSchema: RJSFSchema = { + type: 'object', + properties: { role: { type: 'string', title: 'Role', enum: ['Admin', 'User'], default: 'Admin' } } + }; + render( + + + + ); + const selectNode = screen.getByRole('combobox'); + expect(selectNode.getAttribute('aria-readonly')).toBe('true'); + expect(selectNode.getAttribute('aria-disabled')).toBeNull(); + fireEvent.mouseDown(selectNode); + expect(screen.queryByRole('listbox')).toBeNull(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 3. RadioWidget behavior + // ───────────────────────────────────────────────────────────────────────── + + describe('RadioWidget behavior', () => { + const radioSchema: RJSFSchema = { + type: 'object', + properties: { + color: { type: 'string', title: 'Color', enum: ['Red', 'Green', 'Blue'] } + } + }; + const radioUiSchema = { color: { 'ui:widget': 'radio' } }; + + it('renders all enum options as radio buttons', () => { + render( + + + + ); + expect(screen.getByText('Red')).toBeDefined(); + expect(screen.getByText('Green')).toBeDefined(); + expect(screen.getByText('Blue')).toBeDefined(); + }); + + it('calls onChange when a radio option is selected', () => { + const onChange = jest.fn(); + render( + + + + ); + fireEvent.click(screen.getAllByRole('radio')[1]); + expect(onChange).toHaveBeenCalled(); + }); + + it('only the first radio option has autoFocus when autofocus=true (other radios do not)', () => { + const uiSchema = { color: { 'ui:widget': 'radio', 'ui:autofocus': true } }; + render( + + + + ); + const radios = screen.getAllByRole('radio') as HTMLInputElement[]; + // In MUI, Radio autoFocus focuses the first radio input/button + expect(document.activeElement).toBe(radios[0]); + // Radios at index 1 and 2 must not have the autofocus attribute + // (React maps autoFocus=false to no attribute; only first gets autoFocus=true) + expect(radios[1].hasAttribute('autofocus')).toBe(false); + expect(radios[2].hasAttribute('autofocus')).toBe(false); + // There are exactly 3 radios rendered (Red, Green, Blue) + expect(radios.length).toBe(3); + }); + + it('does not apply autoFocus to any radio when autofocus is absent', () => { + render( + + + + ); + screen.getAllByRole('radio').forEach((r) => + expect(r.hasAttribute('autofocus')).toBe(false) + ); + }); + + it('associates FormLabel with RadioGroup via aria-labelledby for accessibility', () => { + render( + + + + ); + expect(screen.getByRole('radiogroup', { name: 'Color' })).toBeDefined(); + }); + + it('sets aria-label on RadioGroup when hideLabel is true', () => { + const uiSchema = { color: { 'ui:widget': 'radio', 'ui:options': { label: false } } }; + render( + + + + ); + expect(screen.getByRole('radiogroup', { name: 'Color' })).toBeDefined(); + }); + + it('disables all radio options when widget is disabled', () => { + const uiSchema = { color: { 'ui:widget': 'radio', 'ui:disabled': true } }; + render( + + + + ); + screen.getAllByRole('radio').forEach((r) => + expect((r as HTMLInputElement).disabled).toBe(true) + ); + }); + + it('disables all radio options when widget is readonly', () => { + const uiSchema = { color: { 'ui:widget': 'radio', 'ui:readonly': true } }; + render( + + + + ); + screen.getAllByRole('radio').forEach((r) => + expect((r as HTMLInputElement).disabled).toBe(true) + ); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 4. CheckboxesWidget behavior + // ───────────────────────────────────────────────────────────────────────── + + describe('CheckboxesWidget behavior', () => { + const checkboxSchema: RJSFSchema = { + type: 'object', + properties: { + tags: { + type: 'array', + title: 'Tags', + items: { type: 'string', enum: ['A', 'B', 'C'] }, + uniqueItems: true + } + } + }; + const uiSchema = { tags: { 'ui:widget': 'checkboxes' } }; + + it('calls onChange when a checkbox is checked', () => { + const onChange = jest.fn(); + render( + + + + ); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + expect(onChange).toHaveBeenCalled(); + expect(onChange.mock.calls[0][0].formData.tags).toContain('A'); + }); + + it('removes value from array when a checked box is unchecked', () => { + const onChange = jest.fn(); + render( + + + + ); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + expect(onChange).toHaveBeenCalled(); + expect(onChange.mock.calls[0][0].formData.tags).not.toContain('A'); + }); + + it('associates FormLabel with FormGroup via aria-labelledby for accessibility', () => { + render( + + + + ); + expect(screen.getByRole('group', { name: 'Tags' })).toBeDefined(); + }); + + it('sets aria-label on FormGroup when hideLabel is true', () => { + const hiddenLabelUiSchema = { + tags: { 'ui:widget': 'checkboxes', 'ui:options': { label: false } } + }; + render( + + + + ); + expect(screen.getByRole('group', { name: 'Tags' })).toBeDefined(); + }); + + it('does not produce [undefined] when starting from empty formData', () => { + const onChange = jest.fn(); + render( + + + + ); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + const result = onChange.mock.calls[0][0].formData?.tags as unknown[]; + expect(Array.isArray(result)).toBe(true); + result.forEach((v) => expect(v).not.toBeUndefined()); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 5. SelectWidget behavior + // ───────────────────────────────────────────────────────────────────────── + + describe('SelectWidget behavior', () => { + const selectSchema: RJSFSchema = { + type: 'object', + properties: { + role: { type: 'string', title: 'Role', enum: ['Admin', 'Editor', 'Viewer'] } + } + }; + + it('renders a select with label', () => { + render( + + + + ); + expect(screen.getByLabelText(/Role/i)).toBeDefined(); + }); + + it('calls onChange when an option is selected', () => { + const onChange = jest.fn(); + render( + + + + ); + // Open the MUI Select popover, then click an option + const combobox = screen.getByRole('combobox'); + fireEvent.mouseDown(combobox); + const options = screen.getAllByRole('option'); + fireEvent.click(options[0]); + expect(onChange).toHaveBeenCalled(); + }); + + it('prevents ui:options.mui from overriding RJSF-controlled value, onChange, and disabled state', () => { + const formOnChange = jest.fn(); + const maliciousOverrideOnChange = jest.fn(); + const uiSchema = { + role: { + 'ui:options': { + mui: { + value: 'Viewer', + onChange: maliciousOverrideOnChange, + disabled: false + } + }, + 'ui:disabled': true + } + }; + render( + + + + ); + // Value must reflect RJSF formData ('Admin'), not the mui override ('Viewer') + expect(screen.getByText('Admin')).toBeDefined(); + // Disabled state must reflect RJSF ui:disabled=true + const combobox = screen.getByRole('combobox'); + expect(combobox.getAttribute('aria-disabled')).toBe('true'); + }); + + it('correctly handles readonly vs disabled semantics', () => { + const { rerender } = render( + + + + ); + // disabled=true => combobox is disabled + const disabledSelect = screen.getByRole('combobox'); + expect(disabledSelect.getAttribute('aria-disabled')).toBe('true'); + + // readonly=true => combobox is NOT disabled, has aria-readonly, and does not open listbox + rerender( + + + + ); + const readonlySelect = screen.getByRole('combobox'); + expect(readonlySelect.getAttribute('aria-disabled')).toBeNull(); + expect(readonlySelect.getAttribute('aria-readonly')).toBe('true'); + fireEvent.mouseDown(readonlySelect); + expect(screen.queryByRole('listbox')).toBeNull(); + + // normal select => interactive, opens listbox on mouseDown + rerender( + + + + ); + const normalSelect = screen.getByRole('combobox'); + expect(normalSelect.getAttribute('aria-disabled')).toBeNull(); + expect(normalSelect.getAttribute('aria-readonly')).toBeNull(); + fireEvent.mouseDown(normalSelect); + expect(screen.getByRole('listbox')).toBeDefined(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 6. Toggle/Switch behavior + // ───────────────────────────────────────────────────────────────────────── + + describe('Toggle/Switch widget behavior', () => { + const boolSchema: RJSFSchema = { + type: 'object', + properties: { enabled: { type: 'boolean', title: 'Feature Enabled' } } + }; + + it('renders switch widget with correct label', () => { + render( + + + + ); + expect(screen.getByText(/Feature Enabled/i)).toBeDefined(); + }); + + it('calls onChange with flipped boolean value when toggled', () => { + const onChange = jest.fn(); + render( + + + + ); + // MUI Switch has role="switch" + fireEvent.click(screen.getByRole('switch')); + expect(onChange).toHaveBeenCalled(); + expect(onChange.mock.calls[0][0].formData.enabled).toBe(true); + }); + + it('renders description and connects aria-describedby even when hideLabel is true', () => { + const descSchema: RJSFSchema = { + type: 'object', + properties: { + enabled: { type: 'boolean', title: 'Feature', description: 'Enable experimental feature' } + } + }; + const { container } = render( + + + + ); + const desc = screen.getByText('Enable experimental feature'); + expect(desc.getAttribute('id')).toBe('root_enabled__description'); + const switchBase = container.querySelector('.MuiSwitch-switchBase'); + expect(switchBase?.getAttribute('aria-describedby')).toContain('root_enabled__description'); + }); + + it('renders description exactly once and connects aria-describedby when label is visible for switch widget', () => { + const descSchema: RJSFSchema = { + type: 'object', + properties: { + enabled: { type: 'boolean', title: 'Feature', description: 'Enable experimental feature' } + } + }; + const { container } = render( + + + + ); + expect(screen.getAllByText('Enable experimental feature')).toHaveLength(1); + const desc = screen.getByText('Enable experimental feature'); + expect(desc.getAttribute('id')).toBe('root_enabled__description'); + const switchBase = container.querySelector('.MuiSwitch-switchBase'); + expect(switchBase?.getAttribute('aria-describedby')).toContain('root_enabled__description'); + }); + + it('renders description and connects aria-describedby even when hideLabel is true in CheckboxWidget', () => { + const boolSchema: RJSFSchema = { + type: 'object', + properties: { + agree: { type: 'boolean', title: 'Agree', description: 'I agree to terms' } + } + }; + const { container } = render( + + + + ); + const desc = screen.getByText('I agree to terms'); + expect(desc.getAttribute('id')).toBe('root_agree__description'); + const checkboxRoot = container.querySelector('.MuiCheckbox-root'); + expect(checkboxRoot?.getAttribute('aria-describedby')).toContain('root_agree__description'); + }); + + it('renders description exactly once and connects aria-describedby when label is visible for checkbox widget', () => { + const boolSchema: RJSFSchema = { + type: 'object', + properties: { + agree: { type: 'boolean', title: 'Agree', description: 'I agree to terms' } + } + }; + const { container } = render( + + + + ); + expect(screen.getAllByText('I agree to terms')).toHaveLength(1); + const desc = screen.getByText('I agree to terms'); + expect(desc.getAttribute('id')).toBe('root_agree__description'); + const checkboxRoot = container.querySelector('.MuiCheckbox-root'); + expect(checkboxRoot?.getAttribute('aria-describedby')).toContain('root_agree__description'); + }); + + it('assigns descriptionId(id) to FieldTemplate description and connects via aria-describedby', () => { + const textSchema: RJSFSchema = { + type: 'object', + properties: { + username: { type: 'string', title: 'Username', description: 'Choose a unique handle' } + } + }; + const { container } = render( + + + + ); + const desc = screen.getByText('Choose a unique handle'); + expect(desc.getAttribute('id')).toBe('root_username__description'); + const textField = container.querySelector('.MuiTextField-root'); + expect(textField?.getAttribute('aria-describedby')).toContain('root_username__description'); + }); + + it('combines otherMuiProps.className with slot fieldFormControl.className in FieldTemplate', () => { + const { container } = render( + + + + ); + const formControl = container.querySelector('.rjsf-field-string > .MuiFormControl-root'); + expect(formControl?.classList.contains('general-form-class')).toBe(true); + expect(formControl?.classList.contains('slot-form-class')).toBe(true); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 7. RangeWidget behavior + // ───────────────────────────────────────────────────────────────────────── + + describe('RangeWidget behavior', () => { + const rangeSchema: RJSFSchema = { + type: 'object', + properties: { volume: { type: 'number', title: 'Volume', minimum: 0, maximum: 100 } } + }; + + it('renders a slider', () => { + render( + + + + ); + expect(screen.getByRole('slider')).toBeDefined(); + }); + + it('slider has aria-disabled when widget is disabled', () => { + render( + + + + ); + const slider = screen.getByRole('slider'); + // MUI Slider sets aria-disabled on the thumb span + expect( + slider.hasAttribute('aria-disabled') || (slider as HTMLInputElement).disabled + ).toBe(true); + }); + + it('connects visible label via aria-labelledby', () => { + render( + + + + ); + const label = screen.getByText('Volume'); + expect(label.getAttribute('id')).toBe('root_volume-label'); + const slider = screen.getByRole('slider'); + expect(slider.getAttribute('aria-labelledby')).toBe('root_volume-label'); + }); + + it('sets aria-label when hideLabel is true', () => { + render( + + + + ); + expect(screen.queryByText('Volume')).toBeNull(); + const slider = screen.getByRole('slider'); + expect(slider.getAttribute('aria-label')).toBe('Volume'); + }); + + it('does not set aria-labelledby or aria-label when label is empty', () => { + const noLabelSchema: RJSFSchema = { + type: 'object', + properties: { volume: { type: 'number', title: '', minimum: 0, maximum: 100 } } + }; + render( + + + + ); + const slider = screen.getByRole('slider'); + expect(slider.getAttribute('aria-labelledby')).toBeNull(); + expect(slider.getAttribute('aria-label')).toBeNull(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 8. FileWidget — disabled/readonly + // ───────────────────────────────────────────────────────────────────────── + + describe('FileWidget disabled/readonly behavior', () => { + const fileSchema: RJSFSchema = { + type: 'object', + properties: { doc: { type: 'string', format: 'data-url', title: 'Document' } } + }; + + it('renders an enabled file input by default', () => { + render( + + + + ); + expect((screen.getByLabelText(/Document/i) as HTMLInputElement).disabled).toBe(false); + }); + + it('disables file input when ui:disabled is true', () => { + render( + + + + ); + expect((screen.getByLabelText(/Document/i) as HTMLInputElement).disabled).toBe(true); + }); + + it('disables file input when ui:readonly is true', () => { + render( + + + + ); + expect((screen.getByLabelText(/Document/i) as HTMLInputElement).disabled).toBe(true); + }); + + it('renders img thumbnail preview for image/webp, image/svg+xml, and other image/* files', () => { + const { container } = render( + + + + ); + const img = container.querySelector('img'); + expect(img).not.toBeNull(); + expect(img?.getAttribute('src')).toBe('data:image/webp;name=test.webp;base64,AAAA'); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 9. computeSxProps unit tests + // ───────────────────────────────────────────────────────────────────────── + + describe('computeSxProps utility', () => { + it('returns sxProps unchanged when no muiProps', () => { + const base = { mt: 1, mb: 2 }; + expect(computeSxProps(base, undefined)).toBe(base); + }); + + it('returns sxProps unchanged when muiProps has no sx', () => { + const base = { mt: 1 }; + expect(computeSxProps(base, { className: 'foo' })).toBe(base); + }); + + it('merges two plain objects without losing base values', () => { + const result = computeSxProps({ color: 'red', mt: 1 }, { sx: { mb: 2 } }) as Record; + expect(result['color']).toBe('red'); + expect(result['mt']).toBe(1); + expect(result['mb']).toBe(2); + }); + + it('produces array when muiProps.sx is an array', () => { + const result = computeSxProps({ mt: 1 }, { sx: [{ mb: 2 }, { pt: 3 }] }) as unknown[]; + expect(Array.isArray(result)).toBe(true); + expect(result[0]).toEqual({ mt: 1 }); + expect(result[1]).toEqual({ mb: 2 }); + expect(result[2]).toEqual({ pt: 3 }); + }); + + it('produces array when muiProps.sx is a callback function', () => { + const fn = () => ({ mt: 1 }); + const result = computeSxProps({ mb: 2 }, { sx: fn }) as unknown[]; + expect(Array.isArray(result)).toBe(true); + expect(result[0]).toEqual({ mb: 2 }); + expect(result[1]).toBe(fn); + }); + + it('handles array-valued sxProps + object muiProps.sx without numeric keys', () => { + const baseSx: Parameters[0] = [{ display: 'flex' }, { gap: 2 }]; + const result = computeSxProps(baseSx, { sx: { mt: 1 } }) as unknown[]; + expect(Array.isArray(result)).toBe(true); + // Items must not be at numeric-index keys of a plain object (i.e. must be a real array) + expect(result.length).toBeGreaterThan(0); + expect(result).toContainEqual({ mt: 1 }); + expect(result).toContainEqual({ display: 'flex' }); + expect(result).toContainEqual({ gap: 2 }); + }); + + it('handles array-valued sxProps + array muiProps.sx correctly', () => { + const baseSx: Parameters[0] = [{ display: 'flex' }]; + const result = computeSxProps(baseSx, { sx: [{ mt: 1 }] }) as unknown[]; + expect(Array.isArray(result)).toBe(true); + expect(result).toContainEqual({ display: 'flex' }); + expect(result).toContainEqual({ mt: 1 }); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 10. rjsfSlotProps and MUI styling customization + // ───────────────────────────────────────────────────────────────────────── + + describe('rjsfSlotProps and MUI styling customization', () => { + it('passes rjsfSlotProps.radioGroup attributes to the RadioGroup element', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { pick: { type: 'string', enum: ['X', 'Y'] } } + }; + const uiSchema = { + pick: { + 'ui:widget': 'radio', + 'ui:options': { + mui: { rjsfSlotProps: { radioGroup: { 'data-testid': 'my-radio-group' } } } + } + } + }; + render( + + + + ); + expect(screen.getByTestId('my-radio-group')).toBeDefined(); + }); + + it('merges consumer slot sx with component default styling rather than replacing it', () => { + // Default TitleFieldTemplate provides default margin/divider styling + const defaultSx = { mt: 2, mb: 1 }; + const consumerSlot = { sx: { color: 'primary.main', mb: 4 } }; + const merged = computeSxProps(defaultSx, consumerSlot) as Record; + // mt survives from default; mb is customized by consumer; color is added + expect(merged.mt).toBe(2); + expect(merged.mb).toBe(4); + expect(merged.color).toBe('primary.main'); + }); + + it('handles function/callback consumer sx alongside base object styles', () => { + const defaultSx = { color: 'text.secondary' }; + const consumerCallback = () => ({ fontWeight: 'bold' }); + const merged = computeSxProps(defaultSx, { sx: consumerCallback }) as unknown[]; + expect(Array.isArray(merged)).toBe(true); + expect(merged[0]).toEqual({ color: 'text.secondary' }); + expect(merged[1]).toBe(consumerCallback); + }); + + it('handles consumer array sx preserving all array items and base styles', () => { + const defaultSx = { display: 'flex' }; + const consumerArray = [{ gap: 2 }, { justifyContent: 'space-between' }]; + const merged = computeSxProps(defaultSx, { sx: consumerArray }) as unknown[]; + expect(Array.isArray(merged)).toBe(true); + expect(merged[0]).toEqual({ display: 'flex' }); + expect(merged[1]).toEqual({ gap: 2 }); + expect(merged[2]).toEqual({ justifyContent: 'space-between' }); + }); + + it('preserves RJSF-generated accessibility IDs when slot props specify custom id', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + bio: { type: 'string', title: 'Bio', description: 'Your bio' } + } + }; + const uiSchema = { + bio: { + 'ui:help': 'Help text for bio', + 'ui:options': { + mui: { + rjsfSlotProps: { + helpFormHelperText: { id: 'overridden-help-id' }, + descTypography: { id: 'overridden-desc-id' }, + fieldErrorList: { id: 'overridden-error-id' } + } + } + } + } + }; + const extraErrors: ErrorSchema = { + bio: { __errors: ['Bio is invalid'] } + }; + render( + + + + ); + // Help text should retain root_bio__help + const help = screen.getByText('Help text for bio'); + expect(help.getAttribute('id')).toBe('root_bio__help'); + + // Description text should retain root_bio__description + const desc = screen.getByText('Your bio'); + expect(desc.getAttribute('id')).toBe('root_bio__description'); + + // Field error list should retain root_bio__error + const errorItem = screen.getByText('Bio is invalid'); + expect(errorItem.getAttribute('id')).toBe('root_bio__error-0'); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 11. CodeRabbit Review Regressions & Edge Cases + // ───────────────────────────────────────────────────────────────────────── + + describe('Slot props and widget customization edge cases', () => { + it('preserves caller formRef attachment on RJSFFormWrapper regardless of rest props', () => { + const ref = React.createRef>>(); + const schema: RJSFSchema = { + type: 'object', + properties: { name: { type: 'string' } } + }; + render( + + + + ); + expect(ref.current).not.toBeNull(); + expect(typeof ref.current.validateForm).toBe('function'); + }); + + it('merges slot-provided className and style on WrapIfAdditionalTemplate container', () => { + const schema: RJSFSchema = { + type: 'object', + additionalProperties: { type: 'string' } + }; + const uiSchema = { + additionalProperties: { + 'ui:options': { + mui: { + rjsfSlotProps: { + wrapGridContainer: { + className: 'custom-wrap-container-class', + style: { backgroundColor: 'rgb(240, 240, 240)' } + } + } + } + } + } + }; + const { container } = render( + + + + ); + const gridContainer = container.querySelector('.custom-wrap-container-class') as HTMLElement; + expect(gridContainer).not.toBeNull(); + expect(gridContainer.style.backgroundColor).toBe('rgb(240, 240, 240)'); + }); + + it('keeps multiple file input required when all files are removed', () => { + const schema: RJSFSchema = { + type: 'object', + required: ['docs'], + properties: { + docs: { + type: 'array', + title: 'Documents', + items: { type: 'string', format: 'data-url' } + } + } + }; + const { rerender } = render( + + + + ); + // When files are present, the file input is not required + let fileInput = screen.getByLabelText(/Documents/i) as HTMLInputElement; + expect(fileInput.required).toBe(false); + + // When all files are removed (empty array), the field becomes required again + rerender( + + + + ); + fileInput = screen.getByLabelText(/Documents/i) as HTMLInputElement; + expect(fileInput.required).toBe(true); + }); + + it('applies muiSlotProps.formLabel to FormLabel in RadioWidget and RangeWidget', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + choice: { type: 'string', title: 'Choice', enum: ['A', 'B'] }, + level: { type: 'number', title: 'Level', minimum: 0, maximum: 10 } + } + }; + const uiSchema = { + choice: { + 'ui:widget': 'radio', + 'ui:options': { + mui: { + rjsfSlotProps: { + formLabel: { className: 'custom-radio-form-label' } + } + } + } + }, + level: { + 'ui:widget': 'range', + 'ui:options': { + mui: { + rjsfSlotProps: { + formLabel: { className: 'custom-range-form-label' } + } + } + } + } + }; + const { container } = render( + + + + ); + expect(container.querySelector('.custom-radio-form-label')).not.toBeNull(); + expect(container.querySelector('.custom-range-form-label')).not.toBeNull(); + }); + + it('applies muiSlotProps.menuItem to MenuItem elements in SelectWidget', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + status: { type: 'string', title: 'Status', enum: ['Active', 'Inactive'] } + } + }; + const uiSchema = { + status: { + 'ui:options': { + mui: { + rjsfSlotProps: { + menuItem: { className: 'custom-select-menu-item' } + } + } + } + } + }; + render( + + + + ); + // Open select dropdown + fireEvent.mouseDown(screen.getByRole('combobox')); + const menuItems = screen.getAllByRole('option'); + expect(menuItems.length).toBeGreaterThan(0); + expect(menuItems[0].classList.contains('custom-select-menu-item')).toBe(true); + }); + + it('applies aria-label on Switch in ToggleWidget when hideLabel is true', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + enabled: { type: 'boolean', title: 'Feature Toggle' } + } + }; + const uiSchema = { + enabled: { + 'ui:widget': 'toggle', + 'ui:options': { label: false } + } + }; + render( + + + + ); + const switchInput = screen.getByRole('switch', { name: 'Feature Toggle' }); + expect(switchInput).toBeDefined(); + }); + + it('applies muiSlotProps.formLabel to FormLabel in CheckboxesWidget', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + permissions: { + type: 'array', + title: 'Permissions', + items: { type: 'string', enum: ['read', 'write'] }, + uniqueItems: true + } + } + }; + const uiSchema = { + permissions: { + 'ui:widget': 'checkboxes', + 'ui:options': { + mui: { + rjsfSlotProps: { + formLabel: { className: 'custom-checkboxes-form-label' } + } + } + } + } + }; + const { container } = render( + + + + ); + expect(container.querySelector('.custom-checkboxes-form-label')).not.toBeNull(); + }); + + it('applies aria-label on CheckboxWidget and BaseInputTemplate when hideLabel is true', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + agree: { type: 'boolean', title: 'I Agree' }, + username: { type: 'string', title: 'User Name' }, + role: { type: 'string', title: 'User Role', enum: ['Admin', 'User'] } + } + }; + const uiSchema = { + agree: { + 'ui:widget': 'checkbox', + 'ui:options': { label: false } + }, + username: { + 'ui:options': { label: false } + }, + role: { + 'ui:options': { label: false } + } + }; + render( + + + + ); + expect(screen.getByRole('checkbox', { name: 'I Agree' })).toBeDefined(); + expect(screen.getByRole('textbox', { name: 'User Name' })).toBeDefined(); + expect(screen.getByRole('combobox', { name: 'User Role' })).toBeDefined(); + }); + + it('prevents muiSlotProps.fieldFormControl from overriding RJSF validation error state', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + username: { type: 'string', title: 'Username' } + } + }; + const uiSchema = { + username: { + 'ui:options': { + mui: { + rjsfSlotProps: { + fieldFormControl: { error: false } + } + } + } + } + }; + render( + + + + ); + const input = screen.getByRole('textbox'); + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(screen.getByText('Invalid username')).toBeDefined(); + }); + + it('supports rangeSlider and toggle slot prop aliases', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + sliderVal: { type: 'number', title: 'Volume', minimum: 0, maximum: 100 }, + toggleVal: { type: 'boolean', title: 'Notifications' } + } + }; + const uiSchema = { + sliderVal: { + 'ui:widget': 'range', + 'ui:options': { + mui: { + rjsfSlotProps: { + rangeSlider: { 'data-testid': 'custom-range-slider' } + } + } + } + }, + toggleVal: { + 'ui:widget': 'switch', + 'ui:options': { + mui: { + rjsfSlotProps: { + toggle: { 'data-testid': 'custom-toggle-switch' } + } + } + } + } + }; + render( + + + + ); + expect(screen.getByTestId('custom-range-slider')).toBeDefined(); + expect(screen.getByTestId('custom-toggle-switch')).toBeDefined(); + }); + + it('renders object fields with stable property keys and supports dynamic property addition/removal without state corruption', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + hiddenField: { type: 'string' } + }, + additionalProperties: { type: 'string' } + }; + const uiSchema = { + hiddenField: { 'ui:widget': 'hidden' } + }; + const initialFormData = { + hiddenField: 'secret', + propA: 'value A', + propB: 'value B' + }; + + const { rerender } = render( + + + + ); + + expect(screen.getByDisplayValue('propA')).toBeDefined(); + expect(screen.getByDisplayValue('propB')).toBeDefined(); + expect(screen.getByDisplayValue('value A')).toBeDefined(); + expect(screen.getByDisplayValue('value B')).toBeDefined(); + + // Dynamically remove propA (leaving only propB) + const updatedFormData = { + hiddenField: 'secret', + propB: 'value B' + }; + + rerender( + + + + ); + + // propA must be completely removed, propB must be retained without state reuse corruption + expect(screen.queryByDisplayValue('propA')).toBeNull(); + expect(screen.queryByDisplayValue('value A')).toBeNull(); + expect(screen.getByDisplayValue('propB')).toBeDefined(); + expect(screen.getByDisplayValue('value B')).toBeDefined(); + + // Dynamically insert a new property before propB + const reorderedFormData = { + hiddenField: 'secret', + propNew: 'value New', + propB: 'value B' + }; + + rerender( + + + + ); + + expect(screen.getByDisplayValue('propNew')).toBeDefined(); + expect(screen.getByDisplayValue('value New')).toBeDefined(); + expect(screen.getByDisplayValue('propB')).toBeDefined(); + expect(screen.getByDisplayValue('value B')).toBeDefined(); + }); + }); +}); + diff --git a/src/custom/RJSFFormWrapper/RJSFFormModal.tsx b/src/custom/RJSFFormWrapper/RJSFFormModal.tsx index 2766162a6..056338f72 100644 --- a/src/custom/RJSFFormWrapper/RJSFFormModal.tsx +++ b/src/custom/RJSFFormWrapper/RJSFFormModal.tsx @@ -97,29 +97,42 @@ export function RJSFFormModal({ onValidationError, hideRootTitle = true, ...rest -}: RJSFFormModalProps): JSX.Element { +}: RJSFFormModalProps): React.JSX.Element { // eslint-disable-next-line @typescript-eslint/no-explicit-any const formRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const [formData, setFormData] = useState(initialData ?? {}); + const prevOpenRef = useRef(open); useEffect(() => { - if (!open) { + // Synchronize formData only when modal transitions between open and closed. + // Re-rendering the parent with a new initialData object reference while the modal + // remains open must not overwrite user-entered form data. + if (!prevOpenRef.current && open) { + setFormData(initialData ?? {}); + } else if (prevOpenRef.current && !open) { setFormData({}); - return; } - setFormData(initialData ?? {}); + prevOpenRef.current = open; }, [open, initialData]); const handlePrimaryClick = (): void => { if (!formRef.current) { return; } - // Delegate to RJSF's submit lifecycle — this triggers internal - // validation and fan-out to `onSubmit` / `onError` props on the - // form, which we pass through below. + // Dispatch a bubbling submit event on formElement.current (with fallback to formRef.current.submit()). + // This directly invokes RJSF's onSubmit handler to execute schema validation, omitExtraData, and + // onError / onSubmit routing, rather than relying solely on browser-native requestSubmit() which + // can halt submission before RJSF runs if HTML5 constraint validation fails on empty required fields. try { - formRef.current.submit(); + if (formRef.current.formElement?.current) { + const submitEvent = new Event('submit', { bubbles: true, cancelable: true }); + formRef.current.formElement.current.dispatchEvent(submitEvent); + return; + } + if (typeof formRef.current.submit === 'function') { + formRef.current.submit(); + } } catch (err) { const message = (err as Error)?.message ?? String(err); const errors: RJSFValidationError[] = [{ stack: `Form could not be validated: ${message}` }]; @@ -150,7 +163,14 @@ export function RJSFFormModal({
+ {/* + Adjacent modal UX fix: Remount RJSFFormWrapper when `open` toggles + so that internal RJSF validation errors (Form.state.errors) and uncommitted + transient input state from a canceled session do not persist when the modal + is reopened with fresh initialData. + */} , 'validator' | 'children'> { + T = any, + S extends StrictRJSFSchema = RJSFSchema, // eslint-disable-next-line @typescript-eslint/no-explicit-any - formRef?: Ref; + F extends FormContextType = any +> extends Omit, 'validator' | 'children'> { + formRef?: Ref>; children?: React.ReactNode; /** * Suppress the form's ROOT object title and description so its child @@ -56,26 +60,32 @@ export interface RJSFFormWrapperProps * `RJSFFormModal`) that own the submit affordance should explicitly * pass an empty fragment to suppress it. */ -export function RJSFFormWrapper({ +export function RJSFFormWrapper< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T = any, + S extends StrictRJSFSchema = RJSFSchema, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + F extends FormContextType = any +>({ formRef, children, hideRootTitle = false, uiSchema, ...rest -}: RJSFFormWrapperProps): JSX.Element { +}: RJSFFormWrapperProps): React.JSX.Element { const resolvedUiSchema = hideRootTitle ? hideRootObjectTitle(uiSchema) : uiSchema; return ( - - - {children} - - + + {children} + ); } diff --git a/src/custom/RJSFFormWrapper/index.ts b/src/custom/RJSFFormWrapper/index.ts index ee95142bd..ffaab41ca 100644 --- a/src/custom/RJSFFormWrapper/index.ts +++ b/src/custom/RJSFFormWrapper/index.ts @@ -1,3 +1,4 @@ export { hideRootObjectTitle } from './hideRootObjectTitle'; export { RJSFFormModal, type RJSFFormModalProps, type RJSFValidationError } from './RJSFFormModal'; export { RJSFFormWrapper, type RJSFFormWrapperProps } from './RJSFFormWrapper'; +export * from './theme'; diff --git a/src/custom/RJSFFormWrapper/theme/generateTheme.ts b/src/custom/RJSFFormWrapper/theme/generateTheme.ts new file mode 100644 index 000000000..368335fd9 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/generateTheme.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { ThemeProps } from '@rjsf/core'; +import type { FormContextType, RJSFSchema, StrictRJSFSchema } from '@rjsf/utils'; +import { generateTemplates } from './templates'; +import { generateWidgets } from './widgets'; + +/** + * Generates the complete Sistent RJSF theme object with all default templates and widgets. + * + * Note: This generates the RJSF template and widget registry. Components resolve Sistent's + * palette and typography dynamically via `useTheme()`, so direct consumers using `withTheme(generateTheme())` + * must ensure an ambient `SistentThemeProvider` is present in their component tree. + */ +export function generateTheme< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(): ThemeProps { + return { + templates: generateTemplates(), + widgets: generateWidgets() + }; +} + +export default generateTheme(); diff --git a/src/custom/RJSFFormWrapper/theme/index.ts b/src/custom/RJSFFormWrapper/theme/index.ts new file mode 100644 index 000000000..fdec00b58 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/index.ts @@ -0,0 +1,6 @@ +export { generateTheme, default as sistentRJSFTheme } from './generateTheme'; +export { generateTemplates, default as sistentTemplates } from './templates'; +export * from './templates'; +export { sistentTheme, default } from './theme'; +export { generateWidgets, default as sistentWidgets } from './widgets'; +export * from './widgets'; diff --git a/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldItemTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldItemTemplate.tsx new file mode 100644 index 000000000..63755f885 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldItemTemplate.tsx @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type ArrayFieldItemTemplateProps, + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + getTemplate, + getUiOptions +} from '@rjsf/utils'; +import React, { type CSSProperties } from 'react'; +import { Box } from '../../../../base/Box'; +import { Grid } from '../../../../base/Grid'; +import { Paper } from '../../../../base/Paper'; +import { computeSxProps, getMuiProps } from '../util'; + +/** + * Sistent's `ArrayFieldItemTemplate` renders individual items in an array list with reorder/remove controls. + */ +export default function ArrayFieldItemTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: ArrayFieldItemTemplateProps): React.JSX.Element { + const { children, buttonsProps, hasDescription, hasToolbar, uiSchema, registry } = props; + const uiOptions = getUiOptions(uiSchema); + const ArrayFieldItemButtonsTemplate = getTemplate<'ArrayFieldItemButtonsTemplate', T, S, F>( + 'ArrayFieldItemButtonsTemplate', + registry, + uiOptions + ); + + const btnStyle: CSSProperties = { + flex: 1, + paddingLeft: 4, + paddingRight: 4, + fontWeight: 'bold', + minWidth: 0 + }; + + const { + rjsfSlotProps: { + arrayItemGridContainer, + arrayItemGridItem, + arrayItemInnerBox, + arrayItemOuterBox, + arrayItemPaper, + arrayItemToolbarGrid + } = {} + } = getMuiProps(uiOptions); + + return ( + + + + + + {children} + + + + + {hasToolbar && ( + + + + )} + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldTemplate.tsx new file mode 100644 index 000000000..7081b57f0 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldTemplate.tsx @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type ArrayFieldTemplateProps, + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + buttonId, + getTemplate, + getUiOptions +} from '@rjsf/utils'; +import React from 'react'; +import { Box } from '../../../../base/Box'; +import { Grid } from '../../../../base/Grid'; +import { Paper } from '../../../../base/Paper'; +import { computeSxProps, getMuiProps } from '../util'; + +/** + * Sistent's `ArrayFieldTemplate` renders dynamic arrays with Sistent containers and add-item buttons. + */ +export default function ArrayFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: ArrayFieldTemplateProps): React.JSX.Element { + const { + canAdd, + disabled, + fieldPathId, + uiSchema, + items, + optionalDataControl, + onAddClick, + readonly, + registry, + required, + schema, + title + } = props; + + const uiOptions = getUiOptions(uiSchema); + const ArrayFieldDescriptionTemplate = getTemplate<'ArrayFieldDescriptionTemplate', T, S, F>( + 'ArrayFieldDescriptionTemplate', + registry, + uiOptions + ); + const ArrayFieldTitleTemplate = getTemplate<'ArrayFieldTitleTemplate', T, S, F>( + 'ArrayFieldTitleTemplate', + registry, + uiOptions + ); + const effectiveTitle = uiOptions.title || title; + const showOptionalDataControlInTitle = Boolean(effectiveTitle) && !readonly && !disabled; + + const { + ButtonTemplates: { AddButton } + } = registry.templates; + + const { + rjsfSlotProps: { + arrayPaper, + arrayBox, + arrayAddButtonGridContainer, + arrayAddButtonGridItem, + arrayAddButtonBox + } = {} + } = getMuiProps(uiOptions); + + return ( + + + + + {!showOptionalDataControlInTitle ? optionalDataControl : undefined} + {items} + {canAdd && ( + + + + + + + + )} + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx new file mode 100644 index 000000000..5b0e78862 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx @@ -0,0 +1,164 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import InputAdornment from '@mui/material/InputAdornment'; +import { SchemaExamples } from '@rjsf/core'; +import { + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + type WidgetProps, + ariaDescribedByIds, + examplesId, + getInputProps, + labelValue +} from '@rjsf/utils'; +import React, { useCallback, type ChangeEvent, type FocusEvent } from 'react'; +import { TextField, type TextFieldProps } from '../../../../base/TextField'; +import { getMuiProps } from '../util'; + +const TYPES_THAT_SHRINK_LABEL = ['date', 'datetime-local', 'file', 'time']; + +/** + * Sistent's `BaseInputTemplate` renders the basic `` / `TextField` component. + * It is used for text, email, number, url, password, and other text-based widgets. + */ +export default function BaseInputTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: WidgetProps): React.JSX.Element { + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + id, + name: _name, + htmlName, + placeholder, + required, + readonly, + disabled, + type, + label, + hideLabel, + hideError: _hideError, + value, + onChange, + onChangeOverride, + onBlur, + onFocus, + autofocus, + options, + schema, + uiSchema: _uiSchema, + rawErrors = [], + errorSchema: _errorSchema, + registry, + InputLabelProps, + InputProps, + formContext: _formContext, + color: _color, + ...textFieldProps + } = props; + /* eslint-enable @typescript-eslint/no-unused-vars */ + + const forwardedTextFieldProps: Partial> = textFieldProps; + const { ClearButton } = registry.templates.ButtonTemplates; + const { step, min, max, accept, ...rest } = getInputProps(schema, type, options); + const muiProps = getMuiProps(options); + const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = muiProps; + + const htmlInputProps = { + ...muiSlotProps?.htmlInput, + step, + min, + max, + accept, + ...(schema.examples ? { list: examplesId(id) } : undefined), + ...(readonly ? { readOnly: true } : undefined), + ...(hideLabel && label ? { 'aria-label': label } : undefined) + }; + + const _onChange = ({ target: { value: nextValue } }: ChangeEvent): void => { + onChange(nextValue === '' ? options.emptyValue : nextValue); + }; + + const _onBlur = ({ target }: FocusEvent): void => { + onBlur(id, target && target.value); + }; + + const _onFocus = ({ target }: FocusEvent): void => { + onFocus(id, target && target.value); + }; + + const DisplayInputLabelProps = TYPES_THAT_SHRINK_LABEL.includes(type) + ? { + ...muiSlotProps?.inputLabel, + ...InputLabelProps, + shrink: true + } + : { + ...muiSlotProps?.inputLabel, + ...InputLabelProps + }; + + const _onClear = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onChange(options.emptyValue ?? ''); + }, + [onChange, options.emptyValue] + ); + + const inputProps = { + ...InputProps, + ...muiSlotProps?.input, + ...(readonly ? { readOnly: true } : undefined) + }; + + if (options.allowClearTextInputs && value && !readonly && !disabled) { + const clearAdornment = ( + + + + ); + inputProps.endAdornment = !inputProps.endAdornment ? ( + clearAdornment + ) : ( + <> + {inputProps.endAdornment} + {clearAdornment} + + ); + } + + return ( + <> + 0} + onChange={onChangeOverride || _onChange} + onBlur={_onBlur} + onFocus={_onFocus} + aria-describedby={ariaDescribedByIds(id, !!schema.examples)} + /> + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx b/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx new file mode 100644 index 000000000..a8875b4fb --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx @@ -0,0 +1,241 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import AddIcon from '@mui/icons-material/Add'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ClearIcon from '@mui/icons-material/Clear'; +import CopyIcon from '@mui/icons-material/ContentCopy'; +import RemoveIcon from '@mui/icons-material/Remove'; +import { + type FormContextType, + type IconButtonProps, + type RJSFSchema, + type StrictRJSFSchema, + type SubmitButtonProps, + TranslatableString, + getSubmitButtonOptions, + getUiOptions +} from '@rjsf/utils'; +import React from 'react'; +import { Box } from '../../../../base/Box'; +import { Button } from '../../../../base/Button'; +import { IconButton } from '../../../../base/IconButton'; +import { computeSxProps, getMuiProps } from '../util'; + +/** + * Submit button template for RJSF forms, supporting text, custom styling, and slot customization. + */ +export function SubmitButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ uiSchema }: SubmitButtonProps): React.JSX.Element | null { + const { + submitText, + norender, + props: submitButtonProps = {} + } = getSubmitButtonOptions(uiSchema); + if (norender) { + return null; + } + const uiOptions = getUiOptions(uiSchema); + const { rjsfSlotProps: { submitButton: submitButtonSlotProps, submitBox } = {}, ...otherMuiProps } = + getMuiProps(uiOptions); + return ( + + + + ); +} + +/** + * Add button template for appending items to array fields in RJSF. + */ +export function AddButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ uiSchema, registry, color, ...props }: IconButtonProps): React.JSX.Element { + const { translateString } = registry; + const uiOptions = getUiOptions(uiSchema); + const muiProps = getMuiProps(uiOptions, [ + 'color', + 'disableFocusRipple', + 'disableRipple', + 'edge', + 'size', + 'sx' + ]); + const { color: muiColor, ...otherMuiProps } = muiProps; + const resolvedColor = (muiColor || color || 'primary') as any; + return ( + + + + ); +} + +/** + * Shared icon button component used across RJSF array and action button templates. + */ +export function SistentIconButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps): React.JSX.Element { + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + icon, + color, + uiSchema, + registry: _registry, + iconType: _iconType, + ...otherProps + } = props; + /* eslint-enable @typescript-eslint/no-unused-vars */ + const uiOptions = getUiOptions(uiSchema); + const muiProps = getMuiProps(uiOptions, [ + 'color', + 'disableFocusRipple', + 'disableRipple', + 'edge', + 'size', + 'sx' + ]); + const { color: muiColor, ...otherMuiProps } = muiProps; + const resolvedColor = (muiColor || color) as any; + return ( + + {icon} + + ); +} + +/** + * Copy button template for duplicating an array item in RJSF. + */ +export function CopyButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps): React.JSX.Element { + const { + registry: { translateString } + } = props; + return ( + } + /> + ); +} + +/** + * Move-down button template for shifting an array item downward in RJSF. + */ +export function MoveDownButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps): React.JSX.Element { + const { + registry: { translateString } + } = props; + return ( + } + /> + ); +} + +/** + * Move-up button template for shifting an array item upward in RJSF. + */ +export function MoveUpButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps): React.JSX.Element { + const { + registry: { translateString } + } = props; + return ( + } + /> + ); +} + +/** + * Remove button template for deleting an array item in RJSF. + */ +export function RemoveButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps): React.JSX.Element { + const { iconType, registry, ...otherProps } = props; + const { translateString } = registry; + return ( + } + /> + ); +} + +/** + * Clear button template for resetting a text input field in RJSF. + */ +export function ClearButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps): React.JSX.Element { + const { iconType, registry, ...otherProps } = props; + const { translateString } = registry; + return ( + } + /> + ); +} + +export default { + AddButton, + CopyButton, + MoveDownButton, + MoveUpButton, + RemoveButton, + SubmitButton, + ClearButton +}; diff --git a/src/custom/RJSFFormWrapper/theme/templates/DescriptionFieldTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/DescriptionFieldTemplate.tsx new file mode 100644 index 000000000..72e45dbd4 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/DescriptionFieldTemplate.tsx @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { RichDescription } from '@rjsf/core'; +import { + type DescriptionFieldProps, + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + getUiOptions +} from '@rjsf/utils'; +import React from 'react'; +import { Typography } from '../../../../base/Typography'; +import { useTheme } from '../../../../theme'; +import { computeSxProps, getMuiProps } from '../util'; + +/** + * Sistent's `DescriptionFieldTemplate` renders field/section descriptions. + */ +export default function DescriptionFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: DescriptionFieldProps): React.JSX.Element | null { + const theme = useTheme(); + const { id, description, registry, uiSchema } = props; + const uiOptions = getUiOptions(uiSchema); + const { rjsfSlotProps: { descTypography } = {} } = getMuiProps(uiOptions); + + if (description) { + return ( + + + + ); + } + return null; +} diff --git a/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx new file mode 100644 index 000000000..f0fb2ee15 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type ErrorListProps, + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + TranslatableString, + getUiOptions +} from '@rjsf/utils'; +import React from 'react'; +import { Alert } from '../../../../base/Alert'; +import { AlertTitle } from '../../../../base/AlertTitle'; +import { List } from '../../../../base/List'; +import { ListItem } from '../../../../base/ListItem'; +import { Typography } from '../../../../base/Typography'; +import { useTheme } from '../../../../theme'; +import { computeSxProps, getMuiProps } from '../util'; + +/** + * Sistent's `ErrorListTemplate` renders top-level validation error summaries using Sistent Alert. + */ +export default function ErrorListTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ errors, registry, uiSchema }: ErrorListProps): React.JSX.Element { + const theme = useTheme(); + const { translateString } = registry; + const uiOptions = getUiOptions(uiSchema); + const { + rjsfSlotProps: { + errorAlert, + errorList, + errorListItem, + errorListItemText + } = {} + } = getMuiProps(uiOptions); + + return ( + + {translateString(TranslatableString.ErrorsLabel)} + + {errors.map((error, i) => ( + + + {error.stack} + + + ))} + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/templates/FieldErrorTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/FieldErrorTemplate.tsx new file mode 100644 index 000000000..ea7bd326c --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/FieldErrorTemplate.tsx @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import FormHelperText from '@mui/material/FormHelperText'; +import { + type FieldErrorProps, + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + errorId, + getUiOptions +} from '@rjsf/utils'; +import React from 'react'; +import { List } from '../../../../base/List'; +import { ListItem } from '../../../../base/ListItem'; +import { getMuiProps } from '../util'; + +/** + * Sistent's `FieldErrorTemplate` renders inline validation errors with status.error color tokens. + */ +export default function FieldErrorTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: FieldErrorProps): React.JSX.Element | null { + const { errors = [], fieldPathId, uiSchema } = props; + if (errors.length === 0) { + return null; + } + const id = errorId(fieldPathId); + const uiOptions = getUiOptions(uiSchema); + const muiProps = getMuiProps(uiOptions); + const { rjsfSlotProps: muiSlotProps } = muiProps; + + return ( + + {errors.map((error, i) => ( + + + {error} + + + ))} + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/templates/FieldHelpTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/FieldHelpTemplate.tsx new file mode 100644 index 000000000..bf51c732f --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/FieldHelpTemplate.tsx @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import FormHelperText from '@mui/material/FormHelperText'; +import { RichHelp } from '@rjsf/core'; +import { + type FieldHelpProps, + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + getUiOptions, + helpId +} from '@rjsf/utils'; +import React from 'react'; +import { computeSxProps, getMuiProps } from '../util'; + +/** + * Sistent's `FieldHelpTemplate` renders field helper text. + */ +export default function FieldHelpTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: FieldHelpProps): React.JSX.Element | null { + const { fieldPathId, help, uiSchema, registry } = props; + if (!help) { + return null; + } + const uiOptions = getUiOptions(uiSchema); + const { rjsfSlotProps: { helpFormHelperText } = {} } = getMuiProps(uiOptions); + + return ( + + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/templates/FieldTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/FieldTemplate.tsx new file mode 100644 index 000000000..acb18b969 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/FieldTemplate.tsx @@ -0,0 +1,113 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FieldTemplateProps, + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + descriptionId, + getTemplate, + getUiOptions +} from '@rjsf/utils'; +import React from 'react'; +import { FormControl } from '../../../../base/FormControl'; +import { Typography } from '../../../../base/Typography'; +import { useTheme } from '../../../../theme'; +import { computeSxProps, getMuiProps } from '../util'; + +/** + * Sistent's `FieldTemplate` wraps every schema field with Sistent FormControl styling. + */ +export default function FieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: FieldTemplateProps): React.JSX.Element { + const theme = useTheme(); + const { + id, + children, + classNames, + style, + disabled, + displayLabel, + hidden, + label, + onKeyRename, + onKeyRenameBlur, + onRemoveProperty, + readonly, + required, + rawErrors = [], + errors, + help, + description, + rawDescription, + schema, + uiSchema, + registry + } = props; + + const uiOptions = getUiOptions(uiSchema); + const WrapIfAdditionalTemplate = getTemplate<'WrapIfAdditionalTemplate', T, S, F>( + 'WrapIfAdditionalTemplate', + registry, + uiOptions + ); + + if (hidden) { + return
{children}
; + } + + const isSelfDescribingWidget = + uiOptions.widget === 'checkbox' || + uiOptions.widget === 'switch' || + uiOptions.widget === 'toggle' || + (schema.type === 'boolean' && !uiOptions.widget); + const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = getMuiProps(uiOptions); + + return ( + + 0} + required={required} + sx={computeSxProps(otherMuiProps.sx ?? {}, muiSlotProps?.fieldFormControl)} + className={[otherMuiProps.className, muiSlotProps?.fieldFormControl?.className] + .filter(Boolean) + .join(' ') || undefined} + > + {children} + {displayLabel && !isSelfDescribingWidget && rawDescription ? ( + + {description} + + ) : null} + {errors} + {help} + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/templates/ObjectFieldTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/ObjectFieldTemplate.tsx new file mode 100644 index 000000000..c50cb5ad5 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/ObjectFieldTemplate.tsx @@ -0,0 +1,141 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FormContextType, + type ObjectFieldTemplateProps, + type RJSFSchema, + type StrictRJSFSchema, + buttonId, + canExpand, + descriptionId, + getTemplate, + getUiOptions, + titleId +} from '@rjsf/utils'; +import React from 'react'; +import { Grid } from '../../../../base/Grid'; +import { computeSxProps, getMuiProps } from '../util'; + +/** + * Sistent's `ObjectFieldTemplate` renders objects with clean grid spacing and section headings. + */ +export default function ObjectFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: ObjectFieldTemplateProps): React.JSX.Element { + const { + description, + title, + properties, + required, + disabled, + readonly, + uiSchema, + fieldPathId, + schema, + formData, + optionalDataControl, + onAddProperty, + registry + } = props; + + const uiOptions = getUiOptions(uiSchema); + const TitleFieldTemplate = getTemplate<'TitleFieldTemplate', T, S, F>( + 'TitleFieldTemplate', + registry, + uiOptions + ); + const DescriptionFieldTemplate = getTemplate<'DescriptionFieldTemplate', T, S, F>( + 'DescriptionFieldTemplate', + registry, + uiOptions + ); + const showOptionalDataControlInTitle = !readonly && !disabled; + + const { + ButtonTemplates: { AddButton } + } = registry.templates; + + const { + rjsfSlotProps: { + objectGridContainer, + objectGridItem, + objectAddButtonGridContainer, + objectAddButtonGridItem + } = {} + } = getMuiProps(uiOptions); + + return ( + <> + {title && ( + + )} + {description && ( + + )} + + {(!title || !showOptionalDataControlInTitle) && optionalDataControl} + {properties.map((element, index) => { + // Prefer RJSF's stable key from element.content.key (which includes rename tracking), + // falling back to element.name, and only using an index fallback if neither exists. + const key = + (element.content && (element.content as React.ReactElement).key) ?? + (typeof element.name === 'string' && element.name.length > 0 ? element.name : undefined) ?? + `property-${index}`; + return element.hidden ? ( + {element.content} + ) : ( + + {element.content} + + ); + })} + + {canExpand(schema, uiSchema, formData) && ( + + + + + + )} + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/templates/TitleFieldTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/TitleFieldTemplate.tsx new file mode 100644 index 000000000..496a684be --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/TitleFieldTemplate.tsx @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + type TitleFieldProps, + getUiOptions +} from '@rjsf/utils'; +import React from 'react'; +import { Box } from '../../../../base/Box'; +import { Divider } from '../../../../base/Divider'; +import { Grid } from '../../../../base/Grid'; +import { Typography } from '../../../../base/Typography'; +import { computeSxProps, getMuiProps } from '../util'; + +/** + * Sistent's `TitleFieldTemplate` renders section headers with Sistent typography. + */ +export default function TitleFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: TitleFieldProps): React.JSX.Element { + const { id, title, optionalDataControl, uiSchema } = props; + const uiOptions = getUiOptions(uiSchema); + const { + rjsfSlotProps: { + titleBox, + titleDivider, + titleTypography, + titleGridContainer, + titleGridItem, + titleOptionalDataGridItem + } = {} + } = getMuiProps(uiOptions); + + let heading = ( + + {title} + + ); + + if (optionalDataControl) { + heading = ( + + + {heading} + + + {optionalDataControl} + + + ); + } + + return ( + + {heading} + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/templates/WrapIfAdditionalTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/WrapIfAdditionalTemplate.tsx new file mode 100644 index 000000000..1cc33f8a5 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/WrapIfAdditionalTemplate.tsx @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + ADDITIONAL_PROPERTY_FLAG, + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + TranslatableString, + type WrapIfAdditionalTemplateProps, + buttonId, + getUiOptions +} from '@rjsf/utils'; +import React, { type CSSProperties } from 'react'; +import { Grid } from '../../../../base/Grid'; +import { TextField } from '../../../../base/TextField'; +import { computeSxProps, getMuiProps } from '../util'; + +/** + * Sistent's `WrapIfAdditionalTemplate` allows renaming and removing dynamic keys in `additionalProperties`. + */ +export default function WrapIfAdditionalTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: WrapIfAdditionalTemplateProps): React.JSX.Element { + const { + children, + classNames, + style, + disabled, + id, + label, + displayLabel, + onKeyRenameBlur, + onRemoveProperty, + readonly, + required, + schema, + uiSchema, + registry + } = props; + + const { templates, translateString } = registry; + const { RemoveButton } = templates.ButtonTemplates; + const keyLabel = translateString(TranslatableString.KeyLabel, [label]); + const additional = ADDITIONAL_PROPERTY_FLAG in schema; + const btnStyle: CSSProperties = { + flex: 1, + paddingLeft: 6, + paddingRight: 6, + fontWeight: 'bold' + }; + const uiOptions = getUiOptions(uiSchema); + const { + rjsfSlotProps: { + wrapGridContainer, + wrapKeyGridItem, + wrapChildrenGridItem, + wrapRemoveButtonGridItem + } = {} + } = getMuiProps(uiOptions); + + if (!additional) { + return ( +
+ {children} +
+ ); + } + + const { + className: slotContainerClassName, + style: slotContainerStyle, + ...otherWrapGridContainer + } = wrapGridContainer || {}; + + return ( + + + + + + {children} + + + + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/templates/index.ts b/src/custom/RJSFFormWrapper/theme/templates/index.ts new file mode 100644 index 000000000..f55b53e64 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/index.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { FormContextType, RJSFSchema, StrictRJSFSchema, TemplatesType } from '@rjsf/utils'; +import ArrayFieldItemTemplate from './ArrayFieldItemTemplate'; +import ArrayFieldTemplate from './ArrayFieldTemplate'; +import BaseInputTemplate from './BaseInputTemplate'; +import ButtonTemplates, { + AddButton, + ClearButton, + CopyButton, + MoveDownButton, + MoveUpButton, + RemoveButton, + SubmitButton +} from './ButtonTemplates'; +import DescriptionFieldTemplate from './DescriptionFieldTemplate'; +import ErrorListTemplate from './ErrorListTemplate'; +import FieldErrorTemplate from './FieldErrorTemplate'; +import FieldHelpTemplate from './FieldHelpTemplate'; +import FieldTemplate from './FieldTemplate'; +import ObjectFieldTemplate from './ObjectFieldTemplate'; +import TitleFieldTemplate from './TitleFieldTemplate'; +import WrapIfAdditionalTemplate from './WrapIfAdditionalTemplate'; + +export function generateTemplates< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(): Partial> { + return { + ArrayFieldItemTemplate, + ArrayFieldTemplate, + BaseInputTemplate, + ButtonTemplates, + DescriptionFieldTemplate, + ErrorListTemplate, + FieldErrorTemplate, + FieldHelpTemplate, + FieldTemplate, + ObjectFieldTemplate, + TitleFieldTemplate, + WrapIfAdditionalTemplate + }; +} + +export { + AddButton, + ArrayFieldItemTemplate, + ArrayFieldTemplate, + BaseInputTemplate, + ButtonTemplates, + ClearButton, + CopyButton, + DescriptionFieldTemplate, + ErrorListTemplate, + FieldErrorTemplate, + FieldHelpTemplate, + FieldTemplate, + MoveDownButton, + MoveUpButton, + ObjectFieldTemplate, + RemoveButton, + SubmitButton, + TitleFieldTemplate, + WrapIfAdditionalTemplate +}; + +export default generateTemplates(); diff --git a/src/custom/RJSFFormWrapper/theme/theme.ts b/src/custom/RJSFFormWrapper/theme/theme.ts new file mode 100644 index 000000000..eca05b8df --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/theme.ts @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { ThemeProps } from '@rjsf/core'; +import type { RJSFSchema } from '@rjsf/utils'; +import defaultTheme, { generateTheme } from './generateTheme'; + +/** + * Default Sistent RJSF theme containing standard templates and widgets. + * + * Note: `sistentTheme` provides the RJSF component registry. Components resolve Sistent's + * palette and typography dynamically via `useTheme()`, so direct consumers using `withTheme(sistentTheme)` + * must ensure an ambient `SistentThemeProvider` is present in their component tree. + */ +export const sistentTheme: ThemeProps = defaultTheme; + +export default sistentTheme; +export { generateTheme }; diff --git a/src/custom/RJSFFormWrapper/theme/util.ts b/src/custom/RJSFFormWrapper/theme/util.ts new file mode 100644 index 000000000..4588c26ad --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/util.ts @@ -0,0 +1,189 @@ +import type { + AlertProps, + BoxProps, + ButtonProps, + CardProps, + CheckboxProps, + DividerProps, + FormControlLabelProps, + FormControlProps, + FormGroupProps, + FormHelperTextProps, + FormLabelProps, + GridProps, + InputBaseComponentProps, + InputLabelProps, + InputProps, + ListItemProps, + ListProps, + MenuItemProps, + PaperProps, + RadioGroupProps, + RadioProps, + SelectProps, + SliderProps, + SvgIconProps, + SwitchProps, + TextFieldProps, + TypographyProps +} from '@mui/material'; +import type { SxProps, Theme } from '@mui/material/styles'; +import type { FormContextType, RJSFSchema, StrictRJSFSchema, UIOptionsType } from '@rjsf/utils'; + +export type SistentSlotProps

= Omit; + +/** + * Slot props for individual Sistent/MUI sub-components across RJSF widgets and templates. + * Explicitly declares known sub-component slot props with their corresponding MUI prop types. + */ +export interface SistentMuiSlotProps { + // Radio & Checkbox slots + radioGroup?: Partial>; + formGroup?: Partial>; + formControlLabel?: Partial>; + formLabel?: Partial>; + radio?: Partial>; + checkbox?: Partial>; + switch?: Partial>; + toggle?: Partial>; + + // Form field & Typography slots + fieldFormControl?: Partial>; + fieldTypography?: Partial>; + descTypography?: Partial>; + helpFormHelperText?: Partial>; + fieldErrorList?: Partial>; + fieldErrorListItem?: Partial>; + fieldErrorFormHelperText?: Partial>; + + // Title slots + titleBox?: Partial>; + titleTypography?: Partial>; + titleDivider?: Partial>; + titleGridContainer?: Partial>; + titleGridItem?: Partial>; + titleOptionalDataGridItem?: Partial>; + + // Error list slots + errorAlert?: Partial>; + errorListRoot?: Partial>; + errorListCard?: Partial>; + errorListHeading?: Partial>; + errorList?: Partial>; + errorListItem?: Partial>; + errorListItemText?: Partial>; + + // Button slots + submitButton?: Partial>; + submitBox?: Partial>; + addButton?: Partial>; + removeButton?: Partial>; + moveUpButton?: Partial>; + moveDownButton?: Partial>; + + // Input & Select slots + textField?: Partial>; + input?: Partial>; + htmlInput?: InputBaseComponentProps; + inputLabel?: Partial>; + select?: Partial>; + menuItem?: Partial>; + + // Range widget slots + rangeBox?: Partial>; + slider?: Partial>; + rangeSlider?: Partial>; + rangeTypography?: Partial>; + + // Array slots + arrayBox?: Partial>; + arrayPaper?: Partial>; + arrayToolbar?: Partial>; + arrayAddButton?: Partial>; + arrayAddButtonBox?: Partial>; + arrayAddButtonGridContainer?: Partial>; + arrayAddButtonGridItem?: Partial>; + arrayItemGridContainer?: Partial>; + arrayItemGridItem?: Partial>; + arrayItemInnerBox?: Partial>; + arrayItemOuterBox?: Partial>; + arrayItemPaper?: Partial>; + arrayItemToolbarGrid?: Partial>; + + // Object field & Wrapper slots + objectBox?: Partial>; + objectGrid?: Partial>; + objectGridContainer?: Partial>; + objectGridItem?: Partial>; + objectAddButtonGridContainer?: Partial>; + objectAddButtonGridItem?: Partial>; + wrapBox?: Partial>; + wrapGridContainer?: Partial>; + wrapKeyGridItem?: Partial>; + wrapChildrenGridItem?: Partial>; + wrapRemoveButtonGridItem?: Partial>; + wrapHelpIcon?: Partial>; +} + +/** + * Top-level MUI customization options read from `uiSchema.ui:options.mui`. + * Known fields are explicitly typed; additional MUI props can be passed as unknown. + */ +export interface SistentMuiOptions { + sx?: SxProps; + className?: string; + rjsfSlotProps?: SistentMuiSlotProps; + [key: string]: unknown; +} + +/** + * Extract props meant for MUI/Sistent components from the `options` field of the `uiSchema`. + */ +export function getMuiProps< + T = unknown, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = unknown +>( + options?: UIOptionsType, + propsToFilter?: string[] +): SistentMuiOptions { + const muiProps = (options?.mui as SistentMuiOptions) || {}; + if (propsToFilter) { + return Object.keys(muiProps) + .filter((key) => propsToFilter.includes(key)) + .reduce((obj: Record, key) => { + obj[key] = muiProps[key]; + return obj; + }, {}) as SistentMuiOptions; + } + return muiProps; +} + +/** + * Merges base sx props with any custom sx specified in uiOptions.mui. + */ +export function computeSxProps( + sxProps: SxProps, + muiProps?: SistentMuiOptions +): SxProps { + if (!muiProps) { + return sxProps; + } + const sxIsObject = sxProps !== null && typeof sxProps === 'object' && !Array.isArray(sxProps); + if (Array.isArray(muiProps?.sx)) { + return sxIsObject + ? [sxProps, ...muiProps.sx] as unknown as SxProps + : [...(Array.isArray(sxProps) ? sxProps : [sxProps]), ...muiProps.sx] as unknown as SxProps; + } + if (typeof muiProps?.sx === 'function') { + return sxIsObject + ? [sxProps, muiProps.sx] as unknown as SxProps + : [...(Array.isArray(sxProps) ? sxProps : [sxProps]), muiProps.sx] as unknown as SxProps; + } + if (muiProps?.sx) { + return sxIsObject + ? { ...(sxProps as object), ...(muiProps.sx as object) } as unknown as SxProps + : [...(Array.isArray(sxProps) ? sxProps : [sxProps]), muiProps.sx] as unknown as SxProps; + } + return sxProps; +} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/CheckboxWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxWidget.tsx new file mode 100644 index 000000000..aae44ad0c --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxWidget.tsx @@ -0,0 +1,94 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + type WidgetProps, + ariaDescribedByIds, + descriptionId, + getTemplate, + labelValue, + schemaRequiresTrueValue +} from '@rjsf/utils'; +import React, { type ChangeEvent } from 'react'; +import { Checkbox } from '../../../../base/Checkbox'; +import { FormControlLabel } from '../../../../base/FormControlLabel'; +import { getMuiProps } from '../util'; + +/** + * Sistent's `CheckboxWidget` renders boolean properties using Sistent Checkbox. + */ +export default function CheckboxWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: WidgetProps): React.JSX.Element { + const { + schema, + id, + htmlName, + value, + disabled, + readonly, + label = '', + hideLabel, + autofocus, + onChange, + onBlur, + onFocus, + registry, + options, + uiSchema + } = props; + + const DescriptionFieldTemplate = getTemplate<'DescriptionFieldTemplate', T, S, F>( + 'DescriptionFieldTemplate', + registry, + options + ); + + const required = schemaRequiresTrueValue(schema); + const _onChange = (_: ChangeEvent, checked: boolean): void => { + onChange(checked); + }; + const _onBlur = (): void => onBlur(id, value); + const _onFocus = (): void => onFocus(id, value); + const description = options.description ?? schema.description; + const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = getMuiProps(options); + + return ( + <> + {description && ( + + )} + + } + label={labelValue(label, hideLabel, false)} + /> + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx new file mode 100644 index 000000000..ef9868bd6 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + type WidgetProps, + ariaDescribedByIds, + enumOptionsDeselectValue, + enumOptionsIsSelected, + enumOptionsSelectValue, + labelValue, + optionId +} from '@rjsf/utils'; +import React, { type ChangeEvent } from 'react'; +import { Checkbox } from '../../../../base/Checkbox'; +import { FormControlLabel } from '../../../../base/FormControlLabel'; +import { FormGroup } from '../../../../base/FormGroup'; +import { FormLabel } from '../../../../base/FormLabel'; +import { getMuiProps } from '../util'; + +/** + * Sistent's `CheckboxesWidget` renders checkbox groups for enum arrays. + */ +export default function CheckboxesWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: WidgetProps): React.JSX.Element { + const { + label, + hideLabel, + id, + htmlName, + disabled, + options, + value, + autofocus, + readonly, + required, + onChange, + onBlur, + onFocus + } = props; + + const { enumOptions, enumDisabled, inline } = options; + const checkboxesValues = Array.isArray(value) ? value : value !== undefined ? [value] : []; + + const _onChange = + (index: number) => + ({ target: { checked } }: ChangeEvent): void => { + if (checked) { + onChange(enumOptionsSelectValue(index, checkboxesValues, enumOptions)); + } else { + onChange(enumOptionsDeselectValue(index, checkboxesValues, enumOptions)); + } + }; + + const _onBlur = (): void => { + onBlur(id, checkboxesValues); + }; + + const _onFocus = (): void => { + onFocus(id, checkboxesValues); + }; + + const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = getMuiProps(options); + + return ( + <> + {labelValue( + + {label || undefined} + , + hideLabel + )} + + {Array.isArray(enumOptions) && + enumOptions.map((option, index) => { + const checked = enumOptionsIsSelected(option.value, checkboxesValues); + const itemDisabled = + Array.isArray(enumDisabled) && enumDisabled.indexOf(option.value) !== -1; + const checkbox = ( + + ); + return ( + + ); + })} + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx new file mode 100644 index 000000000..db5a83827 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx @@ -0,0 +1,165 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FileInfoType, + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + TranslatableString, + type WidgetProps, + getTemplate, + useFileWidgetProps +} from '@rjsf/utils'; +import React, { type ChangeEvent } from 'react'; +import { Box } from '../../../../base/Box'; +import { Link } from '../../../../base/Link'; +import { List } from '../../../../base/List'; +import { ListItem } from '../../../../base/ListItem'; +import { Typography } from '../../../../base/Typography'; +import { useTheme } from '../../../../theme'; + +function FileInfoPreview< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ + fileInfo, + registry +}: { + fileInfo: FileInfoType; + registry: WidgetProps['registry']; +}): React.JSX.Element | null { + const { translateString } = registry; + const { dataURL, type, name } = fileInfo; + if (!dataURL) { + return null; + } + if (type && (type.startsWith('image/') || ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml', 'image/gif', 'image/avif'].includes(type))) { + return ( + {name + ); + } + return ( + + {translateString(TranslatableString.PreviewLabel)} + + ); +} + +function FilesInfo< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ + filesInfo, + registry, + preview, + onRemove, + options, + disabled +}: { + filesInfo: FileInfoType[]; + registry: WidgetProps['registry']; + preview?: boolean; + onRemove: (index: number) => void; + options: WidgetProps['options']; + disabled?: boolean; +}): React.JSX.Element | null { + const theme = useTheme(); + if (filesInfo.length === 0) { + return null; + } + const { RemoveButton } = getTemplate<'ButtonTemplates', T, S, F>( + 'ButtonTemplates', + registry, + options + ); + + return ( + + {filesInfo.map((fileInfo, key) => { + const { name, size, type } = fileInfo; + const handleRemove = (): void => { + if (!disabled) { + onRemove(key); + } + }; + return ( + + + + {name} + + + ({type || 'unknown'}, {size ? `${(size / 1024).toFixed(1)} KB` : '0 KB'}) + + {preview && } + + + + ); + })} + + ); +} + +/** + * Sistent's `FileWidget` renders file upload inputs with Sistent-styled file list info and previews. + */ +export default function FileWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: WidgetProps): React.JSX.Element { + const { disabled, readonly, required, multiple, onChange, value, options, registry } = props; + const { filesInfo, handleChange, handleRemove } = useFileWidgetProps(value, onChange, multiple); + const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>( + 'BaseInputTemplate', + registry, + options + ); + + const handleOnChangeEvent = (event: ChangeEvent): void => { + if (event.target.files) { + void handleChange(event.target.files); + } + }; + + return ( + + 0 ? false : required} + onChangeOverride={handleOnChangeEvent} + value="" + accept={options.accept ? String(options.accept) : undefined} + /> + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx new file mode 100644 index 000000000..35c6a5ac6 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + type WidgetProps, + ariaDescribedByIds, + enumOptionSelectedValue, + enumOptionValueDecoder, + enumOptionValueEncoder, + getOptionValueFormat, + labelValue, + optionId +} from '@rjsf/utils'; +import React, { type ChangeEvent, type FocusEvent } from 'react'; +import { FormControlLabel } from '../../../../base/FormControlLabel'; +import { FormLabel } from '../../../../base/FormLabel'; +import { Radio } from '../../../../base/Radio'; +import { RadioGroup } from '../../../../base/RadioGroup'; +import { getMuiProps } from '../util'; + +/** + * Sistent's `RadioWidget` renders single-choice radio option groups. + */ +export default function RadioWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: WidgetProps): React.JSX.Element { + const { + id, + htmlName, + options, + value, + required, + disabled, + readonly, + label, + hideLabel, + autofocus, + onChange, + onBlur, + onFocus + } = props; + + const { enumOptions, enumDisabled, emptyValue } = options; + const optionValueFormat = getOptionValueFormat(options); + + const _onChange = (_: ChangeEvent, val: string): void => { + onChange(enumOptionValueDecoder(val, enumOptions, optionValueFormat, emptyValue)); + }; + + const _onBlur = ({ target }: FocusEvent): void => { + onBlur( + id, + enumOptionValueDecoder(target && target.value, enumOptions, optionValueFormat, emptyValue) + ); + }; + + const _onFocus = ({ target }: FocusEvent): void => { + onFocus( + id, + enumOptionValueDecoder(target && target.value, enumOptions, optionValueFormat, emptyValue) + ); + }; + + const row = options ? Boolean(options.inline) : false; + const selectValue = enumOptionSelectedValue(value, enumOptions, false, optionValueFormat, ''); + const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = getMuiProps(options); + + return ( + <> + {labelValue( + + {label || undefined} + , + hideLabel + )} + + {Array.isArray(enumOptions) && + enumOptions.map((option, index) => { + const itemDisabled = + Array.isArray(enumDisabled) && enumDisabled.indexOf(option.value) !== -1; + return ( + + } + label={option.label} + value={enumOptionValueEncoder(option.value, index, optionValueFormat)} + key={index} + disabled={disabled || itemDisabled || readonly} + /> + ); + })} + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx new file mode 100644 index 000000000..0bc08fe36 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx @@ -0,0 +1,101 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + type WidgetProps, + ariaDescribedByIds, + labelValue, + rangeSpec +} from '@rjsf/utils'; +import React, { type FocusEvent } from 'react'; +import { Box } from '../../../../base/Box'; +import { FormLabel } from '../../../../base/FormLabel'; +import { Slider } from '../../../../base/Slider'; +import { Typography } from '../../../../base/Typography'; +import { useTheme } from '../../../../theme'; +import { computeSxProps, getMuiProps } from '../util'; + +/** + * Sistent's `RangeWidget` renders numeric range sliders. + */ +export default function RangeWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: WidgetProps): React.JSX.Element { + const theme = useTheme(); + const { + value, + readonly, + disabled, + onBlur, + onFocus, + options, + schema, + onChange, + required, + label, + hideLabel, + id + } = props; + + const sliderProps = { value, id, ...rangeSpec(schema) }; + + const _onChange = (_: Event, val: number | number[]): void => { + onChange(val); + }; + + const _onBlur = ({ target }: FocusEvent): void => { + onBlur(id, target && target.value); + }; + + const _onFocus = ({ target }: FocusEvent): void => { + onFocus(id, target && target.value); + }; + + const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = getMuiProps(options); + + return ( + <> + {labelValue( + + {label || undefined} + , + hideLabel + )} + + + + {value ?? sliderProps.min ?? 0} + + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx new file mode 100644 index 000000000..0d2cfae49 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx @@ -0,0 +1,171 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + type WidgetProps, + ariaDescribedByIds, + enumOptionSelectedValue, + enumOptionValueDecoder, + enumOptionValueEncoder, + getOptionValueFormat, + labelValue +} from '@rjsf/utils'; +import React, { type ChangeEvent, type FocusEvent } from 'react'; +import { MenuItem } from '../../../../base/MenuItem'; +import { TextField, type TextFieldProps } from '../../../../base/TextField'; +import { getMuiProps } from '../util'; + +/** + * Sistent's `SelectWidget` renders dropdown menus using Sistent Select / MenuItem. + */ +export default function SelectWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: WidgetProps): React.JSX.Element { + const { + schema, + id, + htmlName, + options, + label, + hideLabel, + required, + disabled, + placeholder, + readonly, + value, + multiple, + autofocus, + onChange, + onBlur, + onFocus, + rawErrors = [] + } = props; + + const { enumOptions, enumDisabled, emptyValue: optEmptyVal } = options; + const optionValueFormat = getOptionValueFormat(options); + const isMultiple = typeof multiple === 'undefined' ? false : Boolean(multiple); + const emptyValue = isMultiple ? [] : ''; + const isEmpty = + typeof value === 'undefined' || + (isMultiple && Array.isArray(value) && value.length < 1) || + (!isMultiple && value === emptyValue); + + const _onChange = ({ target: { value: nextVal } }: ChangeEvent): void => { + onChange(enumOptionValueDecoder(nextVal, enumOptions, optionValueFormat, optEmptyVal)); + }; + + const _onBlur = ({ target }: FocusEvent): void => { + onBlur( + id, + enumOptionValueDecoder(target && target.value, enumOptions, optionValueFormat, optEmptyVal) + ); + }; + + const _onFocus = ({ target }: FocusEvent): void => { + onFocus( + id, + enumOptionValueDecoder(target && target.value, enumOptions, optionValueFormat, optEmptyVal) + ); + }; + + const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = getMuiProps(options); + const showPlaceholderOption = !isMultiple && schema.default === undefined; + + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + schema: _schema, + id: _id, + htmlName: _htmlName, + options: _options, + label: _label, + hideLabel: _hideLabel, + required: _required, + disabled: _disabled, + placeholder: _placeholder, + readonly: _readonly, + value: _value, + multiple: _multiple, + autofocus: _autofocus, + onChange: _onChange2, + onBlur: _onBlur2, + onFocus: _onFocus2, + rawErrors: _rawErrors, + // exclude RJSF-only props that must not reach TextField + name: _name, + hideError: _hideError, + errorSchema: _errorSchema, + uiSchema: _uiSchema, + registry: _registry, + InputLabelProps: _InputLabelProps, + SelectProps: _SelectProps, + formContext: _formContext, + color: _color, + ...textFieldProps + } = props; + /* eslint-enable @typescript-eslint/no-unused-vars */ + + const forwardedTextFieldProps: Partial> = textFieldProps; + + return ( + 0} + onChange={_onChange} + onBlur={_onBlur} + onFocus={_onFocus} + select + slotProps={{ + ...muiSlotProps, + input: { + ...muiSlotProps?.input, + ...(readonly ? { readOnly: true } : undefined) + }, + inputLabel: { + ...muiSlotProps?.inputLabel, + shrink: !isEmpty + }, + select: { + ...muiSlotProps?.select, + multiple: isMultiple, + ...(readonly ? { readOnly: true } : undefined), + ...(hideLabel && label ? { 'aria-label': label } : undefined) + } + }} + aria-describedby={ariaDescribedByIds(id)} + > + {showPlaceholderOption && ( + + {placeholder || 'Select...'} + + )} + {Array.isArray(enumOptions) && + enumOptions.map(({ value: optVal, label: optLabel }, i) => { + const itemDisabled = Array.isArray(enumDisabled) && enumDisabled.indexOf(optVal) !== -1; + return ( + + {optLabel} + + ); + })} + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/TextWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/TextWidget.tsx new file mode 100644 index 000000000..6ffce9855 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/TextWidget.tsx @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + type WidgetProps, + getTemplate +} from '@rjsf/utils'; +import React from 'react'; + +/** + * Sistent's `TextWidget` delegates text input rendering to `BaseInputTemplate`. + */ +export default function TextWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: WidgetProps): React.JSX.Element { + const { options, registry } = props; + const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>( + 'BaseInputTemplate', + registry, + options + ); + return ; +} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/TextareaWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/TextareaWidget.tsx new file mode 100644 index 000000000..ad2169c04 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/TextareaWidget.tsx @@ -0,0 +1,32 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + type WidgetProps, + getTemplate +} from '@rjsf/utils'; +import React from 'react'; + +/** + * Sistent's `TextareaWidget` renders multiline text fields. + */ +export default function TextareaWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: WidgetProps): React.JSX.Element { + const { options, registry } = props; + const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>( + 'BaseInputTemplate', + registry, + options + ); + + let rows: string | number = 4; + if (typeof options.rows === 'string' || typeof options.rows === 'number') { + rows = options.rows; + } + + return ; +} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/ToggleWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/ToggleWidget.tsx new file mode 100644 index 000000000..701d018a4 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/ToggleWidget.tsx @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + type WidgetProps, + ariaDescribedByIds, + descriptionId, + getTemplate, + labelValue, + schemaRequiresTrueValue +} from '@rjsf/utils'; +import React, { type ChangeEvent } from 'react'; +import { FormControlLabel } from '../../../../base/FormControlLabel'; +import { Switch } from '../../../../base/Switch'; +import { getMuiProps } from '../util'; + +/** + * Sistent's `ToggleWidget` (or `SwitchWidget`) renders boolean toggles using Sistent Switch. + */ +export default function ToggleWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: WidgetProps): React.JSX.Element { + const { + schema, + id, + htmlName, + value, + disabled, + readonly, + label = '', + hideLabel, + autofocus, + onChange, + onBlur, + onFocus, + registry, + options, + uiSchema + } = props; + + const DescriptionFieldTemplate = getTemplate<'DescriptionFieldTemplate', T, S, F>( + 'DescriptionFieldTemplate', + registry, + options + ); + + const required = schemaRequiresTrueValue(schema); + const _onChange = (_: ChangeEvent, checked: boolean): void => { + onChange(checked); + }; + const _onBlur = (): void => onBlur(id, value); + const _onFocus = (): void => onFocus(id, value); + const description = options.description ?? schema.description; + const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = getMuiProps(options); + + return ( + <> + {description && ( + + )} + + } + label={labelValue(label, hideLabel, false)} + /> + + ); +} + +export const SwitchWidget = ToggleWidget; diff --git a/src/custom/RJSFFormWrapper/theme/widgets/index.ts b/src/custom/RJSFFormWrapper/theme/widgets/index.ts new file mode 100644 index 000000000..bc02caac0 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/index.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + FormContextType, + RJSFSchema, + RegistryWidgetsType, + StrictRJSFSchema +} from '@rjsf/utils'; +import CheckboxWidget from './CheckboxWidget'; +import CheckboxesWidget from './CheckboxesWidget'; +import FileWidget from './FileWidget'; +import RadioWidget from './RadioWidget'; +import RangeWidget from './RangeWidget'; +import SelectWidget from './SelectWidget'; +import TextWidget from './TextWidget'; +import TextareaWidget from './TextareaWidget'; +import ToggleWidget, { SwitchWidget } from './ToggleWidget'; + +export function generateWidgets< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(): RegistryWidgetsType { + return { + CheckboxWidget, + CheckboxesWidget, + FileWidget, + RadioWidget, + RangeWidget, + SelectWidget, + TextWidget, + TextareaWidget, + ToggleWidget, + SwitchWidget, + switch: SwitchWidget, + toggle: ToggleWidget + }; +} + +export { + CheckboxWidget, + CheckboxesWidget, + FileWidget, + RadioWidget, + RangeWidget, + SelectWidget, + SwitchWidget, + TextWidget, + TextareaWidget, + ToggleWidget +}; + +export default generateWidgets(); diff --git a/src/index.tsx b/src/index.tsx index 24a87e1a1..9f990c1ae 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -164,3 +164,21 @@ export { type Team as TeamPickerRecord, type TeamSearchFieldProps } from './custom/DashboardWidgets/GettingStartedWidget/TeamSearchField'; + +// Explicit root re-exports for RJSFFormWrapper, RJSFFormModal, and Sistent RJSF theme +// to ensure rollup-plugin-dts includes their types in dist/index.d.ts. +export { + RJSFFormModal, + RJSFFormWrapper, + hideRootObjectTitle, + sistentTheme, + sistentTemplates, + sistentWidgets, + generateTheme, + generateTemplates, + generateWidgets, + type RJSFFormModalProps, + type RJSFFormWrapperProps, + type RJSFValidationError +} from './custom/RJSFFormWrapper'; +