chore: Update integration modal (#39976)

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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
- Action creators now support optional callbacks and redirection
controls to enhance query operations.
- UI tabs can now be configured to remain persistently mounted, ensuring
a smoother editing experience.
- New functionality to add datasources to the existing list without
complex tracking.

- **Bug Fixes**
- Simplified logic for determining the saving state of datasources,
improving UI responsiveness.

- **Refactor**
- Datasource creation and saving state handling have been streamlined
for improved reliability.
- Enhanced management of action payloads and state updates enables more
robust processing of user operations.
- Improved type safety and clarity in action handling within sagas for
better maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/14167301395>
> Commit: be05d6bda56aff96421faf435804f56707a597ba
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=14167301395&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Datasource`
> Spec:
> <hr>Mon, 31 Mar 2025 09:14:22 UTC
<!-- end of auto-generated comment: Cypress test results  -->
This commit is contained in:
Pawan Kumar 2025-03-31 15:43:18 +05:30 committed by GitHub
parent a25f5f9f15
commit ae80556758
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 162 additions and 90 deletions

View File

@ -396,6 +396,17 @@ export const setUnconfiguredDatasourcesDuringImport = (
payload,
});
// this actions concats the newly created datasource to the datasource list
// it's not required if createDatasourceSuccess is used, that also concats the new datasource to the datasource list
// The main difference between this and createDatasourceSuccess is that this action only adds the datasource to the datasource list
// and is not tracked by any saga, whereas createDatasourceSuccess is tracked by many sagas with different logic.
export const updateDatasoruceRefs = (datasource: Datasource) => {
return {
type: ReduxActionTypes.UPDATE_DATASOURCE_REFS,
payload: datasource,
};
};
export const removeTempDatasource = () => {
return {
type: ReduxActionTypes.REMOVE_TEMP_DATASOURCE_SUCCESS,

View File

@ -21,20 +21,30 @@ import type { GenerateDestinationIdInfoReturnType } from "ee/sagas/helpers";
import type { Span } from "instrumentation/types";
import type { EvaluationReduxAction } from "./EvaluationReduxActionTypes";
export const createActionRequest = (payload: Partial<Action>) => {
export const createActionRequest = (
payload: Partial<Action>,
onSuccess?: ReduxAction<unknown>,
) => {
return {
type: ReduxActionTypes.CREATE_ACTION_REQUEST,
payload,
onSuccess,
};
};
export const createActionInit = (payload: Partial<Action>) => {
export const createActionInit = (
payload: Partial<Action>,
onSuccess?: ReduxAction<unknown>,
) => {
return {
type: ReduxActionTypes.CREATE_ACTION_INIT,
payload,
onSuccess,
};
};
export const createActionSuccess = (payload: Action) => {
export const createActionSuccess = (
payload: Action & { shouldRedirectToQueryEditor?: boolean },
) => {
return {
type: ReduxActionTypes.CREATE_ACTION_SUCCESS,
payload,

View File

@ -52,6 +52,7 @@ import {
changeDatasource,
createDatasourceSuccess,
createTempDatasourceFromForm,
deleteTempDSFromDraft,
fetchDatasourceStructure,
removeTempDatasource,
resetDefaultKeyValPairFlag,
@ -184,6 +185,7 @@ import type { ActionParentEntityTypeInterface } from "ee/entities/Engine/actionH
import { getCurrentModuleId } from "ee/selectors/modulesSelector";
import type { ApplicationPayload } from "entities/Application";
import { openGeneratePageModalWithSelectedDS } from "../../utils/GeneratePageUtils";
import { createDatasourceAPIPayloadFromAction } from "ee/sagas/helpers";
export function* fetchDatasourcesSaga(
action: ReduxAction<
@ -1189,9 +1191,8 @@ export function* createDatasourceFromFormSaga(
checkAndGetPluginFormConfigsSaga,
actionPayload.payload.pluginId,
);
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formConfig: Record<string, any>[] = yield select(
const formConfig: ReturnType<typeof getPluginForm> = yield select(
getPluginForm,
actionPayload.payload.pluginId,
);
@ -1199,35 +1200,16 @@ export function* createDatasourceFromFormSaga(
getCurrentEditingEnvironmentId,
);
const initialValues: unknown = yield call(
const initialValues: ReturnType<typeof getConfigInitialValues> = yield call(
getConfigInitialValues,
formConfig,
);
let datasourceStoragePayload =
actionPayload.payload.datasourceStorages[currentEnvironment];
datasourceStoragePayload = merge(initialValues, datasourceStoragePayload);
// in the datasourcestorages, we only need one key, the currentEnvironment
// we need to remove any other keys present
const datasourceStorages = {
[currentEnvironment]: datasourceStoragePayload,
};
const payload = omit(
{
...actionPayload.payload,
datasourceStorages,
},
["id", "new", "type", "datasourceConfiguration"],
);
if (payload.datasourceStorages)
datasourceStoragePayload.isConfigured = true;
// remove datasourceId from payload if it is equal to TEMP_DATASOURCE_ID
if (datasourceStoragePayload.datasourceId === TEMP_DATASOURCE_ID)
datasourceStoragePayload.datasourceId = "";
const payload = createDatasourceAPIPayloadFromAction({
actionPayload: actionPayload.payload,
currentEnvId: currentEnvironment,
initialValues,
});
const response: ApiResponse<Datasource> =
yield DatasourcesApi.createDatasource({
@ -1260,14 +1242,11 @@ export function* createDatasourceFromFormSaga(
isFormValid: isFormValid,
editedFields: formDiffPaths,
connectionMethod: getConnectionMethod(
datasourceStoragePayload,
payload.datasourceStorages?.[currentEnvironment] as DatasourceStorage,
plugin?.packageName,
),
});
yield put({
type: ReduxActionTypes.UPDATE_DATASOURCE_REFS,
payload: response.data,
});
yield put(
createDatasourceSuccess(
response.data,
@ -1307,12 +1286,7 @@ export function* createDatasourceFromFormSaga(
yield put(actionPayload.onSuccess);
}
yield put({
type: ReduxActionTypes.DELETE_DATASOURCE_DRAFT,
payload: {
id: TEMP_DATASOURCE_ID,
},
});
yield put(deleteTempDSFromDraft());
// for all datasources, except for REST and GraphQL, need to delete temp datasource data
// as soon as possible, for REST and GraphQL it is getting deleted in APIPaneSagas.ts
@ -1323,6 +1297,8 @@ export function* createDatasourceFromFormSaga(
// updating form initial values to latest data, so that next time when form is opened
// isDirty will use updated initial values data to compare actual values with
yield put(initialize(DATASOURCE_DB_FORM, response.data));
return response.data;
}
} catch (error) {
yield put({

View File

@ -1,9 +1,15 @@
import omit from "lodash/omit";
import merge from "lodash/merge";
import type { CreateNewActionKeyInterface } from "ee/entities/Engine/actionHelpers";
import { CreateNewActionKey } from "ee/entities/Engine/actionHelpers";
import type { DeleteErrorLogPayload } from "actions/debuggerActions";
import type { Action } from "entities/Action";
import type { Log } from "entities/AppsmithConsole";
import type { EvaluationError } from "utils/DynamicBindingUtils";
import { TEMP_DATASOURCE_ID } from "constants/Datasource";
import type { getConfigInitialValues } from "components/formControls/utils";
import type { CreateDatasourceConfig } from "ee/api/DatasourcesApi";
import type { Datasource } from "entities/Datasource";
export interface ResolveParentEntityMetadataReturnType {
parentEntityId?: string;
@ -52,3 +58,41 @@ export function* transformDeleteErrorLogsSaga(payload: DeleteErrorLogPayload) {
export function* transformTriggerEvalErrors(errors: EvaluationError[]) {
return errors;
}
interface CreateDatasourcePayloadFromActionParams {
currentEnvId: string;
actionPayload: Datasource | CreateDatasourceConfig;
initialValues: ReturnType<typeof getConfigInitialValues>;
}
export const createDatasourceAPIPayloadFromAction = (
props: CreateDatasourcePayloadFromActionParams,
) => {
const { actionPayload, currentEnvId, initialValues } = props;
let datasourceStoragePayload = actionPayload.datasourceStorages[currentEnvId];
datasourceStoragePayload = merge(initialValues, datasourceStoragePayload);
// in the datasourcestorages, we only need one key, the currentEnvironment
// we need to remove any other keys present
const datasourceStorages = {
[currentEnvId]: datasourceStoragePayload,
};
const payload = omit(
{
...actionPayload,
datasourceStorages,
},
["id", "new", "type", "datasourceConfiguration"],
);
if (payload.datasourceStorages) datasourceStoragePayload.isConfigured = true;
// remove datasourceId from payload if it is equal to TEMP_DATASOURCE_ID
if (datasourceStoragePayload.datasourceId === TEMP_DATASOURCE_ID)
datasourceStoragePayload.datasourceId = "";
return payload;
};

View File

@ -35,6 +35,7 @@ export interface BottomTab {
title: string;
count?: number;
panelComponent: React.ReactNode;
forceMount?: true;
}
interface EntityBottomTabsProps {
@ -93,7 +94,11 @@ function EntityBottomTabs(
})}
</TabsListWrapper>
{props.tabs.map((tab) => (
<TabPanelWrapper key={tab.key} value={tab.key}>
<TabPanelWrapper
forceMount={tab.forceMount}
key={tab.key}
value={tab.key}
>
{tab.panelComponent}
</TabPanelWrapper>
))}

View File

@ -1199,7 +1199,7 @@ const mapStateToProps = (state: AppState, props: any): ReduxStateProps => {
formName,
isInsideReconnectModal: props.isInsideReconnectModal ?? false,
pluginId,
isSaving: datasources.loading && datasources.loadingPluginId === pluginId,
isSaving: datasources.loading,
isDeleting: !!(datasource as Datasource)?.isDeleting,
isPluginAuthorized: !!isPluginAuthorized,
isTesting: datasources.isTesting,

View File

@ -858,7 +858,7 @@ const mapStateToProps = (state: AppState, props: any) => {
datasourceButtonConfiguration,
datasourceId,
documentationLink: documentationLinks[pluginId],
isSaving: datasources.loading && datasources.loadingPluginId === pluginId,
isSaving: datasources.loading,
isDeleting: !!datasource?.isDeleting,
isTesting: datasources.isTesting,
formData: formData,

View File

@ -19,8 +19,6 @@ import { assign } from "lodash";
export interface DatasourceDataState {
list: Datasource[];
loading: boolean;
// this prop tells which plugin is being loaded. Mainly used on the save button of datasource editor page.
loadingPluginId: string | null;
loadingTokenForDatasourceId: string | null;
isTesting: boolean;
isListing: boolean; // fetching unconfigured datasource list
@ -51,7 +49,6 @@ export interface DatasourceDataState {
const initialState: DatasourceDataState = {
list: [],
loading: false,
loadingPluginId: null,
loadingTokenForDatasourceId: null,
isTesting: false,
isListing: false,
@ -121,14 +118,10 @@ const datasourceReducer = createReducer(initialState, {
[ReduxActionTypes.FETCH_DATASOURCES_INIT]: (state: DatasourceDataState) => {
return { ...state, loading: true };
},
[ReduxActionTypes.CREATE_DATASOURCE_INIT]: (
state: DatasourceDataState,
action: ReduxAction<{ pluginId: string }>,
) => {
[ReduxActionTypes.CREATE_DATASOURCE_INIT]: (state: DatasourceDataState) => {
return {
...state,
loading: true,
loadingPluginId: action.payload.pluginId,
};
},
[ReduxActionTypes.CREATE_DATASOURCE_FROM_FORM_INIT]: (
@ -143,7 +136,6 @@ const datasourceReducer = createReducer(initialState, {
return {
...state,
loading: !!action.payload.loading,
loadingPluginId: null,
};
},
[ReduxActionTypes.UPDATE_DATASOURCE_INIT]: (state: DatasourceDataState) => {
@ -342,6 +334,15 @@ const datasourceReducer = createReducer(initialState, {
}),
};
},
[ReduxActionTypes.UPDATE_DATASOURCE_REFS]: (
state: DatasourceDataState,
action: ReduxAction<Datasource>,
) => {
return {
...state,
list: state.list.concat(action.payload),
};
},
[ReduxActionTypes.CREATE_DATASOURCE_SUCCESS]: (
state: DatasourceDataState,
action: ReduxAction<Datasource>,

View File

@ -51,7 +51,10 @@ import {
ERROR_ACTION_MOVE_FAIL,
ERROR_ACTION_RENAME_FAIL,
} from "ee/constants/messages";
import type { ReduxAction } from "actions/ReduxActionTypes";
import type {
ReduxAction,
ReduxActionWithCallbacks,
} from "actions/ReduxActionTypes";
import {
ReduxActionErrorTypes,
ReduxActionTypes,
@ -271,24 +274,31 @@ export function* getPluginActionDefaultValues(pluginId: string) {
return initialValues;
}
type CreateActionRequestSagaAction = Partial<Action> & {
eventData?: unknown;
pluginId: string;
shouldRedirectToQueryEditor?: boolean;
};
/**
* This saga prepares the action request i.e it helps generating a
* new name of an action. This is to reduce any dependency on name generation
* on the caller of this saga.
*/
export function* createActionRequestSaga(
actionPayload: ReduxAction<
Partial<Action> & { eventData?: unknown; pluginId: string }
action: ReduxActionWithCallbacks<
CreateActionRequestSagaAction,
unknown,
unknown
>,
) {
const payload = { ...actionPayload.payload };
const payload = { ...action.payload };
const pluginId =
actionPayload.payload.pluginId ||
actionPayload.payload.datasource?.pluginId;
action.payload.pluginId || action.payload.datasource?.pluginId;
if (!actionPayload.payload.name) {
if (!action.payload.name) {
const { parentEntityId, parentEntityKey } = resolveParentEntityMetadata(
actionPayload.payload,
action.payload,
);
if (!parentEntityId || !parentEntityKey) return;
@ -314,20 +324,22 @@ export function* createActionRequestSaga(
});
}
yield put(createActionInit(payload));
yield put(createActionInit(payload, action.onSuccess));
}
type CreateActionSagaPayload = Partial<Action> & {
eventData: unknown;
pluginId: string;
shouldRedirectToQueryEditor?: boolean;
};
export function* createActionSaga(
actionPayload: ReduxAction<
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Partial<Action> & { eventData: any; pluginId: string }
>,
action: ReduxActionWithCallbacks<CreateActionSagaPayload, unknown, unknown>,
) {
try {
// Indicates that source of action creation is self
actionPayload.payload.source = ActionCreationSourceTypeEnum.SELF;
const payload = actionPayload.payload;
action.payload.source = ActionCreationSourceTypeEnum.SELF;
const payload = action.payload;
const response: ApiResponse<ActionCreateUpdateResponse> =
yield ActionAPI.createAction(payload);
@ -344,7 +356,7 @@ export function* createActionSaga(
// @ts-expect-error: name does not exists on type ActionCreateUpdateResponse
actionName: response.data.name,
pageName: pageName,
...actionPayload.payload.eventData,
...action.payload.eventData,
});
AppsmithConsole.info({
@ -360,8 +372,17 @@ export function* createActionSaga(
const newAction = response.data;
// @ts-expect-error: type mismatch ActionCreateUpdateResponse vs Action
yield put(createActionSuccess(newAction));
yield put(
createActionSuccess({
...(newAction as unknown as Action),
shouldRedirectToQueryEditor:
action.payload.shouldRedirectToQueryEditor,
}),
);
if (action.onSuccess) {
yield put(action.onSuccess);
}
// we fork to prevent the call from blocking
yield fork(fetchActionDatasourceStructure, newAction);
@ -369,7 +390,7 @@ export function* createActionSaga(
} catch (error) {
yield put({
type: ReduxActionErrorTypes.CREATE_ACTION_ERROR,
payload: actionPayload.payload,
payload: action.payload,
});
}
}

View File

@ -392,14 +392,17 @@ function* formValueChangeSaga(
}
}
function* handleQueryCreatedSaga(actionPayload: ReduxAction<QueryAction>) {
function* handleQueryCreatedSaga(
action: ReduxAction<QueryAction & { shouldRedirectToQueryEditor?: boolean }>,
) {
const {
actionConfiguration,
baseId: baseActionId,
pageId,
pluginId,
pluginType,
} = actionPayload.payload;
shouldRedirectToQueryEditor = true,
} = action.payload;
if (
![
@ -424,17 +427,19 @@ function* handleQueryCreatedSaga(actionPayload: ReduxAction<QueryAction>) {
const basePageId: string = yield select(convertToBasePageIdSelector, pageId);
history.replace(
queryEditorIdURL({
basePageId,
baseQueryId: baseActionId,
params: {
editName: "true",
showTemplate,
from: "datasources",
},
}),
);
if (shouldRedirectToQueryEditor) {
history.replace(
queryEditorIdURL({
basePageId,
baseQueryId: baseActionId,
params: {
editName: "true",
showTemplate,
from: "datasources",
},
}),
);
}
}
function* handleDatasourceCreatedSaga(

View File

@ -13,7 +13,6 @@ import type { ActionData } from "ee/reducers/entityReducers/actionsReducer";
import type { Page } from "entities/Page";
import { getActions, getPlugins } from "ee/selectors/entitiesSelector";
import type { Plugin } from "entities/Plugin";
import type { DragDetails } from "reducers/uiReducers/dragResizeReducer";
import type { DataTreeForActionCreator } from "components/editorComponents/ActionCreator/types";
import type { MetaWidgetsReduxState } from "reducers/entityReducers/metaWidgetsReducer";
@ -248,7 +247,7 @@ export const getIsNewWidgetBeingDragged = (state: AppState) => {
if (!isDragging) return false;
const dragDetails: DragDetails = getDragDetails(state);
const dragDetails = getDragDetails(state);
const { dragGroupActualParent: dragParent, newWidget } = dragDetails;
return !!newWidget && !dragParent;
@ -258,7 +257,7 @@ export const isCurrentCanvasDragging = createSelector(
(state: AppState) => state.ui.widgetDragResize.isDragging,
getDragDetails,
(state: AppState, canvasId: string) => canvasId,
(isDragging: boolean, dragDetails: DragDetails, canvasId: string) => {
(isDragging: boolean, dragDetails, canvasId: string) => {
return dragDetails?.draggedOn === canvasId && isDragging;
},
);