diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03d..40c3d680 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/package-lock.json b/package-lock.json index a0d833fb..feb6353a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "html-to-text": "^9.0.5", "js-cookie": "^3.0.5", "next": "^14.2.10", + "qs": "^6.13.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-intersection-observer": "^9.10.3", @@ -5133,11 +5134,11 @@ "dev": true }, "node_modules/qs": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", - "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz", + "integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" diff --git a/package.json b/package.json index e5599c8e..ea22bf2d 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "html-to-text": "^9.0.5", "js-cookie": "^3.0.5", "next": "^14.2.10", + "qs": "^6.13.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-intersection-observer": "^9.10.3", diff --git a/src/app/mentors/page.tsx b/src/app/mentors/page.tsx index 58f5a4ef..8ac26b1b 100644 --- a/src/app/mentors/page.tsx +++ b/src/app/mentors/page.tsx @@ -20,6 +20,7 @@ import ClearFacets from "../../components/ResetButton"; import ResultView from "../../components/ResultView"; import "@elastic/react-search-ui-views/lib/styles/styles.css"; import "../../styles/App.css"; +import { useNextRouting } from "../../utils/useNextRouting"; const App = () => { const WAVE = { waveId: "2024", waveName: "2024 Wave" }; @@ -75,10 +76,15 @@ const App = () => { }, }; + const configWithNextRouting = useNextRouting( + configurationOptions, + "/mentors", + ); + return (
- +
{ - window.history.pushState({}, ""); + // Open the modal + const newUrl = `${window.location.origin}${window.location.pathname}?uid=${id}`; + window.history.pushState({ id }, "", newUrl); setIsModalOpen(true); + + // Track interaction window.umami.track("Click", { id, env: process.env.NODE_ENV }); + + // Log + console.log( + "ResultViewGrid :: handleOpen : new modal opened with URL", + newUrl, + ); }; const handleClose = () => { - window.history.back(); + if (window.history.state && window.history.state.id === id) { + // Case 1: User clicked into the modal -> We should execute `window.history.back()` to return to the previous URL + console.log("ResultViewGrid :: handleClose :: going back"); + window.history.back(); + } else { + // Case 2: User came from a direct link -> We should clear the query string (since we don't know what the previous URL was) + console.log("ResultViewGrid :: handleClose :: clearing query string"); + const newUrl = `${window.location.origin}${window.location.pathname}`; + window.history.replaceState(null, "", newUrl); + } + + setIsModalOpen(false); }; useEffect(() => { - if (isModalOpen) { - window.onpopstate = () => { - setIsModalOpen(false); - }; + // We should open this modal if the URL's `uid` query parameter matches this modal's `id` + const searchParams = new URLSearchParams(window.location.search); + const uidFromUrl = searchParams.get("uid"); + if (uidFromUrl === id) { + console.log("ResultViewGrid :: useEffect :: open the modal"); + setIsModalOpen(true); } - }); + }, [id]); return ( { + // Construct link + const link = `${window.location.origin}${window.location.pathname}?uid=${id}&q=${id}`; + + // Copy link to clipboard + navigator.clipboard.writeText(link).then(() => { + setSnackbarOpen(true); + setTimeout(() => setSnackbarOpen(false), 1000); + }); + }; + return (
@@ -40,6 +54,10 @@ const ResultViewList = ({ }} /> )} + + + +
  • diff --git a/src/utils/preserveTypesEncoder.js b/src/utils/preserveTypesEncoder.js new file mode 100644 index 00000000..6d32a664 --- /dev/null +++ b/src/utils/preserveTypesEncoder.js @@ -0,0 +1,43 @@ +// From https://github.com/elastic/search-ui/blob/main/packages/search-ui/src/preserveTypesEncoder.ts +// License: Apache-2.0 + +function isTypeNumber(value) { + return value !== undefined && value !== null && typeof value === "number"; +} + +function isTypeBoolean(value) { + return value && typeof value === "boolean"; +} + +function toBoolean(value) { + if (value === "true") return true; + if (value === "false") return false; + throw "Invalid type parsed as Boolean value"; +} + +/* Encoder for qs library which preserve number types on the URL. Numbers + are padded with "n_{number}_n", and booleans with "b_{boolean}_b"*/ + +export default { + encode(value, encode) { + if (isTypeNumber(value)) { + return `n_${value}_n`; + } + if (isTypeBoolean(value)) { + return `b_${value}_b`; + } + return encode(value); + }, + decode(value, decode) { + //eslint-disable-next-line + if (/n_-?[\d\.]*_n/.test(value)) { + const numericValueString = value.substring(2, value.length - 2); + return Number(numericValueString); + } + if (/^b_(true|false)*_b$/.test(value)) { + const booleanValueString = value.substring(2, value.length - 2); + return toBoolean(booleanValueString); + } + return decode(value); + }, +}; diff --git a/src/utils/useNextRouting.ts b/src/utils/useNextRouting.ts new file mode 100644 index 00000000..174f6414 --- /dev/null +++ b/src/utils/useNextRouting.ts @@ -0,0 +1,82 @@ +import { + Filter, + RequestState, + SearchDriverOptions, + SortOption, +} from "@elastic/search-ui"; +import { usePathname, useRouter } from "next/navigation"; +var qs = require("qs"); +import preserveTypesEncoder from "./preserveTypesEncoder"; + +export const useNextRouting = ( + config: SearchDriverOptions, + basePathUrl: string, +) => { + const router = useRouter(); + const pathName = usePathname(); + + // From https://github.com/elastic/search-ui/blob/6583ad0c03056b3df0541d585337dfad12c16272/packages/search-ui/src/URLManager.ts#L9 + type QueryParams = { + filters?: Filter[]; + current?: number; + q?: string; + size?: number; + "sort-field"?: string; + "sort-direction"?: string; + sort?: SortOption[]; + }; + + // From https://github.com/elastic/search-ui/blob/6583ad0c03056b3df0541d585337dfad12c16272/packages/search-ui/src/URLManager.ts#L84 + function stateToParams({ + searchTerm, + current, + filters, + resultsPerPage, + sortDirection, + sortField, + sortList, + }: RequestState): QueryParams { + const params: QueryParams = {}; + if (current !== undefined && current > 1) params.current = current; + if (searchTerm) params.q = searchTerm; + if (resultsPerPage) params.size = resultsPerPage; + if (filters && filters.length > 0) { + params["filters"] = filters; + } + if (sortList && sortList.length > 0) { + params["sort"] = sortList; + } else if (sortField) { + params["sort-field"] = sortField; + params["sort-direction"] = sortDirection; + } + return params; + } + + // From https://github.com/elastic/search-ui/blob/6583ad0c03056b3df0541d585337dfad12c16272/packages/search-ui/src/URLManager.ts#L109 + function stateToQueryString(state: RequestState): string { + return qs.stringify(stateToParams(state), { + encoder: preserveTypesEncoder.encode, + }); + } + + const routingOptions = { + stateToUrl: (state: RequestState) => { + // if URL contains ?uid=... then we are in modal view, and hence we should not update the URL + if (window.location.search.includes("uid=")) { + console.log( + "stateToUrl :: in modal view, not updating URL. URL: ", + window.location.href, + ); + return window.location.search.replace("?", ""); // get the query string without the ?, eg. uid=123 + } else { + console.log("stateToUrl :: updating URL with state: ", state); + return stateToQueryString(state); + } + }, + }; + + return { + ...config, + routingOptions, + }; +};