From fdf77936eb4bb9be419b8b17a9b75f456a40f371 Mon Sep 17 00:00:00 2001 From: Ayush Pahwa Date: Thu, 13 Feb 2025 20:19:44 +0700 Subject: [PATCH] feat: select widget lazy loading (#38867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This PR adds capability of server side pagination to the dropdown form component. There is another PR in works to add server side search. To ensure both grouping and pagination work correctly, the dropdown control component is refactored by adding memoization and fixing some rendering issues. Fixes #38079 ## Automation /ok-to-test tags="@tag.All" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: 01f464953b487f2f066af6fe53ae2c79577b7fd3 > Cypress dashboard. > Tags: `@tag.All` > Spec: >
Thu, 13 Feb 2025 12:58:12 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit - **New Features** - Enabled dynamic pagination for form options, allowing users to load additional choices smoothly. - Enhanced dropdown controls for both single and multi-select modes with improved responsiveness and clearer grouping. - Improved form evaluation processes for a more seamless and performant user experience. - Introduced new functionality for fetching paginated dynamic values, enhancing the overall data handling experience. - Added new function to retrieve conditional output based on form configuration. - **Bug Fixes** - Improved error handling and logging for dynamic value fetching processes. --- app/client/src/actions/evaluationActions.ts | 20 + .../src/ce/constants/ReduxActionConstants.tsx | 4 + app/client/src/ce/sagas/index.tsx | 5 +- .../formControls/DropDownControl.test.tsx | 210 ++++-- .../formControls/DropDownControl.tsx | 640 +++++++++++------- .../evaluationReducers/triggerReducer.ts | 48 ++ app/client/src/sagas/FormEvaluationSaga.ts | 88 ++- app/client/src/selectors/formSelectors.ts | 18 + 8 files changed, 701 insertions(+), 332 deletions(-) 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 = (