PromucFlow_constructor/app/client/src/components/formControls/DropDownControl.tsx
sneha122 95e8e3703c
fix: UQI dropdown search issue fixed (#39330)
## Description
This PR fixes search issues with dropdown UQI control. 

Steps to reproduce:
1. Create zendesk datasource
2. Create a query
3. Check commands dropdown
4. Try searching for "Create ticket"
5. It should show the relevant options


Fixes #39164   
_or_  
Fixes `Issue URL`
> [!WARNING]  
> _If no issue exists, please create an issue first, and check with the
maintainers if the issue is valid._

## Automation

/ok-to-test tags="@tag.All"

### 🔍 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/13389048508>
> Commit: 261eb4369682e2e65fae19101403002debd90c91
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=13389048508&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.All`
> Spec:
> <hr>Tue, 18 Feb 2025 12:17:03 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

- **New Features**
- Enhanced the dropdown control by introducing a search capability.
Users can now type keywords to filter options based on their labels,
making it easier and faster to locate the desired option while
preserving the existing functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: “sneha122” <“sneha@appsmith.com”>
2025-02-18 18:05:04 +05:30

615 lines
19 KiB
TypeScript

import React from "react";
import memoizeOne from "memoize-one";
import { get, isEmpty, isNil, uniqBy } from "lodash";
import {
Field,
change,
getFormValues,
type WrappedFieldInputProps,
type WrappedFieldMetaProps,
} from "redux-form";
import { connect } from "react-redux";
import type { AppState } from "ee/reducers";
import type { ControlProps } from "./BaseControl";
import BaseControl from "./BaseControl";
import type { ControlType } from "constants/PropertyControlConstants";
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 { getFormConfigConditionalOutput } from "selectors/formSelectors";
import { fetchFormDynamicValNextPage } from "actions/evaluationActions";
import { objectKeys } from "@appsmith/utils";
import type {
ConditionalOutput,
DynamicValues,
} from "reducers/evaluationReducers/formEvaluationReducer";
export interface DropDownGroupedOptions {
label: string;
children: SelectOptionProps[];
}
/**
* Groups dropdown options based on provided configuration
* The grouping is only done if the optionGroupConfig is provided
* The default group is "others" if not provided
* @param {SelectOptionProps[]} options - Array of options to be grouped
* @param {Record<string, DropDownGroupedOptions>} [optionGroupConfig] - Configuration for grouping options
* @returns {DropDownGroupedOptions[] | null} Grouped options array or null if no grouping needed
*/
function buildGroupedOptions(
options: SelectOptionProps[],
optionGroupConfig?: Record<string, DropDownGroupedOptions>,
): DropDownGroupedOptions[] | null {
if (!optionGroupConfig) return null;
const defaultGroupKey = "others";
const defaultGroupConfig: DropDownGroupedOptions = {
label: "Others",
children: [],
};
// Copy group config so we don't mutate the original
const groupMap = { ...optionGroupConfig };
// Re-initialize every group's children to an empty array
objectKeys(groupMap).forEach((key) => {
groupMap[key] = { ...groupMap[key], children: [] };
});
// Ensure we have an "others" group
if (!Object.hasOwn(groupMap, defaultGroupKey)) {
groupMap[defaultGroupKey] = { ...defaultGroupConfig };
} else {
// Re-initialize "others" group's children to an empty array
groupMap[defaultGroupKey] = { ...groupMap[defaultGroupKey], children: [] };
}
// Distribute each option to the correct group
options.forEach((opt) => {
const groupKey =
Object.hasOwn(opt, "optionGroupType") && opt.optionGroupType
? opt.optionGroupType
: defaultGroupKey;
// If the groupKey doesn't exist in config, fall back to "others"
if (!Object.hasOwn(groupMap, groupKey)) {
groupMap[defaultGroupKey].children.push(opt);
return;
}
groupMap[groupKey].children.push(opt);
});
// Return only groups that actually have children
const grouped: DropDownGroupedOptions[] = [];
objectKeys(groupMap).forEach((key) => {
const group = groupMap[key];
if (group.children.length > 0) grouped.push(group);
});
return grouped;
}
const memoizedBuildGroupedOptions = memoizeOne(buildGroupedOptions);
export interface DropDownControlProps extends ControlProps {
options: SelectOptionProps[];
optionGroupConfig?: Record<string, DropDownGroupedOptions>;
optionWidth?: string;
maxTagCount?: number;
placeholderText: string;
propertyValue: string;
subtitle?: string;
isMultiSelect?: boolean;
isAllowClear?: boolean;
isSearchable?: boolean;
fetchOptionsConditionally?: boolean;
isLoading: boolean;
formValues: Partial<Action>;
setFirstOptionAsDefault?: boolean;
nextPageNeeded?: boolean;
appendGroupIdentifierToValue?: boolean;
paginationPayload?: {
value: ConditionalOutput;
dynamicFetchedValues: DynamicValues;
actionId: string;
datasourceId: string;
pluginId: string;
identifier: string;
};
}
interface ReduxDispatchProps {
updateConfigPropertyValue: (
formName: string,
field: string,
value: unknown,
) => void;
fetchFormTriggerNextPage: (paginationPayload: {
value: ConditionalOutput;
dynamicFetchedValues: DynamicValues;
actionId: string;
datasourceId: string;
pluginId: string;
identifier: string;
}) => void;
}
type Props = DropDownControlProps & ReduxDispatchProps;
class DropDownControl extends BaseControl<Props> {
componentDidUpdate(prevProps: Props) {
// If dependencies changed in multi-select, reset values
if (this.props.fetchOptionsConditionally && this.props.isMultiSelect) {
const dependencies = matchExact(
MATCH_ACTION_CONFIG_PROPERTY,
this.props.conditionals?.fetchDynamicValues?.condition,
);
let hasDependenciesChanged = false;
if (dependencies?.length) {
dependencies.forEach((depPath) => {
const prevValue = get(prevProps.formValues, depPath);
const currValue = get(this.props.formValues, depPath);
if (prevValue !== currValue) hasDependenciesChanged = true;
});
}
if (hasDependenciesChanged) {
this.props.updateConfigPropertyValue(
this.props.formName,
this.props.configProperty,
[],
);
}
}
// Clear entity type if the command changed
if (this.props.configProperty === FormDataPaths.ENTITY_TYPE) {
const prevCommandValue = get(prevProps.formValues, FormDataPaths.COMMAND);
const currCommandValue = get(
this.props.formValues,
FormDataPaths.COMMAND,
);
if (prevCommandValue !== currCommandValue) {
this.props.updateConfigPropertyValue(
this.props.formName,
this.props.configProperty,
"",
);
}
}
}
getControlType(): ControlType {
return "DROP_DOWN";
}
render() {
const styles = {
...("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>
);
}
}
/**
* Renders a dropdown component with support for single and multi-select.
* Handles initialization of selected values, including:
* - Using initialValue prop if no value is selected
* - Converting string values to arrays for multi-select
* - Setting first option as default if configured
* - Deduplicating selected values in multi-select mode
* Supports pagination through onPopupScroll handler when nextPageNeeded
* and paginationPayload props are provided
* @param {Object} props - Component props
* @returns {JSX.Element} Rendered dropdown component
*/
function renderDropdown(
props: {
input?: {
value?: string | string[];
onChange: (val: string | string[]) => void;
} & WrappedFieldInputProps;
meta?: Partial<WrappedFieldMetaProps>;
width: string;
} & DropDownControlProps &
ReduxDispatchProps,
): JSX.Element {
const {
appendGroupIdentifierToValue,
input,
isAllowClear,
isMultiSelect,
optionGroupConfig,
options = [],
setFirstOptionAsDefault,
} = props;
// Safeguard the selectedValue (since it might be empty, null, or a string/string[])
let selectedValue = input?.value;
// If no selectedValue, use `initialValue` or set to empty array/string
if (isEmpty(selectedValue)) {
if (isMultiSelect) {
selectedValue = props.initialValue ? (props.initialValue as string) : [];
} else {
selectedValue = props.initialValue
? (props.initialValue as string[])
: "";
// If user wants the first option as default
if (setFirstOptionAsDefault && options.length > 0) {
selectedValue = options[0].value as string;
input?.onChange(selectedValue);
}
}
}
// If multi-select but we have a string, convert it to an array
if (isMultiSelect && !Array.isArray(selectedValue)) {
selectedValue = [selectedValue];
}
// Deduplicate if multi-select
if (isMultiSelect && Array.isArray(selectedValue)) {
// If your items have stable 'value' keys, use `uniqBy(...)`.
// For pure strings you can do `uniq([...selectedValue])`.
selectedValue = uniqBy(selectedValue, (v) => v);
}
// Use memoized grouping
const groupedOptions = memoizedBuildGroupedOptions(
options,
optionGroupConfig,
);
// Find the selected options based on the selectedValue
// If appendGroupIdentifierToValue is true, we need to check if the selected value includes the group identifier
// Eg: if the selected value is "group1:1", we need to find the option with value "1"
// If appendGroupIdentifierToValue is false, we just need to find the option with value "1"
const selectedOptions = options.filter((opt) => {
const checkGroupIdentifier =
appendGroupIdentifierToValue && optionGroupConfig;
const valueToCompare = checkGroupIdentifier
? opt.optionGroupType + ":" + opt.value
: opt.value;
return isMultiSelect
? (selectedValue as string[]).includes(valueToCompare)
: selectedValue === valueToCompare;
});
// Re-sync multi-select if stale
if (isMultiSelect && Array.isArray(selectedValue)) {
const validValues = selectedOptions.map((so) => so.value);
if (
!appendGroupIdentifierToValue &&
validValues.length !== selectedValue.length
) {
input?.onChange(validValues);
}
}
// Re-sync single-select if stale
if (!isMultiSelect && selectedOptions.length) {
const singleVal = selectedOptions[0].value;
if (!appendGroupIdentifierToValue && singleVal !== selectedValue) {
input?.onChange(singleVal);
}
}
// If required but the chosen single value is disabled, pick first enabled
if (
!isMultiSelect &&
props.isRequired &&
options.some((opt) => "disabled" in opt)
) {
const isCurrentOptionDisabled = options.some(
(opt) => opt.value === selectedValue && opt.disabled,
);
if (isCurrentOptionDisabled) {
const firstEnabled = options.find((opt) => !opt.disabled);
if (firstEnabled) {
input?.onChange(firstEnabled.value);
}
}
}
/**
* Handles the selection of options
* If multi select is enabled, we need to add the value to the current array
* If multi select is not enabled, we just set the value
* If appendGroupIdentifierToValue is true, we need to add the group identifier to the value
* Eg: if the selected value is "1" of "group1", we need to add "group1:1" to the current array
* @param {string | undefined} optionValueToSelect - The selected value
*/
function onSelectOptions(optionValueToSelect: string | undefined) {
if (isNil(optionValueToSelect)) return;
// If appendGroupIdentifierToValue is true and we have grouped options, add the group identifier
const shouldAppendGroup = appendGroupIdentifierToValue && optionGroupConfig;
let valueToStore = optionValueToSelect;
if (shouldAppendGroup) {
const selectedOption = options.find(
(opt) => opt.value === optionValueToSelect,
);
if (selectedOption) {
valueToStore = `${selectedOption.optionGroupType || "others"}:${optionValueToSelect}`;
}
}
if (!isMultiSelect) {
input?.onChange(valueToStore);
return;
}
// In case the component config is changed to multi-select, we need to convert the selectedValue to an array
const currentArray = Array.isArray(selectedValue) ? [...selectedValue] : [];
if (!currentArray.includes(valueToStore)) {
currentArray.push(valueToStore);
}
input?.onChange(currentArray);
}
/**
* Handles the removal of options
* If multi select is enabled, we need to remove the value from the current array
* If multi select is not enabled, we just set the value to an empty string
* If appendGroupIdentifierToValue is true, we need to check the value with the group identifier
* Eg: the function will be called with "1" and the current array is ["group1:1", "others:2"]
* We need to check if "1" is present in the array after removing the group identifier
* The function will return ["others:2"]
* @param {string | undefined} optionValueToRemove - The value to remove
*/
function onRemoveOptions(optionValueToRemove: string | undefined) {
if (isNil(optionValueToRemove)) return;
if (!isMultiSelect) {
input?.onChange("");
return;
}
const currentArray = Array.isArray(selectedValue) ? [...selectedValue] : [];
const filtered = currentArray.filter((v) => {
let selectedValueToCheck = v;
if (appendGroupIdentifierToValue && optionGroupConfig) {
// For grouped values, we need to compare just the value part after the group identifier
selectedValueToCheck = v.split(":")[1];
}
return selectedValueToCheck !== optionValueToRemove;
});
input?.onChange(filtered);
}
/**
* Clears all options
* If multi select is enabled, we need to set the value to an empty array
* If multi select is not enabled, we just set the value to an empty string
*/
function clearAllOptions() {
if (isNil(selectedValue)) return;
if (isMultiSelect) {
input?.onChange([]);
} else {
input?.onChange("");
}
}
/**
* Subscribes to the scroll event of the popup and notifies when end of scroll is reached
* If pagination is needed and there is a payload, we need to fetch the next page on end of scroll
* @param {React.UIEvent<HTMLDivElement>} e - The event object
*/
function handlePopupScroll(e: React.UIEvent<HTMLDivElement>) {
if (!props.nextPageNeeded || !props.paginationPayload) return;
const target = e.currentTarget;
if (target.scrollHeight - target.scrollTop === target.clientHeight) {
props.fetchFormTriggerNextPage(props.paginationPayload);
}
}
return (
<Select
allowClear={(isMultiSelect || isAllowClear) && !isEmpty(selectedValue)}
data-testid={`t--dropdown-${props.configProperty}`}
defaultValue={props.initialValue}
isDisabled={props.disabled}
isLoading={props.isLoading}
isMultiSelect={isMultiSelect}
maxTagCount={props.maxTagCount}
onClear={clearAllOptions}
onDeselect={onRemoveOptions}
onPopupScroll={handlePopupScroll}
onSelect={onSelectOptions}
// Default value of optionFilterProp prop is `value` which searches the dropdown based on value and not label,
// hence explicitly setting this to label to search based on label.
// For eg. If value is `Create_ticket` and label is `Create ticket`, we should be able to search using `Create ticket`.
optionFilterProp="label"
placeholder={props.placeholderText}
showSearch={props.isSearchable}
value={isMultiSelect ? selectedOptions : selectedOptions[0]}
>
{groupedOptions
? groupedOptions.map(({ children, label }) => (
<OptGroup aria-label={label} key={label}>
{children.map(renderOptionWithIcon)}
</OptGroup>
))
: options.map(renderOptionWithIcon)}
</Select>
);
}
function renderOptionWithIcon(option: SelectOptionProps) {
return (
<Option
aria-label={option.label}
disabled={option.disabled}
isDisabled={option.isDisabled}
key={option.value}
label={option.label}
value={option.value}
>
{option.icon && <Icon color={option.color} name={option.icon} />}
{option.label}
</Option>
);
}
const mapStateToProps = (
state: AppState,
ownProps: DropDownControlProps,
): {
isLoading: boolean;
options: SelectOptionProps[];
formValues: Partial<Action>;
nextPageNeeded: boolean;
paginationPayload?: {
value: ConditionalOutput;
dynamicFetchedValues: DynamicValues;
actionId: string;
datasourceId: string;
pluginId: string;
identifier: string;
};
} => {
let isLoading = false;
// Start with the user-provided options if not fetching conditionally
let options = ownProps.fetchOptionsConditionally ? [] : ownProps.options;
const formValues: Partial<Action> = getFormValues(ownProps.formName)(state);
let nextPageNeeded = false;
let paginationPayload;
try {
if (ownProps.fetchOptionsConditionally) {
const conditionalOutput = getFormConfigConditionalOutput(state, ownProps);
const dynamicFetchedValues =
conditionalOutput.fetchDynamicValues || ({} as DynamicValues);
const { data } = dynamicFetchedValues;
if (data && data.content && data.startIndex != null) {
const { content, count, startIndex, total } = data;
options = content;
if (startIndex + count < total) {
nextPageNeeded = true;
// Prepare the next page request
const modifiedParams = {
...dynamicFetchedValues.evaluatedConfig.params,
parameters: {
...dynamicFetchedValues.evaluatedConfig.params.parameters,
startIndex: startIndex + count,
},
};
const modifiedDFV: DynamicValues = {
...dynamicFetchedValues,
evaluatedConfig: {
...dynamicFetchedValues.evaluatedConfig,
params: modifiedParams,
},
};
paginationPayload = {
value: { ...conditionalOutput, fetchDynamicValues: modifiedDFV },
dynamicFetchedValues: modifiedDFV,
actionId: formValues.id || "",
datasourceId: formValues.datasource?.id || "",
pluginId: formValues.pluginId || "",
identifier:
ownProps.propertyName ||
ownProps.configProperty ||
ownProps.identifier ||
"",
};
}
} else {
// No pagination, so just use the fetched data
options = dynamicFetchedValues.data || [];
}
isLoading = dynamicFetchedValues.isLoading;
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
}
return {
isLoading,
options,
formValues,
nextPageNeeded,
paginationPayload,
};
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapDispatchToProps = (dispatch: any): ReduxDispatchProps => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateConfigPropertyValue: (formName: string, field: string, value: any) => {
dispatch(change(formName, field, value));
},
fetchFormTriggerNextPage: (paginationPayload?: {
value: ConditionalOutput;
dynamicFetchedValues: DynamicValues;
actionId: string;
datasourceId: string;
pluginId: string;
identifier: string;
}) => {
dispatch(fetchFormDynamicValNextPage(paginationPayload));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(DropDownControl);