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:
Ayush Pahwa 2025-01-31 14:06:42 +07:00 committed by GitHub
parent 0f8e41fc7c
commit a39fe9bd1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 238 additions and 22 deletions

View File

@ -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 };

View File

@ -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;

View File

@ -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);

View File

@ -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();
});
});

View File

@ -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;