feat: Abort Query Execution (#15775)

* Snaps response view to certain height and adds abort query execution feature

* make raw string a constant

* Add cypress tests

* Remove theme variable

* move raw strings to constants in messages.ts file

* Fix test case description

* Comment out query abort action execution

* cy fix

* Fix abortion of parallel action execution like onPageLoad

Co-authored-by: Aishwarya UR <aishwarya@appsmith.com>
This commit is contained in:
Ayangade Adeoluwa 2022-08-12 08:19:17 +01:00 committed by GitHub
parent a5733da363
commit 0e36569b06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 210 additions and 18 deletions

View File

@ -0,0 +1,47 @@
const commonlocators = require("../../../../locators/commonlocators.json");
const queryLocators = require("../../../../locators/QueryEditor.json");
const datasource = require("../../../../locators/DatasourcesEditor.json");
const formControls = require("../../../../locators/FormControl.json");
const testApiUrl = "https://jsonplaceholder.typicode.com/photos";
const ERROR_ACTION_EXECUTE_FAIL = (actionName) =>
`${actionName} action returned an error response`;
describe("Abort Action Execution", function() {
it("Cancel Request button should abort API action execution", function() {
cy.createAndFillApi(testApiUrl, "");
cy.RunAPIWithoutWaitingForResolution();
cy.get(commonlocators.cancelActionExecution).click();
cy.VerifyErrorMsgPresence(ERROR_ACTION_EXECUTE_FAIL("Api1"));
});
// Queries were resolving quicker than we could cancel them
// Commenting this out till we can find a query that resolves slow enough for us to cancel its execution.
// it("Cancel Request button should abort Query action execution", function() {
// cy.NavigateToDatasourceEditor();
// cy.get(datasource.MongoDB).click();
// cy.fillMongoDatasourceForm();
// cy.testSaveDatasource();
// cy.get("@createDatasource").then(
// (httpResponse) => httpResponse.response.body.data.name,
// );
// cy.NavigateToQueryEditor();
// cy.NavigateToActiveTab();
// cy.get(queryLocators.createQuery)
// .last()
// .click();
// cy.get(queryLocators.queryNameField).type("AbortQuery");
// cy.ValidateAndSelectDropdownOption(
// formControls.commandDropdown,
// "Find Document(s)",
// );
// cy.typeValueNValidate("friends", formControls.mongoCollection);
// cy.typeValueNValidate("300", formControls.mongoFindLimit);
// cy.RunQueryWithoutWaitingForResolution();
// cy.get(commonlocators.cancelActionExecution).click();
// cy.VerifyErrorMsgPresence(ERROR_ACTION_EXECUTE_FAIL("AbortQuery"));
// });
});

View File

@ -183,5 +183,6 @@
"selectThemeBackBtn": ".t--theme-select-back-btn", "selectThemeBackBtn": ".t--theme-select-back-btn",
"themeAppBorderRadiusBtn": ".t--theme-appBorderRadius", "themeAppBorderRadiusBtn": ".t--theme-appBorderRadius",
"codeEditorWrapper": ".unfocused-code-editor", "codeEditorWrapper": ".unfocused-code-editor",
"textWidgetContainer": ".t--text-widget-container" "textWidgetContainer": ".t--text-widget-container",
} "cancelActionExecution": ".t--cancel-action-button"
}

View File

