Skip to content

Commit

Permalink
404 anywhere
Browse files Browse the repository at this point in the history
  • Loading branch information
macfarlandian committed Mar 18, 2021
1 parent 8e7c5e1 commit 8e5afbf
Show file tree
Hide file tree
Showing 10 changed files with 102 additions and 19 deletions.
57 changes: 57 additions & 0 deletions spotlight-client/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
ByRoleOptions,
within,
fireEvent,
waitFor,
} from "@testing-library/react";
import testContent from "./contentApi/sources/us_nd";
import { renderNavigableApp, segmentMock } from "./testUtils";
Expand Down Expand Up @@ -165,4 +166,60 @@ describe("navigation", () => {
);
expect(segmentMock.page).toHaveBeenCalledTimes(3);
});

describe("invalid URLs", () => {
const notFoundRoleArgs = [
"heading",
{ name: /page not found/i, level: 1 },
] as [ByRoleMatcher, ByRoleOptions];

test("invalid tenant", async () => {
renderNavigableApp({ route: "/invalid" });

expect(screen.getByRole(...notFoundRoleArgs)).toBeVisible();
expect(document.title).toBe("Page not found — Spotlight by Recidiviz");

fireEvent.click(screen.getByRole("link", { name: "Spotlight" }));

await waitFor(() =>
expect(screen.queryByRole(...notFoundRoleArgs)).not.toBeInTheDocument()
);
});

test("valid tenant with invalid path", async () => {
renderNavigableApp({ route: "/us-nd/invalid" });
expect(screen.getByRole(...notFoundRoleArgs)).toBeVisible();
expect(document.title).toBe(
"Page not found — North Dakota — Spotlight by Recidiviz"
);

// navigation within the tenant should be available
expect(
screen.getByRole("button", { name: "Data Narratives" })
).toBeVisible();
fireEvent.click(screen.getByRole("link", { name: "North Dakota" }));

await waitFor(() =>
expect(screen.queryByRole(...notFoundRoleArgs)).not.toBeInTheDocument()
);
});

test("invalid narrative", async () => {
renderNavigableApp({ route: "/us-nd/collections/invalid" });
expect(screen.getByRole(...notFoundRoleArgs)).toBeVisible();
expect(document.title).toBe(
"Page not found — North Dakota — Spotlight by Recidiviz"
);

// navigation within the tenant should be available
expect(
screen.getByRole("button", { name: "Data Narratives" })
).toBeVisible();
fireEvent.click(screen.getByRole("link", { name: "North Dakota" }));

await waitFor(() =>
expect(screen.queryByRole(...notFoundRoleArgs)).not.toBeInTheDocument()
);
});
});
});
5 changes: 4 additions & 1 deletion spotlight-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import styled from "styled-components/macro";
import AuthWall from "./AuthWall";
import { FOOTER_HEIGHT, NAV_BAR_HEIGHT } from "./constants";
import GlobalStyles from "./GlobalStyles";
import NotFound from "./NotFound";
import PageNarrative from "./PageNarrative";
import PageNotFound from "./PageNotFound";
import PageTenant from "./PageTenant";
import PageviewTracker from "./PageviewTracker";
import { NarrativesSlug } from "./routerUtils/types";
Expand All @@ -35,6 +35,7 @@ import SiteNavigation from "./SiteNavigation";
import StoreProvider from "./StoreProvider";
import TooltipMobile from "./TooltipMobile";
import { breakpoints } from "./UiLibrary";
import withRouteSync from "./withRouteSync";

