From beb31aff4a324e1adc3decd1c3257ad5e9d34d91 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Thu, 27 Aug 2026 10:01:25 +0000 Subject: [PATCH 01/13] feat(rjsf): export theme for RJSF and integrate into RJSFFormWrapper Signed-off-by: Parth Gartan --- src/__testing__/RJSFFormWrapper.test.tsx | 22 ++ src/__testing__/RJSFTheme.test.tsx | 247 ++++++++++++++++++ src/__testing__/permissionKeySet.test.tsx | 2 +- src/custom/PermissionProvider.tsx | 12 +- .../RJSFFormWrapper/RJSFFormWrapper.tsx | 8 +- src/custom/RJSFFormWrapper/index.ts | 1 + .../RJSFFormWrapper/theme/generateTheme.ts | 21 ++ src/custom/RJSFFormWrapper/theme/index.ts | 6 + .../templates/ArrayFieldItemTemplate.tsx | 86 ++++++ .../theme/templates/ArrayFieldTemplate.tsx | 118 +++++++++ .../theme/templates/BaseInputTemplate.tsx | 161 ++++++++++++ .../theme/templates/ButtonTemplates.tsx | 202 ++++++++++++++ .../templates/DescriptionFieldTemplate.tsx | 40 +++ .../theme/templates/ErrorListTemplate.tsx | 55 ++++ .../theme/templates/FieldErrorTemplate.tsx | 49 ++++ .../theme/templates/FieldHelpTemplate.tsx | 40 +++ .../theme/templates/FieldTemplate.tsx | 103 ++++++++ .../theme/templates/ObjectFieldTemplate.tsx | 135 ++++++++++ .../theme/templates/TitleFieldTemplate.tsx | 69 +++++ .../templates/WrapIfAdditionalTemplate.tsx | 111 ++++++++ .../RJSFFormWrapper/theme/templates/index.ts | 67 +++++ src/custom/RJSFFormWrapper/theme/theme.ts | 8 + src/custom/RJSFFormWrapper/theme/util.ts | 62 +++++ .../theme/widgets/CheckboxWidget.tsx | 92 +++++++ .../theme/widgets/CheckboxesWidget.tsx | 122 +++++++++ .../theme/widgets/FileWidget.tsx | 156 +++++++++++ .../theme/widgets/RadioWidget.tsx | 115 ++++++++ .../theme/widgets/RangeWidget.tsx | 90 +++++++ .../theme/widgets/SelectWidget.tsx | 137 ++++++++++ .../theme/widgets/TextWidget.tsx | 26 ++ .../theme/widgets/TextareaWidget.tsx | 32 +++ .../theme/widgets/ToggleWidget.tsx | 94 +++++++ .../RJSFFormWrapper/theme/widgets/index.ts | 52 ++++ src/custom/permissions.tsx | 2 +- src/custom/useAccessibleOrgs.ts | 8 +- src/index.tsx | 18 ++ 36 files changed, 2560 insertions(+), 9 deletions(-) create mode 100644 src/__testing__/RJSFTheme.test.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/generateTheme.ts create mode 100644 src/custom/RJSFFormWrapper/theme/index.ts create mode 100644 src/custom/RJSFFormWrapper/theme/templates/ArrayFieldItemTemplate.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/templates/ArrayFieldTemplate.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/templates/DescriptionFieldTemplate.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/templates/FieldErrorTemplate.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/templates/FieldHelpTemplate.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/templates/FieldTemplate.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/templates/ObjectFieldTemplate.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/templates/TitleFieldTemplate.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/templates/WrapIfAdditionalTemplate.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/templates/index.ts create mode 100644 src/custom/RJSFFormWrapper/theme/theme.ts create mode 100644 src/custom/RJSFFormWrapper/theme/util.ts create mode 100644 src/custom/RJSFFormWrapper/theme/widgets/CheckboxWidget.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/widgets/TextWidget.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/widgets/TextareaWidget.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/widgets/ToggleWidget.tsx create mode 100644 src/custom/RJSFFormWrapper/theme/widgets/index.ts diff --git a/src/__testing__/RJSFFormWrapper.test.tsx b/src/__testing__/RJSFFormWrapper.test.tsx index c106c6574..03c401640 100644 --- a/src/__testing__/RJSFFormWrapper.test.tsx +++ b/src/__testing__/RJSFFormWrapper.test.tsx @@ -44,4 +44,26 @@ 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 re-exports RJSF and theme symbols at root', () => { + const full = path.resolve(__dirname, '..', 'index.tsx'); + expect(fs.existsSync(full)).toBe(true); + const source = fs.readFileSync(full, 'utf8'); + expect(source).toMatch(/sistentTheme/); + expect(source).toMatch(/sistentTemplates/); + expect(source).toMatch(/sistentWidgets/); + expect(source).toMatch(/generateTheme/); + expect(source).toMatch(/generateTemplates/); + expect(source).toMatch(/generateWidgets/); + expect(source).toMatch(/RJSFFormWrapper/); + expect(source).toMatch(/RJSFFormModal/); + }); }); + diff --git a/src/__testing__/RJSFTheme.test.tsx b/src/__testing__/RJSFTheme.test.tsx new file mode 100644 index 000000000..080475028 --- /dev/null +++ b/src/__testing__/RJSFTheme.test.tsx @@ -0,0 +1,247 @@ +import type { RJSFSchema } from '@rjsf/utils'; +import { 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, + sistentTheme +} from '../custom/RJSFFormWrapper/theme'; +import { SistentThemeProvider } from '../theme'; + +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); + }); + }); + + describe('RJSFFormWrapper with sistentTheme default integration', () => { + it('renders a basic schema form with Sistent widgets and templates', () => { + const schema: RJSFSchema = { + title: 'User Profile Form', + type: 'object', + properties: { + username: { type: 'string', title: 'Username' }, + bio: { type: 'string', title: 'Bio' }, + role: { type: 'string', title: 'Role', enum: ['Admin', 'Editor', 'Viewer'] }, + newsletter: { type: 'boolean', title: 'Subscribe to newsletter' } + } + }; + + const uiSchema = { + bio: { 'ui:widget': 'textarea' } + }; + + render( + + + + ); + + expect(screen.getByText('User Profile Form')).toBeDefined(); + expect(screen.getByLabelText(/Username/i)).toBeDefined(); + expect(screen.getByLabelText(/Bio/i)).toBeDefined(); + expect(screen.getByLabelText(/Role/i)).toBeDefined(); + expect(screen.getByLabelText(/Subscribe to newsletter/i)).toBeDefined(); + }); + + it('renders array fields with Sistent add/remove action controls', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + tags: { + type: 'array', + title: 'Tags List', + items: { type: 'string' } + } + } + }; + + render( + + + + ); + + expect(screen.getByText('Tags List')).toBeDefined(); + expect(screen.getByDisplayValue('first-tag')).toBeDefined(); + expect(screen.getByDisplayValue('second-tag')).toBeDefined(); + }); + + it('renders error list when validation fails', () => { + const schema: RJSFSchema = { + type: 'object', + required: ['email'], + properties: { + email: { type: 'string', title: 'Email Address' } + } + }; + + render( + + + + ); + + expect( + screen.getAllByText(/Custom validation error on email/i).length + ).toBeGreaterThanOrEqual(1); + }); + + it('renders radio and checkboxes widgets correctly', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + choice: { + type: 'string', + title: 'Single Choice', + enum: ['Option A', 'Option B'] + }, + multiChoice: { + type: 'array', + title: 'Multiple Choice', + items: { type: 'string', enum: ['Tag 1', 'Tag 2'] }, + uniqueItems: true + } + } + }; + + const uiSchema = { + choice: { 'ui:widget': 'radio' }, + multiChoice: { 'ui:widget': 'checkboxes' } + }; + + render( + + + + ); + + expect(screen.getByText('Option A')).toBeDefined(); + expect(screen.getByText('Option B')).toBeDefined(); + expect(screen.getByText('Tag 1')).toBeDefined(); + expect(screen.getByText('Tag 2')).toBeDefined(); + }); + + it('renders toggle/switch widget correctly', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + featureEnabled: { + type: 'boolean', + title: 'Enable Experimental Feature' + } + } + }; + + const uiSchema = { + featureEnabled: { 'ui:widget': 'switch' } + }; + + render( + + + + ); + + expect(screen.getByLabelText(/Enable Experimental Feature/i)).toBeDefined(); + }); + + it('renders file widget correctly', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + attachment: { + type: 'string', + format: 'data-url', + title: 'Upload File' + } + } + }; + + render( + + + + ); + + expect(screen.getByLabelText(/Upload File/i)).toBeDefined(); + }); + }); +}); diff --git a/src/__testing__/permissionKeySet.test.tsx b/src/__testing__/permissionKeySet.test.tsx index e001b8e03..39b057552 100644 --- a/src/__testing__/permissionKeySet.test.tsx +++ b/src/__testing__/permissionKeySet.test.tsx @@ -1,9 +1,9 @@ -import { Key } from '@meshery/schemas/permissions'; import { fireEvent, render, renderHook, screen, within } from '@testing-library/react'; import { NavigationNavbar, type NavigationItem } from '../custom/NavigationNavbar'; import { PermissionProvider, useHasPermission, + type Key, type PermissionKeySpec } from '../custom/PermissionProvider'; import { PermissionShield } from '../custom/permissions'; diff --git a/src/custom/PermissionProvider.tsx b/src/custom/PermissionProvider.tsx index 7065805b1..1132ff6d2 100644 --- a/src/custom/PermissionProvider.tsx +++ b/src/custom/PermissionProvider.tsx @@ -1,6 +1,16 @@ -import { Key } from '@meshery/schemas/permissions'; import React, { createContext, useContext } from 'react'; +/** + * Shape of a permission key. + */ +export interface Key { + id: string; + category?: string; + subcategory?: string; + function?: string; + description?: string; +} + /** * Determines how a component responds when the user lacks the required permission. diff --git a/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx b/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx index 604e982da..214b5fec7 100644 --- a/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx +++ b/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx @@ -1,11 +1,11 @@ import { withTheme, type FormProps } from '@rjsf/core'; -import { Theme as MaterialUITheme } from '@rjsf/mui'; 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 @@ -66,7 +66,7 @@ export function RJSFFormWrapper({ const resolvedUiSchema = hideRootTitle ? hideRootObjectTitle(uiSchema) : uiSchema; return ( - {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..7f257a994 --- /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..c47a96768 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldTemplate.tsx @@ -0,0 +1,118 @@ +/* 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 showOptionalDataControlInTitle = !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..b0f1e6454 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx @@ -0,0 +1,161 @@ +/* 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 } 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 { + 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, + slotProps, + ...textFieldProps + } = props; + + const { ClearButton } = registry.templates.ButtonTemplates; + const { step, min, max, accept, ...rest } = getInputProps(schema, type, options); + const muiProps = getMuiProps(options); + const { slotProps: muiSlotProps, ...otherMuiProps } = muiProps; + + const htmlInputProps = { + ...slotProps?.htmlInput, + ...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) + ? { + ...slotProps?.inputLabel, + ...muiSlotProps?.inputLabel, + ...InputLabelProps, + shrink: true + } + : { + ...slotProps?.inputLabel, + ...muiSlotProps?.inputLabel, + ...InputLabelProps + }; + + const _onClear = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onChange(options.emptyValue ?? ''); + }, + [onChange, options.emptyValue] + ); + + const inputProps = { + ...InputProps, + ...slotProps?.input, + ...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)} + {...otherMuiProps} + {...(textFieldProps as any)} + /> + + + ); +} diff --git a/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx b/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx new file mode 100644 index 000000000..ff2a80f60 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx @@ -0,0 +1,202 @@ +/* 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 { Button } from '../../../../base/Button'; +import { IconButton } from '../../../../base/IconButton'; +import { getMuiProps } from '../util'; + +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; + } + return ( + + ); +} + +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 ( + + + + ); +} + +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} + + ); +} + +export function CopyButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps): JSX.Element { + const { + registry: { translateString } + } = props; + return ( + } + /> + ); +} + +export function MoveDownButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps): JSX.Element { + const { + registry: { translateString } + } = props; + return ( + } + /> + ); +} + +export function MoveUpButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps): JSX.Element { + const { + registry: { translateString } + } = props; + return ( + } + /> + ); +} + +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 ( + } + /> + ); +} + +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..0dcfbd183 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/DescriptionFieldTemplate.tsx @@ -0,0 +1,40 @@ +/* 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..a152177c0 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx @@ -0,0 +1,55 @@ +/* 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..b1965f3ad --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/templates/FieldTemplate.tsx @@ -0,0 +1,103 @@ +/* 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 { 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={otherMuiProps.sx} + 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..0be409606 --- /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 && ( + + )} + + {!showOptionalDataControlInTitle ? optionalDataControl : undefined} + {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..714aa89a6 --- /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..32d5ec90a --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/util.ts @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { SxProps, Theme } from '@mui/material/styles'; +import type { FormContextType, RJSFSchema, StrictRJSFSchema, UIOptionsType } from '@rjsf/utils'; + +export interface SistentMuiSlotProps { + [key: string]: any; +} + +export interface SistentMuiOptions { + sx?: SxProps; + className?: string; + rjsfSlotProps?: SistentMuiSlotProps; + [key: string]: any; +} + +/** + * Extract props meant for MUI/Sistent components from the `options` field of the `uiSchema`. + */ +export function getMuiProps< + P = SistentMuiOptions, + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>( + 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; + } + if (Array.isArray(muiProps?.sx)) { + return [sxProps, ...muiProps.sx] as unknown as SxProps; + } + if (muiProps?.sx) { + return { ...(sxProps as object), ...(muiProps.sx as object) } 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..58898b6ad --- /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..cba84a988 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx @@ -0,0 +1,122 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type FormContextType, + type RJSFSchema, + type StrictRJSFSchema, + type WidgetProps, + ariaDescribedByIds, + enumOptionValueDecoder, + enumOptionsDeselectValue, + enumOptionsIsSelected, + enumOptionsSelectValue, + getOptionValueFormat, + labelValue, + optionId +} from '@rjsf/utils'; +import React, { type ChangeEvent, type FocusEvent } 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, emptyValue } = options; + const optionValueFormat = getOptionValueFormat(options); + const checkboxesValues = Array.isArray(value) ? value : [value]; + + const _onChange = + (index: number) => + ({ target: { checked } }: ChangeEvent): void => { + if (checked) { + onChange(enumOptionsSelectValue(index, checkboxesValues, enumOptions)); + } else { + onChange(enumOptionsDeselectValue(index, checkboxesValues, enumOptions)); + } + }; + + 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 { 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..b92f0283d --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx @@ -0,0 +1,156 @@ +/* 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 +}: { + filesInfo: FileInfoType[]; + registry: WidgetProps['registry']; + preview?: boolean; + onRemove: (index: number) => void; + options: WidgetProps['options']; +}): 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): 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..16dc04efc --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx @@ -0,0 +1,115 @@ +/* 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): JSX.Element { + const { + id, + htmlName, + options, + value, + required, + disabled, + readonly, + label, + hideLabel, + 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..17e1cc012 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx @@ -0,0 +1,90 @@ +/* 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..bb19574d8 --- /dev/null +++ b/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx @@ -0,0 +1,137 @@ +/* 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 } 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; + + const remainingProps = { ...props }; + delete (remainingProps as any).name; + delete (remainingProps as any).hideError; + delete (remainingProps as any).errorSchema; + delete (remainingProps as any).uiSchema; + delete (remainingProps as any).registry; + delete (remainingProps as any).InputLabelProps; + delete (remainingProps as any).SelectProps; + + 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)} + {...otherMuiProps} + {...(remainingProps as any)} + > + {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..aeeedbd03 --- /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): 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..a8dfe8e9e --- /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): 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..7cf30f363 --- /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/custom/permissions.tsx b/src/custom/permissions.tsx index 9da7b6330..a9c2626f0 100644 --- a/src/custom/permissions.tsx +++ b/src/custom/permissions.tsx @@ -1,4 +1,3 @@ -import { Key } from '@meshery/schemas/permissions'; import KeyIcon from '@mui/icons-material/Key'; import LaunchIcon from '@mui/icons-material/Launch'; import SecurityIcon from '@mui/icons-material/Security'; @@ -18,6 +17,7 @@ import { getPermissionKeys, usePermissionUserContext, useUnmetPermissionKeys, + type Key, type PermissionKeySpec } from './PermissionProvider'; export type { Key }; diff --git a/src/custom/useAccessibleOrgs.ts b/src/custom/useAccessibleOrgs.ts index 60bcbf701..791184c78 100644 --- a/src/custom/useAccessibleOrgs.ts +++ b/src/custom/useAccessibleOrgs.ts @@ -1,6 +1,10 @@ -import { Key } from '@meshery/schemas/permissions'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getPermissionKeys, isPermissionKeySet, PermissionKeySpec } from './PermissionProvider'; +import { + getPermissionKeys, + isPermissionKeySet, + PermissionKeySpec, + type Key +} from './PermissionProvider'; /** * For a given set of user keys (as returned by `getUserKeys`), check whether 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'; + From 9fc6eb4b47bb7fb51e93cc83ad0afb4da4928479 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Thu, 27 Aug 2026 11:37:42 +0000 Subject: [PATCH 02/13] fix(lint): configure no-unused-vars to allow _-prefixed names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @typescript-eslint/no-unused-vars rule was not configured to ignore variables prefixed with _ — the standard convention for intentionally-unused destructured bindings (e.g., to exclude props from a rest spread without consuming them). This caused the lint check to fail on BaseInputTemplate.tsx where _name, _hideError, _uiSchema, and _errorSchema are destructured to prevent them spreading into ...textFieldProps. Apply varsIgnorePattern, argsIgnorePattern, caughtErrorsIgnorePattern, and destructuredArrayIgnorePattern all matching ^_ to both the main and test file rule blocks. Signed-off-by: Parth Gartan --- eslint.config.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index 1c8dc0861..0c9c215e5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -58,6 +58,12 @@ module.exports = [ rules: { ...js.configs.recommended.rules, ...typescript.configs.recommended.rules, + "@typescript-eslint/no-unused-vars": ["error", { + varsIgnorePattern: "^_", + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + }], }, linterOptions: { @@ -96,6 +102,12 @@ module.exports = [ rules: { ...js.configs.recommended.rules, ...typescript.configs.recommended.rules, + "@typescript-eslint/no-unused-vars": ["error", { + varsIgnorePattern: "^_", + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + }], }, linterOptions: { From b56869c2f9a078de48671a100cc45246e04f166e Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Thu, 27 Aug 2026 11:44:20 +0000 Subject: [PATCH 03/13] fix(rjsf): address review findings in RJSF theme widgets/templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckboxesWidget: normalize undefined value to [] before enumOptionsSelectValue/Deselect — [value] when value is undefined contributed an undefined element to the checked set. FieldTemplate: switch Typography color from deprecated color="textSecondary" string prop to sx color token (text.secondary); merge muiSlotProps.fieldFormControl.sx and fieldTypography.sx with component-specific styles via computeSxProps instead of discarding them. FileWidget: same color token fix on FilesInfo caption Typography; add disabled prop to FilesInfo and pass disabled || readonly down to RemoveButton so the control is inert when the field is disabled/readonly. RangeWidget: include otherMuiProps.sx in the computeSxProps base so top-level uiSchema mui.sx is not lost when rangeBox slot is also set. SelectWidget: replace manual delete-based remainingProps spread with rest destructuring that excludes all RJSF-specific and already-handled props, matching the BaseInputTemplate pattern and preventing raw RJSF props from overriding computed TextField props. Skipped (with rationale): - RJSFFormWrapper.test.tsx root-export test: static source-text checks are intentional — the barrel imports react-markdown ESM that breaks jest transforms; documented in the test file itself. - PermissionProvider Key type: replacing local Key (all-optional) with @meshery/schemas Key (all-required) is a breaking API change; callers today pass {id} only. Would require a minor-bump label. - BaseInputTemplate scoped ESLint suppression: redundant — the prior commit already configured varsIgnorePattern/argsIgnorePattern globally. Signed-off-by: Parth Gartan --- .../theme/templates/FieldTemplate.tsx | 7 ++-- .../theme/widgets/CheckboxesWidget.tsx | 2 +- .../theme/widgets/FileWidget.tsx | 9 +++-- .../theme/widgets/RangeWidget.tsx | 2 +- .../theme/widgets/SelectWidget.tsx | 39 ++++++++++++++----- 5 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/custom/RJSFFormWrapper/theme/templates/FieldTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/FieldTemplate.tsx index b1965f3ad..fcbc54d70 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/FieldTemplate.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/FieldTemplate.tsx @@ -10,7 +10,7 @@ import { import React from 'react'; import { FormControl } from '../../../../base/FormControl'; import { Typography } from '../../../../base/Typography'; -import { getMuiProps } from '../util'; +import { computeSxProps, getMuiProps } from '../util'; /** * Sistent's `FieldTemplate` wraps every schema field with Sistent FormControl styling. @@ -81,16 +81,15 @@ export default function FieldTemplate< error={rawErrors.length > 0} required={required} {...muiSlotProps?.fieldFormControl} - sx={otherMuiProps.sx} + sx={computeSxProps(otherMuiProps.sx ?? {}, muiSlotProps?.fieldFormControl)} className={otherMuiProps.className} > {children} {displayLabel && !isCheckbox && rawDescription ? ( {description} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx index cba84a988..fadd2577c 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx @@ -46,7 +46,7 @@ export default function CheckboxesWidget< const { enumOptions, enumDisabled, inline, emptyValue } = options; const optionValueFormat = getOptionValueFormat(options); - const checkboxesValues = Array.isArray(value) ? value : [value]; + const checkboxesValues = Array.isArray(value) ? value : value !== undefined ? [value] : []; const _onChange = (index: number) => diff --git a/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx index b92f0283d..cbf47bf53 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx @@ -61,13 +61,15 @@ function FilesInfo< registry, preview, onRemove, - options + 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; @@ -98,12 +100,12 @@ function FilesInfo< {name} - + ({type || 'unknown'}, {size ? `${(size / 1024).toFixed(1)} KB` : '0 KB'}) {preview && } - + ); })} @@ -150,6 +152,7 @@ export default function FileWidget< registry={registry} preview={Boolean(options.filePreview)} options={options} + disabled={disabled || readonly} /> ); diff --git a/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx index 17e1cc012..973f506a4 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx @@ -66,7 +66,7 @@ export default function RangeWidget< {...otherMuiProps} {...muiSlotProps?.rangeBox} sx={computeSxProps( - { display: 'flex', alignItems: 'center', gap: 2 }, + { display: 'flex', alignItems: 'center', gap: 2, ...(otherMuiProps.sx as object) }, muiSlotProps?.rangeBox )} > diff --git a/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx index bb19574d8..3ab34b2c0 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx @@ -74,14 +74,35 @@ export default function SelectWidget< const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = getMuiProps(options); const showPlaceholderOption = !isMultiple && schema.default === undefined; - const remainingProps = { ...props }; - delete (remainingProps as any).name; - delete (remainingProps as any).hideError; - delete (remainingProps as any).errorSchema; - delete (remainingProps as any).uiSchema; - delete (remainingProps as any).registry; - delete (remainingProps as any).InputLabelProps; - delete (remainingProps as any).SelectProps; + 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, + ...textFieldProps + } = props; return ( {showPlaceholderOption && ( From b2f602e8565bb438f6bb1488a5e1cdc26fc69dfa Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Thu, 27 Aug 2026 13:03:00 +0000 Subject: [PATCH 04/13] fix(rjsf): second round of review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit util.ts: computeSxProps now preserves callback-valued sx by returning [sxProps, muiProps.sx] instead of spread-into-object, which would corrupt a function into numeric keys. ArrayFieldItemTemplate: content Grid uses size={12} when hasToolbar is false, taking full row width; preserves responsive split when toolbar is present. ButtonTemplates: SubmitButton reads uiSchema mui props via getMuiProps/ getUiOptions and forwards rjsfSlotProps.submitButton to the Button and submitBox props to a wrapping Box, consistent with other button slots. TitleFieldTemplate: Divider spreads titleDivider before sx and uses computeSxProps so custom sx merges into the baseline { mt, mb } rather than overwriting it. DescriptionFieldTemplate: replace deprecated color='textSecondary' prop with color:'text.secondary' merged into the computeSxProps call. ErrorListTemplate: replace deprecated color='error' prop with sx={{ color:'error.main' }} semantic token. RangeWidget: fix array-valued otherMuiProps.sx — spreading an array into an object creates numeric keys; branch on Array.isArray and use the array path instead. Also replace color='textSecondary' with color:'text.secondary' sx token. Skipped: - PermissionProvider Key -> @meshery/schemas: breaking API change (schema Key has all fields required + branded id; callers pass {id} only). Requires a minor-bump label per AGENTS.md. Signed-off-by: Parth Gartan --- src/__testing__/RJSFFormWrapper.test.tsx | 36 ++++++++++++++----- .../templates/ArrayFieldItemTemplate.tsx | 2 +- .../theme/templates/ButtonTemplates.tsx | 20 ++++++++--- .../templates/DescriptionFieldTemplate.tsx | 3 +- .../theme/templates/ErrorListTemplate.tsx | 2 +- .../theme/templates/TitleFieldTemplate.tsx | 2 +- src/custom/RJSFFormWrapper/theme/util.ts | 14 ++++++-- .../theme/widgets/RangeWidget.tsx | 4 +-- 8 files changed, 61 insertions(+), 22 deletions(-) diff --git a/src/__testing__/RJSFFormWrapper.test.tsx b/src/__testing__/RJSFFormWrapper.test.tsx index 03c401640..fc7327286 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', () => { @@ -52,18 +68,20 @@ describe('RJSFFormWrapper (sistent#1533)', () => { expect(source).toMatch(/export\s+\*\s+from\s+['"]\.\/theme['"]/); }); - it('src/index.tsx re-exports RJSF and theme symbols at root', () => { + it('src/index.tsx re-exports RJSFFormWrapper and RJSFFormModal at root', () => { const full = path.resolve(__dirname, '..', 'index.tsx'); expect(fs.existsSync(full)).toBe(true); const source = fs.readFileSync(full, 'utf8'); - expect(source).toMatch(/sistentTheme/); - expect(source).toMatch(/sistentTemplates/); - expect(source).toMatch(/sistentWidgets/); - expect(source).toMatch(/generateTheme/); - expect(source).toMatch(/generateTemplates/); - expect(source).toMatch(/generateWidgets/); expect(source).toMatch(/RJSFFormWrapper/); expect(source).toMatch(/RJSFFormModal/); }); -}); + 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(); + }); +}); diff --git a/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldItemTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldItemTemplate.tsx index 7f257a994..93677b072 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldItemTemplate.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldItemTemplate.tsx @@ -56,7 +56,7 @@ export default function ArrayFieldItemTemplate< sx={computeSxProps({ alignItems: 'center', mb: 1 }, arrayItemGridContainer)} > diff --git a/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx b/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx index ff2a80f60..ab99fab31 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx @@ -16,9 +16,10 @@ import { getUiOptions } from '@rjsf/utils'; import React from 'react'; +import { Box } from '../../../../base/Box'; import { Button } from '../../../../base/Button'; import { IconButton } from '../../../../base/IconButton'; -import { getMuiProps } from '../util'; +import { computeSxProps, getMuiProps } from '../util'; export function SubmitButton< T = any, @@ -33,10 +34,21 @@ export function SubmitButton< if (norender) { return null; } + const uiOptions = getUiOptions(uiSchema); + const { rjsfSlotProps: { submitButton: submitButtonSlotProps, submitBox } = {}, ...otherMuiProps } = + getMuiProps(uiOptions); return ( - + + + ); } diff --git a/src/custom/RJSFFormWrapper/theme/templates/DescriptionFieldTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/DescriptionFieldTemplate.tsx index 0dcfbd183..0eb7dd7c2 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/DescriptionFieldTemplate.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/DescriptionFieldTemplate.tsx @@ -28,9 +28,8 @@ export default function DescriptionFieldTemplate< diff --git a/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx index a152177c0..0218d0ab3 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx @@ -44,7 +44,7 @@ export default function ErrorListTemplate< {errors.map((error, i) => ( - + {error.stack} diff --git a/src/custom/RJSFFormWrapper/theme/templates/TitleFieldTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/TitleFieldTemplate.tsx index 714aa89a6..f43664de2 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/TitleFieldTemplate.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/TitleFieldTemplate.tsx @@ -63,7 +63,7 @@ export default function TitleFieldTemplate< return ( {heading} - + ); } diff --git a/src/custom/RJSFFormWrapper/theme/util.ts b/src/custom/RJSFFormWrapper/theme/util.ts index 32d5ec90a..1219777d3 100644 --- a/src/custom/RJSFFormWrapper/theme/util.ts +++ b/src/custom/RJSFFormWrapper/theme/util.ts @@ -52,11 +52,21 @@ export function computeSxProps( if (!muiProps) { return sxProps; } + const sxIsObject = sxProps !== null && typeof sxProps === 'object' && !Array.isArray(sxProps); if (Array.isArray(muiProps?.sx)) { - return [sxProps, ...muiProps.sx] as unknown as SxProps; + 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 { ...(sxProps as object), ...(muiProps.sx as object) } as unknown as SxProps; + 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/RangeWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx index 973f506a4..6e18402db 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx @@ -66,7 +66,7 @@ export default function RangeWidget< {...otherMuiProps} {...muiSlotProps?.rangeBox} sx={computeSxProps( - { display: 'flex', alignItems: 'center', gap: 2, ...(otherMuiProps.sx as object) }, + [{ display: 'flex', alignItems: 'center', gap: 2 }, ...(Array.isArray(otherMuiProps.sx) ? otherMuiProps.sx : otherMuiProps.sx ? [otherMuiProps.sx] : [])], muiSlotProps?.rangeBox )} > @@ -81,7 +81,7 @@ export default function RangeWidget< value={Number(value ?? sliderProps.min ?? 0)} aria-describedby={ariaDescribedByIds(id)} /> - + {value ?? sliderProps.min ?? 0} From 4d84a235af117317a12f956857fa26582aa6a2c1 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 28 Aug 2026 03:33:57 +0000 Subject: [PATCH 05/13] fix(rjsf): address maintainer review findings for PR #1820 - Revert unrelated PermissionProvider and Key changes, keeping PR focused on RJSF - Fix RadioWidget to apply autoFocus only to first option when autofocus is true - Improve type safety in theme/util.ts by replacing unrestricted any index signatures - Remove unnecessary as any casts in SelectWidget and BaseInputTemplate - Add comprehensive behavioral test suite in RJSFTheme.test.tsx covering all widgets and templates - Strengthen root-export assertions in RJSFFormWrapper.test.tsx Signed-off-by: Parth Gartan --- src/__testing__/RJSFFormWrapper.test.tsx | 21 +- src/__testing__/RJSFTheme.test.tsx | 530 ++++++++++++++---- src/__testing__/permissionKeySet.test.tsx | 2 +- src/custom/PermissionProvider.tsx | 12 +- .../theme/templates/BaseInputTemplate.tsx | 13 +- src/custom/RJSFFormWrapper/theme/util.ts | 13 +- .../theme/widgets/CheckboxesWidget.tsx | 14 +- .../theme/widgets/RadioWidget.tsx | 2 + .../theme/widgets/SelectWidget.tsx | 4 +- src/custom/permissions.tsx | 2 +- src/custom/useAccessibleOrgs.ts | 8 +- 11 files changed, 469 insertions(+), 152 deletions(-) diff --git a/src/__testing__/RJSFFormWrapper.test.tsx b/src/__testing__/RJSFFormWrapper.test.tsx index fc7327286..6cffb59ca 100644 --- a/src/__testing__/RJSFFormWrapper.test.tsx +++ b/src/__testing__/RJSFFormWrapper.test.tsx @@ -68,12 +68,27 @@ describe('RJSFFormWrapper (sistent#1533)', () => { expect(source).toMatch(/export\s+\*\s+from\s+['"]\.\/theme['"]/); }); - it('src/index.tsx re-exports RJSFFormWrapper and RJSFFormModal at root', () => { + it('src/index.tsx re-exports RJSFFormWrapper, RJSFFormModal, and all theme symbols at root', () => { const full = path.resolve(__dirname, '..', 'index.tsx'); expect(fs.existsSync(full)).toBe(true); const source = fs.readFileSync(full, 'utf8'); - expect(source).toMatch(/RJSFFormWrapper/); - expect(source).toMatch(/RJSFFormModal/); + const expectedExports = [ + 'RJSFFormModal', + 'RJSFFormWrapper', + 'hideRootObjectTitle', + 'sistentTheme', + 'sistentTemplates', + 'sistentWidgets', + 'generateTheme', + 'generateTemplates', + 'generateWidgets', + 'RJSFFormModalProps', + 'RJSFFormWrapperProps', + 'RJSFValidationError' + ]; + for (const exp of expectedExports) { + expect(source).toMatch(new RegExp(`\\b${exp}\\b`)); + } }); it('theme generators and theme objects are defined at the theme sub-barrel', () => { diff --git a/src/__testing__/RJSFTheme.test.tsx b/src/__testing__/RJSFTheme.test.tsx index 080475028..cec27e548 100644 --- a/src/__testing__/RJSFTheme.test.tsx +++ b/src/__testing__/RJSFTheme.test.tsx @@ -1,5 +1,5 @@ import type { RJSFSchema } from '@rjsf/utils'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { RJSFFormWrapper } from '../custom/RJSFFormWrapper/RJSFFormWrapper'; import { @@ -30,8 +30,17 @@ import { 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', () => { @@ -82,166 +91,473 @@ describe('Sistent RJSF Theme Registry (Issue #418)', () => { }); }); - describe('RJSFFormWrapper with sistentTheme default integration', () => { - it('renders a basic schema form with Sistent widgets and templates', () => { + // ───────────────────────────────────────────────────────────────────────── + // 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 = { - title: 'User Profile Form', type: 'object', - properties: { - username: { type: 'string', title: 'Username' }, - bio: { type: 'string', title: 'Bio' }, - role: { type: 'string', title: 'Role', enum: ['Admin', 'Editor', 'Viewer'] }, - newsletter: { type: 'boolean', title: 'Subscribe to newsletter' } - } + properties: { name: { type: 'string', title: 'Full Name' } } }; - - const uiSchema = { - bio: { 'ui:widget': 'textarea' } - }; - render( - - - + + + ); - - expect(screen.getByText('User Profile Form')).toBeDefined(); - expect(screen.getByLabelText(/Username/i)).toBeDefined(); - expect(screen.getByLabelText(/Bio/i)).toBeDefined(); - expect(screen.getByLabelText(/Role/i)).toBeDefined(); - expect(screen.getByLabelText(/Subscribe to newsletter/i)).toBeDefined(); + 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('renders array fields with Sistent add/remove action controls', () => { + it('fires onSubmit with formData when the form element is submitted', () => { + const onSubmit = jest.fn(); const schema: RJSFSchema = { type: 'object', - properties: { - tags: { - type: 'array', - title: 'Tags List', - items: { type: 'string' } - } - } + 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.getByText('Tags List')).toBeDefined(); - expect(screen.getByDisplayValue('first-tag')).toBeDefined(); - expect(screen.getByDisplayValue('second-tag')).toBeDefined(); + expect(screen.getByDisplayValue('test@example.com')).toBeDefined(); }); - it('renders error list when validation fails', () => { + it('shows validation errors with liveValidate + extraErrors', () => { const schema: RJSFSchema = { type: 'object', required: ['email'], - properties: { - email: { type: 'string', title: 'Email Address' } - } + properties: { email: { type: 'string', title: 'Email Address' } } }; - render( - + - + ); - - expect( - screen.getAllByText(/Custom validation error on email/i).length - ).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/Email is required/i).length).toBeGreaterThanOrEqual(1); }); - it('renders radio and checkboxes widgets correctly', () => { + it('renders array fields and shows item values', () => { const schema: RJSFSchema = { type: 'object', properties: { - choice: { - type: 'string', - title: 'Single Choice', - enum: ['Option A', 'Option B'] - }, - multiChoice: { - type: 'array', - title: 'Multiple Choice', - items: { type: 'string', enum: ['Tag 1', 'Tag 2'] }, - uniqueItems: true - } + tags: { type: 'array', title: 'Tags', items: { type: 'string' } } } }; + render( + + + + ); + expect(screen.getByDisplayValue('alpha')).toBeDefined(); + expect(screen.getByDisplayValue('beta')).toBeDefined(); + }); + }); - const uiSchema = { - choice: { 'ui:widget': 'radio' }, - multiChoice: { 'ui:widget': 'checkboxes' } - }; + // ───────────────────────────────────────────────────────────────────────── + // 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(); + }); - expect(screen.getByText('Option A')).toBeDefined(); - expect(screen.getByText('Option B')).toBeDefined(); - expect(screen.getByText('Tag 1')).toBeDefined(); - expect(screen.getByText('Tag 2')).toBeDefined(); + 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('renders toggle/switch widget correctly', () => { - const schema: RJSFSchema = { - type: 'object', - properties: { - featureEnabled: { - type: 'boolean', - title: 'Enable Experimental Feature' - } + 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('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' } }; - const uiSchema = { - featureEnabled: { 'ui:widget': 'switch' } - }; + 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('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(); + }); + }); + + // ───────────────────────────────────────────────────────────────────────── + // 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); + }); + }); - expect(screen.getByLabelText(/Enable Experimental Feature/i)).toBeDefined(); + // ───────────────────────────────────────────────────────────────────────── + // 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('renders file widget correctly', () => { + 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 passthrough + // ───────────────────────────────────────────────────────────────────────── + + describe('rjsfSlotProps customization', () => { + it('passes rjsfSlotProps.radioGroup attributes to the RadioGroup element', () => { const schema: RJSFSchema = { type: 'object', - properties: { - attachment: { - type: 'string', - format: 'data-url', - title: 'Upload File' + 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.getByLabelText(/Upload File/i)).toBeDefined(); + expect(screen.getByTestId('my-radio-group')).toBeDefined(); }); }); }); diff --git a/src/__testing__/permissionKeySet.test.tsx b/src/__testing__/permissionKeySet.test.tsx index 39b057552..e001b8e03 100644 --- a/src/__testing__/permissionKeySet.test.tsx +++ b/src/__testing__/permissionKeySet.test.tsx @@ -1,9 +1,9 @@ +import { Key } from '@meshery/schemas/permissions'; import { fireEvent, render, renderHook, screen, within } from '@testing-library/react'; import { NavigationNavbar, type NavigationItem } from '../custom/NavigationNavbar'; import { PermissionProvider, useHasPermission, - type Key, type PermissionKeySpec } from '../custom/PermissionProvider'; import { PermissionShield } from '../custom/permissions'; diff --git a/src/custom/PermissionProvider.tsx b/src/custom/PermissionProvider.tsx index 1132ff6d2..7065805b1 100644 --- a/src/custom/PermissionProvider.tsx +++ b/src/custom/PermissionProvider.tsx @@ -1,16 +1,6 @@ +import { Key } from '@meshery/schemas/permissions'; import React, { createContext, useContext } from 'react'; -/** - * Shape of a permission key. - */ -export interface Key { - id: string; - category?: string; - subcategory?: string; - function?: string; - description?: string; -} - /** * Determines how a component responds when the user lacks the required permission. diff --git a/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx index b0f1e6454..71b920354 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx @@ -12,7 +12,7 @@ import { labelValue } from '@rjsf/utils'; import React, { useCallback, type ChangeEvent, type FocusEvent } from 'react'; -import { TextField } from '../../../../base/TextField'; +import { TextField, type TextFieldProps } from '../../../../base/TextField'; import { getMuiProps } from '../util'; const TYPES_THAT_SHRINK_LABEL = ['date', 'datetime-local', 'file', 'time']; @@ -52,17 +52,16 @@ export default function BaseInputTemplate< registry, InputLabelProps, InputProps, - slotProps, ...textFieldProps } = props; const { ClearButton } = registry.templates.ButtonTemplates; const { step, min, max, accept, ...rest } = getInputProps(schema, type, options); const muiProps = getMuiProps(options); - const { slotProps: muiSlotProps, ...otherMuiProps } = muiProps; + const { rjsfSlotProps, slotProps: muiSlotPropsFromOptions, ...otherMuiProps } = muiProps; + const muiSlotProps = rjsfSlotProps || muiSlotPropsFromOptions; const htmlInputProps = { - ...slotProps?.htmlInput, ...muiSlotProps?.htmlInput, step, min, @@ -85,13 +84,11 @@ export default function BaseInputTemplate< const DisplayInputLabelProps = TYPES_THAT_SHRINK_LABEL.includes(type) ? { - ...slotProps?.inputLabel, ...muiSlotProps?.inputLabel, ...InputLabelProps, shrink: true } : { - ...slotProps?.inputLabel, ...muiSlotProps?.inputLabel, ...InputLabelProps }; @@ -107,7 +104,6 @@ export default function BaseInputTemplate< const inputProps = { ...InputProps, - ...slotProps?.input, ...muiSlotProps?.input }; @@ -139,7 +135,6 @@ export default function BaseInputTemplate< disabled={disabled || readonly} fullWidth slotProps={{ - ...slotProps, ...muiSlotProps, input: inputProps, htmlInput: htmlInputProps, @@ -153,7 +148,7 @@ export default function BaseInputTemplate< onFocus={_onFocus} aria-describedby={ariaDescribedByIds(id, !!schema.examples)} {...otherMuiProps} - {...(textFieldProps as any)} + {...(textFieldProps as Partial)} /> diff --git a/src/custom/RJSFFormWrapper/theme/util.ts b/src/custom/RJSFFormWrapper/theme/util.ts index 1219777d3..0284ce578 100644 --- a/src/custom/RJSFFormWrapper/theme/util.ts +++ b/src/custom/RJSFFormWrapper/theme/util.ts @@ -2,15 +2,24 @@ import type { SxProps, Theme } from '@mui/material/styles'; import type { FormContextType, RJSFSchema, StrictRJSFSchema, UIOptionsType } from '@rjsf/utils'; +/** + * Slot props for individual Sistent/MUI sub-components within a widget or template. + * Each slot key maps to an object of props for that sub-component. + */ export interface SistentMuiSlotProps { - [key: string]: any; + [key: string]: Record | undefined; } +/** + * 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; + slotProps?: SistentMuiSlotProps; rjsfSlotProps?: SistentMuiSlotProps; - [key: string]: any; + [key: string]: unknown; } /** diff --git a/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx index fadd2577c..0ae1ef330 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx @@ -58,18 +58,12 @@ export default function CheckboxesWidget< } }; - const _onBlur = ({ target }: FocusEvent): void => { - onBlur( - id, - enumOptionValueDecoder(target && target.value, enumOptions, optionValueFormat, emptyValue) - ); + const _onBlur = (): void => { + onBlur(id, checkboxesValues); }; - const _onFocus = ({ target }: FocusEvent): void => { - onFocus( - id, - enumOptionValueDecoder(target && target.value, enumOptions, optionValueFormat, emptyValue) - ); + const _onFocus = (): void => { + onFocus(id, checkboxesValues); }; const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = getMuiProps(options); diff --git a/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx index 16dc04efc..3ad48edcc 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx @@ -37,6 +37,7 @@ export default function RadioWidget< readonly, label, hideLabel, + autofocus, onChange, onBlur, onFocus @@ -100,6 +101,7 @@ export default function RadioWidget< name={htmlName || id} id={optionId(id, index)} color="primary" + autoFocus={Boolean(autofocus && index === 0)} /> } label={option.label} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx index 3ab34b2c0..5acd5214b 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx @@ -13,7 +13,7 @@ import { } from '@rjsf/utils'; import React, { type ChangeEvent, type FocusEvent } from 'react'; import { MenuItem } from '../../../../base/MenuItem'; -import { TextField } from '../../../../base/TextField'; +import { TextField, type TextFieldProps } from '../../../../base/TextField'; import { getMuiProps } from '../util'; /** @@ -133,7 +133,7 @@ export default function SelectWidget< }} aria-describedby={ariaDescribedByIds(id)} {...otherMuiProps} - {...(textFieldProps as any)} + {...(textFieldProps as Partial)} > {showPlaceholderOption && ( diff --git a/src/custom/permissions.tsx b/src/custom/permissions.tsx index a9c2626f0..9da7b6330 100644 --- a/src/custom/permissions.tsx +++ b/src/custom/permissions.tsx @@ -1,3 +1,4 @@ +import { Key } from '@meshery/schemas/permissions'; import KeyIcon from '@mui/icons-material/Key'; import LaunchIcon from '@mui/icons-material/Launch'; import SecurityIcon from '@mui/icons-material/Security'; @@ -17,7 +18,6 @@ import { getPermissionKeys, usePermissionUserContext, useUnmetPermissionKeys, - type Key, type PermissionKeySpec } from './PermissionProvider'; export type { Key }; diff --git a/src/custom/useAccessibleOrgs.ts b/src/custom/useAccessibleOrgs.ts index 791184c78..60bcbf701 100644 --- a/src/custom/useAccessibleOrgs.ts +++ b/src/custom/useAccessibleOrgs.ts @@ -1,10 +1,6 @@ +import { Key } from '@meshery/schemas/permissions'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - getPermissionKeys, - isPermissionKeySet, - PermissionKeySpec, - type Key -} from './PermissionProvider'; +import { getPermissionKeys, isPermissionKeySet, PermissionKeySpec } from './PermissionProvider'; /** * For a given set of user keys (as returned by `getUserKeys`), check whether From 3ddf2d8fbb30690962afd387f38e38fe181e4db4 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 28 Aug 2026 03:41:11 +0000 Subject: [PATCH 06/13] fix(lint): remove unused variables and directives in CheckboxesWidget and hideRootObjectTitle test Signed-off-by: Parth Gartan --- src/__testing__/hideRootObjectTitle.test.tsx | 1 - .../RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx | 7 ++----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/__testing__/hideRootObjectTitle.test.tsx b/src/__testing__/hideRootObjectTitle.test.tsx index 45bf6cec8..d4cdca5de 100644 --- a/src/__testing__/hideRootObjectTitle.test.tsx +++ b/src/__testing__/hideRootObjectTitle.test.tsx @@ -84,7 +84,6 @@ describe('hideRootObjectTitle — RJSF root title/description derivation', () => return importDesignUiSchema; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { label: _label, ...otherOptions } = existingOptions; return { ...importDesignUiSchema, diff --git a/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx index 0ae1ef330..68dbc652c 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx @@ -5,15 +5,13 @@ import { type StrictRJSFSchema, type WidgetProps, ariaDescribedByIds, - enumOptionValueDecoder, enumOptionsDeselectValue, enumOptionsIsSelected, enumOptionsSelectValue, - getOptionValueFormat, labelValue, optionId } from '@rjsf/utils'; -import React, { type ChangeEvent, type FocusEvent } from 'react'; +import React, { type ChangeEvent } from 'react'; import { Checkbox } from '../../../../base/Checkbox'; import { FormControlLabel } from '../../../../base/FormControlLabel'; import { FormGroup } from '../../../../base/FormGroup'; @@ -44,8 +42,7 @@ export default function CheckboxesWidget< onFocus } = props; - const { enumOptions, enumDisabled, inline, emptyValue } = options; - const optionValueFormat = getOptionValueFormat(options); + const { enumOptions, enumDisabled, inline } = options; const checkboxesValues = Array.isArray(value) ? value : value !== undefined ? [value] : []; const _onChange = From a95d313ebc04dc39742bccc6b293f43b3c31d52a Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 28 Aug 2026 04:47:45 +0000 Subject: [PATCH 07/13] fix(rjsf): address review comments on slot typing, prop forwarding, and revert eslint config - Revert eslint.config.js to origin/master to keep PR focused on RJSF scope - Replace unrestricted any index signature in SistentMuiSlotProps with explicit slot map and unknown record dictionary - Type forwardedTextFieldProps without as any cast in SelectWidget and BaseInputTemplate - Ensure all behavioral tests and lint checks pass cleanly Signed-off-by: Parth Gartan --- eslint.config.js | 12 ----- src/__testing__/hideRootObjectTitle.test.tsx | 1 + .../theme/templates/BaseInputTemplate.tsx | 9 ++-- src/custom/RJSFFormWrapper/theme/util.ts | 45 +++++++++++++++++-- .../theme/widgets/SelectWidget.tsx | 7 ++- 5 files changed, 54 insertions(+), 20 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 0c9c215e5..1c8dc0861 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -58,12 +58,6 @@ module.exports = [ rules: { ...js.configs.recommended.rules, ...typescript.configs.recommended.rules, - "@typescript-eslint/no-unused-vars": ["error", { - varsIgnorePattern: "^_", - argsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - destructuredArrayIgnorePattern: "^_", - }], }, linterOptions: { @@ -102,12 +96,6 @@ module.exports = [ rules: { ...js.configs.recommended.rules, ...typescript.configs.recommended.rules, - "@typescript-eslint/no-unused-vars": ["error", { - varsIgnorePattern: "^_", - argsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - destructuredArrayIgnorePattern: "^_", - }], }, linterOptions: { diff --git a/src/__testing__/hideRootObjectTitle.test.tsx b/src/__testing__/hideRootObjectTitle.test.tsx index d4cdca5de..45bf6cec8 100644 --- a/src/__testing__/hideRootObjectTitle.test.tsx +++ b/src/__testing__/hideRootObjectTitle.test.tsx @@ -84,6 +84,7 @@ describe('hideRootObjectTitle — RJSF root title/description derivation', () => return importDesignUiSchema; } + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { label: _label, ...otherOptions } = existingOptions; return { ...importDesignUiSchema, diff --git a/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx index 71b920354..8ba751c1f 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx @@ -26,6 +26,7 @@ export default function BaseInputTemplate< S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any >(props: WidgetProps): JSX.Element { + /* eslint-disable @typescript-eslint/no-unused-vars */ const { id, name: _name, @@ -52,14 +53,16 @@ export default function BaseInputTemplate< 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, slotProps: muiSlotPropsFromOptions, ...otherMuiProps } = muiProps; - const muiSlotProps = rjsfSlotProps || muiSlotPropsFromOptions; + const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = muiProps; const htmlInputProps = { ...muiSlotProps?.htmlInput, @@ -148,7 +151,7 @@ export default function BaseInputTemplate< onFocus={_onFocus} aria-describedby={ariaDescribedByIds(id, !!schema.examples)} {...otherMuiProps} - {...(textFieldProps as Partial)} + {...forwardedTextFieldProps} /> diff --git a/src/custom/RJSFFormWrapper/theme/util.ts b/src/custom/RJSFFormWrapper/theme/util.ts index 0284ce578..47f655ee4 100644 --- a/src/custom/RJSFFormWrapper/theme/util.ts +++ b/src/custom/RJSFFormWrapper/theme/util.ts @@ -3,11 +3,49 @@ import type { SxProps, Theme } from '@mui/material/styles'; import type { FormContextType, RJSFSchema, StrictRJSFSchema, UIOptionsType } from '@rjsf/utils'; /** - * Slot props for individual Sistent/MUI sub-components within a widget or template. - * Each slot key maps to an object of props for that sub-component. + * Slot props for individual Sistent/MUI sub-components across RJSF widgets and templates. + * Explicitly declares known sub-component slot props while allowing type-safe extension. */ export interface SistentMuiSlotProps { - [key: string]: Record | undefined; + // Radio & Checkbox slots + radioGroup?: Record; + formGroup?: Record; + formControlLabel?: Record; + radio?: Record; + checkbox?: Record; + + // Form field & Typography slots + fieldFormControl?: Record; + fieldTypography?: Record; + descTypography?: Record; + + // Title slots + titleBox?: Record; + titleTypography?: Record; + titleDivider?: Record; + titleOptionalDataGridItem?: Record; + + // Error list slots + errorListRoot?: Record; + errorListItem?: Record; + errorListItemText?: Record; + + // Button slots + submitButton?: Record; + submitBox?: Record; + + // Input & Select slots + input?: Record; + htmlInput?: Record; + inputLabel?: Record; + select?: Record; + + // Range widget slots + rangeBox?: Record; + rangeSlider?: Record; + + // Extensible slot dictionary for custom templates/widgets + [key: string]: Record | undefined; } /** @@ -17,7 +55,6 @@ export interface SistentMuiSlotProps { export interface SistentMuiOptions { sx?: SxProps; className?: string; - slotProps?: SistentMuiSlotProps; rjsfSlotProps?: SistentMuiSlotProps; [key: string]: unknown; } diff --git a/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx index 5acd5214b..8865b7972 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx @@ -74,6 +74,7 @@ export default function SelectWidget< const { rjsfSlotProps: muiSlotProps, ...otherMuiProps } = getMuiProps(options); const showPlaceholderOption = !isMultiple && schema.default === undefined; + /* eslint-disable @typescript-eslint/no-unused-vars */ const { schema: _schema, id: _id, @@ -101,8 +102,12 @@ export default function SelectWidget< InputLabelProps: _InputLabelProps, SelectProps: _SelectProps, formContext: _formContext, + color: _color, ...textFieldProps } = props; + /* eslint-enable @typescript-eslint/no-unused-vars */ + + const forwardedTextFieldProps: Partial> = textFieldProps; return ( )} + {...forwardedTextFieldProps} > {showPlaceholderOption && ( From 0b42a6a2f90e21e877c20dbd1d999aa17455e17c Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 28 Aug 2026 05:24:58 +0000 Subject: [PATCH 08/13] fix(rjsf): protect controlled props, strongly type slot map, and verify dts exports - Protect controlled TextField props in SelectWidget and BaseInputTemplate so ui:options.mui cannot override value, onChange, disabled, or select behavior - Add regression test for SelectWidget controlled prop protection - Strongly type SistentMuiSlotProps with concrete MUI component prop types using SistentSlotProps

= Omit - Add JSDoc docstrings for exported button components in ButtonTemplates - Add dist/index.d.ts export validation to RJSFFormWrapper.test.tsx - Add styling merging and precedence tests for slot sx and callback/array sx Signed-off-by: Parth Gartan --- src/__testing__/RJSFFormWrapper.test.tsx | 26 ++++ src/__testing__/RJSFTheme.test.tsx | 66 +++++++++- .../theme/templates/BaseInputTemplate.tsx | 10 +- .../theme/templates/ButtonTemplates.tsx | 24 ++++ src/custom/RJSFFormWrapper/theme/util.ts | 118 ++++++++++++++---- .../theme/widgets/SelectWidget.tsx | 8 +- 6 files changed, 216 insertions(+), 36 deletions(-) diff --git a/src/__testing__/RJSFFormWrapper.test.tsx b/src/__testing__/RJSFFormWrapper.test.tsx index 6cffb59ca..159e1d9f3 100644 --- a/src/__testing__/RJSFFormWrapper.test.tsx +++ b/src/__testing__/RJSFFormWrapper.test.tsx @@ -99,4 +99,30 @@ describe('RJSFFormWrapper (sistent#1533)', () => { expect(sistentTemplates).toBeDefined(); expect(sistentWidgets).toBeDefined(); }); + + it('published declaration bundle 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 expectedDtsSymbols = [ + 'RJSFFormModal', + 'RJSFFormWrapper', + 'hideRootObjectTitle', + 'sistentTheme', + 'sistentTemplates', + 'sistentWidgets', + 'generateTheme', + 'generateTemplates', + 'generateWidgets', + 'RJSFFormModalProps', + 'RJSFFormWrapperProps', + 'RJSFValidationError' + ]; + for (const sym of expectedDtsSymbols) { + expect(dts).toMatch(new RegExp(`\\b${sym}\\b`)); + } + }); }); diff --git a/src/__testing__/RJSFTheme.test.tsx b/src/__testing__/RJSFTheme.test.tsx index cec27e548..823284f1c 100644 --- a/src/__testing__/RJSFTheme.test.tsx +++ b/src/__testing__/RJSFTheme.test.tsx @@ -357,6 +357,38 @@ describe('Sistent RJSF Theme Registry (Issue #418)', () => { 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'); + }); }); // ───────────────────────────────────────────────────────────────────────── @@ -535,10 +567,10 @@ describe('Sistent RJSF Theme Registry (Issue #418)', () => { }); // ───────────────────────────────────────────────────────────────────────── - // 10. rjsfSlotProps passthrough + // 10. rjsfSlotProps and MUI styling customization // ───────────────────────────────────────────────────────────────────────── - describe('rjsfSlotProps customization', () => { + describe('rjsfSlotProps and MUI styling customization', () => { it('passes rjsfSlotProps.radioGroup attributes to the RadioGroup element', () => { const schema: RJSFSchema = { type: 'object', @@ -559,5 +591,35 @@ describe('Sistent RJSF Theme Registry (Issue #418)', () => { ); 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/theme/templates/BaseInputTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx index 8ba751c1f..4d7604678 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/BaseInputTemplate.tsx @@ -129,29 +129,29 @@ export default function BaseInputTemplate< return ( <> 0} onChange={onChangeOverride || _onChange} onBlur={_onBlur} onFocus={_onFocus} aria-describedby={ariaDescribedByIds(id, !!schema.examples)} - {...otherMuiProps} - {...forwardedTextFieldProps} /> diff --git a/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx b/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx index ab99fab31..6d105e0a8 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/ButtonTemplates.tsx @@ -21,6 +21,9 @@ 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, @@ -52,6 +55,9 @@ export function SubmitButton< ); } +/** + * Add button template for appending items to array fields in RJSF. + */ export function AddButton< T = any, S extends StrictRJSFSchema = RJSFSchema, @@ -82,6 +88,9 @@ export function AddButton< ); } +/** + * Shared icon button component used across RJSF array and action button templates. + */ export function SistentIconButton< T = any, S extends StrictRJSFSchema = RJSFSchema, @@ -117,6 +126,9 @@ export function SistentIconButton< ); } +/** + * Copy button template for duplicating an array item in RJSF. + */ export function CopyButton< T = any, S extends StrictRJSFSchema = RJSFSchema, @@ -134,6 +146,9 @@ export function CopyButton< ); } +/** + * Move-down button template for shifting an array item downward in RJSF. + */ export function MoveDownButton< T = any, S extends StrictRJSFSchema = RJSFSchema, @@ -151,6 +166,9 @@ export function MoveDownButton< ); } +/** + * Move-up button template for shifting an array item upward in RJSF. + */ export function MoveUpButton< T = any, S extends StrictRJSFSchema = RJSFSchema, @@ -168,6 +186,9 @@ export function MoveUpButton< ); } +/** + * Remove button template for deleting an array item in RJSF. + */ export function RemoveButton< T = any, S extends StrictRJSFSchema = RJSFSchema, @@ -186,6 +207,9 @@ export function RemoveButton< ); } +/** + * Clear button template for resetting a text input field in RJSF. + */ export function ClearButton< T = any, S extends StrictRJSFSchema = RJSFSchema, diff --git a/src/custom/RJSFFormWrapper/theme/util.ts b/src/custom/RJSFFormWrapper/theme/util.ts index 47f655ee4..5a2cb9846 100644 --- a/src/custom/RJSFFormWrapper/theme/util.ts +++ b/src/custom/RJSFFormWrapper/theme/util.ts @@ -1,51 +1,119 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + BoxProps, + ButtonProps, + CardProps, + CheckboxProps, + DividerProps, + FormControlLabelProps, + FormControlProps, + FormGroupProps, + FormHelperTextProps, + FormLabelProps, + GridProps, + InputBaseComponentProps, + InputLabelProps, + InputProps, + ListItemProps, + ListItemTextProps, + 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 while allowing type-safe extension. + * Explicitly declares known sub-component slot props with their corresponding MUI prop types, + * while allowing type-safe extension for custom templates and widgets. */ export interface SistentMuiSlotProps { // Radio & Checkbox slots - radioGroup?: Record; - formGroup?: Record; - formControlLabel?: Record; - radio?: Record; - checkbox?: Record; + radioGroup?: Partial>; + formGroup?: Partial>; + formControlLabel?: Partial>; + formLabel?: Partial>; + radio?: Partial>; + checkbox?: Partial>; + switch?: Partial>; + toggle?: Partial>; // Form field & Typography slots - fieldFormControl?: Record; - fieldTypography?: Record; - descTypography?: Record; + fieldFormControl?: Partial>; + fieldTypography?: Partial>; + descTypography?: Partial>; + helpFormHelperText?: Partial>; // Title slots - titleBox?: Record; - titleTypography?: Record; - titleDivider?: Record; - titleOptionalDataGridItem?: Record; + titleBox?: Partial>; + titleTypography?: Partial>; + titleDivider?: Partial>; + titleOptionalDataGridItem?: Partial>; // Error list slots - errorListRoot?: Record; - errorListItem?: Record; - errorListItemText?: Record; + errorListRoot?: Partial>; + errorListCard?: Partial>; + errorListHeading?: Partial>; + errorList?: Partial>; + errorListItem?: Partial>; + errorListItemText?: Partial>; // Button slots - submitButton?: Record; - submitBox?: Record; + submitButton?: Partial>; + submitBox?: Partial>; + addButton?: Partial>; + removeButton?: Partial>; + moveUpButton?: Partial>; + moveDownButton?: Partial>; // Input & Select slots - input?: Record; - htmlInput?: Record; - inputLabel?: Record; - select?: Record; + textField?: Partial>; + input?: Partial>; + htmlInput?: InputBaseComponentProps; + inputLabel?: Partial>; + select?: Partial>; + menuItem?: Partial>; // Range widget slots - rangeBox?: Record; - rangeSlider?: Record; + rangeBox?: Partial>; + rangeSlider?: Partial>; + rangeTypography?: Partial>; + + // Array slots + arrayBox?: Partial>; + arrayPaper?: Partial>; + arrayToolbar?: Partial>; + arrayAddButton?: 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>; + wrapBox?: Partial>; + wrapGridContainer?: Partial>; + wrapGridItem?: Partial>; + wrapHelpIcon?: Partial>; // Extensible slot dictionary for custom templates/widgets - [key: string]: Record | undefined; + [key: string]: any; } /** diff --git a/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx index 8865b7972..07eea48c8 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/SelectWidget.tsx @@ -111,6 +111,10 @@ export default function SelectWidget< return ( 0} onChange={_onChange} onBlur={_onBlur} @@ -137,8 +139,6 @@ export default function SelectWidget< } }} aria-describedby={ariaDescribedByIds(id)} - {...otherMuiProps} - {...forwardedTextFieldProps} > {showPlaceholderOption && ( From ffae5651ed39cbe9f913a37daa93c7c5eb2e0968 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 28 Aug 2026 06:07:12 +0000 Subject: [PATCH 09/13] fix(rjsf): strongly type all template slots, preserve generics, and strengthen export tests - Remove [key: string]: any from SistentMuiSlotProps and explicitly type all 40+ slots used across templates/widgets - Remove file-level eslint-disable from util.ts - Support generic schema/formData/context types T, S, F in RJSFFormWrapper and RJSFFormWrapperProps - Validate exported symbols against parsed export statements in src/index.tsx as well as emitted dist/index.d.ts and dist/index.mjs Signed-off-by: Parth Gartan --- src/__testing__/RJSFFormWrapper.test.tsx | 61 +++++++++++++++++-- .../RJSFFormWrapper/RJSFFormWrapper.tsx | 27 +++++--- src/custom/RJSFFormWrapper/theme/util.ts | 31 ++++++---- 3 files changed, 94 insertions(+), 25 deletions(-) diff --git a/src/__testing__/RJSFFormWrapper.test.tsx b/src/__testing__/RJSFFormWrapper.test.tsx index 159e1d9f3..5692146ef 100644 --- a/src/__testing__/RJSFFormWrapper.test.tsx +++ b/src/__testing__/RJSFFormWrapper.test.tsx @@ -68,10 +68,21 @@ describe('RJSFFormWrapper (sistent#1533)', () => { expect(source).toMatch(/export\s+\*\s+from\s+['"]\.\/theme['"]/); }); - it('src/index.tsx re-exports RJSFFormWrapper, RJSFFormModal, and all theme symbols at root', () => { + 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', @@ -86,8 +97,9 @@ describe('RJSFFormWrapper (sistent#1533)', () => { 'RJSFFormWrapperProps', 'RJSFValidationError' ]; + for (const exp of expectedExports) { - expect(source).toMatch(new RegExp(`\\b${exp}\\b`)); + expect(exportedSymbols.has(exp)).toBe(true); } }); @@ -100,13 +112,22 @@ describe('RJSFFormWrapper (sistent#1533)', () => { expect(sistentWidgets).toBeDefined(); }); - it('published declaration bundle exports RJSF and theme symbols when built', () => { + 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', @@ -122,7 +143,39 @@ describe('RJSFFormWrapper (sistent#1533)', () => { 'RJSFValidationError' ]; for (const sym of expectedDtsSymbols) { - expect(dts).toMatch(new RegExp(`\\b${sym}\\b`)); + 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/custom/RJSFFormWrapper/RJSFFormWrapper.tsx b/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx index 214b5fec7..e13a44931 100644 --- a/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx +++ b/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx @@ -1,4 +1,7 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type Form from '@rjsf/core'; import { withTheme, type FormProps } from '@rjsf/core'; +import type { FormContextType, RJSFSchema, StrictRJSFSchema } from '@rjsf/utils'; import validator from '@rjsf/validator-ajv8'; import React, { type Ref } from 'react'; import { SistentThemeProvider } from '../../theme'; @@ -19,11 +22,12 @@ const SistentRJSFForm = withTheme(sistentTheme); * ref — consumers use it to call `validateForm()` and read the * post-validation `state.errors` / `state.formData`. */ -export interface RJSFFormWrapperProps - // eslint-disable-next-line @typescript-eslint/no-explicit-any - extends Omit, 'validator' | 'children'> { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - formRef?: Ref; +export interface RJSFFormWrapperProps< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + 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,22 +60,25 @@ export interface RJSFFormWrapperProps * `RJSFFormModal`) that own the submit affordance should explicitly * pass an empty fragment to suppress it. */ -export function RJSFFormWrapper({ +export function RJSFFormWrapper< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ formRef, children, hideRootTitle = false, uiSchema, ...rest -}: RJSFFormWrapperProps): JSX.Element { +}: RJSFFormWrapperProps): JSX.Element { const resolvedUiSchema = hideRootTitle ? hideRootObjectTitle(uiSchema) : uiSchema; return ( {children} diff --git a/src/custom/RJSFFormWrapper/theme/util.ts b/src/custom/RJSFFormWrapper/theme/util.ts index 5a2cb9846..fba290356 100644 --- a/src/custom/RJSFFormWrapper/theme/util.ts +++ b/src/custom/RJSFFormWrapper/theme/util.ts @@ -1,5 +1,5 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import type { + AlertProps, BoxProps, ButtonProps, CardProps, @@ -15,7 +15,6 @@ import type { InputLabelProps, InputProps, ListItemProps, - ListItemTextProps, ListProps, MenuItemProps, PaperProps, @@ -35,8 +34,7 @@ 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, - * while allowing type-safe extension for custom templates and widgets. + * Explicitly declares known sub-component slot props with their corresponding MUI prop types. */ export interface SistentMuiSlotProps { // Radio & Checkbox slots @@ -54,20 +52,26 @@ export interface SistentMuiSlotProps { 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>; + errorListItemText?: Partial>; // Button slots submitButton?: Partial>; @@ -87,6 +91,7 @@ export interface SistentMuiSlotProps { // Range widget slots rangeBox?: Partial>; + slider?: Partial>; rangeSlider?: Partial>; rangeTypography?: Partial>; @@ -95,6 +100,9 @@ export interface SistentMuiSlotProps { arrayPaper?: Partial>; arrayToolbar?: Partial>; arrayAddButton?: Partial>; + arrayAddButtonBox?: Partial>; + arrayAddButtonGridContainer?: Partial>; + arrayAddButtonGridItem?: Partial>; arrayItemGridContainer?: Partial>; arrayItemGridItem?: Partial>; arrayItemInnerBox?: Partial>; @@ -107,13 +115,14 @@ export interface SistentMuiSlotProps { objectGrid?: Partial>; objectGridContainer?: Partial>; objectGridItem?: Partial>; + objectAddButtonGridContainer?: Partial>; + objectAddButtonGridItem?: Partial>; wrapBox?: Partial>; wrapGridContainer?: Partial>; - wrapGridItem?: Partial>; + wrapKeyGridItem?: Partial>; + wrapChildrenGridItem?: Partial>; + wrapRemoveButtonGridItem?: Partial>; wrapHelpIcon?: Partial>; - - // Extensible slot dictionary for custom templates/widgets - [key: string]: any; } /** @@ -132,9 +141,9 @@ export interface SistentMuiOptions { */ export function getMuiProps< P = SistentMuiOptions, - T = any, + T = unknown, S extends StrictRJSFSchema = RJSFSchema, - F extends FormContextType = any + F extends FormContextType = unknown >( options?: UIOptionsType, propsToFilter?: string[], From 3e68554e250b980dc76129a0e16d2b7f47764057 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 28 Aug 2026 06:24:57 +0000 Subject: [PATCH 10/13] fix(rjsf): replace file-level any suppression with line-scoped suppressions in RJSFFormWrapper - Replace file-level eslint-disable with line-level suppressions in RJSFFormWrapper - Pass full build, lint, and test validation Signed-off-by: Parth Gartan --- src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx b/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx index e13a44931..4176bc872 100644 --- a/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx +++ b/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import type Form from '@rjsf/core'; import { withTheme, type FormProps } from '@rjsf/core'; import type { FormContextType, RJSFSchema, StrictRJSFSchema } from '@rjsf/utils'; @@ -23,8 +22,10 @@ const SistentRJSFForm = withTheme(sistentTheme); * post-validation `state.errors` / `state.formData`. */ export interface RJSFFormWrapperProps< + // 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 > extends Omit, 'validator' | 'children'> { formRef?: Ref>; @@ -61,8 +62,10 @@ export interface RJSFFormWrapperProps< * pass an empty fragment to suppress it. */ 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, @@ -76,8 +79,11 @@ export function RJSFFormWrapper< {children} From 7efeb6ee3df4d7d3a8430fb9ec8505aec3e637ba Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 28 Aug 2026 06:55:14 +0000 Subject: [PATCH 11/13] fix(rjsf): improve accessibility, slot sx merging, and object optional data fallback - Spread errorListItemText before sx with computeSxProps in ErrorListTemplate - Render optionalDataControl fallback when title is absent in ObjectFieldTemplate - Merge titleGridItem.style with flexGrow: 1 in TitleFieldTemplate - Wire RadioWidget FormLabel and RadioGroup with aria-labelledby/aria-label for accessible name discovery - Connect rangeTypography slotProps and computeSxProps to RangeWidget value Typography Signed-off-by: Parth Gartan --- src/__testing__/RJSFTheme.test.tsx | 19 +++++++++++++++++++ .../theme/templates/ErrorListTemplate.tsx | 6 +++++- .../theme/templates/ObjectFieldTemplate.tsx | 2 +- .../theme/templates/TitleFieldTemplate.tsx | 2 +- .../theme/widgets/RadioWidget.tsx | 4 +++- .../theme/widgets/RangeWidget.tsx | 9 ++++++++- 6 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/__testing__/RJSFTheme.test.tsx b/src/__testing__/RJSFTheme.test.tsx index 823284f1c..4fef0b82e 100644 --- a/src/__testing__/RJSFTheme.test.tsx +++ b/src/__testing__/RJSFTheme.test.tsx @@ -241,6 +241,25 @@ describe('Sistent RJSF Theme Registry (Issue #418)', () => { ); }); + 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( diff --git a/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx index 0218d0ab3..63e7d8fce 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/ErrorListTemplate.tsx @@ -44,7 +44,11 @@ export default function ErrorListTemplate< {errors.map((error, i) => ( - + {error.stack} diff --git a/src/custom/RJSFFormWrapper/theme/templates/ObjectFieldTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/ObjectFieldTemplate.tsx index 0be409606..08ff2637f 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/ObjectFieldTemplate.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/ObjectFieldTemplate.tsx @@ -93,7 +93,7 @@ export default function ObjectFieldTemplate< {...objectGridContainer} sx={computeSxProps({ mt: 1 }, objectGridContainer)} > - {!showOptionalDataControlInTitle ? optionalDataControl : undefined} + {(!title || !showOptionalDataControlInTitle) && optionalDataControl} {properties.map((element, index) => element.hidden ? ( element.content diff --git a/src/custom/RJSFFormWrapper/theme/templates/TitleFieldTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/TitleFieldTemplate.tsx index f43664de2..71077ce90 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/TitleFieldTemplate.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/TitleFieldTemplate.tsx @@ -47,7 +47,7 @@ export default function TitleFieldTemplate< if (optionalDataControl) { heading = ( - + {heading} {labelValue( - + {label || undefined} , hideLabel @@ -86,6 +86,8 @@ export default function RadioWidget< onChange={_onChange} onBlur={_onBlur} onFocus={_onFocus} + aria-labelledby={!hideLabel && label ? `${id}-label` : undefined} + aria-label={hideLabel && label ? label : undefined} aria-describedby={ariaDescribedByIds(id)} > {Array.isArray(enumOptions) && diff --git a/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx index 6e18402db..84b46403e 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx @@ -81,7 +81,14 @@ export default function RangeWidget< value={Number(value ?? sliderProps.min ?? 0)} aria-describedby={ariaDescribedByIds(id)} /> - + {value ?? sliderProps.min ?? 0} From c16d2757035a46e6ada6d8003ca3addd286ff6e3 Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 28 Aug 2026 07:35:10 +0000 Subject: [PATCH 12/13] fix(rjsf): preserve parent theme mode and enhance CheckboxesWidget accessibility - Use SistentThemeProviderWithoutBaseLine in RJSFFormWrapper with parent theme mode inheritance - Wire CheckboxesWidget FormGroup with role="group" and aria-labelledby/aria-label - Add regression tests for dark mode inheritance and CheckboxesWidget accessible name discovery Signed-off-by: Parth Gartan --- src/__testing__/RJSFTheme.test.tsx | 44 +++++++++++++++++++ .../RJSFFormWrapper/RJSFFormWrapper.tsx | 8 ++-- .../theme/widgets/CheckboxesWidget.tsx | 5 ++- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/__testing__/RJSFTheme.test.tsx b/src/__testing__/RJSFTheme.test.tsx index 4fef0b82e..4850a706e 100644 --- a/src/__testing__/RJSFTheme.test.tsx +++ b/src/__testing__/RJSFTheme.test.tsx @@ -1,5 +1,6 @@ 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 { @@ -162,6 +163,28 @@ describe('Sistent RJSF Theme Registry (Issue #418)', () => { 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('renders array fields and shows item values', () => { const schema: RJSFSchema = { type: 'object', @@ -327,6 +350,27 @@ describe('Sistent RJSF Theme Registry (Issue #418)', () => { 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( diff --git a/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx b/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx index 4176bc872..1483399f1 100644 --- a/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx +++ b/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx @@ -2,8 +2,9 @@ import type Form from '@rjsf/core'; import { withTheme, type FormProps } from '@rjsf/core'; import type { FormContextType, RJSFSchema, StrictRJSFSchema } from '@rjsf/utils'; import validator from '@rjsf/validator-ajv8'; +import { useTheme } from '@mui/material'; import React, { type Ref } from 'react'; -import { SistentThemeProvider } from '../../theme'; +import { SistentThemeProviderWithoutBaseLine } from '../../theme'; import { hideRootObjectTitle } from './hideRootObjectTitle'; import { sistentTheme } from './theme'; @@ -74,9 +75,10 @@ export function RJSFFormWrapper< uiSchema, ...rest }: RJSFFormWrapperProps): JSX.Element { + const parentTheme = useTheme(); const resolvedUiSchema = hideRootTitle ? hideRootObjectTitle(uiSchema) : uiSchema; return ( - + {children} - + ); } diff --git a/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx index 68dbc652c..681c45193 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/CheckboxesWidget.tsx @@ -68,16 +68,19 @@ export default function CheckboxesWidget< return ( <> {labelValue( - + {label || undefined} , hideLabel )} {Array.isArray(enumOptions) && enumOptions.map((option, index) => { From 0e3425d72dd122b8d99641d0716f155794855cbf Mon Sep 17 00:00:00 2001 From: Parth Gartan Date: Fri, 28 Aug 2026 08:04:46 +0000 Subject: [PATCH 13/13] fix(rjsf): inherit full ambient theme, fix array fallback, protect slot spread, and use React.JSX.Element - Render SistentRJSFForm directly to inherit full parent theme palette, typography, and overrides - Require non-empty title in ArrayFieldTemplate showOptionalDataControlInTitle so optional controls render when title is absent - Place slot prop spreads before RJSF-controlled props in CheckboxWidget, ToggleWidget, and RangeWidget - Use React.JSX.Element return types in TextWidget, TextareaWidget, RadioWidget, FileWidget, and RJSFFormWrapper - Add test verifying inheritance of custom theme palette overrides Signed-off-by: Parth Gartan --- src/__testing__/RJSFTheme.test.tsx | 22 ++++++++++++++ .../RJSFFormWrapper/RJSFFormWrapper.tsx | 29 ++++++++----------- .../theme/templates/ArrayFieldTemplate.tsx | 5 ++-- .../theme/widgets/CheckboxWidget.tsx | 2 +- .../theme/widgets/FileWidget.tsx | 2 +- .../theme/widgets/RadioWidget.tsx | 2 +- .../theme/widgets/RangeWidget.tsx | 4 +-- .../theme/widgets/TextWidget.tsx | 2 +- .../theme/widgets/TextareaWidget.tsx | 2 +- .../theme/widgets/ToggleWidget.tsx | 2 +- 10 files changed, 45 insertions(+), 27 deletions(-) diff --git a/src/__testing__/RJSFTheme.test.tsx b/src/__testing__/RJSFTheme.test.tsx index 4850a706e..5bcc021e0 100644 --- a/src/__testing__/RJSFTheme.test.tsx +++ b/src/__testing__/RJSFTheme.test.tsx @@ -185,6 +185,28 @@ describe('Sistent RJSF Theme Registry (Issue #418)', () => { 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', diff --git a/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx b/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx index 1483399f1..435fb8cea 100644 --- a/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx +++ b/src/custom/RJSFFormWrapper/RJSFFormWrapper.tsx @@ -2,9 +2,7 @@ import type Form from '@rjsf/core'; import { withTheme, type FormProps } from '@rjsf/core'; import type { FormContextType, RJSFSchema, StrictRJSFSchema } from '@rjsf/utils'; import validator from '@rjsf/validator-ajv8'; -import { useTheme } from '@mui/material'; import React, { type Ref } from 'react'; -import { SistentThemeProviderWithoutBaseLine } from '../../theme'; import { hideRootObjectTitle } from './hideRootObjectTitle'; import { sistentTheme } from './theme'; @@ -74,23 +72,20 @@ export function RJSFFormWrapper< hideRootTitle = false, uiSchema, ...rest -}: RJSFFormWrapperProps): JSX.Element { - const parentTheme = useTheme(); +}: RJSFFormWrapperProps): React.JSX.Element { const resolvedUiSchema = hideRootTitle ? hideRootObjectTitle(uiSchema) : uiSchema; return ( - - - {children} - - + + {children} + ); } diff --git a/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldTemplate.tsx b/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldTemplate.tsx index c47a96768..5c3a5c2bf 100644 --- a/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldTemplate.tsx +++ b/src/custom/RJSFFormWrapper/theme/templates/ArrayFieldTemplate.tsx @@ -48,7 +48,8 @@ export default function ArrayFieldTemplate< registry, uiOptions ); - const showOptionalDataControlInTitle = !readonly && !disabled; + const effectiveTitle = uiOptions.title || title; + const showOptionalDataControlInTitle = Boolean(effectiveTitle) && !readonly && !disabled; const { ButtonTemplates: { AddButton } @@ -73,7 +74,7 @@ export default function ArrayFieldTemplate< } label={labelValue(label, hideLabel, false)} diff --git a/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx index cbf47bf53..91ee38edc 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/FileWidget.tsx @@ -120,7 +120,7 @@ export default function FileWidget< T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any ->(props: WidgetProps): JSX.Element { +>(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>( diff --git a/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx index 99b28b639..c3ad88bd6 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/RadioWidget.tsx @@ -26,7 +26,7 @@ export default function RadioWidget< T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any ->(props: WidgetProps): JSX.Element { +>(props: WidgetProps): React.JSX.Element { const { id, htmlName, diff --git a/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx index 84b46403e..3cadcc456 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/RangeWidget.tsx @@ -71,13 +71,13 @@ export default function RangeWidget< )} > diff --git a/src/custom/RJSFFormWrapper/theme/widgets/TextWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/TextWidget.tsx index aeeedbd03..6ffce9855 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/TextWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/TextWidget.tsx @@ -15,7 +15,7 @@ export default function TextWidget< T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any ->(props: WidgetProps): JSX.Element { +>(props: WidgetProps): React.JSX.Element { const { options, registry } = props; const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>( 'BaseInputTemplate', diff --git a/src/custom/RJSFFormWrapper/theme/widgets/TextareaWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/TextareaWidget.tsx index a8dfe8e9e..ad2169c04 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/TextareaWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/TextareaWidget.tsx @@ -15,7 +15,7 @@ export default function TextareaWidget< T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any ->(props: WidgetProps): JSX.Element { +>(props: WidgetProps): React.JSX.Element { const { options, registry } = props; const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>( 'BaseInputTemplate', diff --git a/src/custom/RJSFFormWrapper/theme/widgets/ToggleWidget.tsx b/src/custom/RJSFFormWrapper/theme/widgets/ToggleWidget.tsx index 7cf30f363..4c0aaf143 100644 --- a/src/custom/RJSFFormWrapper/theme/widgets/ToggleWidget.tsx +++ b/src/custom/RJSFFormWrapper/theme/widgets/ToggleWidget.tsx @@ -72,6 +72,7 @@ export default function ToggleWidget< {...muiSlotProps?.formControlLabel} control={ } label={labelValue(label, hideLabel, false)}