From 88906a9aff3d33bbbc353e702df598e4d0ec13de Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Fri, 5 Mar 2021 09:43:26 -0800 Subject: [PATCH 01/11] add RD to tenant --- .../src/contentModels/Tenant.test.ts | 19 +++++++++++++++++++ spotlight-client/src/contentModels/Tenant.ts | 14 ++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/spotlight-client/src/contentModels/Tenant.test.ts b/spotlight-client/src/contentModels/Tenant.test.ts index c1e99586..9878fd73 100644 --- a/spotlight-client/src/contentModels/Tenant.test.ts +++ b/spotlight-client/src/contentModels/Tenant.test.ts @@ -24,6 +24,7 @@ import { } from "../contentApi/types"; import Collection from "./Collection"; import Metric from "./Metric"; +import RacialDisparitiesNarrative from "./RacialDisparitiesNarrative"; import { createTenant } from "./Tenant"; import exhaustiveFixture from "./__fixtures__/tenant_content_exhaustive"; import partialFixture from "./__fixtures__/tenant_content_partial"; @@ -190,3 +191,21 @@ test.each([ Object.keys(tenant.systemNarratives).length ); }); + +test("tenant has racial disparities narrative", () => { + retrieveContentMock.mockReturnValue(exhaustiveFixture); + + const tenant = createTenant({ tenantId: "US_ND" }); + + expect(tenant.racialDisparitiesNarrative).toBeInstanceOf( + RacialDisparitiesNarrative + ); +}); + +test("tenant does not have racial disparities narrative", () => { + retrieveContentMock.mockReturnValue(partialFixture); + + const tenant = createTenant({ tenantId: "US_ND" }); + + expect(tenant.racialDisparitiesNarrative).toBeUndefined(); +}); diff --git a/spotlight-client/src/contentModels/Tenant.ts b/spotlight-client/src/contentModels/Tenant.ts index 496d8356..f2f79346 100644 --- a/spotlight-client/src/contentModels/Tenant.ts +++ b/spotlight-client/src/contentModels/Tenant.ts @@ -23,6 +23,7 @@ import { } from "../contentApi/types"; import { createCollection } from "./Collection"; import createMetricMapping from "./createMetricMapping"; +import RacialDisparitiesNarrative from "./RacialDisparitiesNarrative"; import { createSystemNarrative } from "./SystemNarrative"; import { CollectionMap, MetricMapping, SystemNarrativeMapping } from "./types"; @@ -33,6 +34,7 @@ type InitOptions = { collections: CollectionMap; metrics: MetricMapping; systemNarratives: SystemNarrativeMapping; + racialDisparitiesNarrative?: RacialDisparitiesNarrative; }; /** @@ -55,6 +57,8 @@ export default class Tenant { readonly systemNarratives: SystemNarrativeMapping; + readonly racialDisparitiesNarrative?: RacialDisparitiesNarrative; + constructor({ id, name, @@ -62,6 +66,7 @@ export default class Tenant { collections, metrics, systemNarratives, + racialDisparitiesNarrative, }: InitOptions) { this.id = id; this.name = name; @@ -69,6 +74,7 @@ export default class Tenant { this.collections = collections; this.metrics = metrics; this.systemNarratives = systemNarratives; + this.racialDisparitiesNarrative = racialDisparitiesNarrative; } } @@ -137,6 +143,13 @@ export function createTenant({ tenantId }: TenantFactoryOptions): Tenant { const metrics = getMetricsForTenant(allTenantContent, tenantId); + const racialDisparitiesNarrative = + allTenantContent.racialDisparitiesNarrative && + RacialDisparitiesNarrative.build({ + tenantId, + content: allTenantContent.racialDisparitiesNarrative, + }); + return new Tenant({ id: tenantId, name: allTenantContent.name, @@ -147,5 +160,6 @@ export function createTenant({ tenantId }: TenantFactoryOptions): Tenant { allTenantContent, metrics, }), + racialDisparitiesNarrative, }); } From fc4b423430789bb5c366f968975b8aed260c2bb2 Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Fri, 5 Mar 2021 10:21:50 -0800 Subject: [PATCH 02/11] add RD to URL routing --- spotlight-client/src/DataStore/TenantStore.ts | 24 +++++++++++++++---- .../src/DataStore/UiStore.test.ts | 8 +++++++ spotlight-client/src/DataStore/UiStore.ts | 11 ++++++++- .../src/PageNarrative/PageNarrative.tsx | 19 +++++++++++---- .../SystemNarrativePageContainer.tsx | 6 ++++- spotlight-client/src/contentApi/types.ts | 5 ++++ .../src/routerUtils/normalizeRouteParams.ts | 6 +++-- spotlight-client/src/routerUtils/types.ts | 4 ++-- 8 files changed, 67 insertions(+), 16 deletions(-) diff --git a/spotlight-client/src/DataStore/TenantStore.ts b/spotlight-client/src/DataStore/TenantStore.ts index 1aac4af7..9c50d2c3 100644 --- a/spotlight-client/src/DataStore/TenantStore.ts +++ b/spotlight-client/src/DataStore/TenantStore.ts @@ -16,13 +16,18 @@ // ============================================================================= import { makeAutoObservable } from "mobx"; -import { SystemNarrativeTypeId, TenantId } from "../contentApi/types"; +import { + isSystemNarrativeTypeId, + NarrativeTypeId, + TenantId, +} from "../contentApi/types"; +import RacialDisparitiesNarrative from "../contentModels/RacialDisparitiesNarrative"; import type SystemNarrative from "../contentModels/SystemNarrative"; import Tenant, { createTenant } from "../contentModels/Tenant"; import type RootStore from "./RootStore"; export default class TenantStore { - currentNarrativeTypeId?: SystemNarrativeTypeId; + currentNarrativeTypeId?: NarrativeTypeId; currentTenantId?: TenantId; @@ -54,8 +59,17 @@ export default class TenantStore { return this.tenants.get(this.currentTenantId); } - get currentNarrative(): SystemNarrative | undefined { - if (!this.currentNarrativeTypeId || !this.currentTenant) return undefined; - return this.currentTenant.systemNarratives[this.currentNarrativeTypeId]; + get currentNarrative(): + | SystemNarrative + | RacialDisparitiesNarrative + | undefined { + const { currentNarrativeTypeId, currentTenant } = this; + if (!currentNarrativeTypeId || !currentTenant) return undefined; + + if (isSystemNarrativeTypeId(currentNarrativeTypeId)) { + return currentTenant.systemNarratives[currentNarrativeTypeId]; + } + + return currentTenant.racialDisparitiesNarrative; } } diff --git a/spotlight-client/src/DataStore/UiStore.test.ts b/spotlight-client/src/DataStore/UiStore.test.ts index f3e28cac..2c5917e4 100644 --- a/spotlight-client/src/DataStore/UiStore.test.ts +++ b/spotlight-client/src/DataStore/UiStore.test.ts @@ -77,6 +77,14 @@ test("current page ID", () => { expect(store.currentPageId).toBe("US_ND::Parole"); }); + runInAction(() => { + rootStore.tenantStore.currentNarrativeTypeId = "RacialDisparities"; + }); + + reactImmediately(() => { + expect(store.currentPageId).toBe("US_ND::RacialDisparities"); + }); + runInAction(() => { rootStore.tenantStore.currentTenantId = undefined; }); diff --git a/spotlight-client/src/DataStore/UiStore.ts b/spotlight-client/src/DataStore/UiStore.ts index ed948bba..9677b23a 100644 --- a/spotlight-client/src/DataStore/UiStore.ts +++ b/spotlight-client/src/DataStore/UiStore.ts @@ -16,6 +16,7 @@ // ============================================================================= import { makeAutoObservable, observable } from "mobx"; +import SystemNarrative from "../contentModels/SystemNarrative"; import type RootStore from "./RootStore"; export default class UiStore { @@ -52,9 +53,17 @@ export default class UiStore { if (tenant) { idParts.push(tenant.id); + let narrativeId; + if (narrative) { - idParts.push(narrative.id); + if (narrative instanceof SystemNarrative) { + narrativeId = narrative.id; + } else { + narrativeId = "RacialDisparities"; + } } + + if (narrativeId) idParts.push(narrativeId); } return idParts.join("::"); diff --git a/spotlight-client/src/PageNarrative/PageNarrative.tsx b/spotlight-client/src/PageNarrative/PageNarrative.tsx index 12b3dab5..cddd8d3d 100644 --- a/spotlight-client/src/PageNarrative/PageNarrative.tsx +++ b/spotlight-client/src/PageNarrative/PageNarrative.tsx @@ -15,24 +15,33 @@ // along with this program. If not, see . // ============================================================================= -import { RouteComponentProps } from "@reach/router"; +import { Redirect, RouteComponentProps } from "@reach/router"; import useBreakpoint from "@w11r/use-breakpoint"; import React from "react"; -import { SystemNarrativeTypeId } from "../contentApi/types"; +import { isSystemNarrativeTypeId, NarrativeTypeId } from "../contentApi/types"; import NarrativeFooter from "../NarrativeFooter"; +import { NarrativesSlug } from "../routerUtils/types"; import SystemNarrativePage from "../SystemNarrativePage"; import withRouteSync from "../withRouteSync"; type PageNarrativeProps = RouteComponentProps & { - narrativeTypeId?: SystemNarrativeTypeId; + narrativeTypeId?: NarrativeTypeId; }; -const PageNarrative: React.FC = () => { +const PageNarrative: React.FC = ({ narrativeTypeId }) => { const showFooter = useBreakpoint(true, ["mobile-", false]); + if (narrativeTypeId === undefined) { + return ; + } + return ( <> - + {isSystemNarrativeTypeId(narrativeTypeId) ? ( + + ) : ( +
TK
+ )} {showFooter && } ); diff --git a/spotlight-client/src/SystemNarrativePage/SystemNarrativePageContainer.tsx b/spotlight-client/src/SystemNarrativePage/SystemNarrativePageContainer.tsx index 0cf8b50e..4a2dcf66 100644 --- a/spotlight-client/src/SystemNarrativePage/SystemNarrativePageContainer.tsx +++ b/spotlight-client/src/SystemNarrativePage/SystemNarrativePageContainer.tsx @@ -17,13 +17,17 @@ import { observer } from "mobx-react-lite"; import React from "react"; +import SystemNarrative from "../contentModels/SystemNarrative"; import { useDataStore } from "../StoreProvider"; import SystemNarrativePage from "./SystemNarrativePage"; const SystemNarrativePageContainer: React.FC = () => { const { narrative } = useDataStore(); - if (narrative) return ; + if (narrative instanceof SystemNarrative) { + return ; + } + return null; }; diff --git a/spotlight-client/src/contentApi/types.ts b/spotlight-client/src/contentApi/types.ts index 9c1cb1cb..aa4b002d 100644 --- a/spotlight-client/src/contentApi/types.ts +++ b/spotlight-client/src/contentApi/types.ts @@ -129,6 +129,11 @@ export function isSystemNarrativeTypeId(x: string): x is SystemNarrativeTypeId { return SystemNarrativeTypeIdList.includes(x as SystemNarrativeTypeId); } +export type NarrativeTypeId = SystemNarrativeTypeId | "RacialDisparities"; +export function isNarrativeTypeId(x: string): x is NarrativeTypeId { + return isSystemNarrativeTypeId(x) || x === "RacialDisparities"; +} + type SystemNarrativeSection = { title: string; body: string; diff --git a/spotlight-client/src/routerUtils/normalizeRouteParams.ts b/spotlight-client/src/routerUtils/normalizeRouteParams.ts index ba542137..63b31789 100644 --- a/spotlight-client/src/routerUtils/normalizeRouteParams.ts +++ b/spotlight-client/src/routerUtils/normalizeRouteParams.ts @@ -17,7 +17,7 @@ import { constantCase, pascalCase } from "change-case"; import { ValuesType } from "utility-types"; -import { isSystemNarrativeTypeId, isTenantId } from "../contentApi/types"; +import { isNarrativeTypeId, isTenantId } from "../contentApi/types"; import { NormalizedRouteParams, RouteParams } from "./types"; /** @@ -46,7 +46,9 @@ function normalizeTenantId(rawParam: ValuesType) { function normalizeNarrativeTypeId(rawParam: ValuesType) { if (typeof rawParam === "string") { const normalizedString = pascalCase(rawParam); - if (isSystemNarrativeTypeId(normalizedString)) return normalizedString; + + if (isNarrativeTypeId(normalizedString)) return normalizedString; + throw new Error(`unknown narrative type id: ${normalizedString}`); } return undefined; diff --git a/spotlight-client/src/routerUtils/types.ts b/spotlight-client/src/routerUtils/types.ts index eb58d62c..896e5661 100644 --- a/spotlight-client/src/routerUtils/types.ts +++ b/spotlight-client/src/routerUtils/types.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . // ============================================================================= -import { SystemNarrativeTypeId, TenantId } from "../contentApi/types"; +import { NarrativeTypeId, TenantId } from "../contentApi/types"; export type RouteParams = { // these should match paths as defined in App.tsx @@ -25,7 +25,7 @@ export type RouteParams = { export type NormalizedRouteParams = { tenantId?: TenantId; - narrativeTypeId?: SystemNarrativeTypeId; + narrativeTypeId?: NarrativeTypeId; }; export const NarrativesSlug = "collections"; From 3892f7ff1bb98074147034d808e20c091610d42a Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Fri, 5 Mar 2021 14:18:42 -0800 Subject: [PATCH 03/11] add RD to navigation --- spotlight-client/src/App.test.tsx | 25 ++++ spotlight-client/src/DataStore/UiStore.ts | 11 +- .../OtherNarrativeLinks.tsx | 27 ++-- .../src/PageNarrative/PageNarrative.tsx | 3 +- .../RacialDisparitiesNarrativePage.tsx | 50 +++++++ ...acialDisparitiesNarrativePageContainer.tsx | 34 +++++ .../RacialDisparitiesNarrativePage/index.ts | 18 +++ .../SystemNarrativePage.tsx | 123 ++++-------------- spotlight-client/src/UiLibrary/index.ts | 1 + spotlight-client/src/UiLibrary/narrative.ts | 103 +++++++++++++++ .../RacialDisparitiesNarrative.ts | 2 + spotlight-client/src/contentModels/types.ts | 3 + 12 files changed, 280 insertions(+), 120 deletions(-) create mode 100644 spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx create mode 100644 spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePageContainer.tsx create mode 100644 spotlight-client/src/RacialDisparitiesNarrativePage/index.ts create mode 100644 spotlight-client/src/UiLibrary/narrative.ts diff --git a/spotlight-client/src/App.test.tsx b/spotlight-client/src/App.test.tsx index 1dd799d6..6e7fe9e2 100644 --- a/spotlight-client/src/App.test.tsx +++ b/spotlight-client/src/App.test.tsx @@ -82,6 +82,20 @@ describe("navigation", () => { return verifyWithNavigation({ targetPath, lookupArgs }); }); + test("racial disparities narrative page", () => { + expect.hasAssertions(); + const targetPath = "/us-nd/collections/racial-disparities"; + const lookupArgs = [ + "heading", + { + name: "Racial Disparities", + level: 1, + }, + ] as const; + + return verifyWithNavigation({ targetPath, lookupArgs }); + }); + test("links", async () => { const { history: { navigate }, @@ -106,6 +120,17 @@ describe("navigation", () => { }) ).toBeInTheDocument(); + const disparitiesLink = screen.getByRole("link", { + name: "Racial Disparities", + }); + fireEvent.click(disparitiesLink); + expect( + await screen.findByRole("heading", { + name: "Racial Disparities", + level: 1, + }) + ).toBeInTheDocument(); + fireEvent.click(homeLink); expect( await screen.findByRole("heading", { name: "Spotlight", level: 1 }) diff --git a/spotlight-client/src/DataStore/UiStore.ts b/spotlight-client/src/DataStore/UiStore.ts index 9677b23a..ed948bba 100644 --- a/spotlight-client/src/DataStore/UiStore.ts +++ b/spotlight-client/src/DataStore/UiStore.ts @@ -16,7 +16,6 @@ // ============================================================================= import { makeAutoObservable, observable } from "mobx"; -import SystemNarrative from "../contentModels/SystemNarrative"; import type RootStore from "./RootStore"; export default class UiStore { @@ -53,17 +52,9 @@ export default class UiStore { if (tenant) { idParts.push(tenant.id); - let narrativeId; - if (narrative) { - if (narrative instanceof SystemNarrative) { - narrativeId = narrative.id; - } else { - narrativeId = "RacialDisparities"; - } + idParts.push(narrative.id); } - - if (narrativeId) idParts.push(narrativeId); } return idParts.join("::"); diff --git a/spotlight-client/src/OtherNarrativeLinks/OtherNarrativeLinks.tsx b/spotlight-client/src/OtherNarrativeLinks/OtherNarrativeLinks.tsx index ab6c646b..4db95903 100644 --- a/spotlight-client/src/OtherNarrativeLinks/OtherNarrativeLinks.tsx +++ b/spotlight-client/src/OtherNarrativeLinks/OtherNarrativeLinks.tsx @@ -22,7 +22,7 @@ import React from "react"; import { animated, useSpring } from "react-spring/web.cjs"; import styled from "styled-components/macro"; import { TenantId } from "../contentApi/types"; -import SystemNarrative from "../contentModels/SystemNarrative"; +import { Narrative } from "../contentModels/types"; import getUrlForResource from "../routerUtils/getUrlForResource"; import { useDataStore } from "../StoreProvider"; import { breakpoints, colors } from "../UiLibrary"; @@ -50,6 +50,7 @@ const LinkListItem = styled.li` border: 0 solid transparent; border-width: 0 ${rem(32)} ${rem(32)} 0; flex: 0 0 auto; + white-space: nowrap; /* use width to create 1-4 columns, depending on screen size */ width: 100%; @@ -76,10 +77,14 @@ const LinkListItem = styled.li` } `; +const LinkText = styled.span` + white-space: normal; +`; + const NarrativeLink: React.FC<{ - narrative: SystemNarrative; + narrative: Narrative; tenantId: TenantId; -}> = ({ narrative, tenantId }) => { +}> = observer(({ narrative, tenantId }) => { const [animationStyles, setAnimationStyles] = useSpring(() => ({ opacity: 0, from: { opacity: 0 }, @@ -97,14 +102,14 @@ const NarrativeLink: React.FC<{ onMouseOut={() => setAnimationStyles({ opacity: 0 })} onBlur={() => setAnimationStyles({ opacity: 0 })} > - {narrative.title}  + {narrative.title}  ); -}; +}); /** * Produces a grid of links to available narratives for the current tenant. @@ -118,11 +123,13 @@ const OtherNarrativeLinks = (): React.ReactElement | null => { if (!tenant) return null; - const narrativesToDisplay = Object.values(tenant.systemNarratives) - .filter( - (narrative): narrative is SystemNarrative => narrative !== undefined - ) - .filter((narrative) => narrative.id !== currentNarrativeTypeId); + const narrativesToDisplay = [ + ...Object.values(tenant.systemNarratives), + tenant.racialDisparitiesNarrative, + ].filter((narrative): narrative is Narrative => { + if (narrative === undefined) return false; + return narrative.id !== currentNarrativeTypeId; + }); return ( diff --git a/spotlight-client/src/PageNarrative/PageNarrative.tsx b/spotlight-client/src/PageNarrative/PageNarrative.tsx index cddd8d3d..85f042e9 100644 --- a/spotlight-client/src/PageNarrative/PageNarrative.tsx +++ b/spotlight-client/src/PageNarrative/PageNarrative.tsx @@ -20,6 +20,7 @@ import useBreakpoint from "@w11r/use-breakpoint"; import React from "react"; import { isSystemNarrativeTypeId, NarrativeTypeId } from "../contentApi/types"; import NarrativeFooter from "../NarrativeFooter"; +import RacialDisparitiesNarrativePage from "../RacialDisparitiesNarrativePage"; import { NarrativesSlug } from "../routerUtils/types"; import SystemNarrativePage from "../SystemNarrativePage"; import withRouteSync from "../withRouteSync"; @@ -40,7 +41,7 @@ const PageNarrative: React.FC = ({ narrativeTypeId }) => { {isSystemNarrativeTypeId(narrativeTypeId) ? ( ) : ( -
TK
+ )} {showFooter && } diff --git a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx new file mode 100644 index 00000000..b5d25a4a --- /dev/null +++ b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx @@ -0,0 +1,50 @@ +// Recidiviz - a data platform for criminal justice reform +// 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 +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// ============================================================================= + +import { observer } from "mobx-react-lite"; +import React from "react"; +import RacialDisparitiesNarrative from "../contentModels/RacialDisparitiesNarrative"; +import { + NarrativeIntroContainer, + NarrativeNavContainer, + NarrativeSectionsContainer, + NarrativeTitle, + NarrativeWrapper, +} from "../UiLibrary"; + +// TODO: section navigation + +type RacialDisparitiesNarrativePageProps = { + narrative: RacialDisparitiesNarrative; +}; + +const RacialDisparitiesNarrativePage: React.FC = ({ + narrative, +}) => { + return ( + + + + + {narrative.title} + + + + ); +}; + +export default observer(RacialDisparitiesNarrativePage); diff --git a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePageContainer.tsx b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePageContainer.tsx new file mode 100644 index 00000000..f70ea448 --- /dev/null +++ b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePageContainer.tsx @@ -0,0 +1,34 @@ +// Recidiviz - a data platform for criminal justice reform +// 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 +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// ============================================================================= + +import { observer } from "mobx-react-lite"; +import React from "react"; +import RacialDisparitiesNarrative from "../contentModels/RacialDisparitiesNarrative"; +import { useDataStore } from "../StoreProvider"; +import RacialDisparitiesNarrativePage from "./RacialDisparitiesNarrativePage"; + +const RacialDisparitiesNarrativePageContainer: React.FC = () => { + const { narrative } = useDataStore(); + + if (narrative instanceof RacialDisparitiesNarrative) { + return ; + } + + return null; +}; + +export default observer(RacialDisparitiesNarrativePageContainer); diff --git a/spotlight-client/src/RacialDisparitiesNarrativePage/index.ts b/spotlight-client/src/RacialDisparitiesNarrativePage/index.ts new file mode 100644 index 00000000..27d8bedd --- /dev/null +++ b/spotlight-client/src/RacialDisparitiesNarrativePage/index.ts @@ -0,0 +1,18 @@ +// Recidiviz - a data platform for criminal justice reform +// 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 +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// ============================================================================= + +export { default } from "./RacialDisparitiesNarrativePageContainer"; diff --git a/spotlight-client/src/SystemNarrativePage/SystemNarrativePage.tsx b/spotlight-client/src/SystemNarrativePage/SystemNarrativePage.tsx index 02ab49ad..1807ed19 100644 --- a/spotlight-client/src/SystemNarrativePage/SystemNarrativePage.tsx +++ b/spotlight-client/src/SystemNarrativePage/SystemNarrativePage.tsx @@ -18,105 +18,28 @@ import { navigate, useParams } from "@reach/router"; import useBreakpoint from "@w11r/use-breakpoint"; import HTMLReactParser from "html-react-parser"; -import { rem } from "polished"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { InView } from "react-intersection-observer"; import { useSpring } from "react-spring/web.cjs"; import Sticker from "react-stickyfill"; -import styled from "styled-components/macro"; import { NAV_BAR_HEIGHT } from "../constants"; import SystemNarrative from "../contentModels/SystemNarrative"; import getUrlForResource from "../routerUtils/getUrlForResource"; import normalizeRouteParams from "../routerUtils/normalizeRouteParams"; import { - colors, - typefaces, Chevron, - breakpoints, - PageSection, - CopyBlock, + NarrativeWrapper, + NarrativeIntroContainer, + NarrativeIntroCopy, + NarrativeNavContainer, + NarrativeNavStickyContainer, + NarrativeScrollIndicator, + NarrativeSectionsContainer, + NarrativeTitle, } from "../UiLibrary"; -import { X_PADDING } from "./constants"; import Section from "./Section"; import NarrativeNavigation from "../NarrativeNavigation"; -const Container = styled.article` - display: flex; -`; - -const NavContainer = styled.div` - flex: 0 0 auto; - width: ${rem(X_PADDING)}; -`; - -const NavStickyContainer = styled.div` - display: flex; - height: calc(100vh - ${rem(NAV_BAR_HEIGHT)}); - position: sticky; - top: ${rem(NAV_BAR_HEIGHT)}; -`; - -const IntroContainer = styled(PageSection)` - border-bottom: 1px solid ${colors.rule}; - min-height: calc(100vh - ${rem(NAV_BAR_HEIGHT)}); - padding-top: ${rem(48)}; - padding-bottom: ${rem(48)}; - - @media screen and (min-width: ${breakpoints.tablet[0]}px) { - padding-bottom: ${rem(172)}; - padding-left: 0; - padding-top: ${rem(160)}; - } -`; - -const Title = styled.h1` - font-family: ${typefaces.display}; - font-size: ${rem(32)}; - letter-spacing: -0.05em; - line-height: 1; - margin-bottom: ${rem(24)}; - - @media screen and (min-width: ${breakpoints.tablet[0]}px) { - font-size: ${rem(88)}; - margin-bottom: ${rem(64)}; - } -`; - -const IntroCopy = styled(CopyBlock)` - font-size: ${rem(18)}; - line-height: 1.5; - letter-spacing: -0.025em; - - @media screen and (min-width: ${breakpoints.tablet[0]}px) { - font-size: ${rem(48)}; - } -`; - -const ScrollIndicator = styled.div` - align-items: center; - color: ${colors.caption}; - display: flex; - flex-direction: column; - font-size: ${rem(12)}; - font-weight: 500; - letter-spacing: 0.05em; - margin-top: ${rem(32)}; - - @media screen and (min-width: ${breakpoints.tablet[0]}px) { - margin-top: ${rem(144)}; - } - - span { - margin-bottom: ${rem(16)}; - } -`; - -const SectionsContainer = styled.div` - flex: 1 1 auto; - /* min-width cannot be auto or children will not shrink when viewport does */ - min-width: 0; -`; - const SystemNarrativePage: React.FC<{ narrative: SystemNarrative; }> = ({ narrative }) => { @@ -214,20 +137,20 @@ const SystemNarrativePage: React.FC<{ ]); return ( - + {showSectionNavigation && ( - + - + - + - + )} - + - - {narrative.title} - {HTMLReactParser(narrative.introduction)} - + + {narrative.title} + + {HTMLReactParser(narrative.introduction)} + + SCROLL - - + + {narrative.sections.map((section, index) => { @@ -264,8 +189,8 @@ const SystemNarrativePage: React.FC<{ ); })} - - + + ); }; diff --git a/spotlight-client/src/UiLibrary/index.ts b/spotlight-client/src/UiLibrary/index.ts index 939a79c3..852586ec 100644 --- a/spotlight-client/src/UiLibrary/index.ts +++ b/spotlight-client/src/UiLibrary/index.ts @@ -24,6 +24,7 @@ export { default as colors } from "./colors"; export * from "./Dropdown"; export { default as FixedBottomPanel } from "./FixedBottomPanel"; export * from "./Modal"; +export * from "./narrative"; export { default as PageSection } from "./PageSection"; export * from "./typography"; export { default as zIndex } from "./zIndex"; diff --git a/spotlight-client/src/UiLibrary/narrative.ts b/spotlight-client/src/UiLibrary/narrative.ts new file mode 100644 index 00000000..80bcb455 --- /dev/null +++ b/spotlight-client/src/UiLibrary/narrative.ts @@ -0,0 +1,103 @@ +// Recidiviz - a data platform for criminal justice reform +// 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 +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// ============================================================================= + +import { rem } from "polished"; +import styled from "styled-components/macro"; +import { NAV_BAR_HEIGHT } from "../constants"; +import { X_PADDING } from "../SystemNarrativePage/constants"; +import breakpoints from "./breakpoints"; +import colors from "./colors"; +import CopyBlock from "./CopyBlock"; +import PageSection from "./PageSection"; +import { typefaces } from "./typography"; + +export const NarrativeWrapper = styled.article` + display: flex; +`; + +export const NarrativeNavContainer = styled.div` + flex: 0 0 auto; + width: ${rem(X_PADDING)}; +`; + +export const NarrativeNavStickyContainer = styled.div` + display: flex; + height: calc(100vh - ${rem(NAV_BAR_HEIGHT)}); + position: sticky; + top: ${rem(NAV_BAR_HEIGHT)}; +`; + +export const NarrativeIntroContainer = styled(PageSection)` + border-bottom: 1px solid ${colors.rule}; + min-height: calc(100vh - ${rem(NAV_BAR_HEIGHT)}); + padding-top: ${rem(48)}; + padding-bottom: ${rem(48)}; + + @media screen and (min-width: ${breakpoints.tablet[0]}px) { + padding-bottom: ${rem(172)}; + padding-left: 0; + padding-top: ${rem(160)}; + } +`; + +export const NarrativeTitle = styled.h1` + font-family: ${typefaces.display}; + font-size: ${rem(32)}; + letter-spacing: -0.05em; + line-height: 1; + margin-bottom: ${rem(24)}; + + @media screen and (min-width: ${breakpoints.tablet[0]}px) { + font-size: ${rem(88)}; + margin-bottom: ${rem(64)}; + } +`; + +export const NarrativeIntroCopy = styled(CopyBlock)` + font-size: ${rem(18)}; + line-height: 1.5; + letter-spacing: -0.025em; + + @media screen and (min-width: ${breakpoints.tablet[0]}px) { + font-size: ${rem(48)}; + } +`; + +export const NarrativeScrollIndicator = styled.div` + align-items: center; + color: ${colors.caption}; + display: flex; + flex-direction: column; + font-size: ${rem(12)}; + font-weight: 500; + letter-spacing: 0.05em; + margin-top: ${rem(32)}; + + @media screen and (min-width: ${breakpoints.tablet[0]}px) { + margin-top: ${rem(144)}; + } + + span { + margin-bottom: ${rem(16)}; + } +`; + +export const NarrativeSectionsContainer = styled.div` + flex: 1 1 auto; + /* min-width cannot be auto or children will not shrink when viewport does */ + min-width: 0; +`; diff --git a/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts b/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts index afab1598..2deaee4f 100644 --- a/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts +++ b/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts @@ -115,6 +115,8 @@ type ConstructorOpts = { */ export default class RacialDisparitiesNarrative { // metadata + readonly id = "RacialDisparities"; + readonly title = "Racial Disparities"; readonly chartLabels: RacialDisparitiesChartLabels; diff --git a/spotlight-client/src/contentModels/types.ts b/spotlight-client/src/contentModels/types.ts index 5419d212..91f1b5f7 100644 --- a/spotlight-client/src/contentModels/types.ts +++ b/spotlight-client/src/contentModels/types.ts @@ -32,6 +32,7 @@ import { import type Collection from "./Collection"; import type SystemNarrative from "./SystemNarrative"; import type Metric from "./Metric"; +import type RacialDisparitiesNarrative from "./RacialDisparitiesNarrative"; // ======================================= // Collection types @@ -79,3 +80,5 @@ export type LocalityDataMapping = Record< export type SystemNarrativeMapping = { [key in SystemNarrativeTypeId]?: SystemNarrative; }; + +export type Narrative = SystemNarrative | RacialDisparitiesNarrative; From 960ec16318ebfd8b7ac2010733f5c2d723959b0d Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Fri, 5 Mar 2021 17:02:28 -0800 Subject: [PATCH 04/11] factor out narrative layout --- .../src/NarrativeLayout/NarrativeLayout.tsx | 190 ++++++++++++++++++ .../NarrativeNavigation/AdvanceLink.tsx | 4 +- .../NarrativeNavigation.tsx | 15 +- .../NarrativeNavigation/SectionLinks.tsx | 43 ++-- .../NarrativeNavigation/index.ts | 0 .../NarrativeNavigation/utils.ts | 0 spotlight-client/src/NarrativeLayout/index.ts | 18 ++ spotlight-client/src/NarrativeLayout/types.ts | 18 ++ .../SystemNarrativePage.tsx | 189 +++-------------- spotlight-client/src/UiLibrary/narrative.ts | 23 --- 10 files changed, 277 insertions(+), 223 deletions(-) create mode 100644 spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx rename spotlight-client/src/{ => NarrativeLayout}/NarrativeNavigation/AdvanceLink.tsx (95%) rename spotlight-client/src/{ => NarrativeLayout}/NarrativeNavigation/NarrativeNavigation.tsx (87%) rename spotlight-client/src/{ => NarrativeLayout}/NarrativeNavigation/SectionLinks.tsx (83%) rename spotlight-client/src/{ => NarrativeLayout}/NarrativeNavigation/index.ts (100%) rename spotlight-client/src/{ => NarrativeLayout}/NarrativeNavigation/utils.ts (100%) create mode 100644 spotlight-client/src/NarrativeLayout/index.ts create mode 100644 spotlight-client/src/NarrativeLayout/types.ts diff --git a/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx b/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx new file mode 100644 index 00000000..0ecc823b --- /dev/null +++ b/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx @@ -0,0 +1,190 @@ +// Recidiviz - a data platform for criminal justice reform +// 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 +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// ============================================================================= + +import { navigate, useParams } from "@reach/router"; +import useBreakpoint from "@w11r/use-breakpoint"; +import { rem } from "polished"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { InView } from "react-intersection-observer"; +import { useSpring } from "react-spring/web.cjs"; +import Sticker from "react-stickyfill"; +import styled from "styled-components/macro"; +import { NAV_BAR_HEIGHT } from "../constants"; +import getUrlForResource from "../routerUtils/getUrlForResource"; +import normalizeRouteParams from "../routerUtils/normalizeRouteParams"; +import { X_PADDING } from "../SystemNarrativePage/constants"; +import NarrativeNavigation from "./NarrativeNavigation"; +import { LayoutSection } from "./types"; + +const Wrapper = styled.article` + display: flex; +`; + +const NavContainer = styled.div` + flex: 0 0 auto; + width: ${rem(X_PADDING)}; +`; + +const NavStickyContainer = styled.div` + display: flex; + height: calc(100vh - ${rem(NAV_BAR_HEIGHT)}); + position: sticky; + top: ${rem(NAV_BAR_HEIGHT)}; +`; + +export const SectionsContainer = styled.div` + flex: 1 1 auto; + /* min-width cannot be auto or children will not shrink when viewport does */ + min-width: 0; +`; + +type NarrativeLayoutProps = { + sections: LayoutSection[]; +}; + +const NarrativeLayout: React.FC = ({ sections }) => { + const routeParams = useParams(); + const sectionsContainerRef = useRef() as React.MutableRefObject< + HTMLDivElement + >; + const showSectionNavigation = useBreakpoint(true, ["mobile-", false]); + + // automated scrolling is a special case of section visibility; + // this flag lets us suspend in-page navigation actions while it is in progress + // (it is a local variable rather than a piece of React state because + // the animation functions are outside the render loop and won't receive state updates) + let isScrolling = false; + const cancelAutoScroll = () => { + isScrolling = false; + }; + + const [activeSection, directlySetActiveSection] = useState( + // make sure we consume the section number in the URL, if any, on first mount + Number(routeParams.sectionNumber) || 1 + ); + // wrap the section state setter in a function that respects the flag + const setActiveSection = useCallback( + (sectionNumber: number) => { + if (!isScrolling) { + directlySetActiveSection(sectionNumber); + } + }, + [isScrolling] + ); + // keep section state in sync with URL if it changes externally (e.g. via nav link) + useEffect(() => { + directlySetActiveSection(Number(routeParams.sectionNumber) || 1); + }, [routeParams.sectionNumber]); + + const [, setScrollSpring] = useSpring(() => ({ + onFrame: (props: { top: number }) => { + if (isScrolling) window.scrollTo(0, props.top); + }, + // set the flag while animation is in progress, + // and listen for user-initiated scrolling (which takes priority) + onRest: () => { + isScrolling = false; + window.removeEventListener("wheel", cancelAutoScroll); + window.removeEventListener("touchmove", cancelAutoScroll); + }, + onStart: () => { + isScrolling = true; + window.addEventListener("wheel", cancelAutoScroll, { once: true }); + window.addEventListener("touchmove", cancelAutoScroll, { once: true }); + }, + to: { top: window.pageYOffset }, + })); + + const { tenantId, narrativeTypeId } = normalizeRouteParams(routeParams); + // updating the active section has two key side effects: + // 1. smoothly scrolling to the active section + // 2. updating the page URL so the section can be linked to directly + useEffect(() => { + let scrollDestination; + // scroll to the corresponding section by calculating its offset + const desiredSection = sectionsContainerRef.current.querySelector( + `#section${activeSection}` + ); + if (desiredSection) { + scrollDestination = + window.pageYOffset + desiredSection.getBoundingClientRect().top; + } + + // in practice this should always be defined, this is just type safety + if (scrollDestination !== undefined) { + setScrollSpring({ + to: { top: scrollDestination - NAV_BAR_HEIGHT }, + from: { top: window.pageYOffset }, + reset: true, + }); + + // these should always be defined on this page; more type safety + if (tenantId && narrativeTypeId) { + navigate( + `${getUrlForResource({ + page: "narrative", + params: { tenantId, narrativeTypeId }, + })}/${activeSection}` + ); + } + } + }, [ + activeSection, + narrativeTypeId, + sectionsContainerRef, + setScrollSpring, + tenantId, + ]); + + return ( + + {showSectionNavigation && ( + + + + + + + + )} + + {sections.map((section, index) => { + // 1-indexed for human readability + const pageId = index + 1; + return ( + { + if (inView) setActiveSection(pageId); + }} + > + {section.contents} + + ); + })} + + + ); +}; + +export default NarrativeLayout; diff --git a/spotlight-client/src/NarrativeNavigation/AdvanceLink.tsx b/spotlight-client/src/NarrativeLayout/NarrativeNavigation/AdvanceLink.tsx similarity index 95% rename from spotlight-client/src/NarrativeNavigation/AdvanceLink.tsx rename to spotlight-client/src/NarrativeLayout/NarrativeNavigation/AdvanceLink.tsx index 951b82ce..e3f2ee65 100644 --- a/spotlight-client/src/NarrativeNavigation/AdvanceLink.tsx +++ b/spotlight-client/src/NarrativeLayout/NarrativeNavigation/AdvanceLink.tsx @@ -18,8 +18,8 @@ import { rem } from "polished"; import React, { useState } from "react"; import styled from "styled-components/macro"; -import NavigationLink from "../NavigationLink"; -import { colors, Chevron } from "../UiLibrary"; +import NavigationLink from "../../NavigationLink"; +import { colors, Chevron } from "../../UiLibrary"; const StyledNavLink = styled(NavigationLink)` padding: ${rem(8)}; diff --git a/spotlight-client/src/NarrativeNavigation/NarrativeNavigation.tsx b/spotlight-client/src/NarrativeLayout/NarrativeNavigation/NarrativeNavigation.tsx similarity index 87% rename from spotlight-client/src/NarrativeNavigation/NarrativeNavigation.tsx rename to spotlight-client/src/NarrativeLayout/NarrativeNavigation/NarrativeNavigation.tsx index 13662256..75a04ccb 100644 --- a/spotlight-client/src/NarrativeNavigation/NarrativeNavigation.tsx +++ b/spotlight-client/src/NarrativeLayout/NarrativeNavigation/NarrativeNavigation.tsx @@ -20,9 +20,9 @@ import { format } from "d3-format"; import { rem } from "polished"; import React from "react"; import styled from "styled-components/macro"; -import SystemNarrative from "../contentModels/SystemNarrative"; -import getUrlForResource from "../routerUtils/getUrlForResource"; -import normalizeRouteParams from "../routerUtils/normalizeRouteParams"; +import getUrlForResource from "../../routerUtils/getUrlForResource"; +import normalizeRouteParams from "../../routerUtils/normalizeRouteParams"; +import { LayoutSection } from "../types"; import AdvanceLink from "./AdvanceLink"; import SectionLinks from "./SectionLinks"; @@ -50,12 +50,12 @@ const SectionNumberFaded = styled(SectionNumber)` type NavigationProps = { activeSection: number; - narrative: SystemNarrative; + sections: LayoutSection[]; }; const SectionNavigation: React.FC = ({ activeSection, - narrative, + sections, }) => { const { tenantId, narrativeTypeId } = normalizeRouteParams( useParams() @@ -72,8 +72,7 @@ const SectionNavigation: React.FC = ({ params: { tenantId, narrativeTypeId }, }); - // total includes the introduction - const totalPages = narrative.sections.length + 1; + const totalPages = sections.length; // these will be used to toggle prev/next links const disablePrev = activeSection === 1; @@ -83,7 +82,7 @@ const SectionNavigation: React.FC = ({ {formatPageNum(activeSection)} {formatPageNum(totalPages)} - + const SectionLinks: React.FC<{ activeSection: number; - narrative: SystemNarrative; - totalPages: number; + sections: LayoutSection[]; urlBase: string; -}> = ({ activeSection, narrative, totalPages, urlBase }) => { +}> = ({ activeSection, sections, urlBase }) => { + const totalPages = sections.length; + const progressBarHeight = (THUMB_SIZE.height + THUMB_SIZE.paddingBottom) * totalPages - // subtract one padding unit so there isn't dangling space after the last one @@ -164,34 +165,18 @@ const SectionLinks: React.FC<{ onMouseOut={() => setTrackStyles({ opacity: 1 })} onBlur={() => setTrackStyles({ opacity: 1 })} > - - - - - {narrative.title} - - - - {narrative.sections.map((section, index) => { + {sections.map((section, index) => { return ( - - + + {section.title} diff --git a/spotlight-client/src/NarrativeNavigation/index.ts b/spotlight-client/src/NarrativeLayout/NarrativeNavigation/index.ts similarity index 100% rename from spotlight-client/src/NarrativeNavigation/index.ts rename to spotlight-client/src/NarrativeLayout/NarrativeNavigation/index.ts diff --git a/spotlight-client/src/NarrativeNavigation/utils.ts b/spotlight-client/src/NarrativeLayout/NarrativeNavigation/utils.ts similarity index 100% rename from spotlight-client/src/NarrativeNavigation/utils.ts rename to spotlight-client/src/NarrativeLayout/NarrativeNavigation/utils.ts diff --git a/spotlight-client/src/NarrativeLayout/index.ts b/spotlight-client/src/NarrativeLayout/index.ts new file mode 100644 index 00000000..c4772519 --- /dev/null +++ b/spotlight-client/src/NarrativeLayout/index.ts @@ -0,0 +1,18 @@ +// Recidiviz - a data platform for criminal justice reform +// 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 +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// ============================================================================= + +export { default } from "./NarrativeLayout"; diff --git a/spotlight-client/src/NarrativeLayout/types.ts b/spotlight-client/src/NarrativeLayout/types.ts new file mode 100644 index 00000000..667e6674 --- /dev/null +++ b/spotlight-client/src/NarrativeLayout/types.ts @@ -0,0 +1,18 @@ +// Recidiviz - a data platform for criminal justice reform +// 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 +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// ============================================================================= + +export type LayoutSection = { title: string; contents: React.ReactElement }; diff --git a/spotlight-client/src/SystemNarrativePage/SystemNarrativePage.tsx b/spotlight-client/src/SystemNarrativePage/SystemNarrativePage.tsx index 1807ed19..8e796855 100644 --- a/spotlight-client/src/SystemNarrativePage/SystemNarrativePage.tsx +++ b/spotlight-client/src/SystemNarrativePage/SystemNarrativePage.tsx @@ -15,182 +15,49 @@ // along with this program. If not, see . // ============================================================================= -import { navigate, useParams } from "@reach/router"; -import useBreakpoint from "@w11r/use-breakpoint"; import HTMLReactParser from "html-react-parser"; -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { InView } from "react-intersection-observer"; -import { useSpring } from "react-spring/web.cjs"; -import Sticker from "react-stickyfill"; -import { NAV_BAR_HEIGHT } from "../constants"; +import React from "react"; import SystemNarrative from "../contentModels/SystemNarrative"; -import getUrlForResource from "../routerUtils/getUrlForResource"; -import normalizeRouteParams from "../routerUtils/normalizeRouteParams"; import { Chevron, - NarrativeWrapper, NarrativeIntroContainer, NarrativeIntroCopy, - NarrativeNavContainer, - NarrativeNavStickyContainer, NarrativeScrollIndicator, - NarrativeSectionsContainer, NarrativeTitle, } from "../UiLibrary"; import Section from "./Section"; -import NarrativeNavigation from "../NarrativeNavigation"; +import NarrativeLayout from "../NarrativeLayout"; const SystemNarrativePage: React.FC<{ narrative: SystemNarrative; }> = ({ narrative }) => { - const routeParams = useParams(); - const sectionsContainerRef = useRef() as React.MutableRefObject< - HTMLDivElement - >; - const showSectionNavigation = useBreakpoint(true, ["mobile-", false]); - - // automated scrolling is a special case of section visibility; - // this flag lets us suspend in-page navigation actions while it is in progress - // (it is a local variable rather than a piece of React state because - // the animation functions are outside the render loop and won't receive state updates) - let isScrolling = false; - const cancelAutoScroll = () => { - isScrolling = false; - }; - - const [activeSection, directlySetActiveSection] = useState( - // make sure we consume the section number in the URL, if any, on first mount - Number(routeParams.sectionNumber) || 1 - ); - // wrap the section state setter in a function that respects the flag - const setActiveSection = useCallback( - (sectionNumber: number) => { - if (!isScrolling) { - directlySetActiveSection(sectionNumber); - } - }, - [isScrolling] - ); - // keep section state in sync with URL if it changes externally (e.g. via nav link) - useEffect(() => { - directlySetActiveSection(Number(routeParams.sectionNumber) || 1); - }, [routeParams.sectionNumber]); - - const [, setScrollSpring] = useSpring(() => ({ - onFrame: (props: { top: number }) => { - if (isScrolling) window.scrollTo(0, props.top); - }, - // set the flag while animation is in progress, - // and listen for user-initiated scrolling (which takes priority) - onRest: () => { - isScrolling = false; - window.removeEventListener("wheel", cancelAutoScroll); - window.removeEventListener("touchmove", cancelAutoScroll); - }, - onStart: () => { - isScrolling = true; - window.addEventListener("wheel", cancelAutoScroll, { once: true }); - window.addEventListener("touchmove", cancelAutoScroll, { once: true }); - }, - to: { top: window.pageYOffset }, - })); - - const { tenantId, narrativeTypeId } = normalizeRouteParams(routeParams); - // updating the active section has two key side effects: - // 1. smoothly scrolling to the active section - // 2. updating the page URL so the section can be linked to directly - useEffect(() => { - let scrollDestination; - // scroll to the corresponding section by calculating its offset - const desiredSection = sectionsContainerRef.current.querySelector( - `#section${activeSection}` - ); - if (desiredSection) { - scrollDestination = - window.pageYOffset + desiredSection.getBoundingClientRect().top; - } - - // in practice this should always be defined, this is just type safety - if (scrollDestination !== undefined) { - setScrollSpring({ - to: { top: scrollDestination - NAV_BAR_HEIGHT }, - from: { top: window.pageYOffset }, - reset: true, - }); - - // these should always be defined on this page; more type safety - if (tenantId && narrativeTypeId) { - navigate( - `${getUrlForResource({ - page: "narrative", - params: { tenantId, narrativeTypeId }, - })}/${activeSection}` - ); - } - } - }, [ - activeSection, - narrativeTypeId, - sectionsContainerRef, - setScrollSpring, - tenantId, - ]); - return ( - - {showSectionNavigation && ( - - - - - - - - )} - - { - if (inView) setActiveSection(1); - }} - > - - {narrative.title} - - {HTMLReactParser(narrative.introduction)} - - - SCROLL - - - - - - - {narrative.sections.map((section, index) => { - // the first viz section is "page 2" - const pageId = index + 2; - return ( - { - if (inView) setActiveSection(pageId); - }} - > -
- - ); - })} - - + + {narrative.title} + + {HTMLReactParser(narrative.introduction)} + + + SCROLL + + + + + ), + }, + ...narrative.sections.map((section) => { + return { + title: section.title, + contents:
, + }; + }), + ]} + /> ); }; diff --git a/spotlight-client/src/UiLibrary/narrative.ts b/spotlight-client/src/UiLibrary/narrative.ts index 80bcb455..ef1087eb 100644 --- a/spotlight-client/src/UiLibrary/narrative.ts +++ b/spotlight-client/src/UiLibrary/narrative.ts @@ -18,29 +18,12 @@ import { rem } from "polished"; import styled from "styled-components/macro"; import { NAV_BAR_HEIGHT } from "../constants"; -import { X_PADDING } from "../SystemNarrativePage/constants"; import breakpoints from "./breakpoints"; import colors from "./colors"; import CopyBlock from "./CopyBlock"; import PageSection from "./PageSection"; import { typefaces } from "./typography"; -export const NarrativeWrapper = styled.article` - display: flex; -`; - -export const NarrativeNavContainer = styled.div` - flex: 0 0 auto; - width: ${rem(X_PADDING)}; -`; - -export const NarrativeNavStickyContainer = styled.div` - display: flex; - height: calc(100vh - ${rem(NAV_BAR_HEIGHT)}); - position: sticky; - top: ${rem(NAV_BAR_HEIGHT)}; -`; - export const NarrativeIntroContainer = styled(PageSection)` border-bottom: 1px solid ${colors.rule}; min-height: calc(100vh - ${rem(NAV_BAR_HEIGHT)}); @@ -95,9 +78,3 @@ export const NarrativeScrollIndicator = styled.div` margin-bottom: ${rem(16)}; } `; - -export const NarrativeSectionsContainer = styled.div` - flex: 1 1 auto; - /* min-width cannot be auto or children will not shrink when viewport does */ - min-width: 0; -`; From bbc66506635b1a852a01d3f58eca41bc590de7aa Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Fri, 5 Mar 2021 18:18:23 -0800 Subject: [PATCH 05/11] refactor sticky section and get basic RD layout --- .../StickySection.tsx} | 75 ++++++------------- spotlight-client/src/NarrativeLayout/index.ts | 3 +- .../RacialDisparitiesNarrativePage.test.tsx | 62 +++++++++++++++ .../RacialDisparitiesNarrativePage.tsx | 53 +++++++++---- .../SystemNarrativePage.tsx | 22 +++++- spotlight-client/src/UiLibrary/narrative.ts | 12 +++ .../src/contentApi/sources/us_nd.ts | 18 +++++ spotlight-client/src/contentApi/types.ts | 23 +++++- .../RacialDisparitiesNarrative.ts | 44 +++++++++++ .../__fixtures__/tenant_content_exhaustive.ts | 29 +++++++ 10 files changed, 270 insertions(+), 71 deletions(-) rename spotlight-client/src/{SystemNarrativePage/Section.tsx => NarrativeLayout/StickySection.tsx} (66%) create mode 100644 spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.test.tsx diff --git a/spotlight-client/src/SystemNarrativePage/Section.tsx b/spotlight-client/src/NarrativeLayout/StickySection.tsx similarity index 66% rename from spotlight-client/src/SystemNarrativePage/Section.tsx rename to spotlight-client/src/NarrativeLayout/StickySection.tsx index f5a27229..6bd2086c 100644 --- a/spotlight-client/src/SystemNarrativePage/Section.tsx +++ b/spotlight-client/src/NarrativeLayout/StickySection.tsx @@ -16,24 +16,13 @@ // ============================================================================= import useBreakpoint from "@w11r/use-breakpoint"; -import HTMLReactParser from "html-react-parser"; import { rem } from "polished"; import React, { useEffect, useRef, useState } from "react"; import { useInView } from "react-intersection-observer"; import Sticker from "react-stickyfill"; import styled from "styled-components/macro"; import { NAV_BAR_HEIGHT } from "../constants"; -import Metric from "../contentModels/Metric"; -import { SystemNarrativeSection } from "../contentModels/SystemNarrative"; -import { MetricRecord } from "../contentModels/types"; -import MetricVizMapper from "../MetricVizMapper"; -import { - breakpoints, - colors, - CopyBlock, - PageSection, - typefaces, -} from "../UiLibrary"; +import { breakpoints, colors, CopyBlock, PageSection } from "../UiLibrary"; const COPY_WIDTH = 408; @@ -55,7 +44,7 @@ const Container = styled(PageSection)` } `; -const SectionCopy = styled(CopyBlock)<{ $isSticky: boolean }>` +const LeftContainer = styled(CopyBlock)<{ $isSticky: boolean }>` overflow: hidden; padding-top: ${rem(40)}; @@ -74,21 +63,9 @@ const SectionCopy = styled(CopyBlock)<{ $isSticky: boolean }>` } `; -const CopyOverflowIndicator = styled.div``; +const StickyOverflowIndicator = styled.div``; -const SectionTitle = styled.h2` - font-family: ${typefaces.display}; - font-size: ${rem(24)}; - line-height: 1.25; - letter-spacing: -0.04em; - margin-bottom: ${rem(24)}; -`; - -const SectionBody = styled.div` - line-height: 1.67; -`; - -const VizContainer = styled.div` +const RightContainer = styled.div` display: flex; flex-direction: column; justify-content: center; @@ -103,22 +80,15 @@ const VizContainer = styled.div` } `; -const SectionViz: React.FC<{ metric: Metric }> = ({ metric }) => { - return ( - - - - ); -}; - -const Section: React.FC<{ section: SystemNarrativeSection }> = ({ - section, -}) => { - // on large screens, we want the left column of copy to be sticky - // while the visualization scrolls (if it's taller than one screen, which many are). - // But if the COPY is also taller than one screen we don't want it to be sticky - // or you won't actually be able to read all of it. We won't know that until - // we render it, so we have to detect a copy overflow and disable the sticky +const StickySection: React.FC<{ + leftContents: React.ReactElement; + rightContents: React.ReactElement; +}> = ({ leftContents, rightContents }) => { + // on large screens, we want the left column to be sticky while the + // right column scrolls (if it's taller than one screen, which many are). + // But if the left column is ALSO taller than one screen we don't want it to be sticky + // or you won't actually be able to see all of it. We won't know that until + // we render it, so we have to detect a vertical overflow and disable the sticky // behavior if it happens. const isDesktop = useBreakpoint(false, ["desktop+", true]); @@ -128,7 +98,7 @@ const Section: React.FC<{ section: SystemNarrativeSection }> = ({ root: copyContainerRef.current, }); - const [isCopySticky, setIsCopySticky] = useState(false); + const [isLeftSticky, setIsLeftSticky] = useState(false); // to prevent an endless loop of sticking and unsticking, // keep track of whether we've tried to make the copy sticky @@ -140,29 +110,28 @@ const Section: React.FC<{ section: SystemNarrativeSection }> = ({ // if we re-enable stickiness it will become false again, in an endless loop. // checking this flag prevents us from looping if (!hasBeenSticky) { - setIsCopySticky(true); + setIsLeftSticky(true); // Unfortunately inView is ALWAYS false for the first few render cycles // while the DOM is being bootstrapped, so we want to set a flag the first time // it flips to true (on small screens we will never flip this and that's fine) setHasBeenSticky(true); } } else { - setIsCopySticky(false); + setIsLeftSticky(false); } }, [hasBeenSticky, inView, isDesktop]); return ( - - {section.title} - {HTMLReactParser(section.body)} - - + + {leftContents} + + - + {rightContents} ); }; -export default Section; +export default StickySection; diff --git a/spotlight-client/src/NarrativeLayout/index.ts b/spotlight-client/src/NarrativeLayout/index.ts index c4772519..a8e31064 100644 --- a/spotlight-client/src/NarrativeLayout/index.ts +++ b/spotlight-client/src/NarrativeLayout/index.ts @@ -15,4 +15,5 @@ // along with this program. If not, see . // ============================================================================= -export { default } from "./NarrativeLayout"; +export { default as NarrativeLayout } from "./NarrativeLayout"; +export { default as StickySection } from "./StickySection"; diff --git a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.test.tsx b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.test.tsx new file mode 100644 index 00000000..7614c190 --- /dev/null +++ b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.test.tsx @@ -0,0 +1,62 @@ +// Recidiviz - a data platform for criminal justice reform +// 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 +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// ============================================================================= + +import { screen } from "@testing-library/react"; +import mockContentFixture from "../contentModels/__fixtures__/tenant_content_exhaustive"; +import { renderNavigableApp } from "../testUtils"; + +jest.mock("../contentApi/sources/us_nd", () => mockContentFixture); + +const narrativeContent = mockContentFixture.racialDisparitiesNarrative; + +beforeEach(() => { + renderNavigableApp({ + route: "/us-nd/collections/racial-disparities", + }); +}); + +test("renders all the sections", () => { + expect( + screen.getByRole("heading", { name: "Racial Disparities", level: 1 }) + ).toBeVisible(); + + Object.values(narrativeContent.sections).forEach((section) => { + expect( + screen.getByRole("heading", { name: section.title }) + ).toBeInTheDocument(); + }); +}); + +test("parses HTML in copy", async () => { + expect(screen.getByRole("link", { name: "intro link" })).toBeInTheDocument(); + + expect( + screen.getByRole("link", { name: "conclusion body link" }) + ).toBeInTheDocument(); +}); + +test.skip("renders dynamic text", () => { + // refer to the fixture to see what variables are in the text + expect(screen.getByText("introduction 81.0 26.9 23.1")).toBeVisible(); + // sections: { + // beforeCorrections: { + // title: "beforeCorrections title", + // body: `beforeCorrections body {{ethnonym}} {{ethnonymCapitalized}} + // {{populationPctCurrent}} {{correctionsPctCurrent}}`, + // }, + // TODO: other sections +}); diff --git a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx index b5d25a4a..e9ee06d4 100644 --- a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx +++ b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx @@ -15,19 +15,19 @@ // along with this program. If not, see . // ============================================================================= +import HTMLReactParser from "html-react-parser"; import { observer } from "mobx-react-lite"; import React from "react"; import RacialDisparitiesNarrative from "../contentModels/RacialDisparitiesNarrative"; +import { NarrativeLayout, StickySection } from "../NarrativeLayout"; import { NarrativeIntroContainer, - NarrativeNavContainer, - NarrativeSectionsContainer, + NarrativeIntroCopy, + NarrativeSectionBody, + NarrativeSectionTitle, NarrativeTitle, - NarrativeWrapper, } from "../UiLibrary"; -// TODO: section navigation - type RacialDisparitiesNarrativePageProps = { narrative: RacialDisparitiesNarrative; }; @@ -36,14 +36,41 @@ const RacialDisparitiesNarrativePage: React.FC { return ( - - - - - {narrative.title} - - - + + {narrative.title} + + {HTMLReactParser(narrative.introduction)} + + + ), + }, + ...narrative.sections.map((section) => { + return { + title: section.title, + contents: ( + + + {section.title} + + + {HTMLReactParser(section.body)} + + + } + rightContents={
Placeholder for chart
} + /> + ), + }; + }), + ]} + /> ); }; diff --git a/spotlight-client/src/SystemNarrativePage/SystemNarrativePage.tsx b/spotlight-client/src/SystemNarrativePage/SystemNarrativePage.tsx index 8e796855..ed5f517a 100644 --- a/spotlight-client/src/SystemNarrativePage/SystemNarrativePage.tsx +++ b/spotlight-client/src/SystemNarrativePage/SystemNarrativePage.tsx @@ -23,10 +23,12 @@ import { NarrativeIntroContainer, NarrativeIntroCopy, NarrativeScrollIndicator, + NarrativeSectionBody, + NarrativeSectionTitle, NarrativeTitle, } from "../UiLibrary"; -import Section from "./Section"; -import NarrativeLayout from "../NarrativeLayout"; +import { NarrativeLayout, StickySection } from "../NarrativeLayout"; +import MetricVizMapper from "../MetricVizMapper"; const SystemNarrativePage: React.FC<{ narrative: SystemNarrative; @@ -53,7 +55,21 @@ const SystemNarrativePage: React.FC<{ ...narrative.sections.map((section) => { return { title: section.title, - contents:
, + contents: ( + + + {section.title} + + + {HTMLReactParser(section.body)} + + + } + rightContents={} + /> + ), }; }), ]} diff --git a/spotlight-client/src/UiLibrary/narrative.ts b/spotlight-client/src/UiLibrary/narrative.ts index ef1087eb..ed5e9978 100644 --- a/spotlight-client/src/UiLibrary/narrative.ts +++ b/spotlight-client/src/UiLibrary/narrative.ts @@ -78,3 +78,15 @@ export const NarrativeScrollIndicator = styled.div` margin-bottom: ${rem(16)}; } `; + +export const NarrativeSectionTitle = styled.h2` + font-family: ${typefaces.display}; + font-size: ${rem(24)}; + line-height: 1.25; + letter-spacing: -0.04em; + margin-bottom: ${rem(24)}; +`; + +export const NarrativeSectionBody = styled.div` + line-height: 1.67; +`; diff --git a/spotlight-client/src/contentApi/sources/us_nd.ts b/spotlight-client/src/contentApi/sources/us_nd.ts index 40798cc5..2bdd77f1 100644 --- a/spotlight-client/src/contentApi/sources/us_nd.ts +++ b/spotlight-client/src/contentApi/sources/us_nd.ts @@ -546,6 +546,24 @@ const content: TenantContent = { supervisionPopulation: "People subject to supervision", totalPopulationSentences: "All people sentenced and under DOCR control", }, + introduction: `

In North Dakota, people of color are overrepresented in prison, + on probation, and on parole.

+

Black North Dakotans are {{BLACK}} times as likely to be under DOCR control + as their white counterparts, Latino North Dakotans are {{HISPANIC}} times as + likely, and Native American North Dakotans {{AMERICAN_INDIAN_ALASKAN_NATIVE}} times.`, + sections: { + beforeCorrections: { + title: "Disparities are already present before incarceration", + body: `

Disparities emerge long before a person is incarcerated. By the time + someone comes under the DOCR’s care, they have been arrested, charged, convicted, + and sentenced.1 Even before contact with the criminal justice system, + disparities in community investment (education, housing, healthcare) may + play an important role in creating the disparities that we see in sentencing data.

+

{{ethnonym}} make up {{populationPctCurrent}} of North Dakota’s population, but + {{correctionsPctCurrent}} of the population sentenced to time under DOCR control.

`, + }, + }, + // TODO: remaining sections }, }; diff --git a/spotlight-client/src/contentApi/types.ts b/spotlight-client/src/contentApi/types.ts index aa4b002d..5b20d02d 100644 --- a/spotlight-client/src/contentApi/types.ts +++ b/spotlight-client/src/contentApi/types.ts @@ -134,9 +134,12 @@ export function isNarrativeTypeId(x: string): x is NarrativeTypeId { return isSystemNarrativeTypeId(x) || x === "RacialDisparities"; } -type SystemNarrativeSection = { +type NarrativeSection = { title: string; body: string; +}; + +type SystemNarrativeSection = NarrativeSection & { metricTypeId: MetricTypeId; }; @@ -157,6 +160,24 @@ export type RacialDisparitiesChartLabels = { totalPopulationSentences: string; }; +type RacialDisparitiesSectionKey = + | "beforeCorrections" + | "sentencing" + | "releasesToParole" + | "programming" + | "supervision" + | "conclusion"; + +export type RacialDisparitiesSections = { + [key in RacialDisparitiesSectionKey]?: NarrativeSection; +}; + +/** + * Introduction and section bodies support dynamic text + * via {@link https://mustache.github.io/ Mustache} template syntax + */ export type RacialDisparitiesNarrativeContent = { chartLabels: RacialDisparitiesChartLabels; + introduction: string; + sections: RacialDisparitiesSections; }; diff --git a/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts b/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts index 2deaee4f..c5325b66 100644 --- a/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts +++ b/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts @@ -23,6 +23,7 @@ import { REVOCATION_TYPE_LABELS, SENTENCE_TYPE_LABELS } from "../constants"; import { RacialDisparitiesChartLabels, RacialDisparitiesNarrativeContent, + RacialDisparitiesSections, TenantId, } from "../contentApi/types"; import { getDemographicCategories, RaceIdentifier } from "../demographics"; @@ -96,6 +97,11 @@ function getSentencingMetrics( }; } +type SectionData = { + title: string; + body: string; +}; + type ConstructorOpts = { tenantId: TenantId; defaultCategory?: RaceIdentifier; @@ -119,6 +125,10 @@ export default class RacialDisparitiesNarrative { readonly title = "Racial Disparities"; + readonly introduction: string; + + readonly sectionText: RacialDisparitiesSections; + readonly chartLabels: RacialDisparitiesChartLabels; readonly tenantId: TenantId; @@ -154,6 +164,8 @@ export default class RacialDisparitiesNarrative { this.selectedCategory = defaultCategory || "BLACK"; this.supervisionType = defaultSupervisionType || "supervision"; this.chartLabels = content.chartLabels; + this.introduction = content.introduction; + this.sectionText = content.sections; makeAutoObservable(this, { records: observable.ref, @@ -604,4 +616,36 @@ export default class RacialDisparitiesNarrative { }, ]; } + + get sections(): SectionData[] { + const { sectionText } = this; + const sections = []; + const { + beforeCorrections, + conclusion, + programming, + releasesToParole, + supervision, + sentencing, + } = sectionText; + if (beforeCorrections) { + sections.push(beforeCorrections); + } + if (sentencing) { + sections.push(sentencing); + } + if (releasesToParole) { + sections.push(releasesToParole); + } + if (supervision) { + sections.push(supervision); + } + if (programming) { + sections.push(programming); + } + if (conclusion) { + sections.push(conclusion); + } + return sections; + } } diff --git a/spotlight-client/src/contentModels/__fixtures__/tenant_content_exhaustive.ts b/spotlight-client/src/contentModels/__fixtures__/tenant_content_exhaustive.ts index 3f258849..d687d4a7 100644 --- a/spotlight-client/src/contentModels/__fixtures__/tenant_content_exhaustive.ts +++ b/spotlight-client/src/contentModels/__fixtures__/tenant_content_exhaustive.ts @@ -308,6 +308,35 @@ const content: ExhaustiveTenantContent = { supervisionPopulation: "People subject to supervision", totalPopulationSentences: "All people sentenced and under DOCR control", }, + introduction: + 'introduction {{BLACK}} {{HISPANIC}} {{AMERICAN_INDIAN_ALASKAN_NATIVE}} intro link', + sections: { + beforeCorrections: { + title: "beforeCorrections title", + body: `beforeCorrections body {{ethnonym}} {{ethnonymCapitalized}} + {{populationPctCurrent}} {{correctionsPctCurrent}}`, + }, + conclusion: { + title: "conclusion title", + body: 'conclusion body conclusion body link', + }, + sentencing: { + title: "sentencing title", + body: "sentencing body", + }, + supervision: { + title: "supervision title", + body: "supervision body", + }, + releasesToParole: { + title: "releasesToParole title", + body: "releasesToParole body", + }, + programming: { + title: "programming title", + body: "programming body", + }, + }, }, }; From 8b2bfd795b287b217ebf3e8dc82db2b32b2647cc Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Tue, 9 Mar 2021 12:20:13 -0800 Subject: [PATCH 06/11] dynamic text expansion --- spotlight-client/package.json | 1 + .../src/NarrativeLayout/NarrativeLayout.tsx | 12 +++ spotlight-client/src/NarrativeLayout/index.ts | 1 + .../RacialDisparitiesNarrativePage.test.tsx | 67 ++++++++++++---- .../RacialDisparitiesNarrativePage.tsx | 45 +++++++++-- ...acialDisparitiesNarrativePageContainer.tsx | 1 + spotlight-client/src/UiLibrary/colors.ts | 1 + .../src/contentApi/sources/us_nd.ts | 78 +++++++++++++++++-- spotlight-client/src/contentApi/types.ts | 2 +- .../RacialDisparitiesNarrative.ts | 73 +++++++++++++++++ .../__fixtures__/tenant_content_exhaustive.ts | 26 ++++--- yarn.lock | 2 +- 12 files changed, 268 insertions(+), 41 deletions(-) diff --git a/spotlight-client/package.json b/spotlight-client/package.json index 640db594..d4517e29 100644 --- a/spotlight-client/package.json +++ b/spotlight-client/package.json @@ -67,6 +67,7 @@ "mobx-react-lite": "^3.0.1", "mobx-utils": "^6.0.1", "polished": "^4.0.5", + "pupa": "^2.1.1", "qs": "^6.9.4", "react": "^17.0.0", "react-app-polyfill": "^1.0.6", diff --git a/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx b/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx index 0ecc823b..df902fa2 100644 --- a/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx +++ b/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx @@ -27,6 +27,7 @@ import { NAV_BAR_HEIGHT } from "../constants"; import getUrlForResource from "../routerUtils/getUrlForResource"; import normalizeRouteParams from "../routerUtils/normalizeRouteParams"; import { X_PADDING } from "../SystemNarrativePage/constants"; +import { colors } from "../UiLibrary"; import NarrativeNavigation from "./NarrativeNavigation"; import { LayoutSection } from "./types"; @@ -46,12 +47,23 @@ const NavStickyContainer = styled.div` top: ${rem(NAV_BAR_HEIGHT)}; `; +const dynamicTextClass = "DynamicTextValue"; + export const SectionsContainer = styled.div` flex: 1 1 auto; /* min-width cannot be auto or children will not shrink when viewport does */ min-width: 0; + + .${dynamicTextClass} { + color: ${colors.dynamicText}; + font-weight: 600; + } `; +export function wrapExpandedVariable(text: string): string { + return `${text}`; +} + type NarrativeLayoutProps = { sections: LayoutSection[]; }; diff --git a/spotlight-client/src/NarrativeLayout/index.ts b/spotlight-client/src/NarrativeLayout/index.ts index a8e31064..259176f2 100644 --- a/spotlight-client/src/NarrativeLayout/index.ts +++ b/spotlight-client/src/NarrativeLayout/index.ts @@ -16,4 +16,5 @@ // ============================================================================= export { default as NarrativeLayout } from "./NarrativeLayout"; +export * from "./NarrativeLayout"; export { default as StickySection } from "./StickySection"; diff --git a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.test.tsx b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.test.tsx index 7614c190..0f32358f 100644 --- a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.test.tsx +++ b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.test.tsx @@ -15,7 +15,7 @@ // along with this program. If not, see . // ============================================================================= -import { screen } from "@testing-library/react"; +import { getDefaultNormalizer, screen } from "@testing-library/react"; import mockContentFixture from "../contentModels/__fixtures__/tenant_content_exhaustive"; import { renderNavigableApp } from "../testUtils"; @@ -41,22 +41,57 @@ test("renders all the sections", () => { }); }); -test("parses HTML in copy", async () => { - expect(screen.getByRole("link", { name: "intro link" })).toBeInTheDocument(); +test("renders dynamic text", async () => { + // expanded templates are broken up with internal markup + // that we will want to normalize away + const normalizeContents = getDefaultNormalizer(); + // refer to the fixture to see what variables are in the text expect( - screen.getByRole("link", { name: "conclusion body link" }) - ).toBeInTheDocument(); -}); + await screen.findByText( + (content, element) => + normalizeContents(element.textContent || "") === + "introduction 81.0 26.9 23.1" + ) + ).toBeVisible(); -test.skip("renders dynamic text", () => { - // refer to the fixture to see what variables are in the text - expect(screen.getByText("introduction 81.0 26.9 23.1")).toBeVisible(); - // sections: { - // beforeCorrections: { - // title: "beforeCorrections title", - // body: `beforeCorrections body {{ethnonym}} {{ethnonymCapitalized}} - // {{populationPctCurrent}} {{correctionsPctCurrent}}`, - // }, - // TODO: other sections + expect( + screen.getByText( + (content, element) => + normalizeContents(element.textContent || "") === + "beforeCorrections body People who are Black 1% 18%" + ) + ).toBeVisible(); + + expect( + screen.getByText( + (content, element) => + normalizeContents(element.textContent || "") === + "sentencing body people who are Black 66% 36% 47% 56% greater" + ) + ).toBeVisible(); + + expect( + screen.getByText( + (content, element) => + normalizeContents(element.textContent || "") === + "supervision body 33% 47% 16% 19% 25% 27% 34% 35%" + ) + ).toBeVisible(); + + expect( + screen.getByText( + (content, element) => + normalizeContents(element.textContent || "") === + "releasesToParole body 33% 8%" + ) + ).toBeVisible(); + + expect( + screen.getByText( + (content, element) => + normalizeContents(element.textContent || "") === + "programming body 21% 11% greater" + ) + ).toBeVisible(); }); diff --git a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx index e9ee06d4..c068a0d8 100644 --- a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx +++ b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx @@ -16,10 +16,19 @@ // ============================================================================= import HTMLReactParser from "html-react-parser"; +import mapValues from "lodash.mapvalues"; import { observer } from "mobx-react-lite"; +import pupa from "pupa"; import React from "react"; -import RacialDisparitiesNarrative from "../contentModels/RacialDisparitiesNarrative"; -import { NarrativeLayout, StickySection } from "../NarrativeLayout"; +import RacialDisparitiesNarrative, { + TemplateVariables, +} from "../contentModels/RacialDisparitiesNarrative"; +import Loading from "../Loading"; +import { + NarrativeLayout, + StickySection, + wrapExpandedVariable, +} from "../NarrativeLayout"; import { NarrativeIntroContainer, NarrativeIntroCopy, @@ -32,9 +41,20 @@ type RacialDisparitiesNarrativePageProps = { narrative: RacialDisparitiesNarrative; }; +function wrapDynamicText(vars: TemplateVariables): TemplateVariables { + return mapValues(vars, (templateVar) => { + if (typeof templateVar === "string") { + return wrapExpandedVariable(templateVar); + } + return wrapDynamicText(templateVar); + }); +} + const RacialDisparitiesNarrativePage: React.FC = ({ narrative, }) => { + const templateData = wrapDynamicText(narrative.templateData); + return ( {narrative.title} - - {HTMLReactParser(narrative.introduction)} - + {narrative.isLoading || narrative.isLoading === undefined ? ( + + ) : ( + + {HTMLReactParser(pupa(narrative.introduction, templateData))} + + )} ), }, @@ -59,9 +83,14 @@ const RacialDisparitiesNarrativePage: React.FC {section.title} - - {HTMLReactParser(section.body)} - + {narrative.isLoading || + narrative.isLoading === undefined ? ( + + ) : ( + + {HTMLReactParser(pupa(section.body, templateData))} + + )} } rightContents={
Placeholder for chart
} diff --git a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePageContainer.tsx b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePageContainer.tsx index f70ea448..f1dc746b 100644 --- a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePageContainer.tsx +++ b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePageContainer.tsx @@ -25,6 +25,7 @@ const RacialDisparitiesNarrativePageContainer: React.FC = () => { const { narrative } = useDataStore(); if (narrative instanceof RacialDisparitiesNarrative) { + if (narrative.isLoading === undefined) narrative.hydrate(); return ; } diff --git a/spotlight-client/src/UiLibrary/colors.ts b/spotlight-client/src/UiLibrary/colors.ts index 352e76e8..3ee0a5c8 100644 --- a/spotlight-client/src/UiLibrary/colors.ts +++ b/spotlight-client/src/UiLibrary/colors.ts @@ -48,6 +48,7 @@ export default { chartNoData: gray, dataViz: Array.from(dataVizColorMap.values()), dataVizNamed: dataVizColorMap, + dynamicText: pineAccent2, footerBackground: pineDark, link: pineAccent2, mapFill: "#E9ECEC", diff --git a/spotlight-client/src/contentApi/sources/us_nd.ts b/spotlight-client/src/contentApi/sources/us_nd.ts index 2bdd77f1..7dea6f61 100644 --- a/spotlight-client/src/contentApi/sources/us_nd.ts +++ b/spotlight-client/src/contentApi/sources/us_nd.ts @@ -548,9 +548,9 @@ const content: TenantContent = { }, introduction: `

In North Dakota, people of color are overrepresented in prison, on probation, and on parole.

-

Black North Dakotans are {{BLACK}} times as likely to be under DOCR control - as their white counterparts, Latino North Dakotans are {{HISPANIC}} times as - likely, and Native American North Dakotans {{AMERICAN_INDIAN_ALASKAN_NATIVE}} times.`, +

Black North Dakotans are {likelihoodVsWhite.BLACK} times as likely to be under DOCR control + as their white counterparts, Latino North Dakotans are {likelihoodVsWhite.HISPANIC} times as + likely, and Native American North Dakotans {likelihoodVsWhite.AMERICAN_INDIAN_ALASKAN_NATIVE} times.

`, sections: { beforeCorrections: { title: "Disparities are already present before incarceration", @@ -559,11 +559,77 @@ const content: TenantContent = { and sentenced.1 Even before contact with the criminal justice system, disparities in community investment (education, housing, healthcare) may play an important role in creating the disparities that we see in sentencing data.

-

{{ethnonym}} make up {{populationPctCurrent}} of North Dakota’s population, but - {{correctionsPctCurrent}} of the population sentenced to time under DOCR control.

`, +

{ethnonymCapitalized} make up {beforeCorrections.populationPctCurrent} of North Dakota’s + population, but {beforeCorrections.correctionsPctCurrent} of the population sentenced + to time under DOCR control.

`, + }, + sentencing: { + title: "How can sentencing impact disparities?", + body: `

Many parts of the criminal justice system involve human judgment, creating the potential + for disparities to develop over time. Sentences are imposed based on the type and severity of crime. + In many cases, courts have some discretion over what sentence to impose on a person convicted of an + offense. In the aggregate, these variations in sentencing add up to significant trends.

+

Currently, {sentencing.incarcerationPctCurrent} of {ethnonym} under DOCR jurisdiction are + serving incarceration sentences and {sentencing.probationPctCurrent} are serving probation sentences, + a {sentencing.comparison} percentage serving incarceration sentences compared to the overall distribution of + {sentencing.overall.incarcerationPctCurrent} serving incarceration sentences versus + {sentencing.overall.probationPctCurrent} serving probation sentences.

`, + }, + releasesToParole: { + title: "How can parole grant rates impact disparities?", + body: `

People sentenced to a prison term can serve the end-portion of their term while supervised + in the community, through the parole process.

+

The parole process is governed by the Parole Board, an independent commission that works closely + with the DOCR. In 2019, under guidance from Governor Burgum and then-Director of Corrections Leann + Bertsch, the DOCR and the Parole Board began tracking and reporting racial data for the parole process + in order to monitor and reduce disparities in the population granted parole.

+

In the last 3 years, {ethnonym} comprised {releasesToParole.paroleReleaseProportion36Mo} of + the individuals released on parole. They made up {releasesToParole.prisonPopulationProportion36Mo} + of the overall prison population during that time.

`, + }, + supervision: { + title: "How can community supervision impact disparities?", + body: `

For individuals on probation (community supervision in lieu of a prison sentence) or on parole, + failure can mean revocation: a process that removes people from community supervision and places them + in prison.

+

{ethnonymCapitalized} represent {supervision.populationProportion36Mo} of the supervision + population, but were {supervision.revocationProportion36Mo} of revocation admissions to prison in + the last 3 years.

+

Reasons for a revocation can vary: {ethnonym} are revoked {supervision.technicalProportion36Mo} + of the time for technical violations (a rule of supervision, rather than a crime), + {supervision.absconsionProportion36Mo} of the time for absconsion from supervision, and + {supervision.newCrimeProportion36Mo} of the time for new crimes. In contrast, overall revocations + for technical violations are {supervision.overall.technicalProportion36Mo}, revocations for absconsion + {supervision.overall.absconsionProportion36Mo} and revocations for new crime + {supervision.overall.newCrimeProportion36Mo}

`, + }, + programming: { + title: "Can programming help reduce disparities?", + body: `

Programming is designed to improve outcomes for justice-involved individuals. If programming is + utilized more by groups overrepresented in the justice system, it could help close the gap.

+

In 2018, North Dakota launched Free Through Recovery, a wrap-around behavioral health program that + helps those with behavioral health challenges to succeed on community supervision. {ethnonymCapitalized} + are {programming.participantProportionCurrent} of active participants in FTR, a {programming.comparison} + representation compared to their overall {programming.supervisionProportionCurrent} of the current + supervision population.

`, + }, + conclusion: { + title: + "What are we doing to further improve disparities in criminal justice in North Dakota?", + body: `

In 2019, the DOCR announced participation in the Restoring Promise initiative with the Vera + Institute of Justice and MILPA. This initiative will focus on strategies to improve outcomes for + incarcerated individuals age 18-25 with a strong emphasis on addressing racial inequities.

+

We all have a part to play in reducing racial disparities.

+

The good news is that many approaches have been shown to reduce disparities in criminal justice:

+
    +
  • Investing in community-based education, housing, and healthcare
  • +
  • Re-evaluation of community policing practices
  • +
  • Looking for and reducing bias in charging, and sentencing practices
  • +
+

Finally, progress starts with transparency; this page helps North Dakota and those of us at the + DOCR to continue work to reduce the disparities in our system and create an equitable justice system.

`, }, }, - // TODO: remaining sections }, }; diff --git a/spotlight-client/src/contentApi/types.ts b/spotlight-client/src/contentApi/types.ts index 5b20d02d..ef121cac 100644 --- a/spotlight-client/src/contentApi/types.ts +++ b/spotlight-client/src/contentApi/types.ts @@ -174,7 +174,7 @@ export type RacialDisparitiesSections = { /** * Introduction and section bodies support dynamic text - * via {@link https://mustache.github.io/ Mustache} template syntax + * via {@link https://github.com/sindresorhus/pupa Pupa} template syntax */ export type RacialDisparitiesNarrativeContent = { chartLabels: RacialDisparitiesChartLabels; diff --git a/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts b/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts index c5325b66..5ef4b012 100644 --- a/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts +++ b/spotlight-client/src/contentModels/RacialDisparitiesNarrative.ts @@ -35,6 +35,7 @@ import { RevocationCountKeyList, } from "../metricsApi/RacialDisparitiesRecord"; import { colors } from "../UiLibrary"; +import { formatAsPct } from "../utils"; import calculatePct from "./calculatePct"; import { DemographicCategoryRecords } from "./types"; @@ -97,11 +98,34 @@ function getSentencingMetrics( }; } +const getRoundedPct = (number: number) => Number(number.toFixed(2)); + +/** + * Given two numbers between 0 and 1, rounds them to two decimal places, compares them, + * and returns the result in natural language; i.e., "greater", "smaller", or "similar" + */ +const comparePercentagesAsString = (subject: number, base: number) => { + const roundedSubject = getRoundedPct(subject); + const roundedBase = getRoundedPct(base); + + if (roundedSubject > roundedBase) { + return "greater"; + } + if (roundedSubject < roundedBase) { + return "smaller"; + } + return "similar"; +}; + type SectionData = { title: string; body: string; }; +export type TemplateVariables = { + [key: string]: string | TemplateVariables; +}; + type ConstructorOpts = { tenantId: TenantId; defaultCategory?: RaceIdentifier; @@ -617,6 +641,55 @@ export default class RacialDisparitiesNarrative { ]; } + get templateData(): TemplateVariables { + const data: TemplateVariables = { + ethnonym: this.ethnonym, + ethnonymCapitalized: upperCaseFirst(this.ethnonym), + }; + + if (this.likelihoodVsWhite) { + data.likelihoodVsWhite = mapValues(this.likelihoodVsWhite, (val) => + val.toFixed(1) + ); + } + + (["beforeCorrections", "releasesToParole"] as const).forEach((key) => { + if (this[key]) { + data[key] = mapValues(this[key], formatAsPct); + } + }); + + if (this.programming) { + data.programming = { + ...mapValues(this.programming, formatAsPct), + comparison: comparePercentagesAsString( + this.programming.participantProportionCurrent, + this.programming.supervisionProportionCurrent + ), + }; + } + + if (this.sentencing && this.sentencingOverall) { + data.sentencing = { + ...mapValues(this.sentencing, formatAsPct), + overall: mapValues(this.sentencingOverall, formatAsPct), + comparison: comparePercentagesAsString( + this.sentencing.incarcerationPctCurrent, + this.sentencingOverall.incarcerationPctCurrent + ), + }; + } + + if (this.supervision && this.supervisionOverall) { + data.supervision = { + ...mapValues(this.supervision, formatAsPct), + overall: mapValues(this.supervisionOverall, formatAsPct), + }; + } + + return data; + } + get sections(): SectionData[] { const { sectionText } = this; const sections = []; diff --git a/spotlight-client/src/contentModels/__fixtures__/tenant_content_exhaustive.ts b/spotlight-client/src/contentModels/__fixtures__/tenant_content_exhaustive.ts index d687d4a7..98c1b326 100644 --- a/spotlight-client/src/contentModels/__fixtures__/tenant_content_exhaustive.ts +++ b/spotlight-client/src/contentModels/__fixtures__/tenant_content_exhaustive.ts @@ -308,33 +308,41 @@ const content: ExhaustiveTenantContent = { supervisionPopulation: "People subject to supervision", totalPopulationSentences: "All people sentenced and under DOCR control", }, - introduction: - 'introduction {{BLACK}} {{HISPANIC}} {{AMERICAN_INDIAN_ALASKAN_NATIVE}} intro link', + introduction: `introduction {likelihoodVsWhite.BLACK} {likelihoodVsWhite.HISPANIC} + {likelihoodVsWhite.AMERICAN_INDIAN_ALASKAN_NATIVE}`, sections: { beforeCorrections: { title: "beforeCorrections title", - body: `beforeCorrections body {{ethnonym}} {{ethnonymCapitalized}} - {{populationPctCurrent}} {{correctionsPctCurrent}}`, + body: `beforeCorrections body {ethnonymCapitalized} {beforeCorrections.populationPctCurrent} + {beforeCorrections.correctionsPctCurrent}`, }, conclusion: { title: "conclusion title", - body: 'conclusion body conclusion body link', + body: "conclusion body", }, sentencing: { title: "sentencing title", - body: "sentencing body", + body: `sentencing body {ethnonym} {sentencing.incarcerationPctCurrent} + {sentencing.probationPctCurrent} {sentencing.overall.incarcerationPctCurrent} + {sentencing.overall.probationPctCurrent} {sentencing.comparison}`, }, supervision: { title: "supervision title", - body: "supervision body", + body: `supervision body {supervision.absconsionProportion36Mo} + {supervision.newCrimeProportion36Mo} {supervision.technicalProportion36Mo} + {supervision.populationProportion36Mo} {supervision.revocationProportion36Mo} + {supervision.overall.absconsionProportion36Mo} {supervision.overall.newCrimeProportion36Mo} + {supervision.overall.technicalProportion36Mo}`, }, releasesToParole: { title: "releasesToParole title", - body: "releasesToParole body", + body: `releasesToParole body {releasesToParole.paroleReleaseProportion36Mo} + {releasesToParole.prisonPopulationProportion36Mo}`, }, programming: { title: "programming title", - body: "programming body", + body: `programming body {programming.participantProportionCurrent} + {programming.supervisionProportionCurrent} {programming.comparison}`, }, }, }, diff --git a/yarn.lock b/yarn.lock index da1a6d3c..6a5223ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11997,7 +11997,7 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -pupa@^2.0.1: +pupa@^2.0.1, pupa@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A== From 843cc145aa8799148bedd85b6c3abe9a01c73913 Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Tue, 9 Mar 2021 14:42:05 -0800 Subject: [PATCH 07/11] more text styling --- .../src/NarrativeLayout/NarrativeLayout.tsx | 12 --------- .../RacialDisparitiesNarrativePage.tsx | 7 ++--- spotlight-client/src/UiLibrary/CopyBlock.ts | 26 +++++++++++++++++++ spotlight-client/src/UiLibrary/dynamicText.ts | 22 ++++++++++++++++ spotlight-client/src/UiLibrary/index.ts | 1 + .../src/contentApi/sources/us_nd.ts | 9 ++++--- 6 files changed, 57 insertions(+), 20 deletions(-) create mode 100644 spotlight-client/src/UiLibrary/dynamicText.ts diff --git a/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx b/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx index df902fa2..0ecc823b 100644 --- a/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx +++ b/spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx @@ -27,7 +27,6 @@ import { NAV_BAR_HEIGHT } from "../constants"; import getUrlForResource from "../routerUtils/getUrlForResource"; import normalizeRouteParams from "../routerUtils/normalizeRouteParams"; import { X_PADDING } from "../SystemNarrativePage/constants"; -import { colors } from "../UiLibrary"; import NarrativeNavigation from "./NarrativeNavigation"; import { LayoutSection } from "./types"; @@ -47,23 +46,12 @@ const NavStickyContainer = styled.div` top: ${rem(NAV_BAR_HEIGHT)}; `; -const dynamicTextClass = "DynamicTextValue"; - export const SectionsContainer = styled.div` flex: 1 1 auto; /* min-width cannot be auto or children will not shrink when viewport does */ min-width: 0; - - .${dynamicTextClass} { - color: ${colors.dynamicText}; - font-weight: 600; - } `; -export function wrapExpandedVariable(text: string): string { - return `${text}`; -} - type NarrativeLayoutProps = { sections: LayoutSection[]; }; diff --git a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx index c068a0d8..dc668b52 100644 --- a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx +++ b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx @@ -24,17 +24,14 @@ import RacialDisparitiesNarrative, { TemplateVariables, } from "../contentModels/RacialDisparitiesNarrative"; import Loading from "../Loading"; -import { - NarrativeLayout, - StickySection, - wrapExpandedVariable, -} from "../NarrativeLayout"; +import { NarrativeLayout, StickySection } from "../NarrativeLayout"; import { NarrativeIntroContainer, NarrativeIntroCopy, NarrativeSectionBody, NarrativeSectionTitle, NarrativeTitle, + wrapExpandedVariable, } from "../UiLibrary"; type RacialDisparitiesNarrativePageProps = { diff --git a/spotlight-client/src/UiLibrary/CopyBlock.ts b/spotlight-client/src/UiLibrary/CopyBlock.ts index ed364b1e..a2f4a173 100644 --- a/spotlight-client/src/UiLibrary/CopyBlock.ts +++ b/spotlight-client/src/UiLibrary/CopyBlock.ts @@ -17,6 +17,7 @@ import styled from "styled-components/macro"; import colors from "./colors"; +import { dynamicTextClass } from "./dynamicText"; export default styled.div` p { @@ -26,4 +27,29 @@ export default styled.div` a { color: ${colors.accent}; } + + ul { + list-style: outside; + margin-top: 1em; + padding-left: 1.2em; + } + + li { + margin-top: 0.5em; + } + + /* footnotes */ + sup { + font-size: 0.6em; + vertical-align: super; + } + aside { + font-size: 0.7em; + margin-top: 1.4em; + } + + .${dynamicTextClass} { + color: ${colors.dynamicText}; + font-weight: 600; + } `; diff --git a/spotlight-client/src/UiLibrary/dynamicText.ts b/spotlight-client/src/UiLibrary/dynamicText.ts new file mode 100644 index 00000000..48f6f724 --- /dev/null +++ b/spotlight-client/src/UiLibrary/dynamicText.ts @@ -0,0 +1,22 @@ +// Recidiviz - a data platform for criminal justice reform +// 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 +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// ============================================================================= + +export const dynamicTextClass = "DynamicTextValue"; + +export function wrapExpandedVariable(text: string): string { + return `${text}`; +} diff --git a/spotlight-client/src/UiLibrary/index.ts b/spotlight-client/src/UiLibrary/index.ts index 852586ec..adea7d6c 100644 --- a/spotlight-client/src/UiLibrary/index.ts +++ b/spotlight-client/src/UiLibrary/index.ts @@ -22,6 +22,7 @@ export { default as Chevron } from "./Chevron"; export { default as CopyBlock } from "./CopyBlock"; export { default as colors } from "./colors"; export * from "./Dropdown"; +export * from "./dynamicText"; export { default as FixedBottomPanel } from "./FixedBottomPanel"; export * from "./Modal"; export * from "./narrative"; diff --git a/spotlight-client/src/contentApi/sources/us_nd.ts b/spotlight-client/src/contentApi/sources/us_nd.ts index 7dea6f61..5dc23ab4 100644 --- a/spotlight-client/src/contentApi/sources/us_nd.ts +++ b/spotlight-client/src/contentApi/sources/us_nd.ts @@ -556,12 +556,15 @@ const content: TenantContent = { title: "Disparities are already present before incarceration", body: `

Disparities emerge long before a person is incarcerated. By the time someone comes under the DOCR’s care, they have been arrested, charged, convicted, - and sentenced.1 Even before contact with the criminal justice system, + and sentenced.1 Even before contact with the criminal justice system, disparities in community investment (education, housing, healthcare) may play an important role in creating the disparities that we see in sentencing data.

{ethnonymCapitalized} make up {beforeCorrections.populationPctCurrent} of North Dakota’s population, but {beforeCorrections.correctionsPctCurrent} of the population sentenced - to time under DOCR control.

`, + to time under DOCR control.

+ `, }, sentencing: { title: "How can sentencing impact disparities?", @@ -601,7 +604,7 @@ const content: TenantContent = { {supervision.newCrimeProportion36Mo} of the time for new crimes. In contrast, overall revocations for technical violations are {supervision.overall.technicalProportion36Mo}, revocations for absconsion {supervision.overall.absconsionProportion36Mo} and revocations for new crime - {supervision.overall.newCrimeProportion36Mo}

`, + {supervision.overall.newCrimeProportion36Mo}.

`, }, programming: { title: "Can programming help reduce disparities?", From a7b6ad19fe053c7d415ac764ad9ca2b0dd4a216e Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Tue, 9 Mar 2021 16:57:12 -0800 Subject: [PATCH 08/11] column layout for conclusion --- .../src/NarrativeLayout/StickySection.tsx | 7 +-- .../RacialDisparitiesNarrativePage.tsx | 56 +++++++++++++++++-- spotlight-client/src/UiLibrary/PageSection.ts | 9 ++- spotlight-client/src/UiLibrary/index.ts | 2 +- spotlight-client/src/UiLibrary/narrative.ts | 9 +-- .../src/contentApi/sources/us_nd.ts | 8 +-- 6 files changed, 70 insertions(+), 21 deletions(-) diff --git a/spotlight-client/src/NarrativeLayout/StickySection.tsx b/spotlight-client/src/NarrativeLayout/StickySection.tsx index 6bd2086c..c6d7d966 100644 --- a/spotlight-client/src/NarrativeLayout/StickySection.tsx +++ b/spotlight-client/src/NarrativeLayout/StickySection.tsx @@ -22,14 +22,11 @@ import { useInView } from "react-intersection-observer"; import Sticker from "react-stickyfill"; import styled from "styled-components/macro"; import { NAV_BAR_HEIGHT } from "../constants"; -import { breakpoints, colors, CopyBlock, PageSection } from "../UiLibrary"; +import { breakpoints, CopyBlock, FullScreenSection } from "../UiLibrary"; const COPY_WIDTH = 408; -const Container = styled(PageSection)` - border-bottom: 1px solid ${colors.rule}; - min-height: calc(100vh - ${rem(NAV_BAR_HEIGHT)}); - +const Container = styled(FullScreenSection)` @media screen and (min-width: ${breakpoints.tablet[0]}px) { display: flex; flex-direction: column; diff --git a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx index dc668b52..d37bc63b 100644 --- a/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx +++ b/spotlight-client/src/RacialDisparitiesNarrativePage/RacialDisparitiesNarrativePage.tsx @@ -18,14 +18,18 @@ import HTMLReactParser from "html-react-parser"; import mapValues from "lodash.mapvalues"; import { observer } from "mobx-react-lite"; +import { rem } from "polished"; import pupa from "pupa"; import React from "react"; +import styled from "styled-components/macro"; import RacialDisparitiesNarrative, { TemplateVariables, } from "../contentModels/RacialDisparitiesNarrative"; import Loading from "../Loading"; import { NarrativeLayout, StickySection } from "../NarrativeLayout"; import { + breakpoints, + FullScreenSection, NarrativeIntroContainer, NarrativeIntroCopy, NarrativeSectionBody, @@ -34,9 +38,32 @@ import { wrapExpandedVariable, } from "../UiLibrary"; -type RacialDisparitiesNarrativePageProps = { - narrative: RacialDisparitiesNarrative; -}; +const CopyOnlySection = styled(FullScreenSection)` + @media screen and (min-width: ${breakpoints.tablet[0]}px) { + display: flex; + flex-direction: column; + justify-content: center; + padding-left: 0; + } +`; + +const SectionColumns = styled(NarrativeSectionBody)` + padding: ${rem(40)} 0; + + @media screen and (min-width: ${breakpoints.desktop[0]}px) { + columns: 3; + column-gap: ${rem(32)}; + + ${NarrativeSectionTitle} { + break-after: column; + } + + div { + break-after: column; + break-inside: avoid-column; + } + } +`; function wrapDynamicText(vars: TemplateVariables): TemplateVariables { return mapValues(vars, (templateVar) => { @@ -47,6 +74,10 @@ function wrapDynamicText(vars: TemplateVariables): TemplateVariables { }); } +type RacialDisparitiesNarrativePageProps = { + narrative: RacialDisparitiesNarrative; +}; + const RacialDisparitiesNarrativePage: React.FC = ({ narrative, }) => { @@ -70,7 +101,7 @@ const RacialDisparitiesNarrativePage: React.FC ), }, - ...narrative.sections.map((section) => { + ...narrative.sections.slice(0, -1).map((section) => { return { title: section.title, contents: ( @@ -95,6 +126,23 @@ const RacialDisparitiesNarrativePage: React.FC { + return { + title: section.title, + contents: ( + + + {section.title} + {narrative.isLoading || narrative.isLoading === undefined ? ( + + ) : ( + HTMLReactParser(pupa(section.body, templateData)) + )} + + + ), + }; + }), ]} /> ); diff --git a/spotlight-client/src/UiLibrary/PageSection.ts b/spotlight-client/src/UiLibrary/PageSection.ts index b1e2a9f8..a2f02181 100644 --- a/spotlight-client/src/UiLibrary/PageSection.ts +++ b/spotlight-client/src/UiLibrary/PageSection.ts @@ -17,8 +17,10 @@ import { rem } from "polished"; import styled from "styled-components/macro"; +import { NAV_BAR_HEIGHT } from "../constants"; import { X_PADDING } from "../SystemNarrativePage/constants"; import breakpoints from "./breakpoints"; +import colors from "./colors"; /** * Base styled component for all page-level content blocks. @@ -26,10 +28,15 @@ import breakpoints from "./breakpoints"; * (Uses padding rather than margins because many designs call for * borders to bleed on one or both sides) */ -export default styled.section` +export const PageSection = styled.section` padding: 0 ${rem(16)}; @media screen and (min-width: ${breakpoints.tablet[0]}px) { padding: 0 ${rem(X_PADDING)}; } `; + +export const FullScreenSection = styled(PageSection)` + border-bottom: 1px solid ${colors.rule}; + min-height: calc(100vh - ${rem(NAV_BAR_HEIGHT)}); +`; diff --git a/spotlight-client/src/UiLibrary/index.ts b/spotlight-client/src/UiLibrary/index.ts index adea7d6c..92708c3b 100644 --- a/spotlight-client/src/UiLibrary/index.ts +++ b/spotlight-client/src/UiLibrary/index.ts @@ -26,6 +26,6 @@ export * from "./dynamicText"; export { default as FixedBottomPanel } from "./FixedBottomPanel"; export * from "./Modal"; export * from "./narrative"; -export { default as PageSection } from "./PageSection"; +export * from "./PageSection"; export * from "./typography"; export { default as zIndex } from "./zIndex"; diff --git a/spotlight-client/src/UiLibrary/narrative.ts b/spotlight-client/src/UiLibrary/narrative.ts index ed5e9978..f8ad64c3 100644 --- a/spotlight-client/src/UiLibrary/narrative.ts +++ b/spotlight-client/src/UiLibrary/narrative.ts @@ -17,16 +17,13 @@ import { rem } from "polished"; import styled from "styled-components/macro"; -import { NAV_BAR_HEIGHT } from "../constants"; import breakpoints from "./breakpoints"; import colors from "./colors"; import CopyBlock from "./CopyBlock"; -import PageSection from "./PageSection"; +import { FullScreenSection } from "./PageSection"; import { typefaces } from "./typography"; -export const NarrativeIntroContainer = styled(PageSection)` - border-bottom: 1px solid ${colors.rule}; - min-height: calc(100vh - ${rem(NAV_BAR_HEIGHT)}); +export const NarrativeIntroContainer = styled(FullScreenSection)` padding-top: ${rem(48)}; padding-bottom: ${rem(48)}; @@ -87,6 +84,6 @@ export const NarrativeSectionTitle = styled.h2` margin-bottom: ${rem(24)}; `; -export const NarrativeSectionBody = styled.div` +export const NarrativeSectionBody = styled(CopyBlock)` line-height: 1.67; `; diff --git a/spotlight-client/src/contentApi/sources/us_nd.ts b/spotlight-client/src/contentApi/sources/us_nd.ts index 5dc23ab4..1ea8b475 100644 --- a/spotlight-client/src/contentApi/sources/us_nd.ts +++ b/spotlight-client/src/contentApi/sources/us_nd.ts @@ -619,10 +619,10 @@ const content: TenantContent = { conclusion: { title: "What are we doing to further improve disparities in criminal justice in North Dakota?", - body: `

In 2019, the DOCR announced participation in the Restoring Promise initiative with the Vera + body: `

In 2019, the DOCR announced participation in the Restoring Promise initiative with the Vera Institute of Justice and MILPA. This initiative will focus on strategies to improve outcomes for - incarcerated individuals age 18-25 with a strong emphasis on addressing racial inequities.

-

We all have a part to play in reducing racial disparities.

+ incarcerated individuals age 18-25 with a strong emphasis on addressing racial inequities.
+
We all have a part to play in reducing racial disparities.

The good news is that many approaches have been shown to reduce disparities in criminal justice:

  • Investing in community-based education, housing, and healthcare
  • @@ -630,7 +630,7 @@ const content: TenantContent = {
  • Looking for and reducing bias in charging, and sentencing practices

Finally, progress starts with transparency; this page helps North Dakota and those of us at the - DOCR to continue work to reduce the disparities in our system and create an equitable justice system.

`, + DOCR to continue work to reduce the disparities in our system and create an equitable justice system.

`, }, }, }, From a10013e21d0d44f8c9596b5352d3e838de51dbcc Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Tue, 9 Mar 2021 18:25:10 -0800 Subject: [PATCH 09/11] dynamic content documentation --- spotlight-client/README.md | 4 ++ spotlight-client/src/contentApi/README.md | 61 +++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 spotlight-client/src/contentApi/README.md diff --git a/spotlight-client/README.md b/spotlight-client/README.md index 4f8629f5..3bd8610a 100644 --- a/spotlight-client/README.md +++ b/spotlight-client/README.md @@ -101,3 +101,7 @@ You can also run either TS or ESLint individually; while there are not predefine **Note: this is a one-way operation. Once you `eject`, you can’t go back!** This package was bootstrapped with Create React App, which provides the option to `eject` its build tooling and configuration, allowing for full customization. See [the Create React App docs](https://create-react-app.dev/docs/available-scripts#npm-run-eject) for more information. + +## Adding new Tenants + +In addition to data being available from `spotlight-api`, adding a new tenant to the site also requires content for that tenant to be added to the Content API (which is included in the JS bundle, not served by the backend). See the [Content README](src/contentApi/README.md) for more information. diff --git a/spotlight-client/src/contentApi/README.md b/spotlight-client/src/contentApi/README.md new file mode 100644 index 00000000..e13d06f6 --- /dev/null +++ b/spotlight-client/src/contentApi/README.md @@ -0,0 +1,61 @@ +# Tenant Content + +Content (copy, metadata, etc) for a Tenant is provided in code as a JavaScript object, one per tenant. This object determines what Narratives, Metrics, etc., will be included for that Tenant; e.g., a Metric without content will be excluded from the site even if the data is available, and conversely, a Metric with content will be included even if the data is not available (which will cause errors to be reported in the UI). + +For more information on what this object should contain, refer to [the type definitions](./types.ts). + +## Copy can contain HTML + +You can write any HTML you like in a copy string (e.g., the introduction to a narrative, or a section body) and the tags will be rendered to the page. Not all of them will necessarily have useful styles applied to them, but you can always make a feature request to add anything unsupported. Notably: + +- `p`, `a`, and `ul`/`li` tags should do pretty much what you expect +- You can style footnotes! Put the marker in a `sup` tag and the footnote text in an `aside`. (This basically just changes the text size, it doesn't add any special functionality.) +- In multi-column text (e.g., the final Racial Disparities section), wrapping text in a `div` will create a column break after it (and also _prevent_ column breaks within it) + +This HTML is not sanitized in any way (it's considered trusted, since it's checked into version control) so be careful! + +## Dynamic text expansion in Narratives + +Some Narratives support (indeed, require) dynamic text expansion, through a very simple template syntax provided by [Pupa](https://github.com/sindresorhus/pupa#readme). TL;DR: put variable names in `{braces}`! + +For a given Narrative, the application will supply the data needed to plug into your templates when it renders the page. See below for documentation of what variables will be available: + +### Racial Disparities + +Variables reflect the currently selected racial/ethnic group unless otherwise specified. All percentages are rounded to the nearest whole number. Use dot notation to reference nested variables (e.g. `beforeCorrections.populationPctCurrent`) + +- **ethnonym**: name of the selected racial/ethnic group, e.g., "people who are Native American". +- **ethnonymCapitalized**: same but "People" is capitalized. +- **likelihoodVsWhite**: for each group listed, the relative likelihood of its members being justice-involved compared to white people. Rounded to the nearest tenth. + - **BLACK** + - **HISPANIC** + - **AMERICAN_INDIAN_ALASKAN_NATIVE** +- **beforeCorrections**: + - **populationPctCurrent**: % of the statewide population + - **correctionsPctCurrent**: % of the justice-involved population +- **releasesToParole**: + - **paroleReleaseProportion36Mo**: % of all parole grants over the past 3 years + - **prisonPopulationProportion36Mo**: % of total prison population over the past 3 years +- **programming**: + - **participantProportionCurrent**: % of total program participants + - **supervisionProportionCurrent**: % of total supervised population + - **comparison**: natural-language comparison of the former to the latter; will be "greater", "smaller", or "similar" +- **sentencing**: + - **incarcerationPctCurrent**: % currently serving incarceration sentences + - **probationPctCurrent**: % currently serving probation sentences + - **overall**: these values reflect the **total** justice-involved population, not just the selected racial/ethnic group + - **incarcerationPctCurrent** + - **probationPctCurrent** + - **comparison**: natural-language comparison of the selected group's incarceration % to the overall incarceration %; will be "greater", "smaller", or "similar" +- **supervision**: all values reflect the active Supervision Type filter (parole, probation, or both) + - **revocationProportion36Mo**: % of total revocations over the past 3 years + - **populationProportion36Mo**: % of total supervised population over the past 3 years + - **absconsionProportion36Mo**: % of revocations over the past 3 years due to absconsion. + - **newCrimeProportion36Mo**: same but for new offenses + - **technicalProportion36Mo**: same but for technicals + - **unknownProportion36Mo**: same but for unknowns + - **overall**: these values reflect the **total** supervised population, not just the selected racial/ethnic group + - **absconsionProportion36Mo** + - **newCrimeProportion36Mo** + - **technicalProportion36Mo** + - **unknownProportion36Mo** From 5d4f33fa45f73eedaa233c6e079fb567eae1b99f Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Wed, 10 Mar 2021 12:34:46 -0800 Subject: [PATCH 10/11] add RD to collapsible nav menu --- .../src/SiteNavigation/SiteNavigationMobile.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spotlight-client/src/SiteNavigation/SiteNavigationMobile.tsx b/spotlight-client/src/SiteNavigation/SiteNavigationMobile.tsx index d04c755f..c75ba234 100644 --- a/spotlight-client/src/SiteNavigation/SiteNavigationMobile.tsx +++ b/spotlight-client/src/SiteNavigation/SiteNavigationMobile.tsx @@ -148,6 +148,22 @@ const SiteNavigation: React.FC = () => { ) )} + {tenant.racialDisparitiesNarrative && ( + + setExpanded(false)} + to={getUrlForResource({ + page: "narrative", + params: { + tenantId: tenant.id, + narrativeTypeId: "RacialDisparities", + }, + })} + > + {tenant.racialDisparitiesNarrative.title} + + + )} )} From bdd93dbae07fd707433792c1fce8c274aa2d2afb Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Wed, 10 Mar 2021 12:42:10 -0800 Subject: [PATCH 11/11] fix menu test --- .../src/SiteNavigation/SiteNavigation.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/spotlight-client/src/SiteNavigation/SiteNavigation.test.tsx b/spotlight-client/src/SiteNavigation/SiteNavigation.test.tsx index 4009fbe8..126245a2 100644 --- a/spotlight-client/src/SiteNavigation/SiteNavigation.test.tsx +++ b/spotlight-client/src/SiteNavigation/SiteNavigation.test.tsx @@ -105,7 +105,7 @@ describe("on small screens", () => { const menu = screen.getByTestId("NavMenu"); const navLinks = await within(menu).findAllByRole("link"); - expect(navLinks.length).toBe(5); + expect(navLinks.length).toBe(6); expect(navLinks[0]).toHaveTextContent("Home"); expect(navLinks[0]).toHaveAttribute("href", "/us-nd"); @@ -124,6 +124,12 @@ describe("on small screens", () => { expect(navLinks[4]).toHaveTextContent("Parole"); expect(navLinks[4]).toHaveAttribute("href", "/us-nd/collections/parole"); + + expect(navLinks[5]).toHaveTextContent("Racial Disparities"); + expect(navLinks[5]).toHaveAttribute( + "href", + "/us-nd/collections/racial-disparities" + ); }); test("menu closes after navigation", async () => {