Warn user of unsaved outfit changes

This commit is contained in:
Emi Matchu 2021-05-04 16:31:48 -07:00
parent a69bc8184a
commit 76aca48b43
4 changed files with 84 additions and 1 deletions

View file

@ -92,4 +92,42 @@ describe("WardrobePage: Outfit saving", () => {
cy.reload(); cy.reload();
page.getOutfitPreview({ timeout: 20000 }).toMatchImageSnapshot(); page.getOutfitPreview({ timeout: 20000 }).toMatchImageSnapshot();
}); });
it("prompts before navigating away from unsaved changes", () => {
// Create stub methods to reject navigation confirmation prompts. We need
// `confirm` for react-router's client-side nav, and `before:unload` for
// the browser's built-in full-page nav prompts.
const confirmStub = cy.stub().returns(false);
cy.on("window:confirm", confirmStub);
const beforeUnloadStub = cy.stub();
cy.on("window:before:unload", beforeUnloadStub);
cy.logInAs("dti-test");
cy.visit("/outfits/new?species=1&color=8");
// Give the outfit a unique timestamped name
const outfitName = `Cypress Test - Block Navigation - ${new Date().toISOString()}`;
page.getOutfitName({ timeout: 12000 }).click();
cy.focused().type(outfitName + "{enter}");
// Save the outfit, but don't wait for it to finish.
page.getSaveOutfitButton().click().should("have.attr", "data-loading");
// Click the big back button, and observe that it triggers a confirm prompt.
// We'll reject it. HACK: It's a bit flaky if you try the clicks
// immediately in sequence. Is there a better way to do this?
page.showOutfitControls();
cy.wait(100); // eslint-disable-line cypress/no-unnecessary-waiting
page.getNavBackButton().click();
cy.wrap(confirmStub).should("be.called");
// Try to reload the page, and observe that it would trigger the browser's
// built-in prompt, i.e., `defaultPrevented`. Cypress will automatically
// confirm the simulated prompt, and complete the reload.
cy.reload();
cy.wrap(beforeUnloadStub).should(
"be.calledWith",
Cypress.sinon.match((e) => e.defaultPrevented)
);
});
}); });

View file

@ -1,6 +1,7 @@
const withTestId = (testId) => (options) => const withTestId = (testId) => (options) =>
cy.get(`[data-test-id="${CSS.escape(testId)}"]`, options); cy.get(`[data-test-id="${CSS.escape(testId)}"]`, options);
export const getNavBackButton = withTestId("wardrobe-nav-back-button");
export const getSpeciesSelect = withTestId("wardrobe-species-picker"); export const getSpeciesSelect = withTestId("wardrobe-species-picker");
export const getColorSelect = withTestId("wardrobe-color-picker"); export const getColorSelect = withTestId("wardrobe-color-picker");
export const getPosePickerButton = withTestId("wardrobe-pose-picker"); export const getPosePickerButton = withTestId("wardrobe-pose-picker");
@ -38,3 +39,7 @@ export function getOutfitPreview(options = { timeout: 15000 }) {
.get("img") .get("img")
.first(); .first();
} }
export function showOutfitControls(options) {
return cy.get("[data-test-id=wardrobe-outfit-controls]", options).click();
}

View file

@ -146,6 +146,7 @@ function OutfitControls({
setFocusIsLocked(true); setFocusIsLocked(true);
} }
}} }}
data-test-id="wardrobe-outfit-controls"
> >
<Box gridArea="back" onClick={maybeUnlockFocus}> <Box gridArea="back" onClick={maybeUnlockFocus}>
<BackButton outfitState={outfitState} /> <BackButton outfitState={outfitState} />
@ -297,6 +298,7 @@ function BackButton({ outfitState }) {
icon={<ArrowBackIcon />} icon={<ArrowBackIcon />}
aria-label="Leave this outfit" aria-label="Leave this outfit"
d="inline-flex" // Not sure why <a> requires this to style right! ^^` d="inline-flex" // Not sure why <a> requires this to style right! ^^`
data-test-id="wardrobe-nav-back-button"
/> />
); );
} }

View file

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { Prompt } from "react-router-dom";
import { useToast } from "@chakra-ui/react"; import { useToast } from "@chakra-ui/react";
import { loadable } from "../util"; import { loadable } from "../util";
@ -29,7 +30,8 @@ function WardrobePage() {
// We manage outfit saving up here, rather than at the point of the UI where // We manage outfit saving up here, rather than at the point of the UI where
// "Saving" indicators appear. That way, auto-saving still happens even when // "Saving" indicators appear. That way, auto-saving still happens even when
// the indicator isn't on the page, e.g. when searching. // the indicator isn't on the page, e.g. when searching. We also mount a
// <Prompt /> in this component to prevent navigating away before saving.
const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit); const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit);
usePageTitle(outfitState.name || "Untitled outfit"); usePageTitle(outfitState.name || "Untitled outfit");
@ -49,6 +51,27 @@ function WardrobePage() {
} }
}, [error, toast]); }, [error, toast]);
// For new outfits, we only block navigation while saving. For existing
// outfits, we block navigation while there are any unsaved changes.
const shouldBlockNavigation =
outfitSaving.canSaveOutfit &&
((outfitSaving.isNewOutfit && outfitSaving.isSaving) ||
(!outfitSaving.isNewOutfit && !outfitSaving.latestVersionIsSaved));
// In addition to a <Prompt /> for client-side nav, we need to block full nav!
React.useEffect(() => {
if (shouldBlockNavigation) {
const onBeforeUnload = (e) => {
// https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}
}, [shouldBlockNavigation]);
// NOTE: Most components pass around outfitState directly, to make the data // NOTE: Most components pass around outfitState directly, to make the data
// relationships more explicit... but there are some deep components // relationships more explicit... but there are some deep components
// that need it, where it's more useful and more performant to access // that need it, where it's more useful and more performant to access
@ -58,6 +81,21 @@ function WardrobePage() {
<SupportOnly> <SupportOnly>
<WardrobeDevHacks /> <WardrobeDevHacks />
</SupportOnly> </SupportOnly>
{/*
* TODO: This might unnecessarily block navigations that we don't
* necessarily need to, e.g., navigating back to Your Outfits while the
* save request is in flight. We could instead submit the save mutation
* immediately on client-side nav, and have each outfit save mutation
* install a `beforeunload` handler that ensures that you don't close
* the page altogether while it's in flight. But let's start simple and
* see how annoying it actually is in practice lol
*/}
<Prompt
when={shouldBlockNavigation}
message="Are you sure you want to leave? Your changes might not be saved."
/>
<WardrobePageLayout <WardrobePageLayout
previewAndControls={ previewAndControls={
<WardrobePreviewAndControls <WardrobePreviewAndControls