Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,25 @@ should change the heading of the (upcoming) version to include a major version b

-->

# 6.5.0

## @rjsf/utils

- Added `optgroups` property to `UIOptionsBaseType` for grouping enum options into `<optgroup>` elements

## @rjsf/core

- Added `<optgroup>` support to `SelectWidget` via `ui:options.optgroups`, fixing [#1813](https://github.com/rjsf-team/react-jsonschema-form/issues/1813) and [#580](https://github.com/rjsf-team/react-jsonschema-form/issues/580)

## @rjsf/react-bootstrap

- Added `<optgroup>` support to `SelectWidget` via `ui:options.optgroups`

## Dev / docs / playground

- Added `optgroups` documentation to `uiSchema.md`
- Added `optgroups` example to playground widgets sample

# 6.4.2

## @rjsf/antd
Expand Down
84 changes: 73 additions & 11 deletions packages/core/src/components/widgets/SelectWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChangeEvent, FocusEvent, SyntheticEvent, useCallback } from 'react';
import { ChangeEvent, FocusEvent, ReactNode, SyntheticEvent, useCallback } from 'react';
import {
ariaDescribedByIds,
enumOptionsIndexForValue,
Expand Down Expand Up @@ -40,7 +40,7 @@ function SelectWidget<T = any, S extends StrictRJSFSchema = RJSFSchema, F extend
placeholder,
htmlName,
}: WidgetProps<T, S, F>) {
const { enumOptions, enumDisabled, emptyValue: optEmptyVal } = options;
const { enumOptions, enumDisabled, emptyValue: optEmptyVal, optgroups } = options;
const emptyValue = multiple ? [] : '';

const handleFocus = useCallback(
Expand Down Expand Up @@ -70,6 +70,76 @@ function SelectWidget<T = any, S extends StrictRJSFSchema = RJSFSchema, F extend
const selectedIndexes = enumOptionsIndexForValue<S>(value, enumOptions, multiple);
const showPlaceholderOption = !multiple && schema.default === undefined;

function renderOption(i: number): ReactNode {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code looks (nearly) identical to what you put into the react-bootstrap code. I would love for you to find a way to DRY up the two implementations. One approach could be to implement a component in core that is a peer to the RichDescription/RichHelp/SchemaExamples components and use it in both places. It likely would require passing props that are currently local variables. Let me know what you think is the best approach.

if (!Array.isArray(enumOptions) || !enumOptions[i]) {
return null;
}
const { value, label } = enumOptions[i];
const isDisabled = Array.isArray(enumDisabled) && enumDisabled.indexOf(value) !== -1;
return (
<option key={i} value={String(i)} disabled={isDisabled}>
{label}
</option>
);
}

function renderOptions(): ReactNode {
if (!Array.isArray(enumOptions)) {
return null;
}

if (optgroups && typeof optgroups === 'object') {
// Build a map from enum value to its index in enumOptions
const valueToIndex = new Map<any, number>();
enumOptions.forEach(({ value }, i) => {
valueToIndex.set(value, i);
});

// Track which indices are used in groups
const groupedIndices = new Set<number>();

// Render optgroups
const groups = Object.entries(optgroups).map(([label, values]) => {
const groupOptions = (values as any[])
.map((val) => {
const idx = valueToIndex.get(val);
if (idx === undefined) {
return null;
}
groupedIndices.add(idx);
return renderOption(idx);
})
.filter(Boolean);

return (
<optgroup key={label} label={label}>
{groupOptions}
</optgroup>
);
});

// Render ungrouped options
const ungrouped = enumOptions
.map((_, i) => {
if (groupedIndices.has(i)) {
return null;
}
return renderOption(i);
})
.filter(Boolean);

return (
<>
{groups}
{ungrouped}
</>
);
}

// Default: flat list
return enumOptions.map((_, i) => renderOption(i));
}

return (
<select
id={id}
Expand All @@ -87,15 +157,7 @@ function SelectWidget<T = any, S extends StrictRJSFSchema = RJSFSchema, F extend
aria-describedby={ariaDescribedByIds(id)}
>
{showPlaceholderOption && <option value=''>{placeholder}</option>}
{Array.isArray(enumOptions) &&
enumOptions.map(({ value, label }, i) => {
const disabled = enumDisabled && enumDisabled.indexOf(value) !== -1;
return (
<option key={i} value={String(i)} disabled={disabled}>
{label}
</option>
);
})}
{renderOptions()}
</select>
);
}
Expand Down
119 changes: 119 additions & 0 deletions packages/core/test/StringField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,125 @@ describe('StringField', () => {
expect(options[0]).toHaveTextContent('');
expect(options).toHaveLength(1);
});

it('should render optgroups when ui:options.optgroups is provided', () => {
const { node } = createFormComponent({
schema: {
type: 'string',
enum: ['foo', 'bar', 'baz', 'qux'],
},
uiSchema: {
'ui:options': {
optgroups: {
'Group A': ['foo', 'bar'],
'Group B': ['baz', 'qux'],
},
},
},
});

const optgroups = node.querySelectorAll('optgroup');
expect(optgroups).toHaveLength(2);
expect(optgroups[0]).toHaveAttribute('label', 'Group A');
expect(optgroups[1]).toHaveAttribute('label', 'Group B');
expect(optgroups[0].querySelectorAll('option')).toHaveLength(2);
expect(optgroups[1].querySelectorAll('option')).toHaveLength(2);
});

it('should render ungrouped options after optgroups', () => {
const { node } = createFormComponent({
schema: {
type: 'string',
enum: ['foo', 'bar', 'baz', 'qux'],
},
uiSchema: {
'ui:options': {
optgroups: {
'Group A': ['foo', 'bar'],
},
},
},
});

const select = node.querySelector('select')!;
const optgroups = select.querySelectorAll('optgroup');
expect(optgroups).toHaveLength(1);
expect(optgroups[0]).toHaveAttribute('label', 'Group A');

// Ungrouped options (baz, qux) should be direct children of select, not inside optgroup
const directOptions = Array.from(select.children).filter((child) => child.tagName === 'OPTION');
// placeholder + baz + qux = 3 direct option children
expect(directOptions).toHaveLength(3);
});

it('should handle enumDisabled within optgroups', () => {
const { node } = createFormComponent({
schema: {
type: 'string',
enum: ['foo', 'bar', 'baz'],
},
uiSchema: {
'ui:options': {
enumDisabled: ['bar'],
optgroups: {
'Group A': ['foo', 'bar'],
'Group B': ['baz'],
},
},
},
});

const optgroups = node.querySelectorAll('optgroup');
const groupAOptions = optgroups[0].querySelectorAll('option');
expect(groupAOptions[1]).toBeDisabled();
});

it('should reflect the change event with optgroups', () => {
const { node, onChange } = createFormComponent({
schema: {
type: 'string',
enum: ['foo', 'bar', 'baz'],
},
uiSchema: {
'ui:options': {
optgroups: {
'Group A': ['foo', 'bar'],
'Group B': ['baz'],
},
},
},
});

act(() => {
fireEvent.change(node.querySelector('select')!, {
target: { value: '2' }, // index of 'baz'
});
});

expectToHaveBeenCalledWithFormData(onChange, 'baz', 'root');
});

it('should render placeholder with optgroups', () => {
const { node } = createFormComponent({
schema: {
type: 'string',
enum: ['foo', 'bar'],
},
uiSchema: {
'ui:options': {
placeholder: 'Select one',
optgroups: {
'Group A': ['foo', 'bar'],
},
},
},
});

const select = node.querySelector('select')!;
const firstOption = select.querySelector('option');
expect(firstOption).toHaveTextContent('Select one');
expect(firstOption).toHaveValue('');
});
});

