## 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 -->
420 lines
13 KiB
TypeScript
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);
|