Warn user of unsaved outfit changes
This commit is contained in:
parent
a69bc8184a
commit
76aca48b43
4 changed files with 84 additions and 1 deletions
|
@ -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)
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue