feat: select widget grouping (#38686)
## Description
This PR adds grouping capabilities to our dropdown control component
(using `rc-select`). Specifically:
- Introduces an `optionGroupConfig` object that maps each group key to a
label and collects relevant options under it.
- Defaults any ungrouped options to the “Others” group if no matching
group is found.
- Includes refactoring to maintain backward compatibility for
non-grouped dropdown usage.
Additionally:
- New tests are added to validate grouped dropdown behaviour.
- Existing multi-select and clear-all functionality is unaffected.
Sample config for the grouping to be enabled
```
{
"label": "Command",
"description": "Choose method you would like to use to query the database",
"configProperty": "actionConfiguration.formData.command.data",
"controlType": "DROP_DOWN",
"initialValue": "FIND",
"options": [
{
"label": "Find document(s)",
"value": "FIND",
"optionGroupType": "testGrp1"
},
{
"label": "Insert document(s)",
"value": "INSERT",
"optionGroupType": "testGrp1"
},
{
"label": "Update document(s)",
"value": "UPDATE",
"optionGroupType": "testGrp2"
},
{
"label": "Delete document(s)",
"value": "DELETE",
"optionGroupType": "testGrp2"
},
{
"label": "Count",
"value": "COUNT",
"optionGroupType": "testGrp2"
},
{
"label": "Distinct",
"value": "DISTINCT",
"optionGroupType": "testGrp3"
},
{
"label": "Aggregate",
"value": "AGGREGATE",
"optionGroupType": "testGrp3"
},
{
"label": "Raw",
"value": "RAW",
"optionGroupType": "testGrp3"
}
],
"optionGroupConfig": {
"testGrp1": {
"label": "Group 1",
"children": []
},
"testGrp2": {
"label": "Group 2",
"children": []
},
"testGrp3": {
"label": "Group 3",
"children": []
}
}
}
```
Fixes #38079
## Automation
/ok-to-test tags="@tag.Sanity, @tag.IDE"
### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/13059919318>
> Commit: f08c31b3e5d81318144e3a71d652526fd1b01a00
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=13059919318&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Sanity, @tag.IDE`
> Spec:
> <hr>Thu, 30 Jan 2025 20:22:48 UTC
<!-- end of auto-generated comment: Cypress test results -->
## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [x] No
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
## Release Notes
- **New Features**
- Added option grouping functionality to the Select component.
- Introduced the ability to organize dropdown options into labeled
groups.
- Enhanced dropdown visual hierarchy with group-based option display.
- **Improvements**
- Updated Select component type definitions to support option grouping.
- Added CSS styles for improved presentation of option groups and
grouped options.
- **Testing**
- Added comprehensive test coverage for dropdown option grouping
functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
0f8e41fc7c
commit
a39fe9bd1b
|
|
@ -1,5 +1,8 @@
|
|||
import React from "react";
|
||||
import RCSelect, { Option as RCOption } from "rc-select";
|
||||
import RCSelect, {
|
||||
Option as RCOption,
|
||||
OptGroup as RCOptGroup,
|
||||
} from "rc-select";
|
||||
import clsx from "classnames";
|
||||
import "./rc-styles.css";
|
||||
import "./styles.css";
|
||||
|
|
@ -91,5 +94,6 @@ Select.displayName = "Select";
|
|||
Select.defaultProps = {};
|
||||
|
||||
const Option = RCOption;
|
||||
const OptGroup = RCOptGroup;
|
||||
|
||||
export { Select, Option };
|
||||
export { Select, Option, OptGroup };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { SelectProps as RCSelectProps } from "rc-select";
|
||||
import type { Sizes } from "../__config__/types";
|
||||
import type { OptionProps } from "rc-select/lib/Option";
|
||||
import type { OptGroupProps } from "rc-select/lib/OptGroup";
|
||||
|
||||
export type SelectSizes = Extract<Sizes, "sm" | "md">;
|
||||
|
||||
|
|
@ -12,4 +13,8 @@ export type SelectProps = RCSelectProps & {
|
|||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export type SelectOptionProps = OptionProps;
|
||||
export type SelectOptionProps = OptionProps & {
|
||||
// used for grouping the options
|
||||
optionGroupType?: string;
|
||||
};
|
||||
export type SelectOptionGroupProps = OptGroupProps;
|
||||
|
|
|
|||
|
|
@ -231,6 +231,48 @@
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Option group */
|
||||
.ads-v2-select__dropdown .rc-select-item.rc-select-item-group {
|
||||
--select-option-padding: var(--ads-v2-spaces-3);
|
||||
--select-option-gap: var(--ads-v2-spaces-3);
|
||||
--select-option-color-bg: var(--ads-v2-colors-content-surface-default-bg);
|
||||
--select-option-font-size: var(--ads-v2-font-size-4);
|
||||
--select-option-height: 36px;
|
||||
|
||||
padding: var(--select-option-padding) 0 0 var(--select-option-padding);
|
||||
border-radius: var(--ads-v2-border-radius);
|
||||
font-weight: 500;
|
||||
font-size: var(--ads-v2-font-size-4);
|
||||
/* TODO: remove !important after WDS fix their issue in tree select */
|
||||
background-color: var(--select-option-color-bg) !important;
|
||||
position: relative;
|
||||
color: var(--ads-v2-colors-content-label-default-fg);
|
||||
min-height: var(--select-option-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Option when it is grouped */
|
||||
.ads-v2-select__dropdown
|
||||
.rc-select-item.rc-select-item-option.rc-select-item-option-grouped {
|
||||
--select-option-padding: var(--ads-v2-spaces-3);
|
||||
--select-option-gap: var(--ads-v2-spaces-3);
|
||||
--select-option-color-bg: var(--ads-v2-colors-content-surface-default-bg);
|
||||
--select-option-font-size: var(--ads-v2-font-size-4);
|
||||
--select-option-height: 36px;
|
||||
|
||||
padding: var(--select-option-padding);
|
||||
padding-left: var(--ads-v2-spaces-5);
|
||||
margin-bottom: var(--ads-v2-spaces-1);
|
||||
border-radius: var(--ads-v2-border-radius);
|
||||
cursor: pointer;
|
||||
/* TODO: remove !important after WDS fix their issue in tree select */
|
||||
background-color: var(--select-option-color-bg) !important;
|
||||
position: relative;
|
||||
color: var(--ads-v2-colors-content-label-default-fg);
|
||||
min-height: var(--select-option-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* size sm */
|
||||
.ads-v2-select__dropdown.ads-v2-select__dropdown--sm .rc-select-item {
|
||||
--select-option-padding: var(--ads-v2-spaces-2);
|
||||
|
|
|
|||
|
|
@ -118,3 +118,104 @@ describe("DropDownControl", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropDownControl grouping tests", () => {
|
||||
// TODO: Fix this the next time the file is edited
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let store: any;
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({
|
||||
form: {
|
||||
GroupingTestForm: {
|
||||
values: {
|
||||
actionConfiguration: { testPath: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should render grouped options correctly when optionGroupConfig is provided", async () => {
|
||||
// These config & options demonstrate grouping
|
||||
const mockOptionGroupConfig = {
|
||||
testGrp1: {
|
||||
label: "Group 1",
|
||||
children: [],
|
||||
},
|
||||
testGrp2: {
|
||||
label: "Group 2",
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
const mockGroupedOptions = [
|
||||
{
|
||||
label: "Option 1",
|
||||
value: "option1",
|
||||
children: "Option 1",
|
||||
optionGroupType: "testGrp1",
|
||||
},
|
||||
{
|
||||
label: "Option 2",
|
||||
value: "option2",
|
||||
children: "Option 2",
|
||||
// Intentionally no optionGroupType => Should fall under default "Others" group
|
||||
},
|
||||
{
|
||||
label: "Option 3",
|
||||
value: "option3",
|
||||
children: "Option 3",
|
||||
optionGroupType: "testGrp2",
|
||||
},
|
||||
];
|
||||
|
||||
const props = {
|
||||
...dropDownProps,
|
||||
controlType: "DROP_DOWN",
|
||||
options: mockGroupedOptions,
|
||||
optionGroupConfig: mockOptionGroupConfig,
|
||||
};
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ReduxFormDecorator>
|
||||
<DropDownControl {...props} />
|
||||
</ReduxFormDecorator>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// 1. Grab the dropdown container
|
||||
const dropdownSelect = await waitFor(async () =>
|
||||
screen.findByTestId("t--dropdown-actionConfiguration.testPath"),
|
||||
);
|
||||
|
||||
expect(dropdownSelect).toBeInTheDocument();
|
||||
|
||||
// 2. Click to open the dropdown
|
||||
// @ts-expect-error: the test will fail if component doesn't exist
|
||||
fireEvent.mouseDown(dropdownSelect.querySelector(".rc-select-selector"));
|
||||
|
||||
// 3. We expect to see group labels from the config
|
||||
// 'Group 1' & 'Group 2' come from the mockOptionGroupConfig
|
||||
const group1Label = await screen.findByText("Group 1");
|
||||
const group2Label = await screen.findByText("Group 2");
|
||||
|
||||
expect(group1Label).toBeInTheDocument();
|
||||
expect(group2Label).toBeInTheDocument();
|
||||
|
||||
// 4. Check that the 'Others' group also exists because at least one option did not have optionGroupType
|
||||
// The default group label is 'Others' (in your code)
|
||||
const othersGroupLabel = await screen.findByText("Others");
|
||||
|
||||
expect(othersGroupLabel).toBeInTheDocument();
|
||||
|
||||
// 5. Confirm the correct distribution of options
|
||||
// For group1 -> "Option 1"
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
// For group2 -> "Option 3"
|
||||
expect(screen.getByText("Option 3")).toBeInTheDocument();
|
||||
// For default "Others" -> "Option 2"
|
||||
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ import {
|
|||
} from "workers/Evaluation/formEval";
|
||||
import type { Action } from "entities/Action";
|
||||
import type { SelectOptionProps } from "@appsmith/ads";
|
||||
import { Icon, Option, Select } from "@appsmith/ads";
|
||||
import { Icon, Option, OptGroup, Select } from "@appsmith/ads";
|
||||
import { objectKeys } from "@appsmith/utils";
|
||||
|
||||
class DropDownControl extends BaseControl<Props> {
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
|
|
@ -140,6 +141,8 @@ function renderDropdown(
|
|||
}
|
||||
|
||||
let options: SelectOptionProps[] = [];
|
||||
let optionGroupConfig: Record<string, DropDownGroupedOptionsInterface> = {};
|
||||
let groupedOptions: DropDownGroupedOptionsInterface[] = [];
|
||||
let selectedOptions: SelectOptionProps[] = [];
|
||||
|
||||
if (typeof props.options === "object" && Array.isArray(props.options)) {
|
||||
|
|
@ -152,6 +155,54 @@ function renderDropdown(
|
|||
}) || [];
|
||||
}
|
||||
|
||||
const defaultOptionGroupType = "others";
|
||||
const defaultOptionGroupConfig: DropDownGroupedOptionsInterface = {
|
||||
label: "Others",
|
||||
children: [],
|
||||
};
|
||||
|
||||
// For grouping, 2 components are needed
|
||||
// 1) optionGroupConfig: used to render the label text and allows for future expansions
|
||||
// related to UI of the group label
|
||||
// 2) each option should mention a optionGroupType which will help to group the option inside
|
||||
// the group. If not present or the type is not defined in the optionGroupConfig then it will be
|
||||
// added to the default group mentioned above.
|
||||
if (
|
||||
!!props.optionGroupConfig &&
|
||||
typeof props.optionGroupConfig === "object"
|
||||
) {
|
||||
optionGroupConfig = props.optionGroupConfig;
|
||||
options.forEach((opt) => {
|
||||
let optionGroupType = defaultOptionGroupType;
|
||||
let groupConfig: DropDownGroupedOptionsInterface;
|
||||
|
||||
if (Object.hasOwn(opt, "optionGroupType") && !!opt.optionGroupType) {
|
||||
optionGroupType = opt.optionGroupType;
|
||||
}
|
||||
|
||||
if (Object.hasOwn(optionGroupConfig, optionGroupType)) {
|
||||
groupConfig = optionGroupConfig[optionGroupType];
|
||||
} else {
|
||||
// if optionGroupType is not defined in optionGroupConfig
|
||||
// use the default group config
|
||||
groupConfig = defaultOptionGroupConfig;
|
||||
}
|
||||
|
||||
const groupChildren = groupConfig?.children || [];
|
||||
|
||||
groupChildren.push(opt);
|
||||
groupConfig["children"] = groupChildren;
|
||||
optionGroupConfig[optionGroupType] = groupConfig;
|
||||
});
|
||||
|
||||
groupedOptions = [];
|
||||
objectKeys(optionGroupConfig).forEach(
|
||||
(key) =>
|
||||
optionGroupConfig[key].children.length > 0 &&
|
||||
groupedOptions.push(optionGroupConfig[key]),
|
||||
);
|
||||
}
|
||||
|
||||
// Function to handle selection of options
|
||||
const onSelectOptions = (value: string | undefined) => {
|
||||
if (!isNil(value)) {
|
||||
|
|
@ -201,7 +252,7 @@ function renderDropdown(
|
|||
}
|
||||
};
|
||||
|
||||
if (props.options.length > 0) {
|
||||
if (options.length > 0) {
|
||||
if (props.isMultiSelect) {
|
||||
const tempSelectedValues: string[] = [];
|
||||
|
||||
|
|
@ -240,9 +291,7 @@ function renderDropdown(
|
|||
);
|
||||
|
||||
if (!tempSelectedValues || isCurrentOptionDisabled) {
|
||||
const firstEnabledOption = props?.options.find(
|
||||
(opt) => !opt?.disabled,
|
||||
);
|
||||
const firstEnabledOption = options.find((opt) => !opt?.disabled);
|
||||
|
||||
if (firstEnabledOption) {
|
||||
selectedValue = firstEnabledOption?.value as string;
|
||||
|
|
@ -268,26 +317,41 @@ function renderDropdown(
|
|||
showSearch={props.isSearchable}
|
||||
value={props.isMultiSelect ? selectedOptions : selectedOptions[0]}
|
||||
>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<Option
|
||||
aria-label={option.label}
|
||||
disabled={option.disabled}
|
||||
isDisabled={option.isDisabled}
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.icon && <Icon color={option.color} name={option.icon} />}
|
||||
{option.label}
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
{groupedOptions.length === 0
|
||||
? options.map(renderOptionWithIcon)
|
||||
: groupedOptions.map(({ children, label }) => {
|
||||
return (
|
||||
<OptGroup aria-label={label} key={label}>
|
||||
{children.map(renderOptionWithIcon)}
|
||||
</OptGroup>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function renderOptionWithIcon(option: SelectOptionProps) {
|
||||
return (
|
||||
<Option
|
||||
aria-label={option.label}
|
||||
disabled={option.disabled}
|
||||
isDisabled={option.isDisabled}
|
||||
value={option.value}
|
||||
>
|
||||
{option.icon && <Icon color={option.color} name={option.icon} />}
|
||||
{option.label}
|
||||
</Option>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DropDownGroupedOptionsInterface {
|
||||
label: string;
|
||||
children: SelectOptionProps[];
|
||||
}
|
||||
|
||||
export interface DropDownControlProps extends ControlProps {
|
||||
options: SelectOptionProps[];
|
||||
optionGroupConfig?: Record<string, DropDownGroupedOptionsInterface>;
|
||||
optionWidth?: string;
|
||||
placeholderText: string;
|
||||
propertyValue: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user