diff --git a/src/__testing__/RJSFFormWrapper.test.tsx b/src/__testing__/RJSFFormWrapper.test.tsx index c106c6574..5692146ef 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,27 @@ * 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 * 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 +60,122 @@ 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('src/index.tsx explicitly exports RJSFFormWrapper, RJSFFormModal, and all theme symbols in export statements', () => { + const full = path.resolve(__dirname, '..', 'index.tsx'); + expect(fs.existsSync(full)).toBe(true); + const source = fs.readFileSync(full, 'utf8'); + + // Parse all exported identifier names from export { ... } statements + const exportedSymbols = new Set(); + for (const match of source.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(); + exportedSymbols.add(exportedName); + } + } + + const expectedExports = [ + 'RJSFFormModal', + 'RJSFFormWrapper', + 'hideRootObjectTitle', + 'sistentTheme', + 'sistentTemplates', + 'sistentWidgets', + 'generateTheme', + 'generateTemplates', + 'generateWidgets', + 'RJSFFormModalProps', + 'RJSFFormWrapperProps', + 'RJSFValidationError' + ]; + + for (const exp of expectedExports) { + expect(exportedSymbols.has(exp)).toBe(true); + } + }); + + 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('published declaration bundle dist/index.d.ts exports RJSF and theme symbols when built', () => { + const dtsPath = path.resolve(__dirname, '..', '..', 'dist', 'index.d.ts'); + 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 (!fs.existsSync(mjsPath)) { + // Local jest run before build; skip gracefully + 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..5bcc021e0 --- /dev/null +++ b/src/__testing__/RJSFTheme.test.tsx @@ -0,0 +1,710 @@ +import type { RJSFSchema } from '@rjsf/utils'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { useTheme } from '@mui/material'; +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, + sistentTheme +} from '../custom/RJSFFormWrapper/theme'; +import { computeSxProps } from '../custom/RJSFFormWrapper/theme/util'; +import { SistentThemeProvider } 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('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(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 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[]; + // 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'); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 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); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 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); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 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); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 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', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const baseSx = [{ display: 'flex' }, { gap: 2 }] as any; + 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', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const baseSx = [{ display: 'flex' }] as any; + 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' }); + }); + }); +}); diff --git a/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx b/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx index 604e982da..435fb8cea 100644 --- a/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx +++ b/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx @@ -1,11 +1,12 @@ +import type Form from '@rjsf/core'; import { withTheme, type FormProps } from '@rjsf/core'; -import { Theme as MaterialUITheme } from '@rjsf/mui'; +import type { FormContextType, RJSFSchema, StrictRJSFSchema } from '@rjsf/utils'; import validator from '@rjsf/validator-ajv8'; import React, { type Ref } from 'react'; -import { SistentThemeProvider } from '../../theme'; import { hideRootObjectTitle } from './hideRootObjectTitle'; +import { sistentTheme } from './theme'; -const MuiRJSFForm = withTheme(MaterialUITheme); +const SistentRJSFForm = withTheme(sistentTheme); /** * Props accepted by `RJSFFormWrapper`. Inherits the full @@ -19,11 +20,14 @@ const MuiRJSFForm = withTheme(MaterialUITheme); * ref — consumers use it to call `validateForm()` and read the * post-validation `state.errors` / `state.formData`. */ -export interface RJSFFormWrapperProps +export interface RJSFFormWrapperProps< // eslint-disable-next-line @typescript-eslint/no-explicit-any - extends Omit, '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..89fc2466f --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/generateTheme.ts @@ -0,0 +1,21 @@ +/* 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. + */ +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..93677b072 --- /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): 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..5c3a5c2bf --- /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): 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..4d7604678 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx @@ -0,0 +1,159 @@ +/* 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): 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, + 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) + }; + + 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 + }; + + 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..6d105e0a8 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx @@ -0,0 +1,238 @@ +/* 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): 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): 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): JSX.Element { + const { icon, color, uiSchema } = props; + const uiOptions = getUiOptions(uiSchema); + const muiProps = getMuiProps(uiOptions, [ + 'color', + 'disableFocusRipple', + 'disableRipple', + 'edge', + 'size', + 'sx' + ]); + const { color: muiColor, ...otherMuiProps } = muiProps; + const buttonProps = { ...props }; + delete (buttonProps as any).registry; + delete (buttonProps as any).uiSchema; + delete (buttonProps as any).icon; + delete (buttonProps as any).iconType; + delete (buttonProps as any).color; + 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): 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): 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): 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): 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): 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..0eb7dd7c2 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/DescriptionFieldTemplate.tsx @@ -0,0 +1,39 @@ +/* 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 { 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): JSX.Element | null { + 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..63e7d8fce --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx @@ -0,0 +1,59 @@ +/* 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 { 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): JSX.Element { + 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..bdc00b727 --- /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): 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..40b7cf726 --- /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): 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..fcbc54d70 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/FieldTemplate.tsx @@ -0,0 +1,102 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FieldTemplateProps, + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + getTemplate, + getUiOptions +} from '@rjsf/utils'; +import React from 'react'; +import { FormControl } from '../../../../base/FormControl'; +import { Typography } from '../../../../base/Typography'; +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): JSX.Element { + 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 isCheckbox = uiOptions.widget === 'checkbox'; + const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = getMuiProps(uiOptions); + + return ( + + 0} + required={required} + {...muiSlotProps?.fieldFormControl} + sx={computeSxProps(otherMuiProps.sx ?? {}, muiSlotProps?.fieldFormControl)} + className={otherMuiProps.className} + > + {children} + {displayLabel && !isCheckbox && 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..08ff2637f --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/ObjectFieldTemplate.tsx @@ -0,0 +1,135 @@ +/* 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): 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) => + 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..71077ce90 --- /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): 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..547cb34fd --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/WrapIfAdditionalTemplate.tsx @@ -0,0 +1,111 @@ +/* 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): 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} +
+ ); + } + + 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..1786d8a5b --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/theme.ts @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { ThemeProps } from '@rjsf/core'; +import type { RJSFSchema } from '@rjsf/utils'; +import { generateTheme } from './generateTheme'; + +export const sistentTheme: ThemeProps = generateTheme(); + +export default sistentTheme; diff --git a/src/custom/RJSFFormWrapper/theme/util.ts b/src/custom/RJSFFormWrapper/theme/util.ts new file mode 100644 index 000000000..fba290356 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/util.ts @@ -0,0 +1,195 @@ +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< + P = SistentMuiOptions, + T = unknown, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = unknown +>( + options?: UIOptionsType, + propsToFilter?: string[], + rjsfSlotPropsOnly?: boolean +): P { + const muiProps = (options?.mui as SistentMuiOptions) || {}; + if (rjsfSlotPropsOnly) { + const { rjsfSlotProps } = muiProps; + return { rjsfSlotProps } as unknown as P; + } + if (propsToFilter) { + return Object.keys(muiProps) + .filter((key) => propsToFilter.includes(key)) + .reduce((obj: Record, key) => { + obj[key] = muiProps[key]; + return obj; + }, {}) as unknown as P; + } + return muiProps as unknown as P; +} + +/** + * 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..c74d6dfc2 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxWidget.tsx @@ -0,0 +1,92 @@ +/* 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): 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 ( + <> + {!hideLabel && 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..681c45193 --- /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): 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..91ee38edc --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx @@ -0,0 +1,159 @@ +/* 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'; + +function FileInfoPreview< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ + fileInfo, + registry +}: { + fileInfo: FileInfoType; + registry: WidgetProps['registry']; +}): JSX.Element | null { + const { translateString } = registry; + const { dataURL, type, name } = fileInfo; + if (!dataURL) { + return null; + } + if (type && ['image/jpeg', 'image/png'].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; +}): JSX.Element | null { + 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 => 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 ( + + + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx new file mode 100644 index 000000000..c3ad88bd6 --- /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..3cadcc456 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx @@ -0,0 +1,97 @@ +/* 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 { 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): JSX.Element { + const { + value, + readonly, + disabled, + onBlur, + onFocus, + options, + schema, + onChange, + required, + label, + hideLabel, + id + } = props; + + const sliderProps = { value, label, 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..07eea48c8 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx @@ -0,0 +1,163 @@ +/* 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): 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 && (value as unknown as any[]).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, + inputLabel: { + ...muiSlotProps?.inputLabel, + shrink: !isEmpty + }, + select: { + ...muiSlotProps?.select, + multiple: isMultiple + } + }} + 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..4c0aaf143 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/ToggleWidget.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 { 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): 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 ( + <> + {!hideLabel && 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 33a25a51f..5b7997e16 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'; +