diff --git a/app/client/src/actions/evaluationActions.ts b/app/client/src/actions/evaluationActions.ts index abc5a5a9a88f..315cc358f26c 100644 --- a/app/client/src/actions/evaluationActions.ts +++ b/app/client/src/actions/evaluationActions.ts @@ -11,6 +11,10 @@ import { LINT_REDUX_ACTIONS, LOG_REDUX_ACTIONS, } from "ee/actions/evaluationActionsList"; +import type { + ConditionalOutput, + DynamicValues, +} from "reducers/evaluationReducers/formEvaluationReducer"; export const shouldTriggerEvaluation = (action: ReduxAction) => { return ( @@ -126,3 +130,19 @@ const FORCE_EVAL_ACTIONS = { export const shouldForceEval = (action: ReduxAction) => { return !!FORCE_EVAL_ACTIONS[action.type]; }; + +export const fetchFormDynamicValNextPage = (payload?: { + value: ConditionalOutput; + dynamicFetchedValues: DynamicValues; + actionId: string; + datasourceId: string; + pluginId: string; + identifier: string; +}) => { + if (payload) { + return { + type: ReduxActionTypes.FETCH_FORM_DYNAMIC_VAL_NEXT_PAGE_INIT, + payload, + }; + } +}; diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index e0ab5b641690..bacbf4ce3d6b 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -751,6 +751,10 @@ const UQIFormActionTypes = { FETCH_TRIGGER_VALUES_SUCCESS: "FETCH_TRIGGER_VALUES_SUCCESS", SET_TRIGGER_VALUES_LOADING: "SET_TRIGGER_VALUES_LOADING", FORM_EVALUATION_EMPTY_BUFFER: "FORM_EVALUATION_EMPTY_BUFFER", + FETCH_FORM_DYNAMIC_VAL_NEXT_PAGE_INIT: + "FETCH_FORM_DYNAMIC_VAL_NEXT_PAGE_INIT", + FETCH_FORM_DYNAMIC_VAL_NEXT_PAGE_SUCCESS: + "FETCH_FORM_DYNAMIC_VAL_NEXT_PAGE_SUCCESS", }; const ActionActionTypes = { diff --git a/app/client/src/ce/sagas/index.tsx b/app/client/src/ce/sagas/index.tsx index dc529cd1654a..8dbc6ba0777a 100644 --- a/app/client/src/ce/sagas/index.tsx +++ b/app/client/src/ce/sagas/index.tsx @@ -21,7 +21,9 @@ import debuggerSagas from "sagas/DebuggerSagas"; import editorContextSagas from "sagas/editorContextSagas"; import errorSagas from "sagas/ErrorSagas"; import evaluationsSaga from "sagas/EvaluationsSaga"; -import formEvaluationChangeListener from "sagas/FormEvaluationSaga"; +import formEvaluationChangeListener, { + formEvaluationSagas, +} from "sagas/FormEvaluationSaga"; import gitSyncSagas from "sagas/GitSyncSagas"; import globalSearchSagas from "sagas/GlobalSearchSagas"; import initSagas from "sagas/InitSagas"; @@ -81,6 +83,7 @@ export const sagas = [ onboardingSagas, actionExecutionChangeListeners, formEvaluationChangeListener, + formEvaluationSagas, globalSearchSagas, debuggerSagas, saaSPaneSagas, diff --git a/app/client/src/components/formControls/DropDownControl.test.tsx b/app/client/src/components/formControls/DropDownControl.test.tsx index c8c5f0b13029..2df9cb9055db 100644 --- a/app/client/src/components/formControls/DropDownControl.test.tsx +++ b/app/client/src/components/formControls/DropDownControl.test.tsx @@ -5,13 +5,12 @@ import { reduxForm } from "redux-form"; import "@testing-library/jest-dom"; import { Provider } from "react-redux"; import configureStore from "redux-mock-store"; +import type { SelectOptionProps } from "@appsmith/ads"; const mockStore = configureStore([]); const initialValues = { - actionConfiguration: { - testPath: ["option1", "option2"], - }, + actionConfiguration: { testPath: ["option1", "option2"] }, }; // TODO: Fix this the next time the file is edited @@ -20,10 +19,9 @@ function TestForm(props: any) { return
{props.children}
; } -const ReduxFormDecorator = reduxForm({ - form: "TestForm", - initialValues, -})(TestForm); +const ReduxFormDecorator = reduxForm({ form: "TestForm", initialValues })( + TestForm, +); const mockOptions = [ { label: "Option 1", value: "option1", children: "Option 1" }, @@ -34,10 +32,7 @@ const mockOptions = [ const mockAction = { type: "API_ACTION", name: "Test API Action", - datasource: { - id: "datasource1", - name: "Datasource 1", - }, + datasource: { id: "datasource1", name: "Datasource 1" }, actionConfiguration: { body: "", headers: [], @@ -68,14 +63,15 @@ describe("DropDownControl", () => { beforeEach(() => { store = mockStore({ - form: { - TestForm: { - values: initialValues, - }, - }, + form: { TestForm: { values: initialValues } }, appState: {}, }); }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it("should renders dropdownControl and options properly", async () => { render( @@ -118,6 +114,107 @@ describe("DropDownControl", () => { expect(options.length).toBe(0); }); }); + + it("should handle single select mode correctly", async () => { + const singleSelectProps = { + ...dropDownProps, + isMultiSelect: false, + configProperty: "actionConfiguration.singlePath", + inputIcon: undefined, + showArrow: undefined, + }; + + render( + + + + + , + ); + + // Wait for component to be fully rendered + const dropdownSelect = await screen.findByTestId( + "t--dropdown-actionConfiguration.singlePath", + ); + + // Open dropdown + fireEvent.mouseDown(dropdownSelect.querySelector(".rc-select-selector")!); + + // Wait for dropdown to be visible + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeVisible(); + }); + + // Select Option 1 + const option = screen.getByRole("option", { name: "Option 1" }); + + fireEvent.click(option); + + // Wait for selection to be applied and verify + await waitFor(() => { + // Check if the selection is displayed + const selectedItem = screen.getByText("Option 1", { + selector: ".rc-select-selection-item", + }); + + expect(selectedItem).toBeInTheDocument(); + }); + + // Check if the option is marked as selected + const selectedOption = screen.getByRole("option", { name: "Option 1" }); + + expect(selectedOption).toHaveClass("rc-select-item-option-selected"); + + // Check that other options are not selected + const options = screen.getAllByRole("option"); + const selectedCount = options.filter((opt: SelectOptionProps) => + opt.classList.contains("rc-select-item-option-selected"), + ).length; + + expect(selectedCount).toBe(1); + }); + + it("should handle multi-select mode correctly", async () => { + render( + + + + + , + ); + + const dropdownSelect = await screen.findByTestId( + "t--dropdown-actionConfiguration.testPath", + ); + + // Open dropdown + fireEvent.mouseDown(dropdownSelect.querySelector(".rc-select-selector")!); + + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeVisible(); + }); + + // Verify initial selections (Option 1 and Option 2 are selected from initialValues) + const initialSelectedOptions = screen + .getAllByRole("option") + .filter((opt) => opt.getAttribute("aria-selected") === "true"); + + expect(initialSelectedOptions).toHaveLength(2); + }); + + it("should show placeholder if no option is selected", async () => { + const updatedProps = { ...dropDownProps, options: [] }; + + render( + + + + + , + ); + + expect(screen.getByText("Select Columns")).toBeInTheDocument(); + }); }); describe("DropDownControl grouping tests", () => { @@ -128,53 +225,41 @@ describe("DropDownControl grouping tests", () => { beforeEach(() => { store = mockStore({ form: { - GroupingTestForm: { - values: { - actionConfiguration: { testPath: [] }, - }, - }, + GroupingTestForm: { values: { actionConfiguration: { testPath: [] } } }, }, }); }); - it("should render grouped options correctly when optionGroupConfig is provided", async () => { - // These config & options demonstrate grouping + it("should render grouped options correctly", async () => { const mockOptionGroupConfig = { - testGrp1: { - label: "Group 1", - children: [], - }, - testGrp2: { - label: "Group 2", - children: [], - }, + group1: { label: "Group 1", children: [] }, + group2: { label: "Group 2", children: [] }, }; - const mockGroupedOptions = [ + const mockOptions = [ { label: "Option 1", - value: "option1", - children: "Option 1", - optionGroupType: "testGrp1", + value: "1", + optionGroupType: "group1", + children: [], }, { label: "Option 2", - value: "option2", - children: "Option 2", - // Intentionally no optionGroupType => Should fall under default "Others" group + value: "2", + children: [], + // No group - should go to Others }, { label: "Option 3", - value: "option3", - children: "Option 3", - optionGroupType: "testGrp2", + value: "3", + optionGroupType: "group2", + children: [], }, ]; const props = { ...dropDownProps, - controlType: "DROP_DOWN", - options: mockGroupedOptions, + options: mockOptions, optionGroupConfig: mockOptionGroupConfig, }; @@ -191,33 +276,24 @@ describe("DropDownControl grouping tests", () => { 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")); + // Open dropdown + 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"); + // Verify group headers are present + await waitFor(() => { + expect(screen.getByText("Group 1")).toBeInTheDocument(); + }); + expect(screen.getByText("Group 2")).toBeInTheDocument(); + expect(screen.getByText("Others")).toBeInTheDocument(); - expect(othersGroupLabel).toBeInTheDocument(); + // Verify options are in correct groups + const group1Option = screen.getByText("Option 1"); + const group2Option = screen.getByText("Option 3"); + const othersOption = screen.getByText("Option 2"); - // 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(); + expect(group1Option).toBeInTheDocument(); + expect(group2Option).toBeInTheDocument(); + expect(othersOption).toBeInTheDocument(); }); }); diff --git a/app/client/src/components/formControls/DropDownControl.tsx b/app/client/src/components/formControls/DropDownControl.tsx index 4d0ba27fd8f8..51854160beac 100644 --- a/app/client/src/components/formControls/DropDownControl.tsx +++ b/app/client/src/components/formControls/DropDownControl.tsx @@ -1,14 +1,18 @@ 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 { 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, @@ -17,30 +21,146 @@ import { 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; + 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 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 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, + 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 (dependencies?.length) { + dependencies.forEach((depPath) => { + const prevValue = get(prevProps.formValues, depPath); + const currValue = get(this.props.formValues, depPath); - if (prevValue !== currentValue) { - hasDependenciesChanged = true; - } + if (prevValue !== currValue) hasDependenciesChanged = true; }); } @@ -53,20 +173,15 @@ class DropDownControl extends BaseControl { } } - // 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. + // Clear entity type if the command changed if (this.props.configProperty === FormDataPaths.ENTITY_TYPE) { - const prevCommandValue = get( - prevProps?.formValues, - FormDataPaths.COMMAND, - ); - const currentCommandValue = get( - this.props?.formValues, + const prevCommandValue = get(prevProps.formValues, FormDataPaths.COMMAND); + const currCommandValue = get( + this.props.formValues, FormDataPaths.COMMAND, ); - if (prevCommandValue !== currentCommandValue) { + if (prevCommandValue !== currCommandValue) { this.props.updateConfigPropertyValue( this.props.formName, this.props.configProperty, @@ -76,9 +191,12 @@ class DropDownControl extends BaseControl { } } + getControlType(): ControlType { + return "DROP_DOWN"; + } + render() { const styles = { - // width: "280px", ...("customStyles" in this.props && typeof this.props.customStyles === "object" ? this.props.customStyles @@ -87,7 +205,7 @@ class DropDownControl extends BaseControl { return (
@@ -95,240 +213,219 @@ class DropDownControl extends BaseControl { component={renderDropdown} name={this.props.configProperty} props={{ ...this.props, width: styles.width }} - type={this.props?.isMultiSelect ? "select-multiple" : undefined} + type={this.props.isMultiSelect ? "select-multiple" : undefined} />
); } - - getControlType(): ControlType { - return "DROP_DOWN"; - } } +/** + * 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?: WrappedFieldInputProps; + input?: { + value?: string | string[]; + onChange: (val: string | string[]) => void; + } & WrappedFieldInputProps; meta?: Partial; width: string; - } & DropDownControlProps, + } & DropDownControlProps & + ReduxDispatchProps, ): 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 + const { input, isMultiSelect, optionGroupConfig, options = [] } = 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 (props.setFirstOptionAsDefault && props.options.length > 0) { - selectedValue = props.options[0].value as string; - props.input?.onChange(selectedValue); + // If user wants the first option as default + if (props.setFirstOptionAsDefault && options.length > 0) { + selectedValue = options[0].value as string; + input?.onChange(selectedValue); } } - } else { - selectedValue = props.input?.value; + } - if (props.isMultiSelect) { - if (!Array.isArray(selectedValue)) { - selectedValue = [selectedValue]; - } else { - selectedValue = [...new Set(selectedValue)]; - } - } + // If multi-select but we have a string, convert it to an array + if (isMultiSelect && !Array.isArray(selectedValue)) { + selectedValue = [selectedValue]; } - let options: SelectOptionProps[] = []; - let optionGroupConfig: Record = {}; - 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; - }) || []; + // 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); } - const defaultOptionGroupType = "others"; - const defaultOptionGroupConfig: DropDownGroupedOptionsInterface = { - label: "Others", - children: [], - }; + // Identify which items are actually selected + const selectedOptions = options.filter((opt) => + isMultiSelect + ? (selectedValue as string[]).includes(opt.value as string) + : selectedValue === opt.value, + ); - // 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; + // Use memoized grouping + const groupedOptions = memoizedBuildGroupedOptions( + options, + optionGroupConfig, + ); - if (Object.hasOwn(opt, "optionGroupType") && !!opt.optionGroupType) { - optionGroupType = opt.optionGroupType; - } + // Re-sync multi-select if stale + if (isMultiSelect && Array.isArray(selectedValue)) { + const validValues = selectedOptions.map((so) => so.value); - if (Object.hasOwn(optionGroupConfig, optionGroupType)) { - groupConfig = optionGroupConfig[optionGroupType]; - } else { - // if optionGroupType is not defined in optionGroupConfig - // use the default group config - groupConfig = defaultOptionGroupConfig; - } + if (validValues.length !== selectedValue.length) { + input?.onChange(validValues); + } + } - const groupChildren = groupConfig?.children || []; + // Re-sync single-select if stale + if (!isMultiSelect && selectedOptions.length) { + const singleVal = selectedOptions[0].value; - groupChildren.push(opt); - groupConfig["children"] = groupChildren; - optionGroupConfig[optionGroupType] = groupConfig; - }); + if (singleVal !== selectedValue) { + input?.onChange(singleVal); + } + } - groupedOptions = []; - objectKeys(optionGroupConfig).forEach( - (key) => - optionGroupConfig[key].children.length > 0 && - groupedOptions.push(optionGroupConfig[key]), + // 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, ); - } - // 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; + if (isCurrentOptionDisabled) { + const firstEnabled = options.find((opt) => !opt.disabled); - props.input?.onChange(selectedValue); + if (firstEnabled) { + input?.onChange(firstEnabled.value); + } } - }; + } - // 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 = ""; + /** + * 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 + * @param {string | undefined} optionValueToSelect - The selected value + */ + function onSelectOptions(optionValueToSelect: string | undefined) { + if (isNil(optionValueToSelect)) return; - props.input?.onChange(selectedValue); - } - }; + if (!isMultiSelect) { + input?.onChange(optionValueToSelect); - const clearAllOptions = () => { - if (!isNil(selectedValue)) { - if (props.isMultiSelect) { - if (Array.isArray(selectedValue)) { - selectedValue = []; - props.input?.onChange([]); - } - } else { - selectedValue = ""; - props.input?.onChange(""); - } + return; } - }; - if (options.length > 0) { - if (props.isMultiSelect) { - const tempSelectedValues: string[] = []; + const currentArray = Array.isArray(selectedValue) ? [...selectedValue] : []; - selectedOptions.forEach((option: SelectOptionProps) => { - if (selectedValue.includes(option.value as string)) { - tempSelectedValues.push(option.value as string); - } - }); + if (!currentArray.includes(optionValueToSelect)) + currentArray.push(optionValueToSelect); - if (tempSelectedValues.length !== selectedValue.length) { - selectedValue = [...tempSelectedValues]; - props.input?.onChange(tempSelectedValues); - } - } else { - let tempSelectedValues = ""; + input?.onChange(currentArray); + } - 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); - } + /** + * 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 + * @param {string | undefined} optionValueToRemove - The value to remove + */ + function onRemoveOptions(optionValueToRemove: string | undefined) { + if (isNil(optionValueToRemove)) return; - const isOptionDynamic = options.some((opt) => "disabled" in opt); + if (!isMultiSelect) { + input?.onChange(""); - if (isOptionDynamic && !!props?.isRequired) { - const isCurrentOptionDisabled = options.some( - (opt) => opt?.value === selectedValue && opt.disabled, - ); + return; + } - if (!tempSelectedValues || isCurrentOptionDisabled) { - const firstEnabledOption = options.find((opt) => !opt?.disabled); + const currentArray = Array.isArray(selectedValue) ? [...selectedValue] : []; + const filtered = currentArray.filter((v) => v !== optionValueToRemove); - if (firstEnabledOption) { - selectedValue = firstEnabledOption?.value as string; - props.input?.onChange(firstEnabledOption?.value); - } - } - } + 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 ( ); } @@ -339,6 +436,7 @@ function renderOptionWithIcon(option: SelectOptionProps) { aria-label={option.label} disabled={option.disabled} isDisabled={option.isDisabled} + key={option.value} label={option.label} value={option.value} > @@ -348,40 +446,6 @@ function renderOptionWithIcon(option: SelectOptionProps) { ); } -export interface DropDownGroupedOptionsInterface { - label: string; - children: SelectOptionProps[]; -} - -export interface DropDownControlProps extends ControlProps { - options: SelectOptionProps[]; - optionGroupConfig?: Record; - optionWidth?: string; - placeholderText: string; - propertyValue: string; - subtitle?: string; - isMultiSelect?: boolean; - isSearchable?: boolean; - fetchOptionsConditionally?: boolean; - isLoading: boolean; - formValues: Partial; - setFirstOptionAsDefault?: boolean; - maxTagCount?: number; - isAllowClear?: 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, @@ -389,37 +453,107 @@ const mapStateToProps = ( isLoading: boolean; options: SelectOptionProps[]; formValues: Partial; + nextPageNeeded: boolean; + paginationPayload?: { + value: ConditionalOutput; + dynamicFetchedValues: DynamicValues; + actionId: string; + datasourceId: string; + pluginId: string; + identifier: string; + }; } => { - // Added default options to prevent error when options is undefined 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 dynamicFetchedValues = getDynamicFetchedValues(state, ownProps); + 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; - options = dynamicFetchedValues.data; } - } catch (e) { - // Printing error to console + } catch (err) { // eslint-disable-next-line no-console - console.error(e); - } finally { - return { isLoading, options, formValues }; + console.error(err); } + + return { + isLoading, + options, + formValues, + nextPageNeeded, + paginationPayload, + }; }; -// 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)); }, + fetchFormTriggerNextPage: (paginationPayload?: { + value: ConditionalOutput; + dynamicFetchedValues: DynamicValues; + actionId: string; + datasourceId: string; + pluginId: string; + identifier: string; + }) => { + dispatch(fetchFormDynamicValNextPage(paginationPayload)); + }, }); -// Connecting this component to the state to allow for dynamic fetching of options to be updated. export default connect(mapStateToProps, mapDispatchToProps)(DropDownControl); diff --git a/app/client/src/reducers/evaluationReducers/triggerReducer.ts b/app/client/src/reducers/evaluationReducers/triggerReducer.ts index 96f1269f147f..32c1d641a452 100644 --- a/app/client/src/reducers/evaluationReducers/triggerReducer.ts +++ b/app/client/src/reducers/evaluationReducers/triggerReducer.ts @@ -3,9 +3,11 @@ import type { ReduxAction } from "actions/ReduxActionTypes"; import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; import type { ConditionalOutput, + DynamicValues, FormEvalOutput, FormEvaluationState, } from "./formEvaluationReducer"; +import produce from "immer"; // // Type for the object that will store the eval output for the app export type TriggerValuesEvaluationState = Record; @@ -15,6 +17,12 @@ export interface TriggerActionPayload { values: ConditionalOutput; } +export interface TriggerActionNextPagePayload { + actionId: string; + value: DynamicValues; + identifier: string; +} + export interface TriggerActionLoadingPayload { formId: string; keys: string[]; // keys that need their loading states set. @@ -54,6 +62,46 @@ const triggers = createReducer(initialState, { }, }; }, + [ReduxActionTypes.FETCH_FORM_DYNAMIC_VAL_NEXT_PAGE_SUCCESS]: ( + state: FormEvaluationState, + action: ReduxAction, + ) => + produce(state, (draftState) => { + const { actionId, identifier, value: newValue } = action.payload; + + if (!draftState[actionId][identifier].fetchDynamicValues?.data) { + return draftState; + } + + const triggers = state[actionId]; + const storedConditionalOutput = triggers[identifier]; + + let content: Array = + storedConditionalOutput.fetchDynamicValues?.data.content; + + // if stored data is already of the same length or more than the incoming data + // then this might be a duplicate call and needs to be skipped. + if (newValue.data.count + newValue.data.startIndex <= content.length) { + return draftState; + } + + content = [ + ...storedConditionalOutput.fetchDynamicValues?.data.content, + ...newValue.data.content, + ]; + + const updatedData = { + content, + startIndex: newValue.data.startIndex || 0, + count: newValue.data.count || 0, + total: newValue.data.total || 0, + }; + + draftState[actionId][identifier].fetchDynamicValues.data = updatedData; + draftState[actionId][identifier].fetchDynamicValues.isLoading = false; + + return draftState; + }), [ReduxActionTypes.SET_TRIGGER_VALUES_LOADING]: ( state: FormEvaluationState, action: ReduxAction, diff --git a/app/client/src/sagas/FormEvaluationSaga.ts b/app/client/src/sagas/FormEvaluationSaga.ts index 4f5d126bc360..685b0c618c65 100644 --- a/app/client/src/sagas/FormEvaluationSaga.ts +++ b/app/client/src/sagas/FormEvaluationSaga.ts @@ -1,5 +1,13 @@ import type { ActionPattern } from "redux-saga/effects"; -import { call, take, select, put, actionChannel } from "redux-saga/effects"; +import { + call, + take, + select, + put, + actionChannel, + all, + takeLatest, +} from "redux-saga/effects"; import type { ReduxAction } from "actions/ReduxActionTypes"; import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; import log from "loglevel"; @@ -31,6 +39,7 @@ import { buffers } from "redux-saga"; import type { Plugin } from "entities/Plugin"; import { doesPluginRequireDatasource } from "ee/entities/Engine/actionHelpers"; import { klonaLiteWithTelemetry } from "utils/helpers"; +import { objectKeys } from "@appsmith/utils"; export interface FormEvalActionPayload { formId: string; @@ -138,7 +147,7 @@ export function* fetchDynamicValuesSaga( datasourceId: string, pluginId: string, ) { - for (const key of Object.keys(queueOfValuesToBeFetched)) { + for (const key of objectKeys(queueOfValuesToBeFetched)) { queueOfValuesToBeFetched[key].fetchDynamicValues = yield call( fetchDynamicValueSaga, queueOfValuesToBeFetched[key], @@ -162,6 +171,49 @@ export function* fetchDynamicValuesSaga( }); } +function* fetchPaginatedDynamicValuesSaga( + action: ReduxAction<{ + value: ConditionalOutput; + dynamicFetchedValues: DynamicValues; + actionId: string; + datasourceId: string; + pluginId: string; + identifier: string; + }>, +) { + try { + const { + actionId, + datasourceId, + dynamicFetchedValues, + identifier, + pluginId, + value, + } = action.payload; + + const nextPageResponse: DynamicValues = yield call( + fetchDynamicValueSaga, + value, + Object.assign({}, dynamicFetchedValues), + actionId, + datasourceId, + pluginId, + ); + + // Set the values to the state once all values are fetched + yield put({ + type: ReduxActionTypes.FETCH_FORM_DYNAMIC_VAL_NEXT_PAGE_SUCCESS, + payload: { + actionId, + identifier, + value: nextPageResponse, + }, + }); + } catch (e) { + log.error(e); + } +} + function* fetchDynamicValueSaga( value: ConditionalOutput, dynamicFetchedValues: DynamicValues, @@ -214,15 +266,20 @@ function* fetchDynamicValueSaga( // we extract the action diff path of the param value from the dynamic binding i.e. actionConfiguration.formData.sheetUrl.data const dynamicBindingValue = getDynamicBindings(value as string) ?.jsSnippets[0]; - // we convert this action Diff path into the same format as it is stored in the dataTree i.e. config.formData.sheetUrl.data - const dataTreeActionConfigPath = - getDataTreeActionConfigPath(dynamicBindingValue); - // then we get the value of the current parameter from the evaluatedValues in the action object stored in the dataTree. - // TODOD: Find a better way to pass the workspaceId - const evaluatedValue = get( - { ...evalAction, workspaceId }, - dataTreeActionConfigPath, - ); + let evaluatedValue = value as string; + + if (dynamicBindingValue) { + // we convert this action Diff path into the same format as it is stored in the dataTree i.e. config.formData.sheetUrl.data + const dataTreeActionConfigPath = + getDataTreeActionConfigPath(dynamicBindingValue); + + // then we get the value of the current parameter from the evaluatedValues in the action object stored in the dataTree. + // TODOD: Find a better way to pass the workspaceId + evaluatedValue = get( + { ...evalAction, workspaceId }, + dataTreeActionConfigPath, + ); + } // if it exists, we store it in the substituted params object. // we check if that value is enclosed in dynamic bindings i.e the value has not been evaluated or somehow still contains a js expression @@ -315,3 +372,12 @@ export default function* formEvaluationChangeListener() { } } } + +export function* formEvaluationSagas() { + yield all([ + takeLatest( + ReduxActionTypes.FETCH_FORM_DYNAMIC_VAL_NEXT_PAGE_INIT, + fetchPaginatedDynamicValuesSaga, + ), + ]); +} diff --git a/app/client/src/selectors/formSelectors.ts b/app/client/src/selectors/formSelectors.ts index 5b85e98d20c6..538b2658cc85 100644 --- a/app/client/src/selectors/formSelectors.ts +++ b/app/client/src/selectors/formSelectors.ts @@ -2,6 +2,7 @@ import { getFormValues, isValid, getFormInitialValues } from "redux-form"; import type { AppState } from "ee/reducers"; import type { ActionData } from "ee/reducers/entityReducers/actionsReducer"; import type { + ConditionalOutput, DynamicValues, FormEvalOutput, FormEvaluationState, @@ -41,6 +42,23 @@ export const getApiName = (state: AppState, id: string) => { export const getFormEvaluationState = (state: AppState): FormEvaluationState => state.evaluations.formEvaluation; +export const getFormConfigConditionalOutput = ( + state: AppState, + // TODO: Fix this the next time the file is edited + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: any, +): ConditionalOutput => { + const baseActionId = getActionIdFromURL(); + const action = getActionByBaseId(state, baseActionId as string); + const actionId = action?.id ?? ""; + const conditionalOutput = extractConditionalOutput( + config, + state.evaluations.triggers[actionId], + ); + + return conditionalOutput; +}; + // Selector to return the fetched values of the form components, only called for components that // have the fetchOptionsDynamically option set to true export const getDynamicFetchedValues = (