From 34da89a7df68778b9173619eed38fc2059266932 Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Mon, 18 Sep 2023 10:50:47 -0400 Subject: [PATCH] EVG-19954: Force save when changing providers (#2028) --- .../integration/distroSettings/navigation.ts | 49 +++++++ src/pages/distroSettings/HeaderButtons.tsx | 109 ++------------- src/pages/distroSettings/SaveModal.tsx | 130 ++++++++++++++++++ src/pages/distroSettings/Tabs.tsx | 1 + .../tabs/ProviderTab/ProviderTab.tsx | 32 ++++- .../tabs/ProviderTab/UnsavedModal.tsx | 44 ++++++ .../distroSettings/tabs/ProviderTab/types.ts | 3 +- 7 files changed, 263 insertions(+), 105 deletions(-) create mode 100644 src/pages/distroSettings/SaveModal.tsx create mode 100644 src/pages/distroSettings/tabs/ProviderTab/UnsavedModal.tsx diff --git a/cypress/integration/distroSettings/navigation.ts b/cypress/integration/distroSettings/navigation.ts index a8b5a7ec3d..5b0cf7e368 100644 --- a/cypress/integration/distroSettings/navigation.ts +++ b/cypress/integration/distroSettings/navigation.ts @@ -12,4 +12,53 @@ describe("using the distro dropdown", () => { cy.location("pathname").should("not.contain", "localhost"); cy.location("pathname").should("contain", "rhel71-power8-large"); }); + + describe("warning modal", () => { + it("warns when navigating away from distro settings with unsaved changes and allows returning to distro settings", () => { + cy.getInputByLabel("Notes").type("my note"); + cy.dataCy("save-settings-button").should( + "not.have.attr", + "aria-disabled", + "true" + ); + cy.dataCy("project-health-link").click(); + cy.dataCy("navigation-warning-modal").should("be.visible"); + cy.contains("button", "Cancel").click(); + cy.dataCy("navigation-warning-modal").should("not.exist"); + cy.location("pathname").should( + "eq", + "/distro/localhost/settings/general" + ); + }); + + describe("modifying the distro provider", () => { + beforeEach(() => { + cy.visit("/distro/localhost/settings/provider"); + }); + + it("warns when navigating to another distro settings tab after the provider has changed and allows save", () => { + cy.selectLGOption("Provider", "Docker"); + cy.dataCy("save-settings-button").should( + "not.have.attr", + "aria-disabled", + "true" + ); + cy.contains("a", "Task Settings").click(); + cy.dataCy("save-modal").should("be.visible"); + cy.dataCy("provider-warning-banner").should("be.visible"); + }); + + it("shows the standard save warning modal when non-provider fields have changed", () => { + cy.getInputByLabel("User Data").type("test user data"); + cy.dataCy("save-settings-button").should( + "not.have.attr", + "aria-disabled", + "true" + ); + cy.dataCy("project-health-link").click(); + cy.dataCy("navigation-warning-modal").should("be.visible"); + cy.dataCy("provider-warning-banner").should("not.exist"); + }); + }); + }); }); diff --git a/src/pages/distroSettings/HeaderButtons.tsx b/src/pages/distroSettings/HeaderButtons.tsx index 4818fbf476..2a0b63c960 100644 --- a/src/pages/distroSettings/HeaderButtons.tsx +++ b/src/pages/distroSettings/HeaderButtons.tsx @@ -1,24 +1,9 @@ import { useState } from "react"; -import { useMutation } from "@apollo/client"; -import styled from "@emotion/styled"; import Button from "@leafygreen-ui/button"; -import { Radio, RadioGroup } from "@leafygreen-ui/radio-group"; -import { Body, BodyProps } from "@leafygreen-ui/typography"; -import pluralize from "pluralize"; -import { useDistroSettingsAnalytics } from "analytics"; -import { ConfirmationModal } from "components/ConfirmationModal"; -import { size } from "constants/tokens"; -import { useToastContext } from "context/toast"; -import { - DistroOnSaveOperation, - DistroQuery, - SaveDistroMutation, - SaveDistroMutationVariables, -} from "gql/generated/types"; -import { SAVE_DISTRO } from "gql/mutations"; +import { DistroQuery } from "gql/generated/types"; import { useDistroSettingsContext } from "./Context"; -import { formToGqlMap } from "./tabs/transformers"; -import { FormToGqlFunction, WritableDistroSettingsType } from "./tabs/types"; +import { SaveModal } from "./SaveModal"; +import { WritableDistroSettingsType } from "./tabs/types"; interface Props { distro: DistroQuery["distro"]; @@ -26,56 +11,10 @@ interface Props { } export const HeaderButtons: React.FC = ({ distro, tab }) => { - const { sendEvent } = useDistroSettingsAnalytics(); - const dispatchToast = useToastContext(); - - const { getTab, saveTab } = useDistroSettingsContext(); - const { formData, hasChanges, hasError } = getTab(tab); + const { getTab } = useDistroSettingsContext(); + const { hasChanges, hasError } = getTab(tab); const [modalOpen, setModalOpen] = useState(false); - const [onSaveOperation, setOnSaveOperation] = useState( - DistroOnSaveOperation.None - ); - - const [saveDistro] = useMutation< - SaveDistroMutation, - SaveDistroMutationVariables - >(SAVE_DISTRO, { - onCompleted({ saveDistro: { hostCount } }) { - saveTab(tab); - dispatchToast.success( - `Updated distro${ - onSaveOperation !== DistroOnSaveOperation.None - ? ` and scheduled ${hostCount} ${pluralize( - "host", - hostCount - )} to update` - : "" - }.` - ); - }, - onError(err) { - dispatchToast.error(err.message); - }, - refetchQueries: ["Distro"], - }); - - const handleSave = () => { - // Only perform the save operation is the tab is valid. - // eslint-disable-next-line no-prototype-builtins - if (formToGqlMap.hasOwnProperty(tab)) { - const formToGql: FormToGqlFunction = formToGqlMap[tab]; - const changes = formToGql(formData, distro); - saveDistro({ - variables: { - distro: changes, - onSave: onSaveOperation, - }, - }); - setModalOpen(false); - sendEvent({ name: "Save distro", section: tab }); - } - }; return ( <> @@ -87,41 +26,13 @@ export const HeaderButtons: React.FC = ({ distro, tab }) => { > Save changes on page - setModalOpen(false)} onCancel={() => setModalOpen(false)} - onConfirm={handleSave} - title="Save page" - > - - Evergreen can perform one of the following actions on save: - - - setOnSaveOperation(e.target.value as DistroOnSaveOperation) - } - value={onSaveOperation} - > - - Nothing, only new hosts will have updated distro settings applied - - - Decommission hosts of this distro - - - Restart Jasper service on running hosts of this distro - - - Reprovision running hosts of this distro - - - + tab={tab} + /> ); }; - -const StyledBody = styled(Body)` - margin-bottom: ${size.xs}; -`; diff --git a/src/pages/distroSettings/SaveModal.tsx b/src/pages/distroSettings/SaveModal.tsx new file mode 100644 index 0000000000..661abf4f21 --- /dev/null +++ b/src/pages/distroSettings/SaveModal.tsx @@ -0,0 +1,130 @@ +import { useState } from "react"; +import { useMutation } from "@apollo/client"; +import styled from "@emotion/styled"; +import { Radio, RadioGroup } from "@leafygreen-ui/radio-group"; +import { Body, BodyProps } from "@leafygreen-ui/typography"; +import pluralize from "pluralize"; +import { useDistroSettingsAnalytics } from "analytics"; +import { ConfirmationModal } from "components/ConfirmationModal"; +import { size } from "constants/tokens"; +import { useToastContext } from "context/toast"; +import { + DistroOnSaveOperation, + DistroQuery, + SaveDistroMutation, + SaveDistroMutationVariables, +} from "gql/generated/types"; +import { SAVE_DISTRO } from "gql/mutations"; +import { useDistroSettingsContext } from "./Context"; +import { formToGqlMap } from "./tabs/transformers"; +import { FormToGqlFunction, WritableDistroSettingsType } from "./tabs/types"; + +type SaveModalProps = { + banner?: React.ReactNode; + distro: DistroQuery["distro"]; + onCancel?: () => void; + onConfirm?: () => void; + open: boolean; + tab: WritableDistroSettingsType; +}; + +export const SaveModal: React.FC = ({ + banner, + distro, + onCancel, + onConfirm, + open, + tab, +}) => { + const { sendEvent } = useDistroSettingsAnalytics(); + const dispatchToast = useToastContext(); + + const { getTab, saveTab } = useDistroSettingsContext(); + const { formData } = getTab(tab); + const [onSaveOperation, setOnSaveOperation] = useState( + DistroOnSaveOperation.None + ); + + const [saveDistro] = useMutation< + SaveDistroMutation, + SaveDistroMutationVariables + >(SAVE_DISTRO, { + onCompleted({ saveDistro: { hostCount } }) { + saveTab(tab); + dispatchToast.success( + `Updated distro${ + onSaveOperation !== DistroOnSaveOperation.None + ? ` and scheduled ${hostCount} ${pluralize( + "host", + hostCount + )} to update` + : "" + }.` + ); + }, + onError(err) { + dispatchToast.error(err.message); + }, + refetchQueries: ["Distro"], + }); + + const handleSave = () => { + // Only perform the save operation if the tab is valid. + // eslint-disable-next-line no-prototype-builtins + if (formToGqlMap.hasOwnProperty(tab)) { + const formToGql: FormToGqlFunction = formToGqlMap[tab]; + const changes = formToGql(formData, distro); + saveDistro({ + variables: { + distro: changes, + onSave: onSaveOperation, + }, + }); + sendEvent({ name: "Save distro", section: tab }); + } + }; + + return ( + { + onCancel?.(); + }} + onConfirm={() => { + onConfirm?.(); + handleSave(); + }} + title="Save page" + > + {banner} + + Evergreen can perform one of the following actions on save: + + + setOnSaveOperation(e.target.value as DistroOnSaveOperation) + } + value={onSaveOperation} + > + + Nothing, only new hosts will have updated distro settings applied + + + Decommission hosts of this distro + + + Restart Jasper service on running hosts of this distro + + + Reprovision running hosts of this distro + + + + ); +}; + +const StyledBody = styled(Body)` + margin-bottom: ${size.xs}; +`; diff --git a/src/pages/distroSettings/Tabs.tsx b/src/pages/distroSettings/Tabs.tsx index 65e5a374af..51f2299a84 100644 --- a/src/pages/distroSettings/Tabs.tsx +++ b/src/pages/distroSettings/Tabs.tsx @@ -50,6 +50,7 @@ export const DistroSettingsTabs: React.FC = ({ distro }) => { path={DistroSettingsTabRoutes.Provider} element={ } diff --git a/src/pages/distroSettings/tabs/ProviderTab/ProviderTab.tsx b/src/pages/distroSettings/tabs/ProviderTab/ProviderTab.tsx index f6c1b1fcd2..0b71a23728 100644 --- a/src/pages/distroSettings/tabs/ProviderTab/ProviderTab.tsx +++ b/src/pages/distroSettings/tabs/ProviderTab/ProviderTab.tsx @@ -1,14 +1,36 @@ import { useMemo } from "react"; +import { useDistroSettingsContext } from "../../Context"; import { BaseTab } from "../BaseTab"; +import { + FormToGqlFunction, + WritableDistroSettingsTabs, + WritableDistroSettingsType, +} from "../types"; import { getFormSchema } from "./getFormSchema"; -import { TabProps } from "./types"; - -export const ProviderTab: React.FC = ({ distroData }) => { - const initialFormState = distroData; +import { ProviderFormState, TabProps } from "./types"; +import { UnsavedModal } from "./UnsavedModal"; +export const ProviderTab: React.FC = ({ distro, distroData }) => { const formSchema = useMemo(() => getFormSchema(), []); + const { getTab } = useDistroSettingsContext(); + + // @ts-expect-error - see TabState for details. + const { + formData, + initialData, + }: { + formData: ProviderFormState; + initialData: ReturnType>; + } = getTab(WritableDistroSettingsTabs.Provider); + return ( - + <> + {/* Use conditional rendering instead of the shouldBlock prop so that modifying fields other than the provider triggers the standard navigation warning modal */} + {initialData?.provider !== formData?.provider?.providerName && ( + + )} + + ); }; diff --git a/src/pages/distroSettings/tabs/ProviderTab/UnsavedModal.tsx b/src/pages/distroSettings/tabs/ProviderTab/UnsavedModal.tsx new file mode 100644 index 0000000000..9116939822 --- /dev/null +++ b/src/pages/distroSettings/tabs/ProviderTab/UnsavedModal.tsx @@ -0,0 +1,44 @@ +import styled from "@emotion/styled"; +import Banner from "@leafygreen-ui/banner"; +import { + unstable_BlockerFunction as BlockerFunction, + unstable_useBlocker as useBlocker, +} from "react-router-dom"; +import { size } from "constants/tokens"; +import { DistroQuery } from "gql/generated/types"; +import { SaveModal } from "../../SaveModal"; +import { WritableDistroSettingsTabs } from "../types"; + +type UnsavedModalProps = { + distro: DistroQuery["distro"]; + shouldBlock: boolean | BlockerFunction; +}; + +export const UnsavedModal: React.FC = ({ + distro, + shouldBlock, +}) => { + const blocker = useBlocker(shouldBlock); + + return ( + blocker.state === "blocked" && ( + + Your distro provider changes must be saved or reverted before + navigating to a new page. + + } + distro={distro} + open + onCancel={() => blocker.reset?.()} + onConfirm={() => blocker.proceed?.()} + tab={WritableDistroSettingsTabs.Provider} + /> + ) + ); +}; + +const StyledBanner = styled(Banner)` + margin-bottom: ${size.xs}; +`; diff --git a/src/pages/distroSettings/tabs/ProviderTab/types.ts b/src/pages/distroSettings/tabs/ProviderTab/types.ts index c100aead99..c13a4e68f2 100644 --- a/src/pages/distroSettings/tabs/ProviderTab/types.ts +++ b/src/pages/distroSettings/tabs/ProviderTab/types.ts @@ -1,4 +1,4 @@ -import { Provider } from "gql/generated/types"; +import { DistroQuery, Provider } from "gql/generated/types"; interface StaticProviderFormState { provider: { @@ -15,5 +15,6 @@ interface StaticProviderFormState { export type ProviderFormState = StaticProviderFormState; export type TabProps = { + distro: DistroQuery["distro"]; distroData: ProviderFormState; };