## 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" ### 🔍 Cypress test results <!-- This is an auto-generated comment: Cypress test results --> > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: <https://github.com/appsmithorg/appsmith/actions/runs/13306719132> > Commit: 01f464953b487f2f066af6fe53ae2c79577b7fd3 > <a href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=13306719132&attempt=1" target="_blank">Cypress dashboard</a>. > Tags: `@tag.All` > Spec: > <hr>Thu, 13 Feb 2025 12:58:12 UTC <!-- end of auto-generated comment: Cypress test results --> ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
import type { ActionPattern } 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";
|
|
import * as Sentry from "@sentry/react";
|
|
import { getFormEvaluationState } from "selectors/formSelectors";
|
|
import { evalFormConfig } from "./EvaluationsSaga";
|
|
import type {
|
|
ConditionalOutput,
|
|
DynamicValues,
|
|
FormEvaluationState,
|
|
} from "reducers/evaluationReducers/formEvaluationReducer";
|
|
import { FORM_EVALUATION_REDUX_ACTIONS } from "ee/actions/evaluationActionsList";
|
|
import type { Action, ActionConfig } from "entities/Action";
|
|
import type { FormConfigType } from "components/formControls/BaseControl";
|
|
import PluginsApi from "api/PluginApi";
|
|
import type { ApiResponse } from "api/ApiResponses";
|
|
import { getAction, getPlugin } from "ee/selectors/entitiesSelector";
|
|
import { getDataTreeActionConfigPath } from "entities/Action/actionProperties";
|
|
import { getDataTree } from "selectors/dataTreeSelectors";
|
|
import { getDynamicBindings, isDynamicValue } from "utils/DynamicBindingUtils";
|
|
import get from "lodash/get";
|
|
import type { DataTree } from "entities/DataTree/dataTreeTypes";
|
|
import {
|
|
extractFetchDynamicValueFormConfigs,
|
|
extractQueueOfValuesToBeFetched,
|
|
} from "./helper";
|
|
import type { DatasourceConfiguration } from "entities/Datasource";
|
|
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;
|
|
datasourceId?: string;
|
|
pluginId?: string;
|
|
actionConfiguration?: ActionConfig;
|
|
editorConfig?: FormConfigType[];
|
|
settingConfig?: FormConfigType[];
|
|
actionDiffPath?: string;
|
|
hasRouteChanged?: boolean;
|
|
datasourceConfiguration?: DatasourceConfiguration;
|
|
}
|
|
|
|
// This value holds an array of values that needs to be dynamically fetched
|
|
// when we run form evaluations we store dynamic values to be fetched in this array
|
|
// and when evaluations are finally done, we pick the last dynamic values and call it.
|
|
|
|
function* setFormEvaluationSagaAsync(
|
|
action: ReduxAction<FormEvalActionPayload>,
|
|
// TODO: Fix this the next time the file is edited
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
): any {
|
|
try {
|
|
// Get current state from redux
|
|
const currentEvalState: FormEvaluationState = yield select(
|
|
getFormEvaluationState,
|
|
);
|
|
// Trigger the worker to compute the new eval state
|
|
const workerResponse = yield call(evalFormConfig, {
|
|
...action,
|
|
currentEvalState,
|
|
});
|
|
|
|
if (action?.type === ReduxActionTypes.INIT_FORM_EVALUATION) {
|
|
const fetchDynamicValueFormConfigs = extractFetchDynamicValueFormConfigs(
|
|
workerResponse[action?.payload?.formId],
|
|
);
|
|
|
|
yield put({
|
|
type: ReduxActionTypes.INIT_TRIGGER_VALUES,
|
|
payload: {
|
|
[action?.payload?.formId]: klonaLiteWithTelemetry(
|
|
fetchDynamicValueFormConfigs,
|
|
"FormEvaluationSaga.setFormEvaluationSagaAsync",
|
|
),
|
|
},
|
|
});
|
|
}
|
|
|
|
// RUN_FORM_EVALUATION shouldn't be called before INIT_FORM_EVALUATION has been called with
|
|
// the same `formId` else `extractQueueOfValuesToBeFetched` will be sent an undefined value.
|
|
let queueOfValuesToBeFetched;
|
|
|
|
if (
|
|
action?.type === ReduxActionTypes.RUN_FORM_EVALUATION &&
|
|
workerResponse[action?.payload?.formId]
|
|
) {
|
|
queueOfValuesToBeFetched = extractQueueOfValuesToBeFetched(
|
|
workerResponse[action?.payload?.formId],
|
|
);
|
|
}
|
|
|
|
// Update the eval state in redux only if it is not empty
|
|
if (workerResponse) {
|
|
yield put({
|
|
type: ReduxActionTypes.SET_FORM_EVALUATION,
|
|
payload: workerResponse,
|
|
});
|
|
}
|
|
|
|
// If there are any actions in the queue, run them
|
|
// Once all the actions are done, extract the actions that need to be fetched dynamically
|
|
const formId = action.payload.formId;
|
|
const evalOutput = workerResponse[formId];
|
|
|
|
if (evalOutput && typeof evalOutput === "object") {
|
|
if (queueOfValuesToBeFetched) {
|
|
yield put({
|
|
type: ReduxActionTypes.FETCH_TRIGGER_VALUES_INIT,
|
|
payload: {
|
|
formId,
|
|
values: queueOfValuesToBeFetched,
|
|
},
|
|
});
|
|
|
|
// Pass the queue to the saga to fetch the dynamic values
|
|
yield call(
|
|
fetchDynamicValuesSaga,
|
|
queueOfValuesToBeFetched,
|
|
formId,
|
|
action.payload.datasourceId ? action.payload.datasourceId : "",
|
|
action.payload.pluginId ? action.payload.pluginId : "",
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
log.error(e);
|
|
}
|
|
}
|
|
|
|
// Function to fetch the dynamic values one by one from the queue
|
|
export function* fetchDynamicValuesSaga(
|
|
queueOfValuesToBeFetched: Record<string, ConditionalOutput>,
|
|
formId: string,
|
|
datasourceId: string,
|
|
pluginId: string,
|
|
) {
|
|
for (const key of objectKeys(queueOfValuesToBeFetched)) {
|
|
queueOfValuesToBeFetched[key].fetchDynamicValues = yield call(
|
|
fetchDynamicValueSaga,
|
|
queueOfValuesToBeFetched[key],
|
|
Object.assign(
|
|
{},
|
|
queueOfValuesToBeFetched[key].fetchDynamicValues as DynamicValues,
|
|
),
|
|
formId,
|
|
datasourceId,
|
|
pluginId,
|
|
);
|
|
}
|
|
|
|
// Set the values to the state once all values are fetched
|
|
yield put({
|
|
type: ReduxActionTypes.FETCH_TRIGGER_VALUES_SUCCESS,
|
|
payload: {
|
|
formId,
|
|
values: queueOfValuesToBeFetched,
|
|
},
|
|
});
|
|
}
|
|
|
|
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,
|
|
actionId: string,
|
|
datasourceId: string,
|
|
pluginId: string,
|
|
) {
|
|
try {
|
|
const { config, evaluatedConfig } =
|
|
value.fetchDynamicValues as DynamicValues;
|
|
const { params } = evaluatedConfig;
|
|
|
|
dynamicFetchedValues.hasStarted = true;
|
|
|
|
const plugin: Plugin = yield select(getPlugin, pluginId);
|
|
|
|
let url = PluginsApi.defaultDynamicTriggerURL(datasourceId);
|
|
|
|
if (!doesPluginRequireDatasource(plugin)) {
|
|
url = PluginsApi.dynamicTriggerURLForInternalPlugins(pluginId);
|
|
}
|
|
|
|
if (
|
|
"url" in evaluatedConfig &&
|
|
!!evaluatedConfig.url &&
|
|
evaluatedConfig.url.length > 0
|
|
)
|
|
url = evaluatedConfig.url;
|
|
|
|
// Eval Action is the current action as it is stored in the dataTree
|
|
// TODO: Fix this the next time the file is edited
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let evalAction: any;
|
|
// Evaluated params is the object that will hold the evaluated values of the parameters as computed in the dataTree
|
|
let evaluatedParams;
|
|
// this is a temporary variable, used to derive the evaluated value of the current parameters before being stored in the evaluated params
|
|
let substitutedParameters = {};
|
|
|
|
const action: Action = yield select(getAction, actionId);
|
|
const { workspaceId } = action;
|
|
const dataTree: DataTree = yield select(getDataTree);
|
|
|
|
if (!!action) {
|
|
evalAction = dataTree[action.name];
|
|
}
|
|
|
|
// we use the config parameters to get the action diff path value of the parameters i.e. {{actionConfiguration.formData.sheetUrl.data}. Note that it is enclosed within dynamic bindings
|
|
if ("parameters" in config?.params && !!evalAction) {
|
|
Object.entries(config?.params.parameters).forEach(([key, value]) => {
|
|
// 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];
|
|
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
|
|
// if it is, we return an empty string since we don't want to send dynamic bindings to the server.
|
|
// if it contains a value, we send the value to the server
|
|
if (!!evaluatedValue) {
|
|
substitutedParameters = {
|
|
...substitutedParameters,
|
|
[key]: isDynamicValue(evaluatedValue) ? "" : evaluatedValue,
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
// we destructure the values back to the appropriate places.
|
|
if ("parameters" in params) {
|
|
evaluatedParams = {
|
|
...params,
|
|
parameters: {
|
|
...params.parameters,
|
|
...substitutedParameters,
|
|
},
|
|
};
|
|
} else {
|
|
evaluatedParams = {
|
|
...params,
|
|
};
|
|
}
|
|
|
|
// Call the API to fetch the dynamic values
|
|
const response: ApiResponse<{ trigger?: unknown }> = yield call(
|
|
PluginsApi.fetchDynamicFormValues,
|
|
url,
|
|
{
|
|
actionId,
|
|
datasourceId,
|
|
...evaluatedParams,
|
|
},
|
|
);
|
|
|
|
dynamicFetchedValues.isLoading = false;
|
|
|
|
if (response.responseMeta.status === 200 && "trigger" in response.data) {
|
|
dynamicFetchedValues.data = response.data.trigger;
|
|
dynamicFetchedValues.hasFetchFailed = false;
|
|
} else {
|
|
dynamicFetchedValues.hasFetchFailed = true;
|
|
dynamicFetchedValues.data = [];
|
|
}
|
|
} catch (e) {
|
|
log.error(e);
|
|
dynamicFetchedValues.hasFetchFailed = true;
|
|
dynamicFetchedValues.isLoading = false;
|
|
dynamicFetchedValues.data = [];
|
|
}
|
|
|
|
return dynamicFetchedValues;
|
|
}
|
|
|
|
function* formEvaluationChangeListenerSaga() {
|
|
const buffer = buffers.fixed();
|
|
const formEvalChannel: ActionPattern<ReduxAction<FormEvalActionPayload>> =
|
|
// TODO: Fix this the next time the file is edited
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
yield actionChannel(FORM_EVALUATION_REDUX_ACTIONS, buffer as any);
|
|
|
|
while (true) {
|
|
if (buffer.isEmpty()) {
|
|
yield put({
|
|
type: ReduxActionTypes.FORM_EVALUATION_EMPTY_BUFFER,
|
|
});
|
|
}
|
|
|
|
const action: ReduxAction<FormEvalActionPayload> =
|
|
yield take(formEvalChannel);
|
|
|
|
yield call(setFormEvaluationSagaAsync, action);
|
|
}
|
|
}
|
|
|
|
export default function* formEvaluationChangeListener() {
|
|
yield take(ReduxActionTypes.START_EVALUATION);
|
|
|
|
while (true) {
|
|
try {
|
|
yield call(formEvaluationChangeListenerSaga);
|
|
} catch (e) {
|
|
log.error(e);
|
|
Sentry.captureException(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function* formEvaluationSagas() {
|
|
yield all([
|
|
takeLatest(
|
|
ReduxActionTypes.FETCH_FORM_DYNAMIC_VAL_NEXT_PAGE_INIT,
|
|
fetchPaginatedDynamicValuesSaga,
|
|
),
|
|
]);
|
|
}
|