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} [optionGroupConfig] - Configuration for grouping options * @returns {DropDownGroupedOptions[] | null} Grouped options array or null if no grouping needed */ function buildGroupedOptions( options: SelectOptionProps[], optionGroupConfig?: Record, ): 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; optionWidth?: string; maxTagCount?: number; placeholderText: string; propertyValue: string; subtitle?: string; isMultiSelect?: boolean; isAllowClear?: boolean; isSearchable?: boolean; fetchOptionsConditionally?: boolean; isLoading: boolean; formValues: Partial; 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 { 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 (
); } } /** * 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; 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} e - The event object */ function handlePopupScroll(e: React.UIEvent) { if (!props.nextPageNeeded || !props.paginationPayload) return; const target = e.currentTarget; if (target.scrollHeight - target.scrollTop === target.clientHeight) { props.fetchFormTriggerNextPage(props.paginationPayload); } } return ( ); } function renderOptionWithIcon(option: SelectOptionProps) { return ( ); } const mapStateToProps = ( state: AppState, ownProps: DropDownControlProps, ): { isLoading: boolean; options: SelectOptionProps[]; formValues: Partial; 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 = 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);