PromucFlow_constructor/app/client/src/components/formControls/DropDownControl.tsx
Ayush Pahwa a39fe9bd1b
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 -->
2025-01-31 12:36:42 +05:30

420 lines
13 KiB
TypeScript

import React from "react";
import type { ControlProps } from "./BaseControl";
import BaseControl from "./BaseControl";
import type { ControlType } from "constants/PropertyControlConstants";
import { get, isEmpty, isNil } from "lodash";
import type { WrappedFieldInputProps, WrappedFieldMetaProps } from "redux-form";
import { Field } from "redux-form";
import { connect } from "react-redux";
import type { AppState } from "ee/reducers";
import { getDynamicFetchedValues } from "selectors/formSelectors";
import { change, getFormValues } from "redux-form";
import {
FormDataPaths,
matchExact,
MATCH_ACTION_CONFIG_PROPERTY,
} from "workers/Evaluation/formEval";
import type { Action } from "entities/Action";
import type { SelectOptionProps } from "@appsmith/ads";
import { Icon, Option, OptGroup, Select } from "@appsmith/ads";
import { objectKeys } from "@appsmith/utils";
class DropDownControl extends BaseControl<Props> {
componentDidUpdate(prevProps: Props) {
// if options received by the fetchDynamicValues for the multi select changes, update the config property path's values.
// we do this to make sure, the data does not contain values from the previous options.
// we check if the fetchDynamicValue dependencies of the multiselect dropdown has changed values
// if it has, we reset the values multiselect of the dropdown.
if (this.props.fetchOptionsConditionally && this.props.isMultiSelect) {
const dependencies = matchExact(
MATCH_ACTION_CONFIG_PROPERTY,
this?.props?.conditionals?.fetchDynamicValues?.condition,
);
let hasDependenciesChanged = false;
if (!!dependencies && dependencies.length > 0) {
dependencies.forEach((dependencyPath) => {
const prevValue = get(prevProps?.formValues, dependencyPath);
const currentValue = get(this.props?.formValues, dependencyPath);
if (prevValue !== currentValue) {
hasDependenciesChanged = true;
}
});
}
if (hasDependenciesChanged) {
this.props.updateConfigPropertyValue(
this.props.formName,
this.props.configProperty,
[],
);
}
}
// For entity types to query on the datasource
// when the command is changed, we want to clear the entity, so users can choose the entity type they want to work with
// this also prevents the wrong entity type value from being persisted in the event that the new command value does not match the entity type.
if (this.props.configProperty === FormDataPaths.ENTITY_TYPE) {
const prevCommandValue = get(
prevProps?.formValues,
FormDataPaths.COMMAND,
);
const currentCommandValue = get(
this.props?.formValues,
FormDataPaths.COMMAND,
);
if (prevCommandValue !== currentCommandValue) {
this.props.updateConfigPropertyValue(
this.props.formName,
this.props.configProperty,
"",
);
}
}
}
render() {
const styles = {
// width: "280px",
...("customStyles" in this.props &&
typeof this.props.customStyles === "object"
? this.props.customStyles
: {}),
};
return (
<div
className={`t--${this?.props?.configProperty} uqi-dropdown-select`}
data-testid={this.props.configProperty}
style={styles}
>
<Field
component={renderDropdown}
name={this.props.configProperty}
props={{ ...this.props, width: styles.width }}
type={this.props?.isMultiSelect ? "select-multiple" : undefined}
/>
</div>
);
}
getControlType(): ControlType {
return "DROP_DOWN";
}
}
function renderDropdown(
props: {
input?: WrappedFieldInputProps;
meta?: Partial<WrappedFieldMetaProps>;
width: string;
} & DropDownControlProps,
): JSX.Element {
let selectedValue: string | string[];
if (isEmpty(props.input?.value)) {
if (props.isMultiSelect)
selectedValue = props?.initialValue ? (props.initialValue as string) : [];
else {
selectedValue = props?.initialValue
? (props.initialValue as string[])
: "";
if (props.setFirstOptionAsDefault && props.options.length > 0) {
selectedValue = props.options[0].value as string;
props.input?.onChange(selectedValue);
}
}
} else {
selectedValue = props.input?.value;
if (props.isMultiSelect) {
if (!Array.isArray(selectedValue)) {
selectedValue = [selectedValue];
} else {
selectedValue = [...new Set(selectedValue)];
}
}
}
let options: SelectOptionProps[] = [];
let optionGroupConfig: Record<string, DropDownGroupedOptionsInterface> = {};
let groupedOptions: DropDownGroupedOptionsInterface[] = [];
let selectedOptions: SelectOptionProps[] = [];
if (typeof props.options === "object" && Array.isArray(props.options)) {
options = props.options;
selectedOptions =
options.filter((option: SelectOptionProps) => {
if (props.isMultiSelect)
return selectedValue.includes(option.value as string);
else return selectedValue === option.value;
}) || [];
}
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)) {
if (props.isMultiSelect) {
if (Array.isArray(selectedValue)) {
if (!selectedValue.includes(value))
(selectedValue as string[]).push(value);
} else {
selectedValue = [selectedValue as string, value];
}
} else selectedValue = value;
props.input?.onChange(selectedValue);
}
};
// Function to handle deselection of options
const onRemoveOptions = (value: string | undefined) => {
if (!isNil(value)) {
if (props.isMultiSelect) {
if (Array.isArray(selectedValue)) {
if (selectedValue.includes(value))
(selectedValue as string[]).splice(
(selectedValue as string[]).indexOf(value),
1,
);
} else {
selectedValue = [];
}
} else selectedValue = "";
props.input?.onChange(selectedValue);
}
};
const clearAllOptions = () => {
if (!isNil(selectedValue)) {
if (props.isMultiSelect) {
if (Array.isArray(selectedValue)) {
selectedValue = [];
props.input?.onChange([]);
}
} else {
selectedValue = "";
props.input?.onChange("");
}
}
};
if (options.length > 0) {
if (props.isMultiSelect) {
const tempSelectedValues: string[] = [];
selectedOptions.forEach((option: SelectOptionProps) => {
if (selectedValue.includes(option.value as string)) {
tempSelectedValues.push(option.value as string);
}
});
if (tempSelectedValues.length !== selectedValue.length) {
selectedValue = [...tempSelectedValues];
props.input?.onChange(tempSelectedValues);
}
} else {
let tempSelectedValues = "";
selectedOptions.forEach((option: SelectOptionProps) => {
if (selectedValue === (option.value as string)) {
tempSelectedValues = option.value as string;
}
});
// we also check if the selected options are present at all.
// this is because sometimes when a transition is happening the previous options become an empty array.
// before the new options are loaded.
if (selectedValue !== tempSelectedValues && selectedOptions.length > 0) {
selectedValue = tempSelectedValues;
props.input?.onChange(tempSelectedValues);
}
const isOptionDynamic = options.some((opt) => "disabled" in opt);
if (isOptionDynamic && !!props?.isRequired) {
const isCurrentOptionDisabled = options.some(
(opt) => opt?.value === selectedValue && opt.disabled,
);
if (!tempSelectedValues || isCurrentOptionDisabled) {
const firstEnabledOption = options.find((opt) => !opt?.disabled);
if (firstEnabledOption) {
selectedValue = firstEnabledOption?.value as string;
props.input?.onChange(firstEnabledOption?.value);
}
}
}
}
}
return (
<Select
allowClear={props.isMultiSelect && !isEmpty(selectedValue)}
data-testid={`t--dropdown-${props?.configProperty}`}
defaultValue={props.initialValue}
isDisabled={props.disabled}
isLoading={props.isLoading}
isMultiSelect={props?.isMultiSelect}
onClear={clearAllOptions}
onDeselect={onRemoveOptions}
onSelect={(value) => onSelectOptions(value)}
placeholder={props?.placeholderText}
showSearch={props.isSearchable}
value={props.isMultiSelect ? selectedOptions : selectedOptions[0]}
>
{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;
subtitle?: string;
isMultiSelect?: boolean;
isSearchable?: boolean;
fetchOptionsConditionally?: boolean;
isLoading: boolean;
formValues: Partial<Action>;
setFirstOptionAsDefault?: boolean;
}
interface ReduxDispatchProps {
updateConfigPropertyValue: (
formName: string,
field: string,
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any,
) => void;
}
type Props = DropDownControlProps & ReduxDispatchProps;
const mapStateToProps = (
state: AppState,
ownProps: DropDownControlProps,
): {
isLoading: boolean;
options: SelectOptionProps[];
formValues: Partial<Action>;
} => {
// Added default options to prevent error when options is undefined
let isLoading = false;
let options = ownProps.fetchOptionsConditionally ? [] : ownProps.options;
const formValues: Partial<Action> = getFormValues(ownProps.formName)(state);
try {
if (ownProps.fetchOptionsConditionally) {
const dynamicFetchedValues = getDynamicFetchedValues(state, ownProps);
isLoading = dynamicFetchedValues.isLoading;
options = dynamicFetchedValues.data;
}
} catch (e) {
// Printing error to console
// eslint-disable-next-line no-console
console.error(e);
} finally {
return { isLoading, options, formValues };
}
};
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapDispatchToProps = (dispatch: any): ReduxDispatchProps => ({
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateConfigPropertyValue: (formName: string, field: string, value: any) => {
dispatch(change(formName, field, value));
},
});
// Connecting this component to the state to allow for dynamic fetching of options to be updated.
export default connect(mapStateToProps, mapDispatchToProps)(DropDownControl);