feat: enable post run actions for plugin queries (#39325)

## 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

### 🔍 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/13493868532>
> Commit: 75b13354c6147717360831fff06b60063d14c3ed
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=13493868532&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Sanity`
> Spec:
> <hr>Mon, 24 Feb 2025 09:13:40 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**
- 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ayush Pahwa 2025-02-24 14:48:34 +05:30 committed by GitHub
parent 0401607f50
commit f3eca9b234
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 488 additions and 36 deletions

View File

@ -28,6 +28,13 @@ const defaultProps = {
responseTabHeight: 200,
};
// mock the postrunactionmap
jest.mock("ee/components/PostActionRunComponents", () => ({
PostRunActionComponentMap: {
test_modal: () => <div data-testid="t--post-run-action-test-modal-form" />,
},
}));
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(
<Provider store={store}>
<ThemeProvider theme={lightTheme}>
<Router>
<Response {...props} />
</Router>
</ThemeProvider>
</Provider>,
);
// 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(
<Provider store={store}>
<ThemeProvider theme={lightTheme}>
<Router>
<Response {...props} />
</Router>
</ThemeProvider>
</Provider>,
);
// 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(
<Provider store={store}>
<ThemeProvider theme={lightTheme}>
<Router>
<Response {...props} />
</Router>
</ThemeProvider>
</Provider>,
);
// Check if post run action container is not showing
expect(
container.querySelector("[data-testid='t--post-run-action-container']"),
).toBeNull();
});
});

View File

@ -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) {
}
/>
</Styled.Response>
{showPostRunAction && (
<PostActionRunContainer
postRunAction={actionResponse?.postRunAction}
/>
)}
<ContentTypeSelector
contentTypeOptions={contentTypeOptions}
currentContentType={currentContentType}

View File

@ -0,0 +1,43 @@
import React from "react";
import { getPostRunActionName } from "../utils/postRunActionsUtil";
import styled from "styled-components";
import { PostRunActionComponentMap } from "ee/components/PostActionRunComponents";
import type { PostRunActionNamesInterface } from "ee/components/PostActionRunComponents/types";
import type { PostActionRunConfig } from "api/types";
interface Props {
postRunAction?: PostActionRunConfig;
}
const Container = styled.div`
border: 1px solid var(--ads-v2-color-border);
z-index: 100;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: auto;
padding-bottom: var(--ads-bottom-bar-height);
background-color: var(--ads-v2-color-bg);
`;
export default function PostActionRunContainer({ postRunAction }: Props) {
if (!postRunAction) {
return null;
}
const name: string = getPostRunActionName(postRunAction);
const Component = PostRunActionComponentMap[
name as PostRunActionNamesInterface
] as React.ComponentType;
if (!Component) {
return null;
}
return (
<Container data-testid="t--post-run-action-container">
<Component />
</Container>
);
}

View File

@ -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("");
});
});

View File

@ -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;
}

View File

@ -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

View File

@ -22,3 +22,9 @@ export type AxiosResponseData<T> = AxiosResponse<ApiResponse<T>>["data"];
export type ErrorHandler = (
error: AxiosError<ApiResponse>,
) => Promise<unknown | null>;
export interface PostActionRunConfig {
type: "FORM";
name: string;
config?: Record<string, unknown>;
}

View File

@ -0,0 +1,6 @@
import type { PostRunActionNamesInterface } from "./types";
export const PostRunActionComponentMap: Record<
PostRunActionNamesInterface,
React.ElementType
> = {};

View File

@ -0,0 +1,4 @@
export const PostRunActionNames = {} as const;
export type PostRunActionNamesInterface =
(typeof PostRunActionNames)[keyof typeof PostRunActionNames];

View File

@ -0,0 +1 @@
export * from "ce/components/PostActionRunComponents";

View File

@ -0,0 +1 @@
export * from "ce/components/PostActionRunComponents/types";

View File

@ -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;
};

View File

@ -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);
});
});

View File

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