diff --git a/cypress/integration/WardrobePage/Outfit saving.spec.js b/cypress/integration/WardrobePage/Outfit saving.spec.js index 3dc36e0..3bf44ce 100644 --- a/cypress/integration/WardrobePage/Outfit saving.spec.js +++ b/cypress/integration/WardrobePage/Outfit saving.spec.js @@ -92,4 +92,42 @@ describe("WardrobePage: Outfit saving", () => { cy.reload(); 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) + ); + }); }); diff --git a/cypress/integration/WardrobePage/page.js b/cypress/integration/WardrobePage/page.js index 624f533..2a6bf15 100644 --- a/cypress/integration/WardrobePage/page.js +++ b/cypress/integration/WardrobePage/page.js @@ -1,6 +1,7 @@ const withTestId = (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 getColorSelect = withTestId("wardrobe-color-picker"); export const getPosePickerButton = withTestId("wardrobe-pose-picker"); @@ -38,3 +39,7 @@ export function getOutfitPreview(options = { timeout: 15000 }) { .get("img") .first(); } + +export function showOutfitControls(options) { + return cy.get("[data-test-id=wardrobe-outfit-controls]", options).click(); +} diff --git a/src/app/WardrobePage/OutfitControls.js b/src/app/WardrobePage/OutfitControls.js index ecfe67d..75fe9c9 100644 --- a/src/app/WardrobePage/OutfitControls.js +++ b/src/app/WardrobePage/OutfitControls.js @@ -146,6 +146,7 @@ function OutfitControls({ setFocusIsLocked(true); } }} + data-test-id="wardrobe-outfit-controls" > @@ -297,6 +298,7 @@ function BackButton({ outfitState }) { icon={} aria-label="Leave this outfit" d="inline-flex" // Not sure why requires this to style right! ^^` + data-test-id="wardrobe-nav-back-button" /> ); } diff --git a/src/app/WardrobePage/index.js b/src/app/WardrobePage/index.js index f01acf9..561e06f 100644 --- a/src/app/WardrobePage/index.js +++ b/src/app/WardrobePage/index.js @@ -1,4 +1,5 @@ import React from "react"; +import { Prompt } from "react-router-dom"; import { useToast } from "@chakra-ui/react"; 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 // "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 + // in this component to prevent navigating away before saving. const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit); usePageTitle(outfitState.name || "Untitled outfit"); @@ -49,6 +51,27 @@ function WardrobePage() { } }, [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 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 // relationships more explicit... but there are some deep components // that need it, where it's more useful and more performant to access @@ -58,6 +81,21 @@ function WardrobePage() { + + {/* + * 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 + */} + +