From f3eca9b234b89783e9e41faa160b51fe17b2685a Mon Sep 17 00:00:00 2001 From: Ayush Pahwa Date: Mon, 24 Feb 2025 14:48:34 +0530 Subject: [PATCH] feat: enable post run actions for plugin queries (#39325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This PR enables the response pane of DB queries in appsmith to show a form container once the action is executed and the server returns with a `postRunAction` config. The contents to be shown inside the container are controlled by the `postRunAction` config. The config has a unique identifier which should be registered in the client. Based on this registry, client can render the required form inside the container. If no form is found, the container is not shown. Fixes #39402 ## Automation /test sanity ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: 75b13354c6147717360831fff06b60063d14c3ed > Cypress dashboard. > Tags: `@tag.Sanity` > Spec: >
Mon, 24 Feb 2025 09:13:40 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit - **New Features** - Enhanced action response experience: The application now conditionally displays an interactive post-run action interface. When additional configuration is detected after an action execution, a streamlined modal form appears to guide you through follow-up tasks, making post-action interactions more intuitive and visible. This update offers a smoother, clearer workflow where supplementary steps are seamlessly integrated into your experience. - **Tests** - Added new test cases for the `Response` component to validate the rendering logic based on post-run actions. - Introduced tests for utility functions related to post-run actions to ensure correct behavior and output. --- .../components/Response/Response.test.tsx | 156 ++++++++++++++++++ .../components/Response/Response.tsx | 10 ++ .../components/PostActionRunContainer.tsx | 43 +++++ .../Response/utils/postRunActionsUtil.test.ts | 54 ++++++ .../Response/utils/postRunActionsUtil.ts | 27 +++ app/client/src/api/ActionAPI.tsx | 2 + app/client/src/api/types.ts | 6 + .../PostActionRunComponents/index.ts | 6 + .../PostActionRunComponents/types.ts | 4 + .../PostActionRunComponents/index.ts | 1 + .../PostActionRunComponents/types.ts | 1 + .../sagas/ActionExecution/PluginActionSaga.ts | 36 +--- .../PluginActionSagaUtils.test.ts | 143 +++++++++++++++- .../ActionExecution/PluginActionSagaUtils.ts | 35 +++- 14 files changed, 488 insertions(+), 36 deletions(-) create mode 100644 app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/components/PostActionRunContainer.tsx create mode 100644 app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/utils/postRunActionsUtil.test.ts create mode 100644 app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/utils/postRunActionsUtil.ts create mode 100644 app/client/src/ce/components/PostActionRunComponents/index.ts create mode 100644 app/client/src/ce/components/PostActionRunComponents/types.ts create mode 100644 app/client/src/ee/components/PostActionRunComponents/index.ts create mode 100644 app/client/src/ee/components/PostActionRunComponents/types.ts diff --git a/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/Response.test.tsx b/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/Response.test.tsx index 1c9ed20e28..2181237e22 100644 --- a/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/Response.test.tsx +++ b/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/Response.test.tsx @@ -28,6 +28,13 @@ const defaultProps = { responseTabHeight: 200, }; +// mock the postrunactionmap +jest.mock("ee/components/PostActionRunComponents", () => ({ + PostRunActionComponentMap: { + test_modal: () =>
, + }, +})); + const storeData = getIDETestState({}); describe("Response", () => { @@ -36,6 +43,7 @@ describe("Response", () => { beforeEach(() => { store = mockStore(storeData); + jest.clearAllMocks(); }); /** Test use prepared statement warning **/ @@ -208,4 +216,152 @@ describe("Response", () => { container.querySelector("[data-testid='t--prepared-statement-warning']"), ).toBeNull(); }); + + it("6. Should show post run action container when post run action exists", () => { + const postRunAction = { + type: "FORM", + name: "test_modal", + }; + const actionResponse = { + isExecutionSuccess: true, + body: [{ key: "value" }], + postRunAction, + dataTypes: [{ dataType: "JSON" }], + responseDisplayFormat: "JSON", + } as unknown as ActionResponse; + + store = mockStore({ + ...storeData, + entities: { + ...storeData.entities, + actions: [ + { + config: { + id: "test-action-id", + name: "Test Action", + }, + isLoading: false, + data: actionResponse, + }, + ], + }, + }); + + const props = { + ...defaultProps, + actionResponse, + currentContentType: "JSON", + }; + + const { getByTestId } = render( + + + + + + + , + ); + + // Check if post run action container is showing + expect(getByTestId("t--post-run-action-container")).not.toBeNull(); + expect(getByTestId("t--post-run-action-test-modal-form")).not.toBeNull(); + }); + + it("7. Should not show post run action container when post run action doesn't exist", () => { + const actionResponse = { + isExecutionSuccess: true, + body: [{ key: "value" }], + dataTypes: [{ dataType: "JSON" }], + responseDisplayFormat: "JSON", + } as unknown as ActionResponse; + + store = mockStore({ + ...storeData, + entities: { + ...storeData.entities, + actions: [ + { + config: { + id: "test-action-id", + name: "Test Action", + }, + isLoading: false, + data: actionResponse, + }, + ], + }, + }); + + const props = { + ...defaultProps, + actionResponse, + }; + + const { container } = render( + + + + + + + , + ); + + // Check if post run action container is not showing + expect( + container.querySelector("[data-testid='t--post-run-action-container']"), + ).toBeNull(); + }); + + it("8. Should not show post run action container when correct mapping is not found", () => { + const postRunAction = { + type: "FORM", + name: "invalid_modal", + }; + const actionResponse = { + isExecutionSuccess: true, + body: [{ key: "value" }], + postRunAction, + dataTypes: [{ dataType: "JSON" }], + responseDisplayFormat: "JSON", + } as unknown as ActionResponse; + + store = mockStore({ + ...storeData, + entities: { + ...storeData.entities, + actions: [ + { + config: { + id: "test-action-id", + name: "Test Action", + }, + isLoading: false, + data: actionResponse, + }, + ], + }, + }); + + const props = { + ...defaultProps, + actionResponse, + }; + + const { container } = render( + + + + + + + , + ); + + // Check if post run action container is not showing + expect( + container.querySelector("[data-testid='t--post-run-action-container']"), + ).toBeNull(); + }); }); diff --git a/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/Response.tsx b/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/Response.tsx index c14056cf2b..0ff01ee580 100644 --- a/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/Response.tsx +++ b/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/Response.tsx @@ -32,6 +32,8 @@ import { RESPONSE_TABLE_HEIGHT_OFFSET } from "./constants"; import * as Styled from "./styles"; import { checkForPreparedStatement, parseActionResponse } from "./utils"; import ActionExecutionInProgressView from "./components/ActionExecutionInProgressView"; +import { checkForPostRunAction } from "./utils/postRunActionsUtil"; +import PostActionRunContainer from "./components/PostActionRunContainer"; interface ResponseProps { action: Action; @@ -126,6 +128,9 @@ export function Response(props: ResponseProps) { checkForPreparedStatement(action) && errorMessage, ); + const showPostRunAction = + actionResponse && checkForPostRunAction(actionResponse?.postRunAction); + const actionSource: SourceEntity = useMemo( () => ({ type: ENTITY_TYPE.ACTION, @@ -267,6 +272,11 @@ export function Response(props: ResponseProps) { } /> + {showPostRunAction && ( + + )} + + + ); +} diff --git a/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/utils/postRunActionsUtil.test.ts b/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/utils/postRunActionsUtil.test.ts new file mode 100644 index 0000000000..92c51c1f98 --- /dev/null +++ b/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/utils/postRunActionsUtil.test.ts @@ -0,0 +1,54 @@ +import { + checkForPostRunAction, + getPostRunActionName, +} from "./postRunActionsUtil"; +import type { PostActionRunConfig } from "api/types"; + +describe("checkForPostRunAction", () => { + it("should return true for valid post run action", () => { + const validAction: PostActionRunConfig = { + type: "FORM", + name: "some_name", + }; + + expect(checkForPostRunAction(validAction)).toBe(true); + }); + + it("should return false for undefined input", () => { + expect(checkForPostRunAction(undefined)).toBe(false); + }); + + it("should return false for input without type property", () => { + const invalidAction = { + name: "some_name", + }; + + expect(checkForPostRunAction(invalidAction as PostActionRunConfig)).toBe( + false, + ); + }); +}); + +describe("getPostRunActionName", () => { + it("should return name for valid post run action", () => { + const validAction: PostActionRunConfig = { + type: "FORM", + name: "test_action", + }; + + expect(getPostRunActionName(validAction)).toBe("test_action"); + }); + + it("should return empty string for undefined input", () => { + expect(getPostRunActionName(undefined)).toBe(""); + }); + + it("should return empty string for action without name", () => { + const actionWithoutName: PostActionRunConfig = { + type: "FORM", + name: "", + }; + + expect(getPostRunActionName(actionWithoutName)).toBe(""); + }); +}); diff --git a/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/utils/postRunActionsUtil.ts b/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/utils/postRunActionsUtil.ts new file mode 100644 index 0000000000..e815a1c405 --- /dev/null +++ b/app/client/src/PluginActionEditor/components/PluginActionResponse/components/Response/utils/postRunActionsUtil.ts @@ -0,0 +1,27 @@ +import type { PostActionRunConfig } from "api/types"; + +export function checkForPostRunAction(postRunAction?: PostActionRunConfig) { + if ( + postRunAction && + typeof postRunAction === "object" && + "type" in postRunAction + ) { + return true; + } + + return false; +} + +export function getPostRunActionName(postRunAction?: PostActionRunConfig) { + if (!postRunAction) { + return ""; + } + + const { name } = postRunAction; + + if (!name) { + return ""; + } + + return name; +} diff --git a/app/client/src/api/ActionAPI.tsx b/app/client/src/api/ActionAPI.tsx index d0957fb733..1cd3c0f14d 100644 --- a/app/client/src/api/ActionAPI.tsx +++ b/app/client/src/api/ActionAPI.tsx @@ -9,6 +9,7 @@ import type { Action, ActionViewMode } from "entities/Action"; import type { APIRequest } from "constants/AppsmithActionConstants/ActionConstants"; import type { WidgetType } from "constants/WidgetConstants"; import type { ActionParentEntityTypeInterface } from "ee/entities/Engine/actionHelpers"; +import type { PostActionRunConfig } from "./types"; export interface Property { key: string; @@ -86,6 +87,7 @@ export interface ActionResponse { readableError?: string; responseDisplayFormat?: string; pluginErrorDetails?: PluginErrorDetails; + postRunAction?: PostActionRunConfig; } //This contains the error details from the plugin that is sent to the client in the response diff --git a/app/client/src/api/types.ts b/app/client/src/api/types.ts index 4ce60997ef..88fdf76c06 100644 --- a/app/client/src/api/types.ts +++ b/app/client/src/api/types.ts @@ -22,3 +22,9 @@ export type AxiosResponseData = AxiosResponse>["data"]; export type ErrorHandler = ( error: AxiosError, ) => Promise; + +export interface PostActionRunConfig { + type: "FORM"; + name: string; + config?: Record; +} diff --git a/app/client/src/ce/components/PostActionRunComponents/index.ts b/app/client/src/ce/components/PostActionRunComponents/index.ts new file mode 100644 index 0000000000..a4776490e1 --- /dev/null +++ b/app/client/src/ce/components/PostActionRunComponents/index.ts @@ -0,0 +1,6 @@ +import type { PostRunActionNamesInterface } from "./types"; + +export const PostRunActionComponentMap: Record< + PostRunActionNamesInterface, + React.ElementType +> = {}; diff --git a/app/client/src/ce/components/PostActionRunComponents/types.ts b/app/client/src/ce/components/PostActionRunComponents/types.ts new file mode 100644 index 0000000000..1ccc539068 --- /dev/null +++ b/app/client/src/ce/components/PostActionRunComponents/types.ts @@ -0,0 +1,4 @@ +export const PostRunActionNames = {} as const; + +export type PostRunActionNamesInterface = + (typeof PostRunActionNames)[keyof typeof PostRunActionNames]; diff --git a/app/client/src/ee/components/PostActionRunComponents/index.ts b/app/client/src/ee/components/PostActionRunComponents/index.ts new file mode 100644 index 0000000000..d9b40cf421 --- /dev/null +++ b/app/client/src/ee/components/PostActionRunComponents/index.ts @@ -0,0 +1 @@ +export * from "ce/components/PostActionRunComponents"; diff --git a/app/client/src/ee/components/PostActionRunComponents/types.ts b/app/client/src/ee/components/PostActionRunComponents/types.ts new file mode 100644 index 0000000000..73587152cc --- /dev/null +++ b/app/client/src/ee/components/PostActionRunComponents/types.ts @@ -0,0 +1 @@ +export * from "ce/components/PostActionRunComponents/types"; diff --git a/app/client/src/sagas/ActionExecution/PluginActionSaga.ts b/app/client/src/sagas/ActionExecution/PluginActionSaga.ts index 3096c7b1fe..c21366784f 100644 --- a/app/client/src/sagas/ActionExecution/PluginActionSaga.ts +++ b/app/client/src/sagas/ActionExecution/PluginActionSaga.ts @@ -81,10 +81,7 @@ import type { LayoutOnLoadActionErrors, PageAction, } from "constants/AppsmithActionConstants/ActionConstants"; -import { - EventType, - RESP_HEADER_DATATYPE, -} from "constants/AppsmithActionConstants/ActionConstants"; +import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; import { getCurrentApplicationId, getCurrentBasePageId, @@ -172,10 +169,7 @@ import { selectGitConnectModalOpen, selectGitOpsModalOpen, } from "selectors/gitModSelectors"; - -enum ActionResponseDataTypes { - BINARY = "BINARY", -} +import { createActionExecutionResponse } from "./PluginActionSagaUtils"; interface FilePickerInstumentationObject { numberOfFiles: number; @@ -208,32 +202,6 @@ export const getActionTimeout = ( return undefined; }; -const createActionExecutionResponse = ( - response: ActionExecutionResponse, -): ActionResponse => { - const payload = response.data; - - if (payload.statusCode === "200 OK" && payload.hasOwnProperty("headers")) { - const respHeaders = payload.headers; - - if ( - respHeaders.hasOwnProperty(RESP_HEADER_DATATYPE) && - respHeaders[RESP_HEADER_DATATYPE].length > 0 && - respHeaders[RESP_HEADER_DATATYPE][0] === ActionResponseDataTypes.BINARY && - getType(payload.body) === Types.STRING - ) { - // Decoding from base64 to handle the binary files because direct - // conversion of binary files to string causes corruption in the final output - // this is to only handle the download of binary files - payload.body = atob(payload.body as string); - } - } - - return { - ...payload, - ...response.clientMeta, - }; -}; const isErrorResponse = (response: ActionExecutionResponse) => { return !response.data.isExecutionSuccess; }; diff --git a/app/client/src/sagas/ActionExecution/PluginActionSagaUtils.test.ts b/app/client/src/sagas/ActionExecution/PluginActionSagaUtils.test.ts index 876bb97ef9..a86363f5a6 100644 --- a/app/client/src/sagas/ActionExecution/PluginActionSagaUtils.test.ts +++ b/app/client/src/sagas/ActionExecution/PluginActionSagaUtils.test.ts @@ -1,5 +1,11 @@ import { put } from "redux-saga/effects"; -import { setDefaultActionDisplayFormat } from "./PluginActionSagaUtils"; +import { + RESP_HEADER_DATATYPE, + setDefaultActionDisplayFormat, +} from "./PluginActionSagaUtils"; +import { createActionExecutionResponse } from "./PluginActionSagaUtils"; +import { ActionResponseDataTypes } from "./PluginActionSagaUtils"; +import { HTTP_METHOD } from "PluginActionEditor/constants/CommonApiConstants"; const actionid = "test-id"; @@ -99,3 +105,138 @@ describe("PluginActionSagasUtils", () => { expect(generator.next().value).toBeUndefined(); }); }); + +describe("createActionExecutionResponse", () => { + it("should handle regular response without binary data", () => { + const response = { + data: { + body: { key: "value" }, + statusCode: "200 OK", + headers: {}, + isExecutionSuccess: true, + request: { + url: "https://example.com", + headers: {}, + body: {}, + httpMethod: HTTP_METHOD.GET, + }, + dataTypes: [{ dataType: "JSON" }], + }, + clientMeta: { + duration: "100", + size: "50", + }, + responseMeta: { + status: 200, + success: true, + }, + }; + + const result = createActionExecutionResponse(response); + + expect(result).toEqual({ + ...response.data, + ...response.clientMeta, + }); + }); + + it("should decode base64 binary response", () => { + const rawData = "test binary data"; + const base64String = btoa(rawData); + const response = { + data: { + body: base64String, + statusCode: "200 OK", + headers: { + [RESP_HEADER_DATATYPE]: [ActionResponseDataTypes.BINARY], + }, + isExecutionSuccess: true, + request: { + url: "https://example.com", + headers: {}, + body: {}, + httpMethod: HTTP_METHOD.GET, + }, + dataTypes: [{ dataType: "JSON" }], + }, + clientMeta: { + duration: "100", + size: "50", + }, + responseMeta: { + status: 200, + success: true, + }, + }; + + const result = createActionExecutionResponse(response); + + expect(result.body).toBe(rawData); + }); + + it("should not decode response if status code is not 200 OK", () => { + const base64String = btoa("test binary data"); + const response = { + data: { + body: base64String, + statusCode: "404 Not Found", + headers: { + [RESP_HEADER_DATATYPE]: [ActionResponseDataTypes.BINARY], + }, + isExecutionSuccess: true, + request: { + url: "https://example.com", + headers: {}, + body: {}, + httpMethod: HTTP_METHOD.GET, + }, + dataTypes: [{ dataType: "JSON" }], + }, + clientMeta: { + duration: "100", + size: "50", + }, + responseMeta: { + status: 200, + success: true, + }, + }; + + const result = createActionExecutionResponse(response); + + expect(result.body).toBe(base64String); + }); + + it("should not decode response if header type is not BINARY", () => { + const base64String = btoa("test binary data"); + const response = { + data: { + body: base64String, + statusCode: "200 OK", + headers: { + [RESP_HEADER_DATATYPE]: ["JSON"], + }, + isExecutionSuccess: true, + request: { + url: "https://example.com", + headers: {}, + body: {}, + httpMethod: HTTP_METHOD.GET, + }, + dataTypes: [{ dataType: "JSON" }], + }, + clientMeta: { + duration: "100", + size: "50", + }, + responseMeta: { + status: 200, + success: true, + }, + }; + + const result = createActionExecutionResponse(response); + + expect(result.body).toBe(base64String); + }); +}); diff --git a/app/client/src/sagas/ActionExecution/PluginActionSagaUtils.ts b/app/client/src/sagas/ActionExecution/PluginActionSagaUtils.ts index f353104e5b..bdb5466dcb 100644 --- a/app/client/src/sagas/ActionExecution/PluginActionSagaUtils.ts +++ b/app/client/src/sagas/ActionExecution/PluginActionSagaUtils.ts @@ -1,8 +1,14 @@ import { put } from "redux-saga/effects"; import { setActionResponseDisplayFormat } from "actions/pluginActionActions"; -import type { ActionResponse } from "api/ActionAPI"; +import type { ActionExecutionResponse, ActionResponse } from "api/ActionAPI"; import type { Plugin } from "entities/Plugin"; +import { getType, Types } from "utils/TypeHelpers"; +export enum ActionResponseDataTypes { + BINARY = "BINARY", +} + +export const RESP_HEADER_DATATYPE = "X-APPSMITH-DATATYPE"; export function* setDefaultActionDisplayFormat( actionId: string, plugin: Plugin | undefined, @@ -24,3 +30,30 @@ export function* setDefaultActionDisplayFormat( ); } } + +export const createActionExecutionResponse = ( + response: ActionExecutionResponse, +): ActionResponse => { + const payload = response.data; + + if (payload.statusCode === "200 OK" && payload.hasOwnProperty("headers")) { + const respHeaders = payload.headers; + + if ( + respHeaders.hasOwnProperty(RESP_HEADER_DATATYPE) && + respHeaders[RESP_HEADER_DATATYPE].length > 0 && + respHeaders[RESP_HEADER_DATATYPE][0] === ActionResponseDataTypes.BINARY && + getType(payload.body) === Types.STRING + ) { + // Decoding from base64 to handle the binary files because direct + // conversion of binary files to string causes corruption in the final output + // this is to only handle the download of binary files + payload.body = atob(payload.body as string); + } + } + + return { + ...payload, + ...response.clientMeta, + }; +};