// set custom breakpoints for media queries
setupBreakpoints({
Expand All @@ -48,6 +49,8 @@ const PassThroughPage: React.FC<RouteComponentProps> = ({ children }) => (
<>{children}</>
);

const PageNotFound = withRouteSync(NotFound);

const Main = styled.div.attrs({ role: "main" })`
padding-top: ${rem(NAV_BAR_HEIGHT)};
min-height: calc(100vh - ${rem(NAV_BAR_HEIGHT + FOOTER_HEIGHT)});
Expand Down
9 changes: 8 additions & 1 deletion spotlight-client/src/DataStore/UiStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export default class UiStore {

renderTooltipMobile?: (props: Record<string, unknown>) => React.ReactNode;

isRouteInvalid = false;

constructor({ rootStore }: { rootStore: RootStore }) {
makeAutoObservable(this, {
rootStore: false,
Expand Down Expand Up @@ -67,6 +69,7 @@ export default class UiStore {
get currentPageTitle(): string | undefined {
const titleParts: string[] = [];

const { isRouteInvalid } = this;
const { tenant, narrative } = this.rootStore;

if (tenant) {
Expand All @@ -77,7 +80,7 @@ export default class UiStore {
}
}

if (!titleParts.length) {
if (!titleParts.length && !isRouteInvalid) {
// this is valid if we are on the site homepage;
// otherwise it is an intermediate state that should not leak into reactions
if (window.location.pathname !== "/") {
Expand All @@ -87,6 +90,10 @@ export default class UiStore {

titleParts.push("Spotlight by Recidiviz");

if (isRouteInvalid) {
titleParts.unshift("Page not found");
}

return titleParts.join(" — ");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { format } from "d3-format";
import { rem } from "polished";
import React from "react";
import styled from "styled-components/macro";
import { DeepNonNullable } from "utility-types";
import getUrlForResource from "../../routerUtils/getUrlForResource";
import normalizeRouteParams from "../../routerUtils/normalizeRouteParams";
import { LayoutSection } from "../types";
Expand Down Expand Up @@ -60,7 +61,7 @@ const SectionNavigation: React.FC<NavigationProps> = ({
const { tenantId, narrativeTypeId } = normalizeRouteParams(
useParams()
// these keys should always be present on this page
) as Required<
) as DeepNonNullable<
Pick<
ReturnType<typeof normalizeRouteParams>,
"tenantId" | "narrativeTypeId"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { rem } from "polished";
import React from "react";
import styled from "styled-components/macro";
import { breakpoints, CopyBlock, PageSection, PageTitle } from "../UiLibrary";
import withRouteSync from "../withRouteSync";

const Wrapper = styled(PageSection)`
margin: ${rem(48)} 0;
Expand All @@ -30,7 +29,7 @@ const Wrapper = styled(PageSection)`
}
`;

const PageNotFound = (): React.ReactElement => {
const NotFound = (): React.ReactElement => {
return (
<Wrapper>
<PageTitle>Page not found.</PageTitle>
Expand All @@ -42,4 +41,4 @@ const PageNotFound = (): React.ReactElement => {
);
};

export default withRouteSync(PageNotFound);
export default NotFound;
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Recidiviz - a data platform for criminal justice reform
// Copyright (C) 2020 Recidiviz, Inc.
// Copyright (C) 2021 Recidiviz, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
Expand All @@ -15,4 +15,4 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// =============================================================================

export { default } from "./PageNotFound";
export { default } from "./NotFound";
3 changes: 2 additions & 1 deletion spotlight-client/src/routerUtils/getUrlForResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@

import assertNever from "assert-never";
import { paramCase } from "change-case";
import { DeepNonNullable } from "utility-types";
import { NarrativesSlug, NormalizedRouteParams } from "./types";

function makeRouteParam(param: string) {
return paramCase(param);
}

type RequiredParams = Required<NormalizedRouteParams>;
type RequiredParams = DeepNonNullable<NormalizedRouteParams>;

type GetUrlOptions =
| { page: "home" }
Expand Down
5 changes: 3 additions & 2 deletions spotlight-client/src/routerUtils/normalizeRouteParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ function normalizeTenantId(rawParam: ValuesType<RouteParams>) {
if (typeof rawParam === "string") {
const normalizedString = constantCase(rawParam);
if (isTenantId(normalizedString)) return normalizedString;
throw new Error(`unknown TenantId: ${normalizedString}`);

return null;
}
return undefined;
}
Expand All @@ -49,7 +50,7 @@ function normalizeNarrativeTypeId(rawParam: ValuesType<RouteParams>) {

if (isNarrativeTypeId(normalizedString)) return normalizedString;

throw new Error(`unknown narrative type id: ${normalizedString}`);
return null;
}
return undefined;
}
4 changes: 2 additions & 2 deletions spotlight-client/src/routerUtils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export type RouteParams = {
};

export type NormalizedRouteParams = {
tenantId?: TenantId;
narrativeTypeId?: NarrativeTypeId;
tenantId?: TenantId | null;
narrativeTypeId?: NarrativeTypeId | null;
};

export const NarrativesSlug = "collections";
26 changes: 20 additions & 6 deletions spotlight-client/src/withRouteSync/withRouteSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@

import { RouteComponentProps } from "@reach/router";
import { action } from "mobx";
import React, { useEffect } from "react";
import React, { ComponentType, useEffect } from "react";
import NotFound from "../NotFound";
import normalizeRouteParams from "../routerUtils/normalizeRouteParams";
import { RouteParams } from "../routerUtils/types";
import { useDataStore } from "../StoreProvider";
Expand All @@ -30,20 +31,33 @@ import { useDataStore } from "../StoreProvider";
* All Reach Router route components should be wrapped in this HOC!
*/
const withRouteSync = <Props extends RouteComponentProps & RouteParams>(
RouteComponent: React.FC<Props>
): React.FC<Props> => {
RouteComponent: ComponentType<Props>
): ComponentType<Props> => {
const WrappedRouteComponent: React.FC<Props> = (props) => {
const { tenantStore } = useDataStore();
const { tenantStore, uiStore } = useDataStore();

const normalizedProps = normalizeRouteParams(props);

const { path } = props;

const isRouteInvalid =
// catchall path for partially valid URLs; e.g. :tenantId/something-invalid
Object.values(normalizedProps).includes(null) || path === "/*";

useEffect(
action("sync route params", () => {
tenantStore.currentTenantId = normalizedProps.tenantId;
tenantStore.currentNarrativeTypeId = normalizedProps.narrativeTypeId;
tenantStore.currentTenantId = normalizedProps.tenantId ?? undefined;
tenantStore.currentNarrativeTypeId =
normalizedProps.narrativeTypeId ?? undefined;

uiStore.isRouteInvalid = isRouteInvalid;
})
);

if (isRouteInvalid) {
return <NotFound />;
}

return (
<RouteComponent
{...props}
Expand Down

0 comments on commit 8e5afbf

Please sign in to comment.