Skip to content

Commit

Permalink
Merge bdd93db into d7908ff
Browse files Browse the repository at this point in the history
  • Loading branch information
macfarlandian committed Mar 10, 2021
2 parents d7908ff + bdd93db commit d0ad608
Show file tree
Hide file tree
Showing 41 changed files with 1,263 additions and 366 deletions.
4 changes: 4 additions & 0 deletions spotlight-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions spotlight-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions spotlight-client/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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 })
Expand Down
24 changes: 19 additions & 5 deletions spotlight-client/src/DataStore/TenantStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
}
8 changes: 8 additions & 0 deletions spotlight-client/src/DataStore/UiStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down
190 changes: 190 additions & 0 deletions spotlight-client/src/NarrativeLayout/NarrativeLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
// =============================================================================

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<NarrativeLayoutProps> = ({ 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 (
<Wrapper>
{showSectionNavigation && (
<NavContainer>
<Sticker>
<NavStickyContainer>
<NarrativeNavigation
activeSection={activeSection}
sections={sections}
/>
</NavStickyContainer>
</Sticker>
</NavContainer>
)}
<SectionsContainer ref={sectionsContainerRef}>
{sections.map((section, index) => {
// 1-indexed for human readability
const pageId = index + 1;
return (
<InView
as="div"
id={`section${pageId}`}
key={section.title}
threshold={0.3}
onChange={(inView) => {
if (inView) setActiveSection(pageId);
}}
>
{section.contents}
</InView>
);
})}
</SectionsContainer>
</Wrapper>
);
};

export default NarrativeLayout;
Original file line number Diff line number Diff line change
Expand Up @@ -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)};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -50,12 +50,12 @@ const SectionNumberFaded = styled(SectionNumber)`

type NavigationProps = {
activeSection: number;
narrative: SystemNarrative;
sections: LayoutSection[];
};

const SectionNavigation: React.FC<NavigationProps> = ({
activeSection,
narrative,
sections,
}) => {
const { tenantId, narrativeTypeId } = normalizeRouteParams(
useParams()
Expand All @@ -72,8 +72,7 @@ const SectionNavigation: React.FC<NavigationProps> = ({
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;
Expand All @@ -83,7 +82,7 @@ const SectionNavigation: React.FC<NavigationProps> = ({
<SectionNav aria-label="page sections">
<SectionNumber>{formatPageNum(activeSection)}</SectionNumber>
<SectionNumberFaded>{formatPageNum(totalPages)}</SectionNumberFaded>
<SectionLinks {...{ activeSection, narrative, totalPages, urlBase }} />
<SectionLinks {...{ activeSection, sections, urlBase }} />
<AdvanceLink
urlBase={urlBase}
activeSection={activeSection}
Expand Down
Loading

0 comments on commit d0ad608

Please sign in to comment.