From a39fe9bd1b90630285c2b930df196e9dfe25f14b Mon Sep 17 00:00:00 2001 From: Ayush Pahwa Date: Fri, 31 Jan 2025 14:06:42 +0700 Subject: [PATCH] feat: select widget grouping (#38686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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" ### :mag: Cypress test results > [!TIP] > 🟒 🟒 🟒 All cypress tests have passed! πŸŽ‰ πŸŽ‰ πŸŽ‰ > Workflow run: > Commit: f08c31b3e5d81318144e3a71d652526fd1b01a00 > Cypress dashboard. > Tags: `@tag.Sanity, @tag.IDE` > Spec: >
Thu, 30 Jan 2025 20:22:48 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [x] No ## 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. --- .../design-system/ads/src/Select/Select.tsx | 8 +- .../ads/src/Select/Select.types.ts | 7 +- .../design-system/ads/src/Select/styles.css | 42 ++++++++ .../formControls/DropDownControl.test.tsx | 101 +++++++++++++++++ .../formControls/DropDownControl.tsx | 102 ++++++++++++++---- 5 files changed, 238 insertions(+), 22 deletions(-) diff --git a/app/client/packages/design-system/ads/src/Select/Select.tsx b/app/client/packages/design-system/ads/src/Select/Select.tsx index 76626197a8..e591525d9a 100644 --- a/app/client/packages/design-system/ads/src/Select/Select.tsx +++ b/app/client/packages/design-system/ads/src/Select/Select.tsx @@ -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 }; diff --git a/app/client/packages/design-system/ads/src/Select/Select.types.ts b/app/client/packages/design-system/ads/src/Select/Select.types.ts index f76e4914b2..22befed2bc 100644 --- a/app/client/packages/design-system/ads/src/Select/Select.types.ts +++ b/app/client/packages/design-system/ads/src/Select/Select.types.ts @@ -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; @@ -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; diff --git a/app/client/packages/design-system/ads/src/Select/styles.css b/app/client/packages/design-system/ads/src/Select/styles.css index cc67992917..44ceccaffa 100644 --- a/app/client/packages/design-system/ads/src/Select/styles.css +++ b/app/client/packages/design-system/ads/src/Select/styles.css @@ -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); diff --git a/app/client/src/components/formControls/DropDownControl.test.tsx b/app/client/src/components/formControls/DropDownControl.test.tsx index d5e5ae849d..4326f737fc 100644 --- a/app/client/src/components/formControls/DropDownControl.test.tsx +++ b/app/client/src/components/formControls/DropDownControl.test.tsx @@ -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( + + + + + , + ); + + // 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(); + }); +}); diff --git a/app/client/src/components/formControls/DropDownControl.tsx b/app/client/src/components/formControls/DropDownControl.tsx index cf4796e2ce..6112788e24 100644 --- a/app/client/src/components/formControls/DropDownControl.tsx +++ b/app/client/src/components/formControls/DropDownControl.tsx @@ -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 { componentDidUpdate(prevProps: Props) { @@ -140,6 +141,8 @@ function renderDropdown( } let options: SelectOptionProps[] = []; + let optionGroupConfig: Record = {}; + 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 ( - - ); - })} + {groupedOptions.length === 0 + ? options.map(renderOptionWithIcon) + : groupedOptions.map(({ children, label }) => { + return ( + + {children.map(renderOptionWithIcon)} + + ); + })} ); } +function renderOptionWithIcon(option: SelectOptionProps) { + return ( + + ); +} + +export interface DropDownGroupedOptionsInterface { + label: string; + children: SelectOptionProps[]; +} + export interface DropDownControlProps extends ControlProps { options: SelectOptionProps[]; + optionGroupConfig?: Record; optionWidth?: string; placeholderText: string; propertyValue: string;