diff --git a/changes/issue-3202-improve-save-as-new-query b/changes/issue-3202-improve-save-as-new-query new file mode 100644 index 0000000000..b0391bc354 --- /dev/null +++ b/changes/issue-3202-improve-save-as-new-query @@ -0,0 +1 @@ +* Improved UX around "Save as new" query (Reroutes to new query on save as new, fixes duplicate name error) \ No newline at end of file diff --git a/cypress/integration/all/app/queryflow.spec.ts b/cypress/integration/all/app/queryflow.spec.ts index f5dc599e34..4ac1130a0a 100644 --- a/cypress/integration/all/app/queryflow.spec.ts +++ b/cypress/integration/all/app/queryflow.spec.ts @@ -56,9 +56,18 @@ describe("Query flow (seeded)", () => { cy.getAttached(".ace_scroller") .click() .type("{selectall}SELECT datetime, username FROM windows_crashes;"); - cy.getAttached(".button--brand.query-form__save").click(); + cy.getAttached(".query-form__save").click(); cy.findByText(/query updated/i).should("be.visible"); }); + it("saves an existing query as new query", () => { + cy.getAttached(".name__cell .button--text-link").eq(1).click(); + cy.findByText(/run query/i).should("exist"); + cy.getAttached(".ace_scroller") + .click() + .type("{selectall}SELECT datetime, username FROM windows_crashes;"); + cy.getAttached(".query-form__save-as-new").click(); + cy.findByText(/copy of/i).should("be.visible"); + }); it("deletes an existing query", () => { cy.findByText(/detect linux hosts/i) .parent() diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx b/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx index ca6d0c2f75..c1deb72bf6 100644 --- a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx +++ b/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx @@ -1,4 +1,11 @@ import React, { useState, useContext, useEffect, KeyboardEvent } from "react"; +import { useDispatch } from "react-redux"; +import { push } from "react-router-redux"; +// @ts-ignore +import { renderFlash } from "redux/nodes/notifications/actions"; + +import PATHS from "router/paths"; + import { IAceEditor } from "react-ace/lib/types"; import ReactTooltip from "react-tooltip"; import { size } from "lodash"; @@ -9,6 +16,7 @@ import { addGravatarUrlToResource } from "fleet/helpers"; // @ts-ignore import { listCompatiblePlatforms, parseSqlTables } from "utilities/sql_tools"; +import queryAPI from "services/entities/queries"; import { AppContext } from "context/app"; import { QueryContext } from "context/query"; import { IQuery, IQueryFormData } from "interfaces/query"; @@ -68,6 +76,8 @@ const QueryForm = ({ renderLiveQueryWarning, backendValidators, }: IQueryFormProps): JSX.Element => { + const dispatch = useDispatch(); + const isEditMode = !!queryIdForEdit; const [errors, setErrors] = useState<{ [key: string]: any }>({}); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); @@ -77,6 +87,7 @@ const QueryForm = ({ const [isEditingDescription, setIsEditingDescription] = useState( false ); + const [isSaveAsNewLoading, setIsSaveAsNewLoading] = useState(false); // Note: The QueryContext values should always be used for any mutable query data such as query name // The storedQuery prop should only be used to access immutable metadata such as author id @@ -168,7 +179,7 @@ const QueryForm = ({ } }; - const promptSaveQuery = (forceNew = false) => ( + const promptSaveAsNewQuery = () => ( evt: React.MouseEvent ) => { evt.preventDefault(); @@ -186,7 +197,81 @@ const QueryForm = ({ valid = isValidated; if (valid) { - if (!isEditMode || forceNew) { + setIsSaveAsNewLoading(true); + + queryAPI + .create({ + name: lastEditedQueryName, + description: lastEditedQueryDescription, + query: lastEditedQueryBody, + observer_can_run: lastEditedQueryObserverCanRun, + }) + .then((response: { query: IQuery }) => { + setIsSaveAsNewLoading(false); + dispatch(push(PATHS.EDIT_QUERY(response.query))); + dispatch(renderFlash("success", `Successfully added query.`)); + }) + .catch((createError: any) => { + if (createError.data.errors[0].reason.includes("already exists")) { + queryAPI + .create({ + name: `Copy of ${lastEditedQueryName}`, + description: lastEditedQueryDescription, + query: lastEditedQueryBody, + observer_can_run: lastEditedQueryObserverCanRun, + }) + .then((response: { query: IQuery }) => { + setIsSaveAsNewLoading(false); + dispatch(push(PATHS.EDIT_QUERY(response.query))); + dispatch( + renderFlash( + "success", + `Successfully added query as "Copy of ${lastEditedQueryName}".` + ) + ); + }) + .catch((createCopyError: any) => { + if ( + createCopyError.data.errors[0].reason.includes( + "already exists" + ) + ) { + dispatch( + renderFlash( + "error", + `"Copy of ${lastEditedQueryName}" already exists. Please rename your query and try again.` + ) + ); + } + setIsSaveAsNewLoading(false); + }); + } else { + setIsSaveAsNewLoading(false); + dispatch( + renderFlash("error", "Could not create query. Please try again.") + ); + } + }); + } + }; + + const promptSaveQuery = () => (evt: React.MouseEvent) => { + evt.preventDefault(); + + if (isEditMode && !lastEditedQueryName) { + return setErrors({ + ...errors, + name: "Query name must be present", + }); + } + + let valid = true; + const { valid: isValidated } = validateQuerySQL(lastEditedQueryBody); + + valid = isValidated; + + if (valid) { + if (!isEditMode) { setIsSaveModalOpen(true); } else { onUpdate({ @@ -374,6 +459,11 @@ const QueryForm = ({ const renderForGlobalAdminOrAnyMaintainer = ( <>
+ {isSaveAsNewLoading && ( +
+ +
+ )}
{renderName()} @@ -420,9 +510,9 @@ const QueryForm = ({ <> {isEditMode && (