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;