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, + }; +};