@ -113,6 +113,10 @@ Cypress.Commands.add("RunAPI", () => {
cy.wait("@postExecute"); cy.wait("@postExecute");
}); });
Cypress.Commands.add("RunAPIWithoutWaitingForResolution", () => {
cy.get(ApiEditor.ApiRunBtn).click({ force: true });
});
Cypress.Commands.add("SaveAndRunAPI", () => { Cypress.Commands.add("SaveAndRunAPI", () => {
cy.WaitAutoSave(); cy.WaitAutoSave();
cy.RunAPI(); cy.RunAPI();

View File

@ -1536,6 +1536,17 @@ Cypress.Commands.add("VerifyErrorMsgAbsence", (errorMsgToVerifyAbsence) => {
).should("not.exist"); ).should("not.exist");
}); });
Cypress.Commands.add("VerifyErrorMsgPresence", (errorMsgToVerifyAbsence) => {
// Give this element 10 seconds to appear
//cy.wait(10000)
cy.xpath(
"//div[@class='Toastify']//span[contains(text(),'" +
errorMsgToVerifyAbsence +
"')]",
{ timeout: 0 },
).should("exist");
});
Cypress.Commands.add("setQueryTimeout", (timeout) => { Cypress.Commands.add("setQueryTimeout", (timeout) => {
cy.get(queryLocators.settings).click(); cy.get(queryLocators.settings).click();
cy.xpath(queryLocators.queryTimeout) cy.xpath(queryLocators.queryTimeout)

View File

@ -97,6 +97,12 @@ Cypress.Commands.add("onlyQueryRun", () => {
.wait(1000); .wait(1000);
}); });
Cypress.Commands.add("RunQueryWithoutWaitingForResolution", () => {
cy.xpath(queryEditor.runQuery)
.last()
.click({ force: true });
});
Cypress.Commands.add("hoverAndClick", () => { Cypress.Commands.add("hoverAndClick", () => {
cy.xpath(apiwidget.popover) cy.xpath(apiwidget.popover)
.last() .last()

View File

@ -117,6 +117,7 @@ class ActionAPI extends API {
static url = "v1/actions"; static url = "v1/actions";
static apiUpdateCancelTokenSource: CancelTokenSource; static apiUpdateCancelTokenSource: CancelTokenSource;
static queryUpdateCancelTokenSource: CancelTokenSource; static queryUpdateCancelTokenSource: CancelTokenSource;
static abortActionExecutionTokenSource: CancelTokenSource;
static createAction( static createAction(
apiConfig: Partial<Action>, apiConfig: Partial<Action>,
@ -169,12 +170,14 @@ class ActionAPI extends API {
executeAction: FormData, executeAction: FormData,
timeout?: number, timeout?: number,
): AxiosPromise<ActionExecutionResponse> { ): AxiosPromise<ActionExecutionResponse> {
ActionAPI.abortActionExecutionTokenSource = axios.CancelToken.source();
return API.post(ActionAPI.url + "/execute", executeAction, undefined, { return API.post(ActionAPI.url + "/execute", executeAction, undefined, {
timeout: timeout || DEFAULT_EXECUTE_ACTION_TIMEOUT_MS, timeout: timeout || DEFAULT_EXECUTE_ACTION_TIMEOUT_MS,
headers: { headers: {
accept: "application/json", accept: "application/json",
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
cancelToken: ActionAPI.abortActionExecutionTokenSource.token,
}); });
} }

View File

@ -254,6 +254,16 @@ export const OAUTH_2_0 = () => "OAuth 2.0";
export const ENABLE = () => "ENABLE"; export const ENABLE = () => "ENABLE";
export const UPGRADE = () => "UPGRADE"; export const UPGRADE = () => "UPGRADE";
export const EDIT = () => "EDIT"; export const EDIT = () => "EDIT";
export const UNEXPECTED_ERROR = () => "An unexpected error occurred";
export const EXPECTED_ERROR = () => "An error occurred";
export const NO_DATASOURCE_FOR_QUERY = () =>
`Seems like you dont have any Datasources to create a query`;
export const ACTION_EDITOR_REFRESH = () => "Refresh";
export const INVALID_FORM_CONFIGURATION = () => "Invalid form configuration";
export const ACTION_RUN_BUTTON_MESSAGE_FIRST_HALF = () => "🙌 Click on";
export const ACTION_RUN_BUTTON_MESSAGE_SECOND_HALF = () =>
"after adding your query";
export const CREATE_NEW_DATASOURCE = () => "Create new datasource";
export const ERROR_EVAL_ERROR_GENERIC = () => export const ERROR_EVAL_ERROR_GENERIC = () =>
`Unexpected error occurred while evaluating the application`; `Unexpected error occurred while evaluating the application`;
@ -913,6 +923,8 @@ export const API_EDITOR_TAB_TITLES = {
AUTHENTICATION: () => "Authentication", AUTHENTICATION: () => "Authentication",
SETTINGS: () => "Settings", SETTINGS: () => "Settings",
}; };
export const ACTION_EXECUTION_MESSAGE = (actionType: string) =>
`Sending the ${actionType} request`;
export const WELCOME_FORM_HEADER = () => "Let us get to know you better!"; export const WELCOME_FORM_HEADER = () => "Let us get to know you better!";
export const WELCOME_FORM_FULL_NAME = () => "Full Name"; export const WELCOME_FORM_FULL_NAME = () => "Full Name";

View File

@ -19,6 +19,7 @@ import {
EMPTY_RESPONSE_FIRST_HALF, EMPTY_RESPONSE_FIRST_HALF,
EMPTY_RESPONSE_LAST_HALF, EMPTY_RESPONSE_LAST_HALF,
INSPECT_ENTITY, INSPECT_ENTITY,
ACTION_EXECUTION_MESSAGE,
} from "@appsmith/constants/messages"; } from "@appsmith/constants/messages";
import { Text, TextType } from "design-system"; import { Text, TextType } from "design-system";
import { Text as BlueprintText } from "@blueprintjs/core"; import { Text as BlueprintText } from "@blueprintjs/core";
@ -32,7 +33,7 @@ import Resizer, { ResizerCSS } from "./Debugger/Resizer";
import AnalyticsUtil from "utils/AnalyticsUtil"; import AnalyticsUtil from "utils/AnalyticsUtil";
import { DebugButton } from "./Debugger/DebugCTA"; import { DebugButton } from "./Debugger/DebugCTA";
import EntityDeps from "./Debugger/EntityDependecies"; import EntityDeps from "./Debugger/EntityDependecies";
import Button, { Size } from "components/ads/Button"; import Button, { Size, Category } from "components/ads/Button";
import EntityBottomTabs from "./EntityBottomTabs"; import EntityBottomTabs from "./EntityBottomTabs";
import { DEBUGGER_TAB_KEYS } from "./Debugger/helpers"; import { DEBUGGER_TAB_KEYS } from "./Debugger/helpers";
import { setCurrentTab } from "actions/debuggerActions"; import { setCurrentTab } from "actions/debuggerActions";
@ -43,6 +44,7 @@ import {
UpdateActionPropertyActionPayload, UpdateActionPropertyActionPayload,
} from "actions/pluginActionActions"; } from "actions/pluginActionActions";
import { isHtml } from "./utils"; import { isHtml } from "./utils";
import ActionAPI from "api/ActionAPI";
type TextStyleProps = { type TextStyleProps = {
accent: "primary" | "secondary" | "error"; accent: "primary" | "secondary" | "error";
@ -105,7 +107,7 @@ const TabbedViewWrapper = styled.div`
} }
`; `;
const SectionDivider = styled.div` export const SectionDivider = styled.div`
height: 1px; height: 1px;
width: 100%; width: 100%;
background: ${(props) => props.theme.colors.apiPane.dividerBg}; background: ${(props) => props.theme.colors.apiPane.dividerBg};
@ -174,6 +176,23 @@ const ResponseBodyContainer = styled.div`
display: grid; display: grid;
`; `;
export const CancelRequestButton = styled(Button)`
margin-top: 10px;
`;
export const LoadingOverlayContainer = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: transparent;
position: relative;
z-index: 20;
width: 100%;
height: 100%;
margin-top: 5px;
`;
interface ReduxStateProps { interface ReduxStateProps {
responses: Record<string, ActionResponse | undefined>; responses: Record<string, ActionResponse | undefined>;
isRunning: Record<string, boolean>; isRunning: Record<string, boolean>;
@ -237,6 +256,8 @@ const ResponseDataContainer = styled.div`
`; `;
export const TableCellHeight = 39; export const TableCellHeight = 39;
// When action execution is triggered, open response container to height specified by this variable.
export const ActionExecutionResizerHeight = 307;
export const responseTabComponent = ( export const responseTabComponent = (
responseType: string, responseType: string,
@ -271,6 +292,10 @@ export const responseTabComponent = (
}[responseType]; }[responseType];
}; };
export const handleCancelActionExecution = () => {
ActionAPI.abortActionExecutionTokenSource.cancel();
};
function ApiResponseView(props: Props) { function ApiResponseView(props: Props) {
const { const {
match: { match: {
@ -506,17 +531,37 @@ function ApiResponseView(props: Props) {
return ( return (
<ResponseContainer ref={panelRef}> <ResponseContainer ref={panelRef}>
<Resizer <Resizer
openResizer={isRunning}
panelRef={panelRef} panelRef={panelRef}
setContainerDimensions={(height: number) => setContainerDimensions={(height: number) =>
// TableCellHeight in this case is the height of one table cell in pixels. // TableCellHeight in this case is the height of one table cell in pixels.
setTableBodyHeightHeight(height - TableCellHeight) setTableBodyHeightHeight(height - TableCellHeight)
} }
snapToHeight={ActionExecutionResizerHeight}
/> />
<SectionDivider /> <SectionDivider />
{isRunning && ( {isRunning && (
<LoadingOverlayScreen theme={props.theme}> <>
Sending Request <LoadingOverlayScreen theme={props.theme} />
</LoadingOverlayScreen> <LoadingOverlayContainer>
<div>
<Text textAlign={"center"} type={TextType.P1}>
{createMessage(ACTION_EXECUTION_MESSAGE, "API")}
</Text>
<CancelRequestButton
category={Category.tertiary}
className={`t--cancel-action-button`}
onClick={() => {
handleCancelActionExecution();
}}
size={Size.medium}
tag="button"
text="Cancel Request"
type="button"
/>
</div>
</LoadingOverlayContainer>
</>
)} )}
<TabbedViewWrapper> <TabbedViewWrapper>
{response.statusCode && ( {response.statusCode && (

View File

@ -21,6 +21,8 @@ const Top = styled.div`
type ResizerProps = { type ResizerProps = {
panelRef: RefObject<HTMLDivElement>; panelRef: RefObject<HTMLDivElement>;
setContainerDimensions?: (height: number) => void; setContainerDimensions?: (height: number) => void;
snapToHeight?: number;
openResizer?: boolean;
}; };
function Resizer(props: ResizerProps) { function Resizer(props: ResizerProps) {
@ -51,6 +53,23 @@ function Resizer(props: ResizerProps) {
handleResize(0); handleResize(0);
}, []); }, []);
useEffect(() => {
// if the resizer is configured to open and the user is not actively controlling it
// snap the resizer to a specific height as specified by the snapToHeight prop.
if (props.openResizer && !mouseDown) {
const panel = props.panelRef.current;
if (!panel) return;
const { height } = panel.getBoundingClientRect();
if (props?.snapToHeight && height < props?.snapToHeight) {
panel.style.height = `${props?.snapToHeight}px`;
props.setContainerDimensions &&
props.setContainerDimensions(props?.snapToHeight);
}
}
}, [props?.openResizer]);
useEffect(() => { useEffect(() => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();

View File

@ -56,12 +56,21 @@ import {
DOCUMENTATION, DOCUMENTATION,
DOCUMENTATION_TOOLTIP, DOCUMENTATION_TOOLTIP,
INSPECT_ENTITY, INSPECT_ENTITY,
ACTION_EXECUTION_MESSAGE,
UNEXPECTED_ERROR,
NO_DATASOURCE_FOR_QUERY,
ACTION_EDITOR_REFRESH,
EXPECTED_ERROR,
INVALID_FORM_CONFIGURATION,
ACTION_RUN_BUTTON_MESSAGE_FIRST_HALF,
ACTION_RUN_BUTTON_MESSAGE_SECOND_HALF,
CREATE_NEW_DATASOURCE,
} from "@appsmith/constants/messages"; } from "@appsmith/constants/messages";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { AppState } from "reducers"; import { AppState } from "reducers";
import { ExplorerURLParams } from "../Explorer/helpers"; import { ExplorerURLParams } from "../Explorer/helpers";
import MoreActionsMenu from "../Explorer/Actions/MoreActionsMenu"; import MoreActionsMenu from "../Explorer/Actions/MoreActionsMenu";
import Button, { Size } from "components/ads/Button"; import Button, { Size, Category } from "components/ads/Button";
import { thinScrollbar } from "constants/DefaultTheme"; import { thinScrollbar } from "constants/DefaultTheme";
import ActionRightPane, { import ActionRightPane, {
useEntityDependencies, useEntityDependencies,
@ -90,7 +99,14 @@ import {
responseTabComponent, responseTabComponent,
InlineButton, InlineButton,
TableCellHeight, TableCellHeight,
SectionDivider,
CancelRequestButton,
LoadingOverlayContainer,
handleCancelActionExecution,
ActionExecutionResizerHeight,
} from "components/editorComponents/ApiResponseView"; } from "components/editorComponents/ApiResponseView";
import LoadingOverlayScreen from "components/editorComponents/LoadingOverlayScreen";
import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig";
const QueryFormContainer = styled.form` const QueryFormContainer = styled.form`
flex: 1; flex: 1;
@ -517,7 +533,7 @@ export function EditorJSONtoForm(props: Props) {
<components.MenuList {...props}>{props.children}</components.MenuList> <components.MenuList {...props}>{props.children}</components.MenuList>
<CreateDatasource onClick={() => onCreateDatasourceClick()}> <CreateDatasource onClick={() => onCreateDatasourceClick()}>
<Icon className="createIcon" icon="plus" iconSize={11} /> <Icon className="createIcon" icon="plus" iconSize={11} />
Create new datasource {createMessage(CREATE_NEW_DATASOURCE)}
</CreateDatasource> </CreateDatasource>
</> </>
); );
@ -594,7 +610,9 @@ export function EditorJSONtoForm(props: Props) {
Sentry.captureException(e); Sentry.captureException(e);
return ( return (
<> <>
<ErrorMessage>Invalid form configuration</ErrorMessage> <ErrorMessage>
{createMessage(INVALID_FORM_CONFIGURATION)}
</ErrorMessage>
<Tag <Tag
intent="warning" intent="warning"
interactive interactive
@ -602,7 +620,7 @@ export function EditorJSONtoForm(props: Props) {
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
round round
> >
Refresh {createMessage(ACTION_EDITOR_REFRESH)}
</Tag> </Tag>
</> </>
); );
@ -753,7 +771,7 @@ export function EditorJSONtoForm(props: Props) {
<ErrorContainer> <ErrorContainer>
<AdsIcon keepColors name="warning-triangle" /> <AdsIcon keepColors name="warning-triangle" />
<Text style={{ color: "#F22B2B" }} type={TextType.H3}> <Text style={{ color: "#F22B2B" }} type={TextType.H3}>
An error occurred {createMessage(EXPECTED_ERROR)}
</Text> </Text>
<ErrorDescriptionText <ErrorDescriptionText
@ -804,7 +822,7 @@ export function EditorJSONtoForm(props: Props) {
<NoResponseContainer> <NoResponseContainer>
<AdsIcon name="no-response" /> <AdsIcon name="no-response" />
<Text type={TextType.P1}> <Text type={TextType.P1}>
🙌 Click on {createMessage(ACTION_RUN_BUTTON_MESSAGE_FIRST_HALF)}
<InlineButton <InlineButton
isLoading={isRunning} isLoading={isRunning}
onClick={responeTabOnRunClick} onClick={responeTabOnRunClick}
@ -813,7 +831,7 @@ export function EditorJSONtoForm(props: Props) {
text="Run" text="Run"
type="button" type="button"
/> />
after adding your query {createMessage(ACTION_RUN_BUTTON_MESSAGE_SECOND_HALF)}
</Text> </Text>
</NoResponseContainer> </NoResponseContainer>
)} )}
@ -953,7 +971,7 @@ export function EditorJSONtoForm(props: Props) {
) : ( ) : (
<> <>
<ErrorMessage> <ErrorMessage>
An unexpected error occurred {createMessage(UNEXPECTED_ERROR)}
</ErrorMessage> </ErrorMessage>
<Tag <Tag
intent="warning" intent="warning"
@ -962,15 +980,14 @@ export function EditorJSONtoForm(props: Props) {
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
round round
> >
Refresh {createMessage(ACTION_EDITOR_REFRESH)}
</Tag> </Tag>
</> </>
)} )}
{dataSources.length === 0 && ( {dataSources.length === 0 && (
<NoDataSourceContainer> <NoDataSourceContainer>
<p className="font18"> <p className="font18">
Seems like you dont have any Datasources to {createMessage(NO_DATASOURCE_FOR_QUERY)}
create a query
</p> </p>
<EditorButton <EditorButton
filled filled
@ -1003,12 +1020,39 @@ export function EditorJSONtoForm(props: Props) {
<TabbedViewContainer ref={panelRef}> <TabbedViewContainer ref={panelRef}>
<Resizable <Resizable
openResizer={isRunning}
panelRef={panelRef} panelRef={panelRef}
setContainerDimensions={(height: number) => setContainerDimensions={(height: number) =>
// TableCellHeight in this case is the height of one table cell in pixels. // TableCellHeight in this case is the height of one table cell in pixels.
setTableBodyHeightHeight(height - TableCellHeight) setTableBodyHeightHeight(height - TableCellHeight)
} }
snapToHeight={ActionExecutionResizerHeight}
/> />
<SectionDivider />
{isRunning && (
<>
<LoadingOverlayScreen theme={EditorTheme.LIGHT} />
<LoadingOverlayContainer>
<div>
<Text textAlign={"center"} type={TextType.P1}>
{createMessage(ACTION_EXECUTION_MESSAGE, "Query")}
</Text>
<CancelRequestButton
category={Category.tertiary}
className={`t--cancel-action-button`}
onClick={() => {
handleCancelActionExecution();
}}
size={Size.medium}
tag="button"
text="Cancel Request"
type="button"
/>
</div>
</LoadingOverlayContainer>
</>
)}
{output && !!output.length && ( {output && !!output.length && (
<ResultsCount> <ResultsCount>
<Text type={TextType.P3}> <Text type={TextType.P3}>