From eb602556bf30db9aa22bc6ec0c978fd4c54eab25 Mon Sep 17 00:00:00 2001 From: Matchu Date: Thu, 15 Sep 2022 00:27:49 -0700 Subject: [PATCH] [WIP] Migrate outfit page, with known bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Okay so there's a bug here where navigating directly to /outfits/new?species=X&color=Y will reset to a Blue Acara, because Next.js statically renders the Blue Acara on build, and then rehydrates a Blue Acara on load, and then updates the real page query in—and our state management for outfits doesn't *listen* to URL changes, it only *emits* them. It'd be good to consider like… changing that? It's tricky because our state model is… not simple, when you consider that we have both local state and URL state and saved-outfit state in play. But it could be done! But there might be another option too. I'll take a look at this after moving the home page, which will give me the chance to see what the experience navigating in from there is like! --- pages/outfits/{[id].js => [outfitId].tsx} | 49 +++++++++------ pages/outfits/new.js | 19 ------ pages/outfits/new.tsx | 10 +++ src/app/App.js | 10 --- src/app/WardrobePage/Item.js | 21 +++++-- src/app/WardrobePage/ItemsPanel.js | 6 +- src/app/WardrobePage/OutfitControls.js | 19 +++--- src/app/WardrobePage/index.js | 34 +++++++++- src/app/WardrobePage/useOutfitSaving.js | 10 +-- src/app/WardrobePage/useOutfitState.js | 75 ++++++++++++++++++----- 10 files changed, 167 insertions(+), 86 deletions(-) rename pages/outfits/{[id].js => [outfitId].tsx} (64%) delete mode 100644 pages/outfits/new.js create mode 100644 pages/outfits/new.tsx diff --git a/pages/outfits/[id].js b/pages/outfits/[outfitId].tsx similarity index 64% rename from pages/outfits/[id].js rename to pages/outfits/[outfitId].tsx index 91b0167..d679409 100644 --- a/pages/outfits/[id].js +++ b/pages/outfits/[outfitId].tsx @@ -2,34 +2,38 @@ // extra SSR for outfit sharing meta tags! import Head from "next/head"; +import { NextPageWithLayout } from "../_app"; +import WardrobePage from "../../src/app/WardrobePage"; +// @ts-ignore: doesn't understand module.exports import connectToDb from "../../src/server/db"; +// @ts-ignore: doesn't understand module.exports import { normalizeRow } from "../../src/server/util"; +import { GetServerSideProps } from "next"; -// import NextIndexWrapper from '../../src' +type Outfit = { + id: string; + name: string; + updatedAt: string; +}; +type PageProps = { + outfit: Outfit; +}; -// next/dynamic is used to prevent breaking incompatibilities -// with SSR from window.SOME_VAR usage, if this is not used -// next/dynamic can be removed to take advantage of SSR/prerendering -import dynamic from "next/dynamic"; - -// try changing "ssr" to true below to test for incompatibilities, if -// no errors occur the above static import can be used instead and the -// below removed -const App = dynamic(() => import("../../src/app/App"), { ssr: false }); - -export default function Page({ outfit, ...props }) { +const WardrobePageWrapper: NextPageWithLayout = ({ outfit }) => { return ( <> {outfit.name || "Untitled outfit"} | Dress to Impress - + ); -} +}; -function OutfitMetaTags({ outfit }) { +WardrobePageWrapper.renderWithLayout = (children) => children; + +function OutfitMetaTags({ outfit }: { outfit: Outfit }) { const updatedAtTimestamp = Math.floor( new Date(outfit.updatedAt).getTime() / 1000 ); @@ -57,8 +61,13 @@ function OutfitMetaTags({ outfit }) { ); } -export async function getServerSideProps({ params }) { - const outfit = await loadOutfitData(params.id); +export const getServerSideProps: GetServerSideProps = async ({ params }) => { + const outfitId = params?.outfitId; + if (typeof outfitId !== "string") { + throw new Error(`assertion failed: outfitId route param is missing`); + } + + const outfit = await loadOutfitData(outfitId); if (outfit == null) { return { notFound: true }; } @@ -72,9 +81,9 @@ export async function getServerSideProps({ params }) { }, }, }; -} +}; -async function loadOutfitData(id) { +async function loadOutfitData(id: string) { const db = await connectToDb(); const [rows] = await db.query(`SELECT * FROM outfits WHERE id = ?;`, [id]); if (rows.length === 0) { @@ -83,3 +92,5 @@ async function loadOutfitData(id) { return normalizeRow(rows[0]); } + +export default WardrobePageWrapper; diff --git a/pages/outfits/new.js b/pages/outfits/new.js deleted file mode 100644 index f04f689..0000000 --- a/pages/outfits/new.js +++ /dev/null @@ -1,19 +0,0 @@ -// This is just a copy of our higher-level catch-all page. -// That way, /outfits/new renders as normal, but /outfits/:slug -// does the SSR thing! - -// import NextIndexWrapper from '../../src' - -// next/dynamic is used to prevent breaking incompatibilities -// with SSR from window.SOME_VAR usage, if this is not used -// next/dynamic can be removed to take advantage of SSR/prerendering -import dynamic from "next/dynamic"; - -// try changing "ssr" to true below to test for incompatibilities, if -// no errors occur the above static import can be used instead and the -// below removed -const App = dynamic(() => import("../../src/app/App"), { ssr: false }); - -export default function Page(props) { - return ; -} diff --git a/pages/outfits/new.tsx b/pages/outfits/new.tsx new file mode 100644 index 0000000..3d3d2b9 --- /dev/null +++ b/pages/outfits/new.tsx @@ -0,0 +1,10 @@ +import WardrobePage from "../../src/app/WardrobePage"; +import type { NextPageWithLayout } from "../_app"; + +const WardrobePageWrapper: NextPageWithLayout = () => { + return ; +}; + +WardrobePageWrapper.renderWithLayout = (children) => children; + +export default WardrobePageWrapper; diff --git a/src/app/App.js b/src/app/App.js index 9eaf8e9..dafbe94 100644 --- a/src/app/App.js +++ b/src/app/App.js @@ -7,13 +7,9 @@ import { } from "react-router-dom"; import PageLayout from "./PageLayout"; -import WardrobePageLayout from "./WardrobePage/WardrobePageLayout"; import { loadable } from "./util"; const HomePage = loadable(() => import("./HomePage")); -const WardrobePage = loadable(() => import("./WardrobePage"), { - fallback: , -}); /** * App is the entry point of our application. There's not a ton of exciting @@ -27,12 +23,6 @@ function App() { - - - - - - diff --git a/src/app/WardrobePage/Item.js b/src/app/WardrobePage/Item.js index a7c12e3..e2ba6d5 100644 --- a/src/app/WardrobePage/Item.js +++ b/src/app/WardrobePage/Item.js @@ -10,7 +10,7 @@ import { useTheme, } from "@chakra-ui/react"; import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons"; -import { Link } from "react-router-dom"; +import Link from "next/link"; import { loadable } from "../util"; import { @@ -249,13 +249,13 @@ function ItemActionButton({ icon, label, to, onClick }) { {({ css }) => ( - + + + ); + } else { + return ; + } +} + /** * ItemListContainer is a container for Item components! Wrap your Item * components in this to ensure a consistent list layout. diff --git a/src/app/WardrobePage/ItemsPanel.js b/src/app/WardrobePage/ItemsPanel.js index 4e984e1..ed7cfa5 100644 --- a/src/app/WardrobePage/ItemsPanel.js +++ b/src/app/WardrobePage/ItemsPanel.js @@ -35,7 +35,6 @@ import { WarningTwoIcon, } from "@chakra-ui/icons"; import { CSSTransition, TransitionGroup } from "react-transition-group"; -import { useHistory } from "react-router-dom"; import { Delay, @@ -50,6 +49,7 @@ import { IoCloudUploadOutline } from "react-icons/io5"; import { MdMoreVert } from "react-icons/md"; import { buildOutfitUrl } from "./useOutfitState"; import { gql, useMutation } from "@apollo/client"; +import { useRouter } from "next/router"; /** * ItemsPanel shows the items in the current outfit, and lets the user toggle @@ -455,7 +455,7 @@ function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) { function DeleteOutfitMenuItem({ outfitState }) { const { id, name } = outfitState; const { isOpen, onOpen, onClose } = useDisclosure(); - const history = useHistory(); + const { push: pushHistory } = useRouter(); const [sendDeleteOutfitMutation, { loading, error }] = useMutation( gql` @@ -506,7 +506,7 @@ function DeleteOutfitMenuItem({ outfitState }) { onClick={() => sendDeleteOutfitMutation({ variables: { id } }) .then(() => { - history.push(`/your-outfits`); + pushHistory(`/your-outfits`); }) .catch((e) => { /* handled in error UI */ diff --git a/src/app/WardrobePage/OutfitControls.js b/src/app/WardrobePage/OutfitControls.js index 89957a3..814d77b 100644 --- a/src/app/WardrobePage/OutfitControls.js +++ b/src/app/WardrobePage/OutfitControls.js @@ -33,7 +33,7 @@ import { SettingsIcon, } from "@chakra-ui/icons"; import { MdPause, MdPlayArrow } from "react-icons/md"; -import { Link } from "react-router-dom"; +import Link from "next/link"; import { getBestImageUrlForLayer } from "../components/OutfitPreview"; import HTML5Badge, { layerUsesHTML5 } from "../components/HTML5Badge"; @@ -318,14 +318,15 @@ function BackButton({ outfitState }) { outfitState.creator && outfitState.creator.id === currentUser.id; return ( - } - aria-label="Leave this outfit" - d="inline-flex" // Not sure why requires this to style right! ^^` - data-test-id="wardrobe-nav-back-button" - /> + + } + 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 572fa81..746a1ea 100644 --- a/src/app/WardrobePage/index.js +++ b/src/app/WardrobePage/index.js @@ -1,5 +1,4 @@ import React from "react"; -import { Prompt } from "react-router-dom"; import { useToast } from "@chakra-ui/react"; import { loadable } from "../util"; @@ -11,6 +10,7 @@ import useOutfitState, { OutfitStateContext } from "./useOutfitState"; import { usePageTitle } from "../util"; import WardrobePageLayout from "./WardrobePageLayout"; import WardrobePreviewAndControls from "./WardrobePreviewAndControls"; +import { useRouter } from "next/router"; const WardrobeDevHacks = loadable(() => import("./WardrobeDevHacks")); @@ -119,4 +119,36 @@ function WardrobePage() { ); } +/** + * Prompt blocks client-side navigation via Next.js when the `when` prop is + * true. This is our attempt at a drop-in replacement for the Prompt component + * offered by react-router! + * + * Adapted from https://github.com/vercel/next.js/issues/2694#issuecomment-778225625 + */ +function Prompt({ when, message }) { + const router = useRouter(); + React.useEffect(() => { + const handleWindowClose = (e) => { + if (!when) return; + e.preventDefault(); + return (e.returnValue = message); + }; + const handleBrowseAway = () => { + if (!when) return; + if (window.confirm(message)) return; + router.events.emit("routeChangeError"); + throw "routeChange aborted by ."; + }; + window.addEventListener("beforeunload", handleWindowClose); + router.events.on("routeChangeStart", handleBrowseAway); + return () => { + window.removeEventListener("beforeunload", handleWindowClose); + router.events.off("routeChangeStart", handleBrowseAway); + }; + }, [when, message, router]); + + return null; +} + export default WardrobePage; diff --git a/src/app/WardrobePage/useOutfitSaving.js b/src/app/WardrobePage/useOutfitSaving.js index 58d4265..6e04409 100644 --- a/src/app/WardrobePage/useOutfitSaving.js +++ b/src/app/WardrobePage/useOutfitSaving.js @@ -1,6 +1,6 @@ import React from "react"; import { useToast } from "@chakra-ui/react"; -import { useHistory } from "react-router-dom"; +import { useRouter } from "next/router"; import { useDebounce } from "../util"; import useCurrentUser from "../components/useCurrentUser"; import gql from "graphql-tag"; @@ -9,7 +9,7 @@ import { outfitStatesAreEqual } from "./useOutfitState"; function useOutfitSaving(outfitState, dispatchToOutfit) { const { isLoggedIn, id: currentUserId } = useCurrentUser(); - const history = useHistory(); + const { pathname, push: pushHistory } = useRouter(); const toast = useToast(); // There's not a way to reset an Apollo mutation state to clear out the error @@ -158,8 +158,8 @@ function useOutfitSaving(outfitState, dispatchToOutfit) { // Navigate to the new saved outfit URL. Our Apollo cache should pick // up the data from this mutation response, and combine it with the // existing cached data, to make this smooth without any loading UI. - if (history.location.pathname !== `/outfits/${outfit.id}`) { - history.push(`/outfits/${outfit.id}`); + if (pathname !== `/outfits/[outfitId]`) { + pushHistory(`/outfits/${outfit.id}`); } }) .catch((e) => { @@ -175,7 +175,7 @@ function useOutfitSaving(outfitState, dispatchToOutfit) { // It's important that this callback _doesn't_ change when the outfit // changes, so that the auto-save effect is only responding to the // debounced state! - [sendSaveOutfitMutation, history, toast] + [sendSaveOutfitMutation, pathname, pushHistory, toast] ); const saveOutfit = React.useCallback( diff --git a/src/app/WardrobePage/useOutfitState.js b/src/app/WardrobePage/useOutfitState.js index 9da074f..04c9b2b 100644 --- a/src/app/WardrobePage/useOutfitState.js +++ b/src/app/WardrobePage/useOutfitState.js @@ -2,9 +2,9 @@ import React from "react"; import gql from "graphql-tag"; import produce, { enableMapSet } from "immer"; import { useQuery, useApolloClient } from "@apollo/client"; -import { useLocation, useParams } from "react-router-dom"; import { itemAppearanceFragment } from "../components/useOutfitAppearance"; +import { useRouter } from "next/router"; enableMapSet(); @@ -249,8 +249,13 @@ function useOutfitState() { }; // Keep the URL up-to-date. (We don't listen to it, though 😅) + // TODO: Seems like we should hook this in with the actual router... I'm + // avoiding it rn, but I'm worried Next.js won't necessarily play nice + // with this hack, even though react-router did. Hard to predict! React.useEffect(() => { - window.history.replaceState(null, "", url); + if (typeof history !== "undefined") { + history.replaceState(null, "", url); + } }, [url]); return { @@ -369,39 +374,74 @@ const EMPTY_CUSTOMIZATION_STATE = { }; function useParseOutfitUrl() { - const { id } = useParams(); - const { search } = useLocation(); + const { query } = useRouter(); + const { outfitId } = query; // We memoize this to make `outfitStateWithoutExtras` an even more reliable // stable object! const memoizedOutfitState = React.useMemo(() => { // For the /outfits/:id page, ignore the query string, and just wait for the // outfit data to load in! - if (id != null) { + if (outfitId != null) { return { ...EMPTY_CUSTOMIZATION_STATE, - id, + id: outfitId, }; } // Otherwise, parse the query string, and fill in default values for anything // not specified. - const urlParams = new URLSearchParams(search); return { id: null, - name: urlParams.get("name"), - speciesId: urlParams.get("species") || "1", - colorId: urlParams.get("color") || "8", - pose: urlParams.get("pose") || "HAPPY_FEM", - appearanceId: urlParams.get("state") || null, - wornItemIds: new Set(urlParams.getAll("objects[]")), - closetedItemIds: new Set(urlParams.getAll("closet[]")), + name: getValueFromQuery(query.name), + speciesId: getValueFromQuery(query.species) || "1", + colorId: getValueFromQuery(query.color) || "8", + pose: getValueFromQuery(query.pose) || "HAPPY_FEM", + appearanceId: getValueFromQuery(query.state) || null, + wornItemIds: new Set(getListFromQuery(query["objects[]"])), + closetedItemIds: new Set(getListFromQuery(query["closet[]"])), }; - }, [id, search]); + }, [outfitId, query]); return memoizedOutfitState; } +/** + * getValueFromQuery reads the given value from Next's `router.query` as a + * single value. For example: + * + * ?foo=bar -> "bar" -> "bar" + * ?foo=bar&foo=baz -> ["bar", "baz"] -> "bar" + * ?lol=huh -> undefined -> null + */ +function getValueFromQuery(value) { + if (Array.isArray(value)) { + return value[0]; + } else if (value != null) { + return value; + } else { + return null; + } +} + +/** + * getListFromQuery reads the given value from Next's `router.query` as a list + * of values. For example: + * + * ?foo=bar -> "bar" -> ["bar"] + * ?foo=bar&foo=baz -> ["bar", "baz"] -> ["bar", "baz"] + * ?lol=huh -> undefined -> [] + */ +function getListFromQuery(value) { + if (Array.isArray(value)) { + return value; + } else if (value != null) { + return [value]; + } else { + return []; + } +} + function getOutfitStateFromOutfitData(outfit) { if (!outfit) { return EMPTY_CUSTOMIZATION_STATE; @@ -602,7 +642,10 @@ function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) { export function buildOutfitUrl(outfitState, { withoutOutfitId = false } = {}) { const { id } = outfitState; - const { origin } = window.location; + const origin = + typeof window !== "undefined" + ? window.location.origin + : "https://impress-2020.openneo.net"; if (id && !withoutOutfitId) { return origin + `/outfits/${id}`;