describe('TextareaWidget', () => {
Expand Down
28 changes: 28 additions & 0 deletions packages/docs/docs/api-reference/uiSchema.md
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,34 @@ render(<Form schema={schema} uiSchema={uiSchema} validator={validator} />, docum

This property allows you to reorder the properties that are shown for a particular object. See [Objects](../json-schema/objects.md) for more information.

### optgroups

To group `<option>` elements inside a `<select>` using `<optgroup>`, specify the grouping via the `optgroups` key in `ui:options`. Keys are the group labels, values are arrays of enum values belonging to that group. Any enum values not listed in a group are rendered ungrouped after the groups.

```tsx
import { Form } from '@rjsf/core';
import { RJSFSchema, UiSchema } from '@rjsf/utils';
import validator from '@rjsf/validator-ajv8';

const schema: RJSFSchema = {
type: 'string',
enum: ['lorem', 'ipsum', 'dolorem', 'alpha', 'beta', 'gamma'],
};

const uiSchema: UiSchema = {
'ui:options': {
optgroups: {
Latin: ['lorem', 'ipsum', 'dolorem'],
Greek: ['alpha', 'beta', 'gamma'],
},
},
};

render(<Form schema={schema} uiSchema={uiSchema} validator={validator} />, document.getElementById('app'));
```

Currently supported by the `core` and `react-bootstrap` theme packages.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Currently supported by the `core` and `react-bootstrap` theme packages.
Currently only supported by the `core` and `react-bootstrap` theme packages.


### placeholder

You can add placeholder text to an input by using the `ui:placeholder` uiSchema directive:
Expand Down
13 changes: 13 additions & 0 deletions packages/playground/src/samples/widgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ const widgets: Sample = {
},
],
},
selectWidgetOptions3: {
title: 'Custom select widget with options grouped by optgroups',
type: 'string',
enum: ['lorem', 'ipsum', 'dolorem', 'alpha', 'beta', 'gamma'],
},
},
},
uiSchema: {
Expand Down Expand Up @@ -284,6 +289,14 @@ const widgets: Sample = {
backgroundColor: 'pink',
},
},
selectWidgetOptions3: {
'ui:options': {
optgroups: {
Latin: ['lorem', 'ipsum', 'dolorem'],
Greek: ['alpha', 'beta', 'gamma'],
},
},
},
},
formData: {
stringFormats: {
Expand Down
Loading
Loading