diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index 3a6299b9c..75d1028fa 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -77,7 +77,7 @@ jobs: ./node_modules/.bin/firebase --project demo-dtp emulators:exec --only auth,firestore --import tests/integration/exportedTestData - "yarn firebase-admin run-script backfillTestimonyBallotQuestionId --env local" + "yarn firebase-admin run-script backfillTestimonyScope --env local" - name: Run Integration Tests run: > @@ -87,4 +87,4 @@ jobs: tests/integration/moderation.test.ts tests/integration/profile.test.ts tests/integration/ballotQuestions.test.ts - tests/integration/backfillTestimonyBallotQuestionId.test.ts + tests/integration/backfillTestimonyScope.test.ts diff --git a/components/Newsfeed/Newsfeed.tsx b/components/Newsfeed/Newsfeed.tsx index a3fdf8b9c..57be0a7a5 100644 --- a/components/Newsfeed/Newsfeed.tsx +++ b/components/Newsfeed/Newsfeed.tsx @@ -251,6 +251,7 @@ export default function Newsfeed() { authorUid={element.authorUid} billId={element.billId} bodyText={element.bodyText} + conferenceId={element.conferenceId} court={element.court} header={element.header} isBillMatch={element.isBillMatch} diff --git a/components/Newsfeed/NotificationProps.tsx b/components/Newsfeed/NotificationProps.tsx index e0a5577ec..a9ba4211e 100644 --- a/components/Newsfeed/NotificationProps.tsx +++ b/components/Newsfeed/NotificationProps.tsx @@ -23,6 +23,8 @@ export type NotificationProps = { ballotQuestionId?: string ballotQuestionCourt?: number ballotStatus?: string + // Set on testimony to a committee of conference + conferenceId?: string | null } export type Notifications = NotificationProps[] diff --git a/components/NewsfeedCard/NewsfeedCard.test.tsx b/components/NewsfeedCard/NewsfeedCard.test.tsx index 654f6ac42..d1b1fc144 100644 --- a/components/NewsfeedCard/NewsfeedCard.test.tsx +++ b/components/NewsfeedCard/NewsfeedCard.test.tsx @@ -9,6 +9,7 @@ jest.mock("next-i18next", () => ({ })) jest.mock("components/links", () => ({ + maple: jest.requireActual("components/links").maple, Internal: ({ href, children, @@ -24,6 +25,15 @@ jest.mock("components/links", () => ({ ) })) +// jsdom has no matchMedia; the testimony bodies ask it for the phone layout +beforeAll(() => { + window.matchMedia = jest.fn().mockReturnValue({ + matches: false, + addEventListener: jest.fn(), + removeEventListener: jest.fn() + }) +}) + describe("NewsfeedCard", () => { it("renders ballot-question updates with the follow reason and destination link", () => { render( @@ -48,4 +58,63 @@ describe("NewsfeedCard", () => { screen.getByRole("link", { name: "View ballot question" }) ).toHaveAttribute("href", "/ballotQuestions/25-14") }) + + const testimony = { + authorUid: "author1", + billId: "S2581", + bodyText: "Keep the Senate's phone rules.", + court: "194", + header: "An Act relative to student learning", + isBillMatch: false, + isUserMatch: true, + subheader: "Alice", + testimonyId: "t1", + timestamp: Timestamp.fromDate(new Date("2025-03-01")), + type: "testimony", + userRole: "user" + } + + it("leads conference testimony to its conference with the stance in words", () => { + render( + + ) + + expect(screen.getByText("newsfeed.conferenceInput")).toBeInTheDocument() + expect( + screen.getByText("input.position.passSenateVersion.long") + ).toBeInTheDocument() + expect(screen.getByRole("link", { name: "S.2581" })).toHaveAttribute( + "href", + "/conferences/194/student-learning-and-mental-health" + ) + expect( + screen.getByRole("link", { name: "newsfeed.viewConferenceInput" }) + ).toHaveAttribute( + "href", + "/conferences/194/student-learning-and-mental-health#input" + ) + expect(document.querySelector("img[src^='/thumbs-']")).toBeNull() + expect(document.querySelector("a[href^='/bills/']")).toBeNull() + expect(document.querySelector("a[href^='/testimony/']")).toBeNull() + }) + + it("keeps bill testimony on its bill, testimony page and thumbs", () => { + render() + + expect(screen.getByText("newsfeed.endorsed")).toBeInTheDocument() + expect(screen.getByRole("link", { name: "S.2581" })).toHaveAttribute( + "href", + "/bills/194/S2581" + ) + expect( + screen.getByRole("link", { name: "newsfeed.viewFullTestimony" }) + ).toHaveAttribute("href", "/testimony/t1") + expect( + document.querySelector("img[src='/thumbs-endorse.svg']") + ).not.toBeNull() + }) }) diff --git a/components/NewsfeedCard/NewsfeedCard.tsx b/components/NewsfeedCard/NewsfeedCard.tsx index 48bcdd80c..92ba56d64 100644 --- a/components/NewsfeedCard/NewsfeedCard.tsx +++ b/components/NewsfeedCard/NewsfeedCard.tsx @@ -5,9 +5,11 @@ import { flags } from "../featureFlags" import { NewsfeedBallotQuestionCardBody, NewsfeedBillCardBody, + NewsfeedConferenceTestimonyCardBody, NewsfeedTestimonyCardBody } from "./NewsfeedCardBody" import NewsfeedCardTitle from "./NewsfeedCardTitle" +import { maple } from "components/links" const Container = styled.div` max-width: 700px; @@ -19,6 +21,7 @@ export const NewsfeedCard = (props: { ballotStatus?: string billId?: string bodyText: string + conferenceId?: string | null court?: string header: string isBallotQuestionMatch?: boolean @@ -34,10 +37,14 @@ export const NewsfeedCard = (props: { }) => { const date = props.timestamp.toDate() const formattedTimestamp = `${date.toLocaleDateString()}` + const conferenceHref = props.conferenceId + ? maple.conference({ court: Number(props.court), id: props.conferenceId }) + : undefined const header = ( ) - if (props.type == `testimony`) { + if (props.type == `testimony` && conferenceHref) { + body = ( + + ) + } else if (props.type == `testimony`) { body = ( { ) } +/** Testimony on a committee of conference: the stance is the conference + * page's chip, and the card leads to the conference, not the bill. */ +export const NewsfeedConferenceTestimonyCardBody = (props: { + billText?: string + conferenceHref: string + position?: string + text?: string + timestamp?: string +}) => { + const { billText, conferenceHref, text, timestamp } = props + const { t } = useTranslation("common") + + return ( +
+ + + {billText} + +
+ {ConferencePosition.guard(props.position) && ( + + )} + {`"${truncateText( + text, + 600 + )}"`} +
+
+
+ {t("newsfeed.conferenceInputPost")} + {timestamp} +
+ +
{t("newsfeed.viewConferenceInput")}
+
+
+
+
+ ) +} + const NewsfeedTestimonyCardTail = (props: NewsfeedCardBodyProps) => { const { testimonyId, timestamp } = props const { t } = useTranslation("common") diff --git a/components/NewsfeedCard/NewsfeedCardTitle.tsx b/components/NewsfeedCard/NewsfeedCardTitle.tsx index f1fbe86c2..a49f76f42 100644 --- a/components/NewsfeedCard/NewsfeedCardTitle.tsx +++ b/components/NewsfeedCard/NewsfeedCardTitle.tsx @@ -7,6 +7,8 @@ import { Internal } from "components/links" interface NewsfeedCardTitleProps { authorUid?: string billId?: string + /** Set on testimony to a committee of conference: its conference page */ + conferenceHref?: string court?: string header?: string subheader?: string @@ -25,6 +27,7 @@ export const NewsfeedCardTitle = (props: NewsfeedCardTitleProps) => { const { authorUid, billId, + conferenceHref, court, header, isBillMatch, @@ -43,6 +46,7 @@ export const NewsfeedCardTitle = (props: NewsfeedCardTitleProps) => { { } const CardTitleHeadline = (props: NewsfeedCardTitleProps) => { - const { authorUid, billId, court, header, subheader, type } = props + const { authorUid, billId, conferenceHref, court, header, subheader, type } = + props const { t } = useTranslation("common") switch (type) { @@ -113,8 +118,12 @@ const CardTitleHeadline = (props: NewsfeedCardTitleProps) => { {subheader} - {t("newsfeed.endorsed")} - + {t( + conferenceHref + ? "newsfeed.conferenceInput" + : "newsfeed.endorsed" + )} + {billId && {formatBillId(billId)}} diff --git a/components/TestimonyCard/ReportModal.tsx b/components/TestimonyCard/ReportModal.tsx index e12265344..680f769a4 100644 --- a/components/TestimonyCard/ReportModal.tsx +++ b/components/TestimonyCard/ReportModal.tsx @@ -10,6 +10,7 @@ type Props = { additionalInformationLabel: string requireAdditionalInformation?: boolean + title?: string children?: string | React.ReactNode } @@ -22,6 +23,7 @@ export function ReportModal({ isLoading, additionalInformationLabel, requireAdditionalInformation = false, + title, children }: Props) { const [selectedReason, setSelectedReason] = useState(null) @@ -46,7 +48,7 @@ export function ReportModal({ return (
- {t("reportTestimony")} + {title ?? t("reportTestimony")} {children} @@ -117,8 +119,13 @@ export function ReportModal({ export function RequestDeleteOwnTestimonyModal({ onClose, onReport, - isLoading -}: Pick) { + isLoading, + title, + wrongTarget, + children +}: Pick & { + wrongTarget?: string +}) { const { t } = useTranslation("testimony", { keyPrefix: "reportModal" }) @@ -127,8 +134,9 @@ export function RequestDeleteOwnTestimonyModal({ onClose={onClose} onReport={onReport} isLoading={isLoading} + title={title} reasons={[ - t("wrongBill"), + wrongTarget ?? t("wrongBill"), t("sensitiveInformation"), t("userBelow18YearsOld"), t("other") @@ -136,7 +144,7 @@ export function RequestDeleteOwnTestimonyModal({ additionalInformationLabel={t("reason")} requireAdditionalInformation > - {t("rescind")} + {children ?? t("rescind")} ) } diff --git a/components/conference/ConferenceDetails.switching.test.tsx b/components/conference/ConferenceDetails.switching.test.tsx new file mode 100644 index 000000000..81849a2cd --- /dev/null +++ b/components/conference/ConferenceDetails.switching.test.tsx @@ -0,0 +1,119 @@ +import "@testing-library/jest-dom" +import { fireEvent, render } from "@testing-library/react" +import { useState } from "react" +import { ConferenceDetails } from "./ConferenceDetails" +import type { ConferencePageProps } from "./types" + +jest.mock("next-i18next", () => ({ + useTranslation: () => ({ + // The hero's steps are the one array. + t: (key: string, opts?: { returnObjects?: boolean }) => + opts?.returnObjects ? [] : key, + i18n: { language: "en" } + }), + Trans: () => null +})) +jest.mock("next/router", () => ({ + __esModule: true, + default: { events: { on: () => {} } }, + useRouter: () => ({ asPath: "/conferences/194/a" }) +})) +jest.mock("usehooks-ts", () => ({ useMediaQuery: () => false })) +jest.mock("../featureFlags", () => ({ + useFlags: () => ({ legislators: false, hearingsAndTranscriptions: true }) +})) +jest.mock("../hooks", () => ({ useAppDispatch: () => jest.fn() })) +jest.mock("./input/useFollowedAuthors", () => ({ + useFollowedAuthors: () => new Set() +})) +// The feed is not under test, and its actions reach for the store. +jest.mock("./input/Kebab", () => ({ Kebab: () => null })) +jest.mock("./input/EntryModal", () => ({ EntryModal: () => null })) + +jest.mock("../auth", () => ({ + useAuth: () => ({ + user: { uid: "u1", emailVerified: true }, + authenticated: true, + claims: { role: "user" } + }) +})) + +/** The conference each mount of the draft hook was first given. The real hook + * fixes its user, bill and conference when it mounts and ignores later + * arguments, so a panel that outlives a change of conference keeps filing on + * the old one. */ +const mounted: string[] = [] +jest.mock("../db", () => ({ + useProfile: () => ({ profile: {} }), + hasDraftChanged: () => false, + useConferenceTestimony: () => ({ items: [], loading: false }), + useEditConferenceTestimony: ( + _uid: string, + _court: number, + _billId: string, + conferenceId: string + ) => { + useState(() => mounted.push(conferenceId)) + const noop = { execute: async () => {}, error: undefined, loading: false } + return { + loading: false, + draftLoading: false, + saveDraft: noop, + publishTestimony: noop, + discardDraft: noop + } + } +})) + +class Observer { + observe() {} + unobserve() {} + disconnect() {} +} +Object.assign(global, { ResizeObserver: Observer }) + +const summary = (id: string, senate: string, house: string) => ({ + id, + court: 194, + name: id, + title: id, + senateBillId: senate, + houseBillId: house, + canonicalBillId: senate, + status: "appointed" as const +}) +const phoneFree = summary( + "student-learning-and-mental-health", + "S2581", + "H5366" +) +const privacy = summary("data-privacy", "S2619", "H5479") + +const page = (conference: ConferencePageProps["conference"]) => ({ + conference, + conferences: [phoneFree, privacy], + conferees: [], + hearings: [] +}) + +describe("moving between conferences", () => { + beforeEach(() => { + mounted.length = 0 + }) + + it("starts the panel clean on the new conference", () => { + const { container, rerender } = render( + + ) + const field = () => container.querySelector("textarea")! + fireEvent.change(field(), { target: { value: "I love the house" } }) + expect(field()).toHaveValue("I love the house") + + rerender() + + // Words typed for one conference are not offered on another... + expect(field()).toHaveValue("") + // ...and the draft hook was mounted afresh, so it files on the new one. + expect(mounted).toEqual([phoneFree.id, privacy.id]) + }) +}) diff --git a/components/conference/ConferenceDetails.test.tsx b/components/conference/ConferenceDetails.test.tsx index be7b15f06..aff5f4cfc 100644 --- a/components/conference/ConferenceDetails.test.tsx +++ b/components/conference/ConferenceDetails.test.tsx @@ -70,7 +70,19 @@ class Observer { Object.assign(global, { ResizeObserver: Observer }) const profile = { representative: { id: "AHP1" } } -jest.mock("../db", () => ({ useProfile: () => ({ profile }) })) +jest.mock("../db", () => ({ + useProfile: () => ({ profile }), + useConferenceTestimony: () => ({ items: [], loading: false }) +})) + +// Signed out, so the panel is not mounted and the page needs no store. +jest.mock("../auth", () => ({ + useAuth: () => ({ user: undefined, authenticated: false }) +})) +jest.mock("../hooks", () => ({ useAppDispatch: () => jest.fn() })) +jest.mock("./input/useFollowedAuthors", () => ({ + useFollowedAuthors: () => new Set() +})) const mockBill = jest.fn() jest.mock("../db/bills", () => ({ @@ -102,6 +114,7 @@ const conference: ConferencePageProps["conference"] = { title: "An Act to promote student learning and mental health", senateBillId: "S2581", houseBillId: "H5366", + canonicalBillId: "S2581", status: "appointed" } @@ -116,6 +129,7 @@ const props: ConferencePageProps = { title: "An Act establishing the Massachusetts data privacy act", senateBillId: "S2619", houseBillId: "H5479", + canonicalBillId: "S2619", status: "appointed" } ], @@ -359,6 +373,21 @@ describe("ConferenceDetails", () => { ).toHaveStyle({ fill: "#b53333", fillOpacity: 0.45 }) }) + it("takes input only while the conference is appointed", () => { + const { unmount } = render() + expect( + screen.getByRole("button", { name: "input.share" }) + ).toBeInTheDocument() + unmount() + const reported = { ...conference, status: "reportFiled" as const } + render() + expect( + screen.queryByRole("button", { name: "input.share" }) + ).not.toBeInTheDocument() + // Public Input stays. + expect(screen.getByText("input.empty_title")).toBeInTheDocument() + }) + it("leads from its tabs to its sections", () => { const { container } = render() const tabs = within(screen.getByRole("navigation", { name: "tabs_label" })) @@ -366,6 +395,7 @@ describe("ConferenceDetails", () => { .map(a => [a.textContent, a.getAttribute("href")]) expect(tabs).toEqual([ ["tab_committee", "#committee"], + ["tab_input", "#input"], ["tab_text", "#text"] ]) for (const [, href] of tabs) @@ -373,12 +403,17 @@ describe("ConferenceDetails", () => { }) it("lights the tab of the section that has come up under the tabs", () => { - // jsdom lays nothing out, so each section says where its top is. - const tops: Record = { committee: 0, text: 900 } + // jsdom lays nothing out, so each section says where it is, 850px tall. + const tops: Record = { + committee: 0, + input: 900, + text: 1800 + } const rect = jest .spyOn(HTMLElement.prototype, "getBoundingClientRect") .mockImplementation(function (this: HTMLElement) { - return { top: tops[this.id] ?? 0 } as DOMRect + const top = tops[this.id] ?? 0 + return { top, bottom: top + 850 } as DOMRect }) render() const lit = () => @@ -388,11 +423,14 @@ describe("ConferenceDetails", () => { .map(a => a.textContent) expect(lit()).toEqual(["tab_committee"]) // The last section lights too, once a jump has parked it. + tops.input = -900 tops.text = 0 fireEvent.scroll(document) expect(lit()).toEqual(["tab_text"]) - // A section can move with no scroll, when one above it grows. - tops.text = 900 + // A section can move with no scroll, when one above it grows: posting + // puts Your Input above Public Input. + tops.input = 900 + tops.text = 1800 act(() => resized.forEach(heard => heard())) expect(lit()).toEqual(["tab_committee"]) rect.mockRestore() diff --git a/components/conference/ConferenceDetails.tsx b/components/conference/ConferenceDetails.tsx index e6fff99f3..db18486de 100644 --- a/components/conference/ConferenceDetails.tsx +++ b/components/conference/ConferenceDetails.tsx @@ -1,4 +1,14 @@ -import { useLayoutEffect, useRef } from "react" +import { + CSSProperties, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from "react" +import { useTranslation } from "next-i18next" +import { takesTestimony } from "../../functions/src/shared" import styled from "styled-components" import { ConferenceByline } from "./ConferenceByline" import { ConferenceCommittee } from "./ConferenceCommittee" @@ -7,7 +17,19 @@ import { ConferencePicker } from "./ConferencePicker" import { ConferenceRail } from "./ConferenceRail" import { ConferenceTabs } from "./ConferenceTabs" import { ConferenceText } from "./ConferenceText" +import { useAuth } from "../auth" +import { authStepChanged } from "../auth/redux" +import { useConferenceTestimony } from "../db" +import { useAppDispatch } from "../hooks" import { useConferenceTop, usePinnedNavbar } from "./hooks" +import { InputContext } from "./input/context" +import { InputForm, Step } from "./input/InputForm" +import { Panel, usePanelWidth } from "./input/Panel" +import { PublicInput } from "./input/PublicInput" +import { ShareButton } from "./input/ShareButton" +import { useFollowedAuthors } from "./input/useFollowedAuthors" +import { YourInput } from "./input/YourInput" +import { jumpTo } from "./navbar" import { PageColumn, Title } from "./styles" import { conferenceName, ConferencePageProps } from "./types" @@ -24,6 +46,27 @@ const Page = styled(PageColumn)` } ` +/** The page steps aside for the panel at lg and up, where the panel stands + * beside it rather than over it. The width comes in as --shift, so dragging + * the panel does not mint a class per pixel. */ +const Shift = styled.div` + @media (min-width: 1024px) { + padding-right: var(--shift); + transition: padding-right 400ms ease-out; + } + @media (prefers-reduced-motion: reduce) { + transition: none; + } +` + +/** The title, with the way to share input at its end. */ +const TitleRow = styled.div` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; +` + /** The header and the sections under it. */ const Main = styled.div` min-width: 0; @@ -75,7 +118,7 @@ const Section = styled.div` ` /** In the mockup's order, which goes on to the sections still to come. */ -const SECTIONS = ["committee", "text"] as const +const SECTIONS = ["committee", "input", "text"] as const /** * One conference: who is deciding, and the two texts they are deciding @@ -83,7 +126,16 @@ const SECTIONS = ["committee", "text"] as const * conference runs down the left from md up; below that the title opens it. * The landing alone opens on the hero (`openOnHero`). */ -export function ConferenceDetails({ +export function ConferenceDetails( + props: ConferencePageProps & { openOnHero?: boolean } +) { + // Keyed so moving to another conference starts clean: the panel's draft hook + // is fixed to the conference it first mounted on, and would keep filing on + // the old one. + return
+} + +function Details({ conference, conferences, conferees, @@ -91,6 +143,50 @@ export function ConferenceDetails({ openOnHero = false }: ConferencePageProps & { openOnHero?: boolean }) { const { top, hero, toConference } = useConferenceTop(openOnHero) + const { t } = useTranslation("conferences") + const { user, authenticated } = useAuth() + const dispatch = useAppDispatch() + const { items, loading } = useConferenceTestimony( + conference.court, + conference.id + ) + const mine = useMemo( + () => (user ? items.find(x => x.authorUid === user.uid) : undefined), + [items, user] + ) + const [open, setOpen] = useState(false) + const [step, setStep] = useState("compose") + const { width, drag } = usePanelWidth() + const followed = useFollowedAuthors(user?.uid) + const subject = conferenceName(conference) + const taking = takesTestimony(conference.status) + + const close = useCallback(() => setOpen(false), []) + const edit = useCallback(() => { + setStep("compose") + setOpen(true) + }, []) + // The same doors the bill page's testimony button goes through. + const share = () => { + if (!authenticated) return dispatch(authStepChanged("start")) + if (!user?.emailVerified) return dispatch(authStepChanged("verifyEmail")) + edit() + } + // Posting puts the panel away and takes the reader to what they filed, once + // it has arrived in the feed. + const [landing, setLanding] = useState(false) + useEffect(() => { + if (!landing || !mine) return + setLanding(false) + const el = document.getElementById("your-input") + if (el) jumpTo(el) + history.replaceState(null, "", `${location.pathname}#your-input`) + }, [landing, mine]) + const posted = () => { + setOpen(false) + setLanding(true) + } + // The tabs hold under the title, so they need its height. const main = useRef(null) const band = useRef(null) @@ -105,49 +201,94 @@ export function ConferenceDetails({ ro.observe(el) return () => ro.disconnect() }, []) + const input = useMemo( + () => ({ + uid: user?.uid, + subject, + followed, + onEdit: taking ? edit : undefined, + signedIn: authenticated + }), + [user?.uid, subject, followed, taking, edit, authenticated] + ) return ( - <> - - - -
- -
- + + + + +
+ +
+ +
+ + {subject} + {taking && !mine && } + +
+ + + + +
+ {/* Keyed so a pinned seat does not outlive its conference. */} + -
- - {conferenceName(conference)} - -
- - - - -
- {/* Keyed so a pinned seat does not outlive its conference. */} - -
-
- {/* Keyed so a picked version does not outlive its conference. */} - -
-
-
- + + {mine && ( +
+ +
+ )} +
+ +
+
+ {/* Keyed so a picked version does not outlive its conference. */} + +
+ + + + {authenticated && user && ( + + + + )} + ) } diff --git a/components/conference/ConferenceExplorer.test.tsx b/components/conference/ConferenceExplorer.test.tsx index 44c16af36..eb25ff8c5 100644 --- a/components/conference/ConferenceExplorer.test.tsx +++ b/components/conference/ConferenceExplorer.test.tsx @@ -61,6 +61,7 @@ const props: ConferenceIndexProps = { title: "An Act to promote student learning and mental health", senateBillId: "S2581", houseBillId: "H5366", + canonicalBillId: "S2581", status: "appointed" }, { @@ -70,6 +71,7 @@ const props: ConferenceIndexProps = { title: "An Act establishing the Massachusetts data privacy act", senateBillId: "S2619", houseBillId: "H5479", + canonicalBillId: "S2619", status: "reportFiled" } ], diff --git a/components/conference/ConferenceTabs.test.tsx b/components/conference/ConferenceTabs.test.tsx index cc866865f..21efc325d 100644 --- a/components/conference/ConferenceTabs.test.tsx +++ b/components/conference/ConferenceTabs.test.tsx @@ -21,6 +21,31 @@ describe("ConferenceTabs", () => { expect(container).toBeEmptyDOMElement() }) + it("lights the tab below a block with no tab once the one above has gone", () => { + // Committee has scrolled away; the reader's own input, with no tab, is + // under the bar and Public Input is still below it. + const box = (top: number, bottom: number) => () => + ({ top, bottom } as DOMRect) + const page = document.createElement("main") + for (const [id, top, bottom] of [ + ["committee", -900, -100], + ["your-input", -76, 300], + ["input", 324, 1200] + ] as const) { + const el = document.createElement("section") + el.id = id + el.getBoundingClientRect = box(top, bottom) + page.append(el) + } + document.body.append(page) + const { getByText } = render( + + ) + expect(getByText("tab_input")).toHaveAttribute("aria-current", "true") + expect(getByText("tab_committee")).not.toHaveAttribute("aria-current") + page.remove() + }) + it("keeps Next's state on a tab click, its as path taking the hash", () => { document.body.innerHTML = `
` const as = "/conferences/194/pet-equity" diff --git a/components/conference/ConferenceTabs.tsx b/components/conference/ConferenceTabs.tsx index 554d1cf23..baba4adba 100644 --- a/components/conference/ConferenceTabs.tsx +++ b/components/conference/ConferenceTabs.tsx @@ -50,14 +50,22 @@ export function ConferenceTabs({ sections }: { sections: readonly string[] }) { // it, its own scroll margin, so the tab pressed is the tab lit. Measured // on every scroll rather than observed: an observer picks a band of the // window, and the last section on the page may never reach it. + // A section is read from where the one above it ends, so a block between + // them with no tab of its own, such as the reader's input above the + // public's, lights the tab below it. const read = () => { let last = sections[0] + let above: Element | undefined for (const s of sections) { const el = document.getElementById(s) if (!el) continue const parked = parseFloat(getComputedStyle(el).scrollMarginTop) || 0 + const top = above + ? above.getBoundingClientRect().bottom + : el.getBoundingClientRect().top // The half pixel keeps a landing that rounds down from missing it. - if (el.getBoundingClientRect().top <= parked + 0.5) last = s + if (top <= parked + 0.5) last = s + above = el } setCurrent(last) } diff --git a/components/conference/input/ClampedText.tsx b/components/conference/input/ClampedText.tsx new file mode 100644 index 000000000..11557ec09 --- /dev/null +++ b/components/conference/input/ClampedText.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from "react" +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { brand, ink, red } from "./tokens" + +const Wrap = styled.div` + position: relative; +` + +/** The text and its measuring twin are set identically; Entry sets unclamped + * text with it too. */ +export const Body = styled.p<{ $small: boolean }>` + margin: 0; + font-size: ${({ $small }) => ($small ? "0.8125rem" : "0.875rem")}; + line-height: 1.55; + color: ${ink.strong}; + white-space: pre-line; +` + +const Measure = styled(Body)` + position: absolute; + visibility: hidden; + pointer-events: none; +` + +const More = styled.button` + border: 0; + padding: 0; + background: none; + font-size: 0.8125rem; + font-weight: 600; + color: ${brand.fill}; + &:hover { + color: ${red.hover}; + } +` + +/** + * Text cut where `lines` lines end, with "Show more" run on at the cut. The + * cut is found by measuring a hidden copy, so the control sits on the last + * line rather than under it. + */ +export function ClampedText({ + text, + lines = 6, + small = false +}: { + text: string + lines?: number + /** A step down, for a card too narrow for the full size. */ + small?: boolean +}) { + const { t } = useTranslation("conferences") + const [expanded, setExpanded] = useState(false) + const [cutoff, setCutoff] = useState(null) + const wrapRef = useRef(null) + const measureRef = useRef(null) + + useEffect(() => { + const wrap = wrapRef.current + const m = measureRef.current + if (!wrap || !m) return + // Only a new width moves the cut; expanding changes the height alone. + let measured = -1 + const compute = () => { + if (wrap.clientWidth === measured) return + measured = wrap.clientWidth + m.style.width = `${measured}px` + const maxH = parseFloat(getComputedStyle(m).lineHeight) * lines + 2 + m.textContent = text + if (m.scrollHeight <= maxH) return setCutoff(null) + let lo = 0 + let hi = text.length + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2) + m.textContent = `${text.slice(0, mid).trimEnd()}… ${t( + "input.show_more" + )}` + if (m.scrollHeight <= maxH) lo = mid + else hi = mid - 1 + } + setCutoff(lo) + } + compute() + const ro = new ResizeObserver(compute) + ro.observe(wrap) + return () => ro.disconnect() + }, [text, lines, small, t]) + + const collapsed = !expanded && cutoff !== null + return ( + + + + {collapsed ? `${text.slice(0, cutoff!).trimEnd()}… ` : `${text} `} + {cutoff !== null && ( + setExpanded(x => !x)}> + {expanded ? t("input.show_less") : t("input.show_more")} + + )} + + + ) +} diff --git a/components/conference/input/Entry.tsx b/components/conference/input/Entry.tsx new file mode 100644 index 000000000..fa9ef4ebb --- /dev/null +++ b/components/conference/input/Entry.tsx @@ -0,0 +1,218 @@ +import { useRef } from "react" +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { ConferencePosition, ConferenceTestimony } from "components/db" +import { AccountTypeIcon } from "./Pickers" +import { Body, ClampedText } from "./ClampedText" +import { accountTypeOf, initialsOf, nameOf } from "./accounts" +import { positionColours } from "./positions" +import { brand, ink, line, shadow } from "./tokens" +import { useWide } from "./useWide" + +const Card = styled.article<{ $bare: boolean }>` + position: relative; + padding: 20px; + ${({ $bare }) => + $bare + ? "" + : `background: var(--maple-surface-base); + border: 1px solid ${line.default}; + border-radius: 8px; + box-shadow: ${shadow.raised};`} + + @media (max-width: 450px) { + padding: 14px; + } +` + +const Head = styled.div<{ $wide: boolean }>` + display: flex; + align-items: ${({ $wide }) => ($wide ? "center" : "flex-start")}; + gap: ${({ $wide }) => ($wide ? "18px" : "14px")}; + + @media (max-width: 450px) { + gap: ${({ $wide }) => ($wide ? "18px" : "9px")}; + } +` + +const Disc = styled.div` + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 9999px; + background: ${brand.soft}; + border: 1px solid ${brand.edge}; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + color: ${brand.ink}; +` + +const Who = styled.div` + flex: 1; + min-width: 0; +` + +const NameRow = styled.p` + display: flex; + align-items: flex-end; + flex-wrap: wrap; + gap: 6px; + margin: 0; + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + color: ${ink.strong}; + + @media (max-width: 450px) { + gap: 4px; + font-size: 0.8125rem; + } +` + +const Name = styled.span` + min-width: 0; +` + +const Chip = styled.span<{ $bg: string; $ink: string }>` + flex-shrink: 0; + padding: 1px 8px; + border-radius: 9999px; + background: ${({ $bg }) => $bg}; + color: ${({ $ink }) => $ink}; + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; +` + +/** A filing's stance, coloured as on the mockup: the short label on a card, + * the long one where the stance stands alone, as in the newsfeed. */ +export function PositionChip({ + position, + long = false +}: { + position: ConferencePosition + long?: boolean +}) { + const { t } = useTranslation("conferences") + const { chipBg, chipInk } = positionColours[position] + return ( + + {t(`input.position.${position}.${long ? "long" : "short"}`)} + + ) +} + +const Descriptor = styled.p` + margin: 8px 0 0; + font-size: 0.75rem; + line-height: 1.4; + color: ${ink.faint}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +` + +const When = styled.span` + margin-right: 2px; + font-size: 0.75rem; + white-space: nowrap; + color: ${ink.mid}; +` + +const Corner = styled.div` + display: flex; + align-items: center; + align-self: flex-start; + gap: 2px; + margin: -5px -6px 0 0; + flex-shrink: 0; +` + +const Words = styled.div` + display: flex; + gap: 18px; +` + +const Indent = styled.div` + width: 40px; + flex-shrink: 0; +` + +const Text = styled.div<{ $wide: boolean }>` + flex: 1; + min-width: 0; + padding: ${({ $wide }) => ($wide ? "8px 12px 8px 0" : "12px 0 4px")}; +` + +const FootDate = styled.div` + display: flex; + justify-content: flex-end; + margin-top: 12px; + font-size: 0.8125rem; + color: ${ink.mid}; +` + +/** One filing: who wrote it, what they asked for, and their words. */ +export function Entry({ + testimony, + actions, + bare = false, + fullBody = false +}: { + testimony: ConferenceTestimony + /** The kebab at the corner. */ + actions?: React.ReactNode + /** Without the card's own edge, for a caller that draws one around it. */ + bare?: boolean + /** The words whole, with no clamp: for where the entry is the point. */ + fullBody?: boolean +}) { + const { t, i18n } = useTranslation("conferences") + const ref = useRef(null) + const wide = useWide(ref, 600) + const type = accountTypeOf(testimony.authorRole) + const date = testimony.publishedAt + .toDate() + .toLocaleDateString(i18n.language, { + year: "numeric", + month: "long", + day: "numeric" + }) + const name = nameOf(testimony) + + return ( + + + {initialsOf(name)} + + + {name} + + + + {t(`input.descriptor_${type}`)} + + + {wide && {date}} + {actions} + + + + {wide && } + + {fullBody ? ( + {testimony.content} + ) : ( + + )} + {!wide && {date}} + + + + ) +} diff --git a/components/conference/input/EntryModal.tsx b/components/conference/input/EntryModal.tsx new file mode 100644 index 000000000..abe07d87c --- /dev/null +++ b/components/conference/input/EntryModal.tsx @@ -0,0 +1,203 @@ +import { useCallback, useEffect } from "react" +import { createPortal } from "react-dom" +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { ConferenceTestimony } from "components/db" +import { useInputContext } from "./context" +import { useReport } from "./Kebab" +import { useToggleFollow } from "./useToggleFollow" +import { Entry } from "./Entry" +import { X } from "./icons" +import { brand, ink, wash } from "./tokens" + +const Backdrop = styled.div` + position: fixed; + inset: 0; + z-index: 1045; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(3px); + + @media (min-width: 950px) { + padding: 32px; + } +` + +const Dialog = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + max-height: 100%; + overflow-y: auto; + background: var(--maple-surface-page); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.28); + + @media (min-width: 950px) { + height: auto; + max-width: 880px; + min-height: 480px; + border-radius: 12px; + } +` + +const Header = styled.div` + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 12px; + padding: 20px 20px 12px; + background: var(--maple-surface-page); + @media (min-width: 950px) { + border-radius: 12px 12px 0 0; + } +` + +const Title = styled.p` + flex: 1; + min-width: 0; + margin: 0; + font-size: 1.25rem; + color: ${ink.strong}; +` + +const Close = styled.button` + display: flex; + border: 0; + padding: 0; + background: none; + color: ${ink.mid}; + &:hover { + color: ${ink.strong}; + } +` + +const Columns = styled.div` + display: flex; + flex: 1; + flex-direction: column; + align-items: stretch; + gap: 16px; + padding: 0 20px 20px; + + @media (min-width: 950px) { + flex-direction: row; + } +` + +const Main = styled.div` + flex: 1; + min-width: 0; + background: var(--maple-surface-base); + border-radius: 8px; + + @media (min-width: 950px) { + min-width: 600px; + } +` + +const Aside = styled.div` + flex-shrink: 0; + align-self: flex-start; + width: 100%; + padding: 16px; + background: var(--maple-surface-base); + border-radius: 8px; + + @media (min-width: 950px) { + position: sticky; + top: 68px; + width: 200px; + } +` + +const Label = styled.p` + margin: 0 0 10px; + font-size: 0.6875rem; + font-weight: 600; + color: ${ink.mid}; +` + +const Action = styled.button` + display: block; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 8px; + background: none; + text-align: left; + font-size: 0.8125rem; + font-weight: 600; + color: ${brand.fill}; + &:hover { + background: ${wash.default}; + } +` + +/** An entry opened in place, whole. The card the reader pressed is the card + * they get. */ +export function EntryModal({ + testimony, + onClose +}: { + testimony: ConferenceTestimony + onClose: () => void +}) { + const { t } = useTranslation("conferences") + const { subject } = useInputContext() + const { following, toggle: toggleFollow } = useToggleFollow( + testimony.authorUid + ) + const report = useReport(testimony) + + const escape = useCallback( + (e: KeyboardEvent) => e.key === "Escape" && onClose(), + [onClose] + ) + useEffect(() => { + document.addEventListener("keydown", escape) + const prev = document.body.style.overflow + document.body.style.overflow = "hidden" + return () => { + document.removeEventListener("keydown", escape) + document.body.style.overflow = prev + } + }, [escape]) + + return createPortal( + + e.stopPropagation()}> +
+ {t("input.modal_title", { subject })} + + + +
+ +
+ +
+ +
+ {report.node} +
+
, + document.body + ) +} diff --git a/components/conference/input/InputForm.tsx b/components/conference/input/InputForm.tsx new file mode 100644 index 000000000..135b43f3e --- /dev/null +++ b/components/conference/input/InputForm.tsx @@ -0,0 +1,492 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { Timestamp } from "firebase/firestore" +import { Trans, useTranslation } from "next-i18next" +import styled, { keyframes } from "styled-components" +import { useAuth } from "components/auth" +import { + ConferencePosition, + ConferenceTestimony, + hasDraftChanged, + Testimony, + useEditConferenceTestimony, + useProfile, + WorkingDraft +} from "components/db" +import { Internal } from "components/links" +import { Entry } from "./Entry" +import { conferencePositions, positionColours } from "./positions" +import { brand, ink, line, red, wash } from "./tokens" + +export type Step = "compose" | "review" + +const Body = styled.div` + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: 12px 18px 22px; +` + +const Scroll = styled.div` + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +` + +const Label = styled.p` + margin: 0 0 8px; + font-size: 0.8125rem; + font-weight: 600; + color: ${ink.mid}; +` + +const Options = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 20px; +` + +const Option = styled.button<{ $on: boolean; $position: ConferencePosition }>` + width: 100%; + padding: 10px 14px; + border-radius: 8px; + text-align: left; + font-size: 0.8125rem; + transition: background-color 120ms, border-color 120ms; + ${({ $on, $position }) => { + const c = positionColours[$position] + return $on + ? `font-weight: 600; background: ${c.onBg}; border: 1px solid ${c.onBorder}; color: ${c.onInk};` + : `font-weight: 300; background: #fdfdff; border: 1px solid ${line.default}; color: ${ink.strong}; + &:hover { background: ${wash.default}; border-color: ${line.strong}; }` + }} +` + +const ask = keyframes` + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-3px); } + 80% { transform: translateX(3px); } +` + +const Field = styled.textarea<{ $refused: boolean; $asking: boolean }>` + flex: 1; + min-height: 150px; + max-height: 280px; + width: 100%; + resize: none; + padding: 12px; + background: var(--maple-surface-base); + border: 1px solid ${({ $refused }) => ($refused ? red.refused : line.default)}; + border-radius: 8px; + font-size: 0.875rem; + line-height: 1.55; + color: ${ink.strong}; + &::placeholder { + color: ${ink.faint}; + font-size: 0.875rem; + } + &:focus { + outline: none; + border-color: ${({ $refused }) => ($refused ? red.refused : brand.fill)}; + } + animation: ${({ $asking }) => ($asking ? ask : "none")} 620ms ease-out; + @media (prefers-reduced-motion: reduce) { + animation: none; + } +` + +const Actions = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + flex-shrink: 0; + padding-top: 14px; +` + +const Status = styled.span` + display: inline-flex; + align-items: center; + margin-right: auto; + font-size: 0.75rem; + color: ${ink.mid}; + transition: opacity 500ms; +` + +const Quiet = styled.button` + padding: 8px; + border: 0; + background: none; + font-size: 0.8125rem; + font-weight: 600; + color: ${ink.mid}; + &:hover { + color: ${ink.strong}; + } +` + +/** The way out of the step, a step quieter than the rest. */ +const Muted = styled(Quiet)` + color: ${ink.muted}; +` + +const Primary = styled.button` + padding: 8px 18px; + border: 0; + border-radius: 8px; + background: ${brand.fill}; + color: #fff; + font-size: 0.8125rem; + font-weight: 600; + &:hover:not(:disabled) { + background: ${brand.hover}; + } + &:disabled, + &[aria-disabled="true"] { + background: rgba(31, 59, 97, 0.35); + } +` + +const PreviewBox = styled.div` + background: var(--maple-surface-base); + border: 1px solid ${line.default}; + border-radius: 16px; +` + +const Rules = styled.ul` + margin: 0; + padding-left: 16px; + font-size: 0.75rem; + line-height: 1.5; + color: ${ink.mid}; + li + li { + margin-top: 8px; + } + li::marker { + color: ${ink.faint}; + } +` + +const RuleLabel = styled.p` + margin: 0 0 8px; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: ${ink.mid}; +` + +const Problem = styled.p` + margin: 12px 0 0; + font-size: 0.75rem; + color: ${red.refused}; +` + +const Hint = styled.p` + margin: 0; + text-align: right; + font-size: 0.75rem; + color: ${ink.mid}; +` + +/** Autosave's two timings, as in the mockup: what was just saved shows for a + * couple of seconds and then goes. */ +const SAVE_AFTER = 1200 +const SAVED_FOR = 2000 + +/** + * Writing, reviewing and posting testimony on a conference, in the panel's + * three steps. The draft is the user's own, kept as they type and published + * from the review step. + */ +export function InputForm({ + uid, + court, + conferenceId, + billId, + subject, + step, + onStep, + onCancel, + onPosted +}: { + uid: string + court: number + conferenceId: string + billId: string + subject: string + step: Step + onStep: (s: Step) => void + onCancel: () => void + /** The words are on the record. */ + onPosted: () => void +}) { + const { t } = useTranslation("conferences") + const { t: tt } = useTranslation("testimony") + const { claims } = useAuth() + const { profile } = useProfile() + const edit = useEditConferenceTestimony(uid, court, billId, conferenceId) + const { saveDraft, publishTestimony, discardDraft } = edit + + const [position, setPosition] = useState( + conferencePositions[0] + ) + const [content, setContent] = useState("") + const [reason, setReason] = useState("") + const [refused, setRefused] = useState(false) + const [asking, setAsking] = useState(false) + const [save, setSave] = useState<"none" | "saving" | "saved" | "fading">( + "none" + ) + const [posting, setPosting] = useState(false) + const field = useRef(null) + + // Start from what the user already has: their draft, else what they posted. + const seeded = useRef(false) + useEffect(() => { + if (edit.loading || seeded.current) return + seeded.current = true + const source = edit.draft ?? edit.publication + if (source?.position) setPosition(source.position) + if (source?.content) setContent(source.content) + if (edit.draft?.editReason) setReason(edit.draft.editReason) + }, [edit.loading, edit.draft, edit.publication]) + + // Saving what the draft already holds fires no snapshot, which would leave + // the hook waiting on one, so only differing words are saved. + const held = useRef(edit.draft) + held.current = edit.draft + const inSync = (c: string, p: ConferencePosition, r: string) => + held.current?.content === c && + held.current?.position === p && + (held.current?.editReason ?? "") === r + const write = () => + saveDraft.execute({ position, content, editReason: reason || undefined }) + + // Keep the draft as they type. The first render is not a change. + const touched = useRef(false) + const dirty = useRef(false) + useEffect(() => { + if (!touched.current) { + touched.current = true + return + } + if (!content.trim() || inSync(content, position, reason)) + return setSave("none") + dirty.current = true + setSave("saving") + const id = setTimeout(async () => { + await write() + dirty.current = false + setSave("saved") + }, SAVE_AFTER) + return () => clearTimeout(id) + // saveDraft is a new object each render; the inputs are what matter. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [position, content, reason]) + + useEffect(() => { + if (save !== "saved") return + const fade = setTimeout(() => setSave("fading"), SAVED_FOR) + const gone = setTimeout(() => setSave("none"), SAVED_FOR + 600) + return () => { + clearTimeout(fade) + clearTimeout(gone) + } + }, [save]) + + const written = content.trim().length > 0 + const review = () => { + if (written) return onStep("review") + setRefused(true) + setAsking(false) + field.current?.focus() + requestAnimationFrame(() => setAsking(true)) + } + + // Publishing reads the draft the hook holds, which follows the save by a + // snapshot: wait until it is the words on screen. + const post = async () => { + setPosting(true) + if (!inSync(content, position, reason)) await write() + } + useEffect(() => { + if (!posting || edit.draftLoading) return + if (!inSync(content, position, reason)) return + setPosting(false) + // A failure shows through publishTestimony.error. + publishTestimony.execute().then(onPosted, () => {}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [posting, edit.draft, edit.draftLoading]) + + const discard = useCallback(async () => { + await discardDraft.execute() + setContent("") + setPosition(conferencePositions[0]) + setSave("none") + onCancel() + }, [discardDraft, onCancel]) + + const failed = + edit.error || + saveDraft.error || + publishTestimony.error || + discardDraft.error + // Republishing needs a reason for the change, as it does everywhere else. + const editing = !!edit.publication + const changed = + !editing || + hasDraftChanged( + { position, content } as unknown as WorkingDraft, + edit.publication as unknown as Testimony + ) + const needsReason = editing && changed + const showDelete = !!edit.draft && save === "none" && !dirty.current + + const name = profile?.fullName || t("input.anonymous") + const entry = { + authorUid: uid, + id: "preview", + position, + content, + authorRole: claims?.role ?? "user", + authorDisplayName: name, + fullName: name, + publishedAt: Timestamp.now() + } as unknown as ConferenceTestimony + + if (step === "compose") + return ( + + + + + {conferencePositions.map(p => ( + + ))} + + + setAsking(false)} + onChange={e => { + if (e.target.value.trim()) setRefused(false) + setContent(e.target.value) + }} + /> + {failed && {t("input.error")}} + + + + {save === "saving" && t("input.saving")} + {(save === "saved" || save === "fading") && t("input.saved")} + {showDelete && ( + + {t("input.delete_draft")} + + )} + + + {t("input.cancel")} + + + {t("input.review")} + + + + ) + + return ( + + + + + + +
+ {t("input.rules_label")} + +
  • {t("input.rule_public")}
  • +
  • {t("input.rule_revise")}
  • +
  • + + All posts are governed by our + + Code of Conduct + + . + +
  • +
    +
    + {needsReason && ( +
    + + setReason(e.target.value)} + /> +
    + )} + {failed && {t("input.error")}} +
    +
    + {!written && {t("input.empty_hint")}} + + onStep("compose")} + style={{ marginRight: "auto" }} + > + {t("input.back")} + + + {t("input.cancel")} + + + {t("input.post")} + + +
    + + ) +} diff --git a/components/conference/input/Kebab.test.tsx b/components/conference/input/Kebab.test.tsx new file mode 100644 index 000000000..7b9e53e3d --- /dev/null +++ b/components/conference/input/Kebab.test.tsx @@ -0,0 +1,81 @@ +import "@testing-library/jest-dom" +import { fireEvent, render, screen } from "@testing-library/react" +import type { ConferenceTestimony } from "components/db" +import { InputContext } from "./context" +import { Kebab } from "./Kebab" + +jest.mock("next-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +// The menu's other actions reach for the store and the report API. +jest.mock("components/api/report", () => ({ + useReportTestimony: () => ({ isError: false, isSuccess: false }) +})) +jest.mock("components/TestimonyCard/ReportModal", () => ({})) +jest.mock("components/TestimonyCard/ReportToast", () => () => null) +jest.mock("components/hooks", () => ({ useAppDispatch: () => jest.fn() })) +jest.mock("./useToggleFollow", () => ({ + useToggleFollow: () => ({ following: false, toggle: jest.fn() }) +})) + +const mine = { + id: "t1", + authorUid: "me", + fullName: "Me" +} as unknown as ConferenceTestimony + +const mount = (onEdit?: () => void) => + render( + + + + ) + +// Popper places the menu after it opens, so wait for it. +const menu = async (onEdit?: () => void) => { + mount(onEdit) + fireEvent.click(screen.getByRole("button")) + await screen.findByRole("menu") +} + +describe("Kebab", () => { + it("offers the author an edit while the conference takes input", async () => { + await menu(jest.fn()) + expect( + screen.getByRole("menuitem", { name: "input.edit" }) + ).toBeInTheDocument() + }) + + it("offers no edit once the conference stops taking it, but keeps reporting", async () => { + await menu() + expect( + screen.queryByRole("menuitem", { name: "input.edit" }) + ).not.toBeInTheDocument() + expect( + screen.getByRole("menuitem", { name: "input.report" }) + ).toBeInTheDocument() + }) + + it("is worked from the keyboard: arrows move through it, Escape hands focus back", async () => { + mount(jest.fn()) + const toggle = screen.getByRole("button") + toggle.focus() + fireEvent.keyDown(toggle, { key: "ArrowDown" }) + const [edit, report] = await screen.findAllByRole("menuitem") + expect(edit).toHaveFocus() + fireEvent.keyDown(edit, { key: "ArrowDown" }) + expect(report).toHaveFocus() + fireEvent.keyDown(report, { key: "Escape" }) + expect(toggle).toHaveFocus() + expect(toggle).toHaveAttribute("aria-expanded", "false") + }) +}) diff --git a/components/conference/input/Kebab.tsx b/components/conference/input/Kebab.tsx new file mode 100644 index 000000000..005ab4f6e --- /dev/null +++ b/components/conference/input/Kebab.tsx @@ -0,0 +1,188 @@ +import { ReactNode, useState } from "react" +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { ToastContainer } from "react-bootstrap" +import { Dropdown } from "components/bootstrap" +import { ConferenceTestimony, Testimony } from "components/db" +import { useReportTestimony } from "components/api/report" +import { + ReportModal, + RequestDeleteOwnTestimonyModal +} from "components/TestimonyCard/ReportModal" +import ReportToast from "components/TestimonyCard/ReportToast" +import { useInputContext } from "./context" +import { useToggleFollow } from "./useToggleFollow" +import { BellPlus, EllipsisVertical, FileText, Flag, SquarePen } from "./icons" +import { ink, wash } from "./tokens" +import { Anchor, Menu, Toggle } from "./menu" +import { authStepChanged } from "components/auth/redux" +import { useAppDispatch } from "components/hooks" +import { nameOf } from "./accounts" + +const Trigger = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 0; + border-radius: 8px; + background: none; + color: ${ink.mid}; + &:hover { + color: ${ink.strong}; + background: ${wash.default}; + } +` + +const KebabMenu = styled(Menu)` + && { + --bs-dropdown-min-width: 180px; + } +` + +const Item = styled.button` + --bs-dropdown-item-padding-y: 7px; + --bs-dropdown-item-padding-x: 12px; + && { + display: flex; + align-items: center; + gap: 9px; + font-weight: 500; + } + svg { + flex-shrink: 0; + color: ${ink.mid}; + } +` + +/** Reporting an entry, or asking for one's own to be deleted: the modal the + * bill page's testimony uses, worded for input, and its toast. `start` sends a signed-out reader + * to sign in first; `node` goes anywhere in the tree. */ +export function useReport(testimony: ConferenceTestimony) { + const { t } = useTranslation("conferences") + const { t: tt } = useTranslation("testimony") + const dispatch = useAppDispatch() + const { uid, signedIn } = useInputContext() + const own = !!uid && testimony.authorUid === uid + const [open, setOpen] = useState(false) + const report = useReportTestimony() + const didReport = report.isError || report.isSuccess + // The request reads the author and the id, which these share with Testimony. + const send = (r: { reason: string; additionalInformation: string }) => + report.mutate({ + report: r, + testimony: testimony as unknown as Testimony + }) + + const node = ( + <> + {open && + (own ? ( + setOpen(false)} + onReport={send} + isLoading={report.isLoading} + title={t("input.report_title")} + wrongTarget={t("input.delete_wrong")} + > + {t("input.rescind")} + + ) : ( + setOpen(false)} + onReport={send} + isLoading={report.isLoading} + title={t("input.report_title")} + additionalInformationLabel="Additional information:" + reasons={[ + tt("reportModal.personalInformation"), + tt("reportModal.offensive"), + tt("reportModal.violent"), + tt("reportModal.spam"), + tt("reportModal.phishing") + ]} + /> + ))} + {didReport && ( + + + + )} + + ) + const start = () => + signedIn ? setOpen(true) : dispatch(authStepChanged("start")) + return { own, start, node } +} + +/** What a reader can do with one entry. The first, opening it, is the one a + * reader wants; the rest act on the submission or its author. */ +export function Kebab({ + testimony, + onView +}: { + testimony: ConferenceTestimony + onView?: () => void +}) { + const { t } = useTranslation("conferences") + const { onEdit } = useInputContext() + const { following, toggle: toggleFollow } = useToggleFollow( + testimony.authorUid + ) + + const report = useReport(testimony) + const items: { label: string; icon: ReactNode; act: () => void }[] = [ + ...(onView + ? [{ label: t("input.view"), icon: , act: onView }] + : []), + ...(report.own && onEdit + ? [{ label: t("input.edit"), icon: , act: onEdit }] + : []), + ...(report.own + ? [] + : [ + { + label: following + ? t("input.unfollow_user") + : t("input.follow_user"), + icon: , + act: toggleFollow + } + ]), + { + label: t("input.report"), + icon: , + act: report.start + } + ] + + return ( + <> + + + + + + {items.map(({ label, icon, act }) => ( + + {icon} + {label} + + ))} + + + {report.node} + + ) +} diff --git a/components/conference/input/MoreFilters.tsx b/components/conference/input/MoreFilters.tsx new file mode 100644 index 000000000..a09e6c518 --- /dev/null +++ b/components/conference/input/MoreFilters.tsx @@ -0,0 +1,208 @@ +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { Check, ListFilter } from "./icons" +import { ink, line, shadow, wash } from "./tokens" +import { Anchor, Menu, Toggle } from "./menu" +import { Dropdown } from "components/bootstrap" + +type Followable = { id: string; name: string } + +const Mark = styled.button` + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 0; + border-radius: 8px; + background: none; + color: ${ink.mid}; + &:hover, + &[aria-expanded="true"] { + background: ${wash.default}; + color: ${ink.strong}; + } +` + +const Badge = styled.span` + position: absolute; + top: -3px; + right: -3px; + display: flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 9999px; + background: ${ink.strong}; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 1; +` + +const FollowMenu = styled(Menu)` + && { + --bs-dropdown-padding-x: 16px; + --bs-dropdown-padding-y: 16px; + width: 248px; + } +` + +/** The rows and the switch are the menu's items, so the arrow keys reach + * them, but none of them takes Bootstrap's fill: the mockup marks them by + * their own ink, and the keyboard by the focus ring. */ +const Plain = styled.button` + --bs-dropdown-link-hover-bg: transparent; + --bs-dropdown-link-active-bg: transparent; + &:focus-visible { + outline: 3px solid var(--maple-focus-ring); + outline-offset: 2px; + } +` + +const Head = styled.div` + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 7px; +` + +const Label = styled.span` + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: ${ink.mid}; +` + +const Switch = styled(Plain)` + && { + flex-shrink: 0; + position: relative; + width: 28px; + height: 16px; + padding: 0; + border: 0; + border-radius: 9999px; + background: ${line.strong}; + transition: background-color 120ms; + } + &&[aria-checked="true"] { + background: ${ink.strong}; + } + &::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 9999px; + background: #fff; + box-shadow: ${shadow.popover}; + transition: transform 120ms; + } + &[aria-checked="true"]::after { + transform: translateX(12px); + } +` + +const Row = styled(Plain)` + --bs-dropdown-item-padding-y: 5px; + --bs-dropdown-item-padding-x: 0; + --bs-dropdown-link-color: ${ink.mid}; + && { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + } + &&[aria-checked="true"] { + --bs-dropdown-link-color: ${ink.strong}; + font-weight: 600; + } +` + +/** The tick, drawn from its row's state. */ +const Box = styled.span` + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 15px; + height: 15px; + border-radius: 4px; + border: 1px solid ${line.strong}; + color: #fff; + [aria-checked="true"] > & { + border-color: ${ink.strong}; + background: ${ink.strong}; + } +` + +const Name = styled.span` + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +` + +/** The filters that do not earn a pill of their own, behind one mark that + * carries a count once something inside is set. Here that is the accounts the + * reader follows: none picked means everyone. */ +export function MoreFilters({ + followable, + picked, + onPick +}: { + followable: Followable[] + picked: string[] + onPick: (ids: string[]) => void +}) { + const { t } = useTranslation("conferences") + const all = picked.length === followable.length + + return ( + + + + {picked.length > 0 && 1} + + + + + onPick(all ? [] : followable.map(u => u.id))} + /> + + {followable.map(u => { + const on = picked.includes(u.id) + return ( + + onPick(on ? picked.filter(x => x !== u.id) : [...picked, u.id]) + } + > + {on && } + {u.name} + + ) + })} + + + ) +} diff --git a/components/conference/input/Pagination.tsx b/components/conference/input/Pagination.tsx new file mode 100644 index 000000000..11b3e1c8b --- /dev/null +++ b/components/conference/input/Pagination.tsx @@ -0,0 +1,131 @@ +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { ChevronLeft, ChevronRight } from "./icons" +import { brand, ink, red } from "./tokens" + +/** Zero-based page numbers with "…" gaps: ‹ 1 2 3 … n ›. */ +function pageWindow(current: number, count: number): (number | "…")[] { + if (count <= 7) return Array.from({ length: count }, (_, i) => i) + const around = [current - 1, current, current + 1].filter( + p => p > 0 && p < count - 1 + ) + const items: (number | "…")[] = [0] + if (around[0] > 1) items.push("…") + items.push(...around) + if (around[around.length - 1] < count - 2) items.push("…") + items.push(count - 1) + return items +} + +const Row = styled.nav` + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + margin-top: 18px; + font-size: 0.8125rem; + + @media (max-width: 639.98px) { + gap: 4px; + } +` + +const Step = styled.button` + display: flex; + align-items: center; + justify-content: center; + border: 0; + padding: 0; + background: none; + color: ${ink.strong}; + &:hover:not(:disabled) { + color: ${red.hover}; + } + &:disabled { + color: ${ink.faint}; + } + @media (max-width: 639.98px) { + width: 52px; + height: 52px; + } +` + +const Num = styled.button<{ $on: boolean }>` + border: 0; + padding: 0; + background: none; + font-size: 0.8125rem; + color: ${({ $on }) => ($on ? ink.strong : brand.fill)}; + font-weight: ${({ $on }) => ($on ? 600 : 400)}; + &:hover:not([aria-current]) { + color: ${red.hover}; + } + @media (max-width: 639.98px) { + display: none; + } +` + +const Gap = styled.span` + color: ${ink.mid}; + @media (max-width: 639.98px) { + display: none; + } +` + +const Of = styled.span` + display: none; + font-size: 0.875rem; + color: ${ink.mid}; + font-variant-numeric: tabular-nums; + @media (max-width: 639.98px) { + display: inline; + } +` + +export function Pagination({ + page, + pageCount, + onPage +}: { + page: number + pageCount: number + onPage: (p: number) => void +}) { + const { t } = useTranslation("conferences") + return ( + + onPage(page - 1)} + disabled={page === 0} + aria-label={t("input.previous_page")} + > + + + {t("input.page_of", { page: page + 1, count: pageCount })} + {pageWindow(page, pageCount).map((item, i) => + item === "…" ? ( + … + ) : ( + onPage(item)} + > + {item + 1} + + ) + )} + onPage(page + 1)} + disabled={page >= pageCount - 1} + aria-label={t("input.next_page")} + > + + + + ) +} diff --git a/components/conference/input/Panel.tsx b/components/conference/input/Panel.tsx new file mode 100644 index 000000000..e41449b3c --- /dev/null +++ b/components/conference/input/Panel.tsx @@ -0,0 +1,174 @@ +import { ReactNode, useCallback, useEffect, useState } from "react" +import styled from "styled-components" +import { X } from "./icons" +import { ink, line, wash } from "./tokens" + +/** The panel's resting width: a third of the window, held between 400 and 520. */ +const DRAWER_MIN = 400 +const DRAWER_VW = 0.34 +const DRAWER_MAX = 520 +/** The most a drag may take, and the room the page keeps. */ +const RAIL_MAX_SHARE = 0.48 +const PAGE_MIN = 236 + 64 + 64 + 390 + +const resting = () => + Math.min(Math.max(DRAWER_MIN, DRAWER_VW * window.innerWidth), DRAWER_MAX) + +/** How wide the panel is drawn, which the page also needs so it can step aside. + * `drag` takes a pointer position; `null` is not dragged. */ +export function usePanelWidth() { + const [width, setWidth] = useState(DRAWER_MIN) + const [dragged, setDragged] = useState(null) + + useEffect(() => { + const read = () => setWidth(resting()) + read() + window.addEventListener("resize", read) + return () => window.removeEventListener("resize", read) + }, []) + + const drag = useCallback((px: number) => { + const ceiling = Math.max( + resting(), + Math.min(RAIL_MAX_SHARE * window.innerWidth, window.innerWidth - PAGE_MIN) + ) + setDragged(Math.round(Math.min(Math.max(px, resting()), ceiling))) + }, []) + + return { width: dragged ?? width, drag } +} + +const Aside = styled.aside<{ $open: boolean }>` + position: fixed; + z-index: 40; + right: 0; + top: var(--maple-navbar-height); + bottom: 0; + display: flex; + flex-direction: column; + background: var(--maple-surface-page); + border-left: 1px solid ${line.default}; + transform: ${({ $open }) => ($open ? "none" : "translateX(100%)")}; + visibility: ${({ $open }) => ($open ? "visible" : "hidden")}; + transition: transform 400ms ease-out, + visibility 0s linear ${({ $open }) => ($open ? "0s" : "400ms")}; + overflow: hidden; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + + /* Below lg the panel is a sheet over the whole window. */ + @media (max-width: 1023.98px) { + top: 0; + left: 0; + width: auto; + z-index: 1045; + border-left: 0; + } +` + +const Edge = styled.div` + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 10; + width: 6px; + margin-left: -3px; + cursor: col-resize; + + @media (max-width: 1023.98px) { + display: none; + } +` + +const Header = styled.header` + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + padding: 24px 18px 4px; +` + +const Title = styled.p` + margin: 0; + font-family: var(--maple-font-heading); + font-weight: 400; + font-size: 1.25rem; + line-height: 1.4; + color: ${ink.strong}; +` + +const Close = styled.button` + display: flex; + margin: 0 -6px 0 auto; + padding: 6px; + border: 0; + border-radius: 8px; + background: none; + color: ${ink.mid}; + &:hover { + color: ${ink.strong}; + background: ${wash.default}; + } +` + +/** The side panel the mockup files input in: under the navigation on the + * right, a sheet below lg, its left edge draggable. */ +export function Panel({ + open, + title, + closeLabel, + width, + onDrag, + onClose, + children +}: { + open: boolean + title: string + closeLabel: string + width: number + onDrag: (px: number) => void + onClose: () => void + children: ReactNode +}) { + useEffect(() => { + if (!open) return + const key = (e: KeyboardEvent) => e.key === "Escape" && onClose() + document.addEventListener("keydown", key) + return () => document.removeEventListener("keydown", key) + }, [open, onClose]) + + return ( + + ) +} diff --git a/components/conference/input/Pickers.tsx b/components/conference/input/Pickers.tsx new file mode 100644 index 000000000..40f3f6a64 --- /dev/null +++ b/components/conference/input/Pickers.tsx @@ -0,0 +1,287 @@ +import { ReactNode } from "react" +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { AccountType, accountTypes } from "./accounts" +import { ChevronDown, Lectern, Megaphone, UserRound, Users, X } from "./icons" +import { conferencePositions } from "./positions" +import { brand, ink, line, wash } from "./tokens" +import { Anchor, Menu, Toggle, useRoom } from "./menu" +import { Dropdown } from "components/bootstrap" +import { ConferencePosition } from "components/db" + +export type AccountTypeFilter = "all" | AccountType +const options: AccountTypeFilter[] = ["all", ...accountTypes] + +export type PositionFilter = "all" | ConferencePosition + +/** A choice in either picker. The current one is marked in the brand's + * colour; on a phone the rows grow to a thumb's height. */ +const Item = styled.button` + --bs-dropdown-item-padding-y: 6px; + --bs-dropdown-item-padding-x: 12px; + --bs-dropdown-link-color: ${ink.strong}; + --bs-dropdown-link-hover-color: var(--bs-dropdown-link-color); + --bs-dropdown-link-active-color: var(--bs-dropdown-link-color); + && { + display: flex; + align-items: center; + gap: 8px; + white-space: normal; + font-weight: 500; + } + &&[aria-checked="true"] { + --bs-dropdown-link-color: ${brand.fill}; + font-weight: 600; + } + @media (max-width: 639.98px) { + --bs-dropdown-item-padding-y: 16px; + --bs-dropdown-item-padding-x: 16px; + } +` + +const PickerMenu = styled(Menu)` + @media (max-width: 639.98px) { + && { + --bs-dropdown-padding-y: 8px; + } + } +` + +/** The position sentences, as wide as the longest on a desktop and wrapping + * inside the screen on a phone. */ +const PositionMenu = styled(PickerMenu)` + && { + --bs-dropdown-min-width: 236px; + width: max-content; + } + && .dropdown-item { + line-height: 1.35; + } + @media (max-width: 639.98px) { + && { + --bs-dropdown-min-width: 0; + width: min(340px, calc(100vw - 32px)); + } + } +` + +/** The mockup's account-type icons: a megaphone speaks for an organization. */ +export function AccountTypeIcon({ + type, + size = 16 +}: { + type: AccountType + size?: number +}) { + const { t } = useTranslation("conferences") + const Icon = + type === "organization" + ? Megaphone + : type === "government" + ? Lectern + : UserRound + return ( + + + + ) +} + +const TypeButton = styled.button` + display: inline-flex; + align-items: center; + gap: 7px; + height: 28px; + padding: 0; + border: 0; + background: none; + font-family: var(--maple-font-heading); + font-weight: 500; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: ${ink.strong}; + &:hover { + color: ${brand.fill}; + } + @media (min-width: 992px) { + font-size: 1rem; + } +` + +/** The account-type picker, sitting beside the heading: it names its own + * selection and changes nothing else. */ +export function AccountTypePicker({ + value, + onChange +}: { + value: AccountTypeFilter + onChange: (v: AccountTypeFilter) => void +}) { + const { t } = useTranslation("conferences") + const { ref, onToggle, style } = useRoom() + + // The menu names everyone "All Accounts"; the control names them as users. + const label = (v: AccountTypeFilter, inMenu = false) => + t( + v !== "all" + ? `input.account_${v}` + : inMenu + ? "input.all_accounts" + : "input.all_users" + ) + const icon = (v: AccountTypeFilter, size: number): ReactNode => + v === "all" ? ( + + ) : ( + + ) + + return ( + + + {icon(value, 17)} + {label(value)} + + + + {options.map(v => ( + onChange(v)} + > + + {icon(v, 15)} + + {label(v, true)} + + ))} + + + ) +} + +const Pill = styled.div<{ $set: boolean }>` + display: flex; + align-items: center; + height: 34px; + border: 1px solid ${line.default}; + border-radius: 9999px; + padding-right: ${({ $set }) => ($set ? "4px" : 0)}; + &:hover, + .show > & { + background: ${wash.default}; + } +` + +const PillButton = styled.button<{ $set: boolean }>` + display: flex; + align-items: center; + gap: 7px; + height: 100%; + border: 0; + border-radius: 9999px 0 0 9999px; + background: none; + padding: ${({ $set }) => ($set ? "0 7px 0 9px" : "0 10px 0 11px")}; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; + color: ${ink.strong}; + svg { + color: ${ink.mid}; + } +` + +const Clear = styled.button` + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: 0; + border-radius: 9999px; + background: none; + color: ${ink.mid}; + &:hover { + color: ${ink.strong}; + background: ${wash.strong}; + } +` + +/** The four positions as one pill that opens the rest: the pill names the + * position being looked at, and the menu states all four in full. */ +export function PositionPicker({ + value, + onChange +}: { + value: PositionFilter + onChange: (v: PositionFilter) => void +}) { + const { t } = useTranslation("conferences") + const { ref, onToggle, style } = useRoom() + const short = value === "all" ? null : t(`input.position.${value}.short`) + + return ( + + + + {short ?? t("input.all_positions")} + {!short && } + + {short && ( + onChange("all")} + aria-label={t("input.clear_position", { position: short })} + > + + + )} + + + {conferencePositions.map(p => ( + onChange(p)} + > + {t(`input.position.${p}.long`)} + + ))} + + + ) +} diff --git a/components/conference/input/PublicInput.test.tsx b/components/conference/input/PublicInput.test.tsx new file mode 100644 index 000000000..32e377714 --- /dev/null +++ b/components/conference/input/PublicInput.test.tsx @@ -0,0 +1,106 @@ +import "@testing-library/jest-dom" +import { fireEvent, render, screen, within } from "@testing-library/react" +import type { ConferenceTestimony } from "components/db" +import { PublicInput } from "./PublicInput" + +jest.mock("next-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts && Object.keys(opts).length + ? `${key} ${Object.values(opts).join(" ")}` + : key, + i18n: { language: "en" } + }) +})) + +// The actions reach for the store and the report API; the feed does not. +jest.mock("./Kebab", () => ({ Kebab: () => null })) +jest.mock("./EntryModal", () => ({ EntryModal: () => null })) + +// jsdom has none of these, and the card and the clamp watch their width. +class Observer { + observe() {} + unobserve() {} + disconnect() {} +} +Object.assign(global, { ResizeObserver: Observer }) + +const entry = ( + n: number, + overrides: Partial = {} +): ConferenceTestimony => + ({ + id: `t${n}`, + authorUid: `u${n}`, + authorDisplayName: `Author ${n}`, + fullName: `Author ${n}`, + authorRole: "user", + position: "passSomething", + content: `Words ${n}`, + publishedAt: { toDate: () => new Date(2026, 9, n) }, + ...overrides + } as unknown as ConferenceTestimony) + +const many = (count: number) => + Array.from({ length: count }, (_, i) => + entry(i + 1, { + position: i % 2 ? "passNothing" : "passSomething", + authorRole: i % 3 ? "user" : "organization" + }) + ) + +describe("PublicInput", () => { + it("says so when nothing is filed, and keeps the filters", () => { + render() + expect(screen.getByText("input.empty_title")).toBeInTheDocument() + expect( + screen.getByLabelText("input.filter_by_position") + ).toBeInTheDocument() + expect(screen.queryByText("input.clear_filters")).not.toBeInTheDocument() + }) + + it("shows nothing while loading, not the empty state", () => { + render() + expect(screen.queryByText("input.empty_title")).not.toBeInTheDocument() + }) + + it("leaves the filters off a short feed", () => { + render() + expect(screen.getAllByRole("article")).toHaveLength(3) + expect( + screen.queryByLabelText("input.filter_by_position") + ).not.toBeInTheDocument() + }) + + it("pages five at a time", () => { + render() + expect(screen.getAllByRole("article")).toHaveLength(5) + fireEvent.click(screen.getByRole("button", { name: "2" })) + expect(screen.getAllByRole("article")).toHaveLength(2) + }) + + it("narrows by position and offers a way back from an empty result", async () => { + const items = [ + ...many(4).map(x => ({ ...x, position: "passSomething" as const })) + ] + render() + fireEvent.click(screen.getByLabelText("input.filter_by_position")) + const menu = await screen.findByRole("menu") + fireEvent.click(within(menu).getByText("input.position.passNothing.long")) + expect(screen.getByText("input.no_match_title")).toBeInTheDocument() + fireEvent.click(screen.getByText("input.clear_filters")) + expect(screen.getAllByRole("article")).toHaveLength(4) + }) + + it("narrows by account type", async () => { + render() + // many(): every third author, from the first, is an organization. + fireEvent.click(screen.getByLabelText("input.filter_by_account_type")) + fireEvent.click( + within(await screen.findByRole("menu")).getByText( + "input.account_organization" + ) + ) + expect(screen.getAllByRole("article")).toHaveLength(2) + }) +}) diff --git a/components/conference/input/PublicInput.tsx b/components/conference/input/PublicInput.tsx new file mode 100644 index 000000000..0c4744ef9 --- /dev/null +++ b/components/conference/input/PublicInput.tsx @@ -0,0 +1,205 @@ +import { useEffect, useMemo, useState } from "react" +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { ConferenceTestimony } from "components/db" +import { useInputContext } from "./context" +import { Entry } from "./Entry" +import { EntryModal } from "./EntryModal" +import { Kebab } from "./Kebab" +import { MoreFilters } from "./MoreFilters" +import { Pagination } from "./Pagination" +import { + AccountTypeFilter, + AccountTypePicker, + PositionFilter, + PositionPicker +} from "./Pickers" +import { accountTypeOf, nameOf } from "./accounts" +import { brand, ink, line } from "./tokens" + +/** Filters appear only once the feed reaches this many entries, or when it has + * none, so that an empty feed still shows what could narrow it. */ +const FEED_CONTROLS_MIN = 4 +const PAGE_SIZE = 5 + +const Section = styled.section` + scroll-margin-top: calc( + var(--maple-navbar-height) + var(--title-h, 5rem) + 4.5rem + ); +` + +const Heading = styled.h2` + margin: 12px 0 16px; + font-family: var(--maple-font-heading); + font-weight: 400; + font-size: 1.375rem; + line-height: 1.4; + color: ${ink.strong}; +` + +const Controls = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-bottom: 28px; +` + +const Feed = styled.div` + display: flex; + flex-direction: column; + gap: 20px; +` + +const Empty = styled.div` + padding: 22px; + text-align: center; + background: var(--maple-surface-base); + border: 1.5px dashed ${line.strong}; + border-radius: 12px; + + p { + margin: 0; + } +` + +const EmptyTitle = styled.p` + margin-bottom: 4px !important; + font-size: 1rem; + font-weight: 600; + color: ${ink.strong}; +` + +const EmptyBody = styled.p` + max-width: 560px; + margin: 0 auto !important; + font-size: 0.8125rem; + line-height: 1.5; + color: ${ink.mid}; +` + +const ClearFilters = styled.button` + margin-top: 14px; + padding: 8px 18px; + background: var(--maple-surface-base); + border: 1px solid ${brand.fill}; + border-radius: 9999px; + font-size: 0.8125rem; + font-weight: 600; + color: ${brand.fill}; + &:hover { + background: ${brand.soft}; + } +` + +/** + * Everything filed on a conference, with the controls that narrow it. The way + * to file is the page's own button, not part of the feed. + */ +export function PublicInput({ + items, + loading +}: { + items: ConferenceTestimony[] + loading: boolean +}) { + const { t } = useTranslation("conferences") + const [type, setType] = useState("all") + const [position, setPosition] = useState("all") + const [page, setPage] = useState(0) + const [pickedAuthors, setPickedAuthors] = useState([]) + const [opened, setOpened] = useState() + const { followed } = useInputContext() + + const showFilters = items.length === 0 || items.length >= FEED_CONTROLS_MIN + const shown = useMemo( + () => + showFilters + ? items.filter( + (x: ConferenceTestimony) => + (position === "all" || x.position === position) && + (type === "all" || accountTypeOf(x.authorRole) === type) && + (pickedAuthors.length === 0 || + pickedAuthors.includes(x.authorUid)) + ) + : items, + [items, position, type, pickedAuthors, showFilters] + ) + useEffect(() => setPage(0), [position, type, pickedAuthors]) + + // The accounts being followed that have filed here, which is what the + // filter mark offers. + const followable = useMemo(() => { + const seen = new Map() + items.forEach(x => { + if (followed.has(x.authorUid) && !seen.has(x.authorUid)) + seen.set(x.authorUid, nameOf(x)) + }) + return [...seen].map(([id, name]) => ({ id, name })) + }, [items, followed]) + + const pageCount = Math.max(1, Math.ceil(shown.length / PAGE_SIZE)) + const current = Math.min(page, pageCount - 1) + const paged = shown.slice(current * PAGE_SIZE, (current + 1) * PAGE_SIZE) + + return ( +
    + {t("input.title")} + {showFilters && ( + + + + {followable.length > 0 && ( + + )} + + )} + {loading ? null : paged.length > 0 ? ( + <> + + {paged.map(x => ( + setOpened(x)} />} + /> + ))} + + {pageCount > 1 && ( + + )} + + ) : ( + + + {t( + items.length === 0 ? "input.empty_title" : "input.no_match_title" + )} + + + {t(items.length === 0 ? "input.empty_body" : "input.no_match_body")} + + {items.length > 0 && ( + { + setType("all") + setPosition("all") + setPickedAuthors([]) + }} + > + {t("input.clear_filters")} + + )} + + )} + {opened && ( + setOpened(undefined)} /> + )} +
    + ) +} diff --git a/components/conference/input/ShareButton.tsx b/components/conference/input/ShareButton.tsx new file mode 100644 index 000000000..9f1510567 --- /dev/null +++ b/components/conference/input/ShareButton.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { Plus } from "./icons" +import { brand } from "./tokens" + +const Button = styled.button` + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 6px; + margin-top: 3px; + padding: 8px 16px 8px 12px; + border: 1px solid ${brand.fill}; + border-radius: 8px; + background: ${brand.fill}; + color: #fff; + font-size: 0.8125rem; + font-weight: 600; + white-space: nowrap; + &:hover { + background: ${brand.hover}; + border-color: ${brand.hover}; + } +` + +/** The way in, beside the title. */ +export function ShareButton({ onClick }: { onClick: () => void }) { + const { t } = useTranslation("conferences") + return ( + + ) +} diff --git a/components/conference/input/YourInput.tsx b/components/conference/input/YourInput.tsx new file mode 100644 index 000000000..f08c034ed --- /dev/null +++ b/components/conference/input/YourInput.tsx @@ -0,0 +1,90 @@ +import { useState } from "react" +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { ConferenceTestimony } from "components/db" +import { Entry } from "./Entry" +import { EntryModal } from "./EntryModal" +import { Kebab } from "./Kebab" +import { Check } from "./icons" +import { ink, line, shadow } from "./tokens" + +const Section = styled.section` + scroll-margin-top: calc( + var(--maple-navbar-height) + var(--title-h, 5rem) + 4.5rem + ); +` + +const Head = styled.div` + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + margin: 12px 0 16px; +` + +const Heading = styled.h2` + margin: 0; + font-family: var(--maple-font-heading); + font-weight: 400; + font-size: 1.375rem; + line-height: 1.4; + color: ${ink.strong}; +` + +const OnRecord = styled.p` + display: flex; + align-items: center; + gap: 7px; + margin: 0; + font-size: 0.8125rem; + line-height: 1.5; + color: ${ink.strong}; + svg { + color: #2e7d32; + } +` + +const Box = styled.div` + background: var(--maple-surface-base); + border: 1px solid ${line.default}; + border-radius: 8px; + box-shadow: ${shadow.raised}; +` + +const Private = styled.p` + margin: 12px 0 0; + font-size: 0.8125rem; + line-height: 1.5; + color: ${ink.mid}; +` + +/** What the reader filed, above the public section: it answers to none of that + * section's controls, and says it is also in the record below. */ +export function YourInput({ testimony }: { testimony: ConferenceTestimony }) { + const { t } = useTranslation("conferences") + const [opened, setOpened] = useState(false) + return ( +
    + + {t("input.yours_title")} + + + {t("input.yours_on_record")} + + + + setOpened(true)} /> + } + /> + + {t("input.yours_private")} + {opened && ( + setOpened(false)} /> + )} +
    + ) +} diff --git a/components/conference/input/accounts.ts b/components/conference/input/accounts.ts new file mode 100644 index 000000000..e998fc16d --- /dev/null +++ b/components/conference/input/accounts.ts @@ -0,0 +1,29 @@ +import { Role } from "functions/src/auth/types" + +export type AccountType = "individual" | "organization" | "government" + +export const accountTypes: AccountType[] = [ + "individual", + "organization", + "government" +] + +export const accountTypeOf = (role: Role): AccountType => + role === "organization" + ? "organization" + : role === "legislator" + ? "government" + : "individual" + +/** The name an entry is shown under. */ +export const nameOf = (t: { fullName?: string; authorDisplayName: string }) => + t.fullName || t.authorDisplayName + +/** Two letters for the disc, where the account has no picture of its own. */ +export const initialsOf = (name: string) => + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map(w => w[0].toUpperCase()) + .join("") diff --git a/components/conference/input/context.ts b/components/conference/input/context.ts new file mode 100644 index 000000000..7239d8de4 --- /dev/null +++ b/components/conference/input/context.ts @@ -0,0 +1,25 @@ +import { createContext, useContext } from "react" + +/** What a card needs from the page around it: who is looking, and what the + * actions on an entry do. */ +type InputContextValue = { + /** The viewer, or undefined while signed out. */ + uid?: string + /** What the conference is called, for the headings an opened entry carries. */ + subject: string + /** The authors the viewer follows. */ + followed: Set + /** Reopen the panel on the viewer's own entry; absent once the conference + * stops taking testimony. */ + onEdit?: () => void + /** Whether an entry can be followed or reported by this viewer. */ + signedIn: boolean +} + +export const InputContext = createContext({ + subject: "", + followed: new Set(), + signedIn: false +}) + +export const useInputContext = () => useContext(InputContext) diff --git a/components/conference/input/icons.tsx b/components/conference/input/icons.tsx new file mode 100644 index 000000000..c41641051 --- /dev/null +++ b/components/conference/input/icons.tsx @@ -0,0 +1,126 @@ +import { ReactNode } from "react" + +/** The mockup's line icons (Lucide, ISC), drawn at its sizes. */ +function Icon({ size = 16, children }: { size?: number; children: ReactNode }) { + return ( + + {children} + + ) +} + +type Props = { size?: number } + +export const Megaphone = (p: Props) => ( + + + + +) +export const Lectern = (p: Props) => ( + + + + + +) +export const UserRound = (p: Props) => ( + + + + +) +export const Users = (p: Props) => ( + + + + + + +) +export const ChevronDown = (p: Props) => ( + + + +) +export const ChevronLeft = (p: Props) => ( + + + +) +export const ChevronRight = (p: Props) => ( + + + +) +export const ListFilter = (p: Props) => ( + + + + + +) +export const EllipsisVertical = (p: Props) => ( + + + + + +) +export const X = (p: Props) => ( + + + + +) +export const Plus = (p: Props) => ( + + + + +) +export const FileText = (p: Props) => ( + + + + + + + +) +export const Flag = (p: Props) => ( + + + + +) +export const BellPlus = (p: Props) => ( + + + + + + +) +export const Check = (p: Props) => ( + + + +) +export const SquarePen = (p: Props) => ( + + + + +) diff --git a/components/conference/input/menu.tsx b/components/conference/input/menu.tsx new file mode 100644 index 000000000..fe975d8e3 --- /dev/null +++ b/components/conference/input/menu.tsx @@ -0,0 +1,76 @@ +import { forwardRef, useRef, useState } from "react" +import styled from "styled-components" +import { Dropdown } from "components/bootstrap" +import { ink, line, shadow, wash } from "./tokens" + +/** What a menu hangs from. Dropdown moves focus through the items on the + * arrow keys, and closes on Escape, Tab or a press outside. None of these + * controls wears Bootstrap's caret. */ +export const Anchor = styled(Dropdown)` + flex-shrink: 0; + & .dropdown-toggle::after { + display: none; + } +` + +/** Dropdown's toggle, saying it opens a menu while still closed: Dropdown + * only adds aria-haspopup once the menu has mounted, on the first open. */ +export const Toggle = forwardRef(function Toggle( + props, + ref +) { + return +}) as typeof Dropdown.Toggle + +/** The floating surface under a control, `$gap` pixels below it. Widths and + * padding are set by whoever extends it; items colour themselves through + * Bootstrap's item variables, so its hover and focus rules keep working. */ +export const Menu = styled(Dropdown.Menu).attrs<{ $gap?: number }>( + ({ $gap = 6 }) => ({ + popperConfig: { + modifiers: [ + { name: "offset", options: { offset: [0, $gap] } }, + // It sits in its control's own flow, so scrolling carries both; + // left on, every menu opened once re-places itself on each scroll. + { name: "eventListeners", options: { scroll: false } } + ] + } + }) +)<{ $gap?: number }>` + && { + --bs-dropdown-zindex: 20; + --bs-dropdown-min-width: 200px; + --bs-dropdown-padding-y: 4px; + --bs-dropdown-font-size: 0.8125rem; + --bs-dropdown-bg: var(--maple-surface-base); + --bs-dropdown-border-color: ${line.default}; + --bs-dropdown-border-radius: 8px; + --bs-dropdown-link-color: ${ink.strong}; + --bs-dropdown-link-hover-color: ${ink.strong}; + --bs-dropdown-link-hover-bg: ${wash.default}; + --bs-dropdown-link-active-color: ${ink.strong}; + --bs-dropdown-link-active-bg: ${wash.default}; + box-shadow: ${shadow.popover}; + } +` + +/** On a phone, a picker's menu stays under its control and narrows to the + * room left on the right, rather than sliding left to fit. Measured as it + * opens, so the menu is placed at its narrowed width. Spread onto the Anchor + * (ref, onToggle) and the Menu (style). */ +export function useRoom() { + const ref = useRef(null) + const [room, setRoom] = useState() + const onToggle = (show: boolean) => { + const el = ref.current + setRoom( + show && el && innerWidth < 640 + ? Math.max( + 200, + Math.round(innerWidth - el.getBoundingClientRect().left - 16) + ) + : undefined + ) + } + return { ref, onToggle, style: room ? { maxWidth: room } : undefined } +} diff --git a/components/conference/input/positions.ts b/components/conference/input/positions.ts new file mode 100644 index 000000000..5effbf8d9 --- /dev/null +++ b/components/conference/input/positions.ts @@ -0,0 +1,52 @@ +import { ConferencePosition } from "components/db" + +/** In the order the mockup offers them: wanting a result leads, the refusal, + * which ends the process, is last. */ +export const conferencePositions: ConferencePosition[] = [ + "passSomething", + "passSenateVersion", + "passHouseVersion", + "passNothing" +] + +type Colours = { + /** The chip on a card. */ + chipBg: string + chipInk: string + /** The chosen option in the panel. */ + onBg: string + onBorder: string + onInk: string +} + +/** The mockup's positions.ts colours, from theme.css. */ +export const positionColours: Record = { + passSomething: { + chipBg: "#e0f2d5", + chipInk: "#2c5a2d", + onBg: "#e0f2d5", + onBorder: "#76c353", + onInk: "#2e7d32" + }, + passHouseVersion: { + chipBg: "#faeec5", + chipInk: "#8a5a00", + onBg: "#faeec5", + onBorder: "#ecb72e", + onInk: "#8a5a00" + }, + passSenateVersion: { + chipBg: "#d6e4f6", + chipInk: "#2c5593", + onBg: "#d6e4f6", + onBorder: "#4682d5", + onInk: "#2a6fd5" + }, + passNothing: { + chipBg: "#f7dcdc", + chipInk: "#7d2f2c", + onBg: "#f7dcdc", + onBorder: "#b53333", + onInk: "#b53333" + } +} diff --git a/components/conference/input/tokens.ts b/components/conference/input/tokens.ts new file mode 100644 index 000000000..5ef5b0505 --- /dev/null +++ b/components/conference/input/tokens.ts @@ -0,0 +1,38 @@ +/** The mockup's theme.css values, for the colours the site has no token for. + * The page's own surfaces and brand come from the --maple tokens, as in + * ../styles.ts. */ +export const ink = { + strong: "#141413", + mid: "#73726c", + muted: "#94938d", + faint: "#b5b4ae" +} + +export const line = { + default: "rgba(0, 0, 0, 0.16)", + strong: "rgba(0, 0, 0, 0.26)" +} + +export const wash = { + default: "rgba(0, 0, 0, 0.05)", + strong: "rgba(0, 0, 0, 0.09)" +} + +export const brand = { + fill: "#1f3b61", + hover: "#152842", + soft: "#eaeff5", + edge: "#c4cfde", + ink: "#284e80" +} + +/** The mockup's reds: a link's hover, and a refused field. */ +export const red = { + hover: "#c71e32", + refused: "#b53333" +} + +export const shadow = { + raised: "0 1px 2px rgba(20, 20, 19, 0.05)", + popover: "0 8px 24px rgba(20, 20, 19, 0.1), 0 1px 3px rgba(20, 20, 19, 0.06)" +} diff --git a/components/conference/input/useFollowedAuthors.ts b/components/conference/input/useFollowedAuthors.ts new file mode 100644 index 000000000..b015b011f --- /dev/null +++ b/components/conference/input/useFollowedAuthors.ts @@ -0,0 +1,32 @@ +import { collection, onSnapshot, query, where } from "firebase/firestore" +import { useEffect, useState } from "react" +import { firestore } from "../../firebase" + +const NONE: Set = new Set() + +/** The profile ids the user follows: their testimony subscriptions. Empty + * while signed out. */ +export function useFollowedAuthors(uid?: string) { + const [followed, setFollowed] = useState>(NONE) + + useEffect(() => { + if (!uid) return setFollowed(NONE) + return onSnapshot( + query( + collection(firestore, `/users/${uid}/activeTopicSubscriptions`), + where("type", "==", "testimony") + ), + snapshot => + setFollowed( + new Set( + snapshot.docs + .map(d => d.data().userLookup?.profileId as string | undefined) + .filter((id): id is string => !!id) + ) + ), + error => console.warn("Error in useFollowedAuthors", error) + ) + }, [uid]) + + return followed +} diff --git a/components/conference/input/useToggleFollow.ts b/components/conference/input/useToggleFollow.ts new file mode 100644 index 000000000..a527413b8 --- /dev/null +++ b/components/conference/input/useToggleFollow.ts @@ -0,0 +1,22 @@ +import { authStepChanged } from "components/auth/redux" +import { useAppDispatch } from "components/hooks" +import { + followProfile, + unfollowProfile +} from "components/shared/FollowingQueries" +import { useInputContext } from "./context" + +/** Follow or unfollow an entry's author, sending a signed-out reader to sign + * in first. */ +export function useToggleFollow(authorUid: string) { + const dispatch = useAppDispatch() + const { uid, followed, signedIn } = useInputContext() + const following = followed.has(authorUid) + const toggle = () => + !signedIn + ? dispatch(authStepChanged("start")) + : following + ? unfollowProfile(uid, authorUid) + : followProfile(uid, authorUid) + return { following, toggle } +} diff --git a/components/conference/input/useWide.ts b/components/conference/input/useWide.ts new file mode 100644 index 000000000..25d699041 --- /dev/null +++ b/components/conference/input/useWide.ts @@ -0,0 +1,17 @@ +import { RefObject, useEffect, useState } from "react" + +/** Whether the element is at least `min` px wide. The mockup asks the card + * this with a container query; styled-components 5 does not parse those. */ +export function useWide(ref: RefObject, min: number) { + const [wide, setWide] = useState(false) + useEffect(() => { + const el = ref.current + if (!el) return + const read = () => setWide(el.clientWidth >= min) + read() + const ro = new ResizeObserver(read) + ro.observe(el) + return () => ro.disconnect() + }, [ref, min]) + return wide +} diff --git a/components/conference/load.ts b/components/conference/load.ts index c5d8f11ac..49a7b2a19 100644 --- a/components/conference/load.ts +++ b/components/conference/load.ts @@ -31,6 +31,7 @@ const summarize = (c: Conference): ConferenceSummary => ({ title: c.title, senateBillId: c.senateBillId, houseBillId: c.houseBillId, + canonicalBillId: c.canonicalBillId, status: c.status }) diff --git a/components/conference/types.ts b/components/conference/types.ts index a61b71fe3..dc3162e05 100644 --- a/components/conference/types.ts +++ b/components/conference/types.ts @@ -14,6 +14,8 @@ export type ConferenceSummary = { title: string senateBillId: string houseBillId: string + /** The bill testimony on the conference is filed against. */ + canonicalBillId: string status: ConferenceStatus } diff --git a/components/db/api.ts b/components/db/api.ts index d3c17e76e..1d6fc7243 100644 --- a/components/db/api.ts +++ b/components/db/api.ts @@ -29,6 +29,7 @@ export type TestimonyQuery = { billId: string court: number ballotQuestionId?: string + conferenceId?: string } export type BillQuery = { @@ -72,7 +73,8 @@ export class DbService { authorUid, billId, court, - ballotQuestionId + ballotQuestionId, + conferenceId }: TestimonyQuery): Promise => { const result = await this.getDocs( query( @@ -80,6 +82,7 @@ export class DbService { where("billId", "==", billId), where("court", "==", court), where("ballotQuestionId", "==", ballotQuestionId ?? null), + where("conferenceId", "==", conferenceId ?? null), orderBy("version", "desc") ) ) diff --git a/components/db/conferences.ts b/components/db/conferences.ts index a26de3065..91613de59 100644 --- a/components/db/conferences.ts +++ b/components/db/conferences.ts @@ -1,4 +1,12 @@ -import { collection, getDocs, limit, query, where } from "firebase/firestore" +import { + collection, + doc, + getDoc, + getDocs, + limit, + query, + where +} from "firebase/firestore" import type { Conference } from "../../functions/src/conferences/types" import { firestore } from "../firebase" @@ -13,6 +21,11 @@ export async function listConferences(court: number) { return snap.docs.map(d => d.data() as Conference) } +export async function getConference(court: number, id: string) { + const snap = await getDoc(doc(conferences(court), id)) + return snap.data() as Conference | undefined +} + /** A conference references its two bills; nothing on a bill points back. So * "is this bill in conference" is a query here, not a field there. */ export async function getConferenceForBill(court: number, billId: string) { diff --git a/components/db/testimony/index.ts b/components/db/testimony/index.ts index 3189b1937..292d80201 100644 --- a/components/db/testimony/index.ts +++ b/components/db/testimony/index.ts @@ -1,6 +1,7 @@ export * from "./types" export * from "./useEditTestimony" export * from "./usePublishedTestimonyListing" +export * from "./useConferenceTestimony" export * from "./useDraftTestimonyListing" export * from "./useRecentTestimony" export * from "./useTestimonyAttachment" diff --git a/components/db/testimony/resolveTestimony.ts b/components/db/testimony/resolveTestimony.ts index 12569c80f..4092ef996 100644 --- a/components/db/testimony/resolveTestimony.ts +++ b/components/db/testimony/resolveTestimony.ts @@ -2,12 +2,12 @@ import { collection, collectionGroup, getDocs, + limit, query, where } from "firebase/firestore" import { first } from "lodash" import { firestore } from "../../firebase" -import { matchesBallotQuestionScope } from "./ballotQuestionScope" import { DraftTestimony, Testimony } from "./types" /** Resolves the current draft and publication refs for a given user and bill. */ @@ -15,15 +15,22 @@ export const resolveBillTestimony = async ( uid: string, court: number, billId: string, - ballotQuestionId?: string + ballotQuestionId?: string, + conferenceId?: string ) => { const published = await getPublishedTestimony( uid, court, billId, - ballotQuestionId + ballotQuestionId, + conferenceId + ) + const draft = await getDraftTestimony( + uid, + billId, + ballotQuestionId, + conferenceId ) - const draft = await getDraftTestimony(uid, billId, ballotQuestionId) return { draft: first(draft)?.ref, publication: first(published)?.ref @@ -34,15 +41,22 @@ export const getBillTestimony = async ( uid: string, court: number, billId: string, - ballotQuestionId?: string + ballotQuestionId?: string, + conferenceId?: string ) => { const published = await getPublishedTestimony( uid, court, billId, - ballotQuestionId + ballotQuestionId, + conferenceId + ) + const draft = await getDraftTestimony( + uid, + billId, + ballotQuestionId, + conferenceId ) - const draft = await getDraftTestimony(uid, billId, ballotQuestionId) return { draft: first(draft)?.data() as DraftTestimony | undefined, publication: first(published)?.data() as Testimony | undefined @@ -53,35 +67,35 @@ function getPublishedTestimony( uid: string, court: number, billId: string, - ballotQuestionId?: string + ballotQuestionId?: string, + conferenceId?: string ) { return getDocs( query( collectionGroup(firestore, "publishedTestimony"), where("authorUid", "==", uid), where("billId", "==", billId), - where("court", "==", court) + where("court", "==", court), + where("ballotQuestionId", "==", ballotQuestionId ?? null), + where("conferenceId", "==", conferenceId ?? null), + limit(1) ) - ).then(snapshot => - snapshot.docs.filter(doc => - matchesBallotQuestionScope(doc.data(), ballotQuestionId) - ) - ) + ).then(snapshot => snapshot.docs) } function getDraftTestimony( uid: string, billId: string, - ballotQuestionId?: string + ballotQuestionId?: string, + conferenceId?: string ) { return getDocs( query( collection(firestore, `users/${uid}/draftTestimony`), - where("billId", "==", billId) - ) - ).then(snapshot => - snapshot.docs.filter(doc => - matchesBallotQuestionScope(doc.data(), ballotQuestionId) + where("billId", "==", billId), + where("ballotQuestionId", "==", ballotQuestionId ?? null), + where("conferenceId", "==", conferenceId ?? null), + limit(1) ) - ) + ).then(snapshot => snapshot.docs) } diff --git a/components/db/testimony/types.ts b/components/db/testimony/types.ts index abf555e7e..f5fc40af5 100644 --- a/components/db/testimony/types.ts +++ b/components/db/testimony/types.ts @@ -22,6 +22,16 @@ export const maxTestimonyLength = 10_000 export const Position = Union(L("endorse"), L("oppose"), L("neutral")) export type Position = Static +/** What testimony on a committee of conference asks for. Kept apart from + * `Position` so the bill and ballot question displays never see these. */ +export const ConferencePosition = Union( + L("passSomething"), + L("passSenateVersion"), + L("passHouseVersion"), + L("passNothing") +) +export type ConferencePosition = Static + export const Content = String.withConstraint( s => s.length > 0 || "Content is empty" ).withConstraint(s => s.length < maxTestimonyLength || "Content is too long") @@ -34,10 +44,23 @@ export const BaseTestimony = R({ content: Content, attachmentId: Maybe(String), editReason: Maybe(String), - ballotQuestionId: Maybe(String) + ballotQuestionId: Maybe(String), + conferenceId: Maybe(String) }) export type BaseTestimony = Static +/** A testimony is about one thing: its bill, a ballot question on it, or a + * conference on it. */ +const oneTarget = ({ + ballotQuestionId, + conferenceId +}: { + ballotQuestionId?: string | null + conferenceId?: string | null +}) => + !(ballotQuestionId && conferenceId) || + "Testimony targets a ballot question or a conference, not both" + export type Testimony = Static export const Testimony = BaseTestimony.extend({ authorUid: Id, @@ -55,15 +78,29 @@ export const Testimony = BaseTestimony.extend({ representativeDistrict: Optional(String), draftAttachmentId: Maybe(String), fullName: String -}) +}).withConstraint(oneTarget) export type WorkingDraft = Partial export type DraftTestimony = Static -export const DraftTestimony = BaseTestimony.extend({ +const DraftTestimonyFields = BaseTestimony.extend({ publishedVersion: Optional(Number), /** If present, array of legislator member codes */ recipientMemberCodes: Maybe(Array(String)) }) +export const DraftTestimony = DraftTestimonyFields.withConstraint(oneTarget) + +/** Testimony and drafts as they read when `conferenceId` is set. The listing + * and edit hooks for conferences return these; every other hook returns the + * three-position types above. */ +export type ConferenceTestimony = Omit & { + position: ConferencePosition +} +export type ConferenceWorkingDraft = Omit & { + position?: ConferencePosition +} +export const ConferenceDraftTestimony = DraftTestimonyFields.omit("position") + .extend({ position: ConferencePosition }) + .withConstraint(oneTarget) /** Returns true if both values are either falsy or strictly equal. */ const eqish = (a: any, b: any) => (a || undefined) === (b || undefined) diff --git a/components/db/testimony/useConferenceTestimony.ts b/components/db/testimony/useConferenceTestimony.ts new file mode 100644 index 000000000..f7c4bd73f --- /dev/null +++ b/components/db/testimony/useConferenceTestimony.ts @@ -0,0 +1,47 @@ +import { + collectionGroup, + onSnapshot, + orderBy, + query, + where +} from "firebase/firestore" +import { useEffect, useState } from "react" +import { firestore } from "../../firebase" +import { ConferenceTestimony } from "./types" + +/** Every published testimony on a conference, newest first. Uses the + * (conferenceId, court, publishedAt) index. A conference gathers testimony for + * weeks, not years, so the feed pages and filters it in memory. */ +export function useConferenceTestimony(court: number, conferenceId: string) { + const [state, setState] = useState<{ + items: ConferenceTestimony[] + loading: boolean + error?: Error + }>({ items: [], loading: true }) + + useEffect( + () => + onSnapshot( + query( + collectionGroup(firestore, "publishedTestimony"), + where("conferenceId", "==", conferenceId), + where("court", "==", court), + orderBy("publishedAt", "desc") + ), + { + next: snapshot => + setState({ + items: snapshot.docs.map(d => d.data() as ConferenceTestimony), + loading: false + }), + error: error => { + console.warn("Error in useConferenceTestimony", error) + setState({ items: [], loading: false, error }) + } + } + ), + [court, conferenceId] + ) + + return state +} diff --git a/components/db/testimony/useEditTestimony.test.ts b/components/db/testimony/useEditTestimony.test.ts index 5b22d6f76..4d574918f 100644 --- a/components/db/testimony/useEditTestimony.test.ts +++ b/components/db/testimony/useEditTestimony.test.ts @@ -34,7 +34,9 @@ beforeEach(() => { billId, court: court, content: "fake testimony", - position: "endorse" + position: "endorse", + ballotQuestionId: null, + conferenceId: null } testimony = { authorUid: uid, @@ -47,7 +49,9 @@ beforeEach(() => { court: court, position: draft.position, public: true, - version: 1 + version: 1, + ballotQuestionId: null, + conferenceId: null } updatedDraft = { ...draft, content: "update", position: "oppose" } updatedTestimony = { diff --git a/components/db/testimony/useEditTestimony.ts b/components/db/testimony/useEditTestimony.ts index 83c92d411..3fa7e1521 100644 --- a/components/db/testimony/useEditTestimony.ts +++ b/components/db/testimony/useEditTestimony.ts @@ -13,6 +13,10 @@ import { useAsyncCallback, UseAsyncReturn } from "react-async-hook" import { firestore } from "../../firebase" import { resolveBillTestimony } from "./resolveTestimony" import { + ConferenceDraftTestimony, + ConferencePosition, + ConferenceTestimony, + ConferenceWorkingDraft, deleteTestimony, DraftTestimony, hasDraftChanged, @@ -77,6 +81,17 @@ export interface UseEditTestimony { deleteTestimony: UseAsyncReturn } +/** `UseEditTestimony` for testimony on a committee of conference, which takes + * a `ConferencePosition`. */ +export type UseEditConferenceTestimony = Omit< + UseEditTestimony, + "draft" | "publication" | "saveDraft" +> & { + draft?: ConferenceWorkingDraft + publication?: ConferenceTestimony + saveDraft: UseAsyncReturn +} + /** * Load, save, and publish testimony for a particular user and bill. * @@ -87,6 +102,31 @@ export function useEditTestimony( court: number, billId: string, ballotQuestionId?: string +): UseEditTestimony { + return useEdit(uid, court, billId, { ballotQuestionId }) +} + +/** Load, save, and publish a user's testimony on a committee of conference. + * It is filed against the conference's canonical bill, apart from the user's + * testimony on that bill. */ +export function useEditConferenceTestimony( + uid: string, + court: number, + billId: string, + conferenceId: string +): UseEditConferenceTestimony { + // The scope is what makes the draft and publication carry a conference + // position, so the cast is the one place the narrower types are asserted. + return useEdit(uid, court, billId, { + conferenceId + }) as unknown as UseEditConferenceTestimony +} + +function useEdit( + uid: string, + court: number, + billId: string, + { ballotQuestionId, conferenceId }: TestimonyScope ): UseEditTestimony { const [state, dispatch] = useReducer(reducer, { draftLoading: true, @@ -94,7 +134,8 @@ export function useEditTestimony( uid, court, billId, - ballotQuestionId + ballotQuestionId, + conferenceId }) useTestimony(state, dispatch) @@ -132,17 +173,25 @@ export function useEditTestimony( } function useTestimony( - { uid, billId, draftRef, publicationRef, court, ballotQuestionId }: State, + { + uid, + billId, + draftRef, + publicationRef, + court, + ballotQuestionId, + conferenceId + }: State, dispatch: Dispatch ) { useEffect(() => { - resolveBillTestimony(uid, court, billId, ballotQuestionId) + resolveBillTestimony(uid, court, billId, ballotQuestionId, conferenceId) .then(({ draft, publication }) => { dispatch({ type: "resolveDraft", id: draft?.id }) dispatch({ type: "resolvePublication", id: publication?.id }) }) .catch(error => dispatch({ type: "error", error })) - }, [ballotQuestionId, billId, court, dispatch, uid]) + }, [ballotQuestionId, billId, conferenceId, court, dispatch, uid]) useEffect(() => { if (draftRef) @@ -169,18 +218,19 @@ function useTestimony( } function usePublishTestimony( - { draft: workingDraft, draftRef }: State, + { draft: workingDraft, draftRef, conferenceId }: State, dispatch: Dispatch ) { return useAsyncCallback( useCallback(async () => { - DraftTestimony.check(workingDraft) + if (conferenceId) ConferenceDraftTestimony.check(workingDraft) + else DraftTestimony.check(workingDraft) // TODO: don't publish again if draft.publishedVersion is defined if (draftRef) { const result = await publishTestimony({ draftId: draftRef.id }) dispatch({ type: "resolvePublication", id: result.data.publicationId }) } - }, [dispatch, draftRef, workingDraft]), + }, [conferenceId, dispatch, draftRef, workingDraft]), { onError: error => dispatch({ type: "error", error }) } ) } @@ -222,12 +272,16 @@ type SaveDraftRequest = Pick< | "recipientMemberCodes" | "editReason" > & { ballotQuestionId?: string | null } +type SaveConferenceDraftRequest = Omit & { + position: ConferencePosition +} function useSaveDraft( { draftRef, draftLoading, billId, ballotQuestionId, + conferenceId, uid, court, publication, @@ -257,7 +311,8 @@ function useSaveDraft( recipientMemberCodes: recipientMemberCodes ?? null, attachmentId: attachmentId ?? null, ballotQuestionId: - requestBallotQuestionId ?? ballotQuestionId ?? null + requestBallotQuestionId ?? ballotQuestionId ?? null, + conferenceId: conferenceId ?? null } const result = await addDoc( collection(firestore, `/users/${uid}/draftTestimony`), @@ -282,6 +337,7 @@ function useSaveDraft( [ ballotQuestionId, billId, + conferenceId, dispatch, draftLoading, draftRef, @@ -295,11 +351,14 @@ function useSaveDraft( ) } +type TestimonyScope = { ballotQuestionId?: string; conferenceId?: string } + type State = { court: number uid: string billId: string ballotQuestionId?: string + conferenceId?: string error?: Error draft?: WorkingDraft diff --git a/components/db/testimony/usePublishedTestimonyListing.ts b/components/db/testimony/usePublishedTestimonyListing.ts index d164902a4..0280cae25 100644 --- a/components/db/testimony/usePublishedTestimonyListing.ts +++ b/components/db/testimony/usePublishedTestimonyListing.ts @@ -12,7 +12,6 @@ import { firestore } from "../../firebase" import { nullableQuery } from "../common" import { createTableHook } from "../createTableHook" import { Testimony } from "./types" -import { matchesBallotQuestionScope } from "./ballotQuestionScope" type Refinement = { senatorId?: string @@ -22,13 +21,15 @@ type Refinement = { court?: number billId?: string ballotQuestionId?: string + conferenceId?: string } const initialRefinement = ( uid?: string, court?: number, billId?: string, - ballotQuestionId?: string + ballotQuestionId?: string, + conferenceId?: string ): Refinement => ({ representativeId: undefined, senatorId: undefined, @@ -36,7 +37,8 @@ const initialRefinement = ( uid, court, billId, - ballotQuestionId + ballotQuestionId, + conferenceId }) const useTable = createTableHook({ @@ -57,15 +59,17 @@ export function usePublishedTestimonyListing({ uid, court, billId, - ballotQuestionId + ballotQuestionId, + conferenceId }: { uid?: string court?: number billId?: string ballotQuestionId?: string + conferenceId?: string } = {}) { const { pagination, items, refine, refinement } = useTable( - initialRefinement(uid, court, billId, ballotQuestionId) + initialRefinement(uid, court, billId, ballotQuestionId, conferenceId) ) useEffect(() => { @@ -74,7 +78,8 @@ export function usePublishedTestimonyListing({ if (refinement.court !== court) refine({ court }) if (refinement.ballotQuestionId !== ballotQuestionId) refine({ ballotQuestionId }) - }, [ballotQuestionId, billId, court, refine, refinement, uid]) + if (refinement.conferenceId !== conferenceId) refine({ conferenceId }) + }, [ballotQuestionId, billId, conferenceId, court, refine, refinement, uid]) return useMemo(() => { return { @@ -98,7 +103,8 @@ function getWhere({ representativeId, court, senatorId, - ballotQuestionId + ballotQuestionId, + conferenceId }: Refinement): QueryConstraint[] { const constraints: Parameters[] = [] const singularUserRoles: string[] = [ @@ -109,8 +115,12 @@ function getWhere({ ] if (uid) constraints.push(["authorUid", "==", uid]) if (billId) constraints.push(["billId", "==", billId]) - if (ballotQuestionId) - constraints.push(["ballotQuestionId", "==", ballotQuestionId]) + // A bill's listing is one scope: the bill alone, a ballot question or a + // conference. + if (billId || ballotQuestionId) + constraints.push(["ballotQuestionId", "==", ballotQuestionId ?? null]) + if (billId || conferenceId) + constraints.push(["conferenceId", "==", conferenceId ?? null]) if (representativeId) constraints.push(["representativeId", "==", representativeId]) if (senatorId) constraints.push(["senatorId", "==", senatorId]) @@ -139,15 +149,11 @@ async function listTestimony( startAfterKey !== null && startAfter(startAfterKey) ) ) - return result.docs - .map(d => d.data() as Testimony) - .filter(testimony => { - if (refinement.ballotQuestionId) - return matchesBallotQuestionScope( - testimony, - refinement.ballotQuestionId - ) - if (refinement.billId) return matchesBallotQuestionScope(testimony) - return true - }) + const testimony = result.docs.map(d => d.data() as Testimony) + return refinement.billId || + refinement.ballotQuestionId || + refinement.conferenceId + ? testimony + : // Unscoped listings would need an index per filter for this. + testimony.filter(t => !t.conferenceId) } diff --git a/components/db/testimony/useTestimonyListing.ts b/components/db/testimony/useTestimonyListing.ts index 2dccd4b93..463b2d166 100644 --- a/components/db/testimony/useTestimonyListing.ts +++ b/components/db/testimony/useTestimonyListing.ts @@ -122,8 +122,12 @@ function merge( return Array.from(testimony.values()) } +/** Conference testimony shares its bill's id, so it would merge into the bill's + * row; it is listed on the conference page instead. */ function mapSnap(snap: QuerySnapshot) { - return snap.docs.map(d => ({ id: d.id, value: d.data() as T })) + return snap.docs + .map(d => ({ id: d.id, value: d.data() as T })) + .filter(d => !d.value.conferenceId) } function byDraftOnlyThenPublishedAt(t1: BillTestimony, t2: BillTestimony) { diff --git a/components/testimony/TestimonyDetailPage/PolicyActions.test.tsx b/components/testimony/TestimonyDetailPage/PolicyActions.test.tsx index 27183ba8b..9c45dd014 100644 --- a/components/testimony/TestimonyDetailPage/PolicyActions.test.tsx +++ b/components/testimony/TestimonyDetailPage/PolicyActions.test.tsx @@ -38,21 +38,23 @@ jest.mock("components/db/api", () => ({ }) })) +const ballotQuestionDetails = { + bill: { + id: "H123", + court: 194 + }, + ballotQuestion: { + id: "25-14", + title: "Should we do the thing?", + description: null + }, + revision: { + ballotQuestionId: "25-14" + } +} +let mockDetails: Record = ballotQuestionDetails jest.mock("./testimonyDetailSlice", () => ({ - useCurrentTestimonyDetails: () => ({ - bill: { - id: "H123", - court: 194 - }, - ballotQuestion: { - id: "25-14", - title: "Should we do the thing?", - description: null - }, - revision: { - ballotQuestionId: "25-14" - } - }) + useCurrentTestimonyDetails: () => mockDetails })) jest.mock("components/shared/FollowingQueries", () => ({ @@ -85,6 +87,7 @@ describe("PolicyActions", () => { beforeEach(() => { jest.clearAllMocks() uid = "user-1" + mockDetails = ballotQuestionDetails push.mockClear() mockFollowsTopic.mockResolvedValue(false) mockFollowBill.mockResolvedValue(undefined) @@ -161,4 +164,41 @@ describe("PolicyActions", () => { expect(mockFollowBallotQuestion).not.toHaveBeenCalled() expect(mockFollowBill).not.toHaveBeenCalled() }) + + describe("on conference testimony", () => { + const onConference = (status: string) => { + mockDetails = { + bill: { id: "S2619", court: 194 }, + ballotQuestion: null, + conference: { + id: "data-privacy", + court: 194, + name: "Data Privacy", + title: "An Act establishing the Massachusetts data privacy act", + status + }, + revision: { conferenceId: "data-privacy" } + } + render( + + + + ) + } + + it("sends an edit to the conference page while it is appointed", () => { + onConference("appointed") + expect( + screen.getByText("Edit Your Input for Data Privacy").closest("a") + ).toHaveAttribute("href", "/conferences/194/data-privacy") + }) + + it("offers no edit once the conference has reported", () => { + onConference("reportFiled") + expect(screen.getByText("Report Testimony")).toBeInTheDocument() + expect(screen.queryByText(/Input for/)).not.toBeInTheDocument() + }) + }) }) diff --git a/components/testimony/TestimonyDetailPage/PolicyActions.tsx b/components/testimony/TestimonyDetailPage/PolicyActions.tsx index c0733e8e6..b118d1553 100644 --- a/components/testimony/TestimonyDetailPage/PolicyActions.tsx +++ b/components/testimony/TestimonyDetailPage/PolicyActions.tsx @@ -1,5 +1,8 @@ import { Card, ListItem, ListItemProps } from "components/Card" import { dbService } from "components/db/api" +import { conferenceName } from "components/conference/types" +import { takesTestimony } from "functions/src/shared" +import { maple } from "components/links" import { useFlags } from "components/featureFlags" import { formatBillId } from "components/formatting" import { formUrl } from "components/publish" @@ -51,7 +54,8 @@ export const PolicyActions: FC> = ({ isReporting, setReporting }) => { - const { bill, revision, ballotQuestion } = useCurrentTestimonyDetails(), + const { bill, revision, ballotQuestion, conference } = + useCurrentTestimonyDetails(), billLabel = formatBillId(bill.id) const { notifications } = useFlags() @@ -167,7 +171,20 @@ export const PolicyActions: FC> = ({ onClick={() => setReporting(!isReporting)} /> ) - if (canEditBallotQuestionTestimony) + // Conference testimony is written on the conference page, while it takes + // testimony. + if (revision.conferenceId) { + if (conference && takesTestimony(conference.status)) + items.push( + + ) + } else if (canEditBallotQuestionTestimony) items.push( { const { - data: { archive, bill, ballotQuestion, author, testimony }, + data: { archive, bill, ballotQuestion, conference, author, testimony }, selectedVersion, ...rest } = check(testimonyDetail) @@ -75,6 +77,7 @@ const selectTestimonyDetails = createAppSelector(({ testimonyDetail }) => { isEdited: revision.version > 1, bill, ballotQuestion, + conference, author, publishedId: testimony.id, version: revision.version, diff --git a/firestore.indexes.json b/firestore.indexes.json index e78707bc2..97950fce8 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -812,6 +812,24 @@ } ] }, + { + "collectionGroup": "publishedTestimony", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "conferenceId", + "order": "ASCENDING" + }, + { + "fieldPath": "court", + "order": "ASCENDING" + }, + { + "fieldPath": "publishedAt", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "publishedTestimony", "queryScope": "COLLECTION", @@ -1031,6 +1049,54 @@ "order": "DESCENDING" } ] + }, + { + "collectionGroup": "archivedTestimony", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "billId", + "order": "ASCENDING" + }, + { + "fieldPath": "court", + "order": "ASCENDING" + }, + { + "fieldPath": "ballotQuestionId", + "order": "ASCENDING" + }, + { + "fieldPath": "conferenceId", + "order": "ASCENDING" + }, + { + "fieldPath": "version", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "publishedTestimony", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "billId", + "order": "ASCENDING" + }, + { + "fieldPath": "court", + "order": "ASCENDING" + }, + { + "fieldPath": "ballotQuestionId", + "order": "ASCENDING" + }, + { + "fieldPath": "conferenceId", + "order": "ASCENDING" + } + ] } ], "fieldOverrides": [ @@ -1180,6 +1246,17 @@ "queryScope": "COLLECTION_GROUP" } ] + }, + { + "collectionGroup": "publishedTestimony", + "fieldPath": "conferenceId", + "ttl": false, + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] } ] } diff --git a/functions/src/auth/createFakeTestimony.ts b/functions/src/auth/createFakeTestimony.ts index 8687230fb..11fbbb58b 100644 --- a/functions/src/auth/createFakeTestimony.ts +++ b/functions/src/auth/createFakeTestimony.ts @@ -45,7 +45,9 @@ export const createFakeTestimonyV2 = onCall( fullName: fullName, content: fullName + " " + fullName + " " + fullName + " " + fullName, public: true, - updatedAt: Timestamp.now() + updatedAt: Timestamp.now(), + ballotQuestionId: null, + conferenceId: null } const testRef = db.doc(`users/${uid}/publishedTestimony/${id}`) diff --git a/functions/src/bills/backfillTestimonyCounts.ts b/functions/src/bills/backfillTestimonyCounts.ts index ec9d2ca16..34026b9ca 100644 --- a/functions/src/bills/backfillTestimonyCounts.ts +++ b/functions/src/bills/backfillTestimonyCounts.ts @@ -25,6 +25,7 @@ class BackfillTestimonyCounts extends BillProcessor { .collectionGroup("publishedTestimony") .where("court", "==", currentGeneralCourt) .where("ballotQuestionId", "==", null) + .where("conferenceId", "==", null) .select("court", "billId", "position") .get() .then(t => t.docs.map(d => d.data() as PartialTestimony)) diff --git a/functions/src/email/partials/index.ts b/functions/src/email/partials/index.ts index be44c2efa..2cd5a0064 100644 --- a/functions/src/email/partials/index.ts +++ b/functions/src/email/partials/index.ts @@ -1,5 +1,6 @@ import bill from "./bills/bill.handlebars" import bills from "./bills/bills.handlebars" +import conferenceItem from "./users/conferenceItem.handlebars" import footer from "./footer.handlebars" import header from "./header.handlebars" import newsFeedLink from "./newsFeedLink.handlebars" @@ -10,6 +11,7 @@ import users from "./users/users.handlebars" export const partials = { bill, bills, + conferenceItem, footer, header, newsFeedLink, diff --git a/functions/src/email/partials/users/conferenceItem.handlebars b/functions/src/email/partials/users/conferenceItem.handlebars new file mode 100644 index 000000000..9158564be --- /dev/null +++ b/functions/src/email/partials/users/conferenceItem.handlebars @@ -0,0 +1,12 @@ +{{!-- Testimony on a committee of conference: the bill links its conference, + and the stance is in words where a bill stance has an image --}} + + + + + {{stance}} + diff --git a/functions/src/email/partials/users/user.handlebars b/functions/src/email/partials/users/user.handlebars index 5552d69fc..69b6c1292 100644 --- a/functions/src/email/partials/users/user.handlebars +++ b/functions/src/email/partials/users/user.handlebars @@ -32,7 +32,9 @@ {{!-- Item 1 --}} - {{#if this.bills.0.billId}} + {{#if this.bills.0.conferenceId}} + {{> conferenceItem this.bills.[0]}} + {{else if this.bills.0.billId}}
    @@ -49,7 +51,9 @@ {{!-- Item 2 --}} - {{#if this.bills.1.billId}} + {{#if this.bills.1.conferenceId}} + {{> conferenceItem this.bills.[1]}} + {{else if this.bills.1.billId}}
    @@ -67,7 +71,9 @@ {{!-- Item 3 --}} - {{#if this.bills.2.billId}} + {{#if this.bills.2.conferenceId}} + {{> conferenceItem this.bills.[2]}} + {{else if this.bills.2.billId}}
    @@ -84,7 +90,9 @@ {{!-- Item 4 --}} - {{#if this.bills.3.billId}} + {{#if this.bills.3.conferenceId}} + {{> conferenceItem this.bills.[3]}} + {{else if this.bills.3.billId}}
    @@ -103,7 +111,9 @@ {{!-- Item 5 --}} - {{#if this.bills.4.billId}} + {{#if this.bills.4.conferenceId}} + {{> conferenceItem this.bills.[4]}} + {{else if this.bills.4.billId}} + {{else if this.bills.5.conferenceId}} + {{> conferenceItem this.bills.[5]}} {{else}}
    diff --git a/functions/src/email/userPartial.test.ts b/functions/src/email/userPartial.test.ts new file mode 100644 index 000000000..092313752 --- /dev/null +++ b/functions/src/email/userPartial.test.ts @@ -0,0 +1,66 @@ +import * as handlebars from "handlebars" +import * as path from "path" +import { registerHelpers, registerPartials } from "./handlebarsHelpers" + +const render = (bills: object[]) => { + registerHelpers() + registerPartials(path.join(__dirname, "partials")) + return handlebars.compile("{{> user}}")({ + userId: "author1", + userName: "Alice", + newTestimonyCount: bills.length, + bills + }) +} + +describe("user digest partial", () => { + it("links conference testimony to its conference and words the stance", () => { + const html = render([ + { + billId: "S2581", + court: "194", + conferenceId: "student-learning-and-mental-health", + stance: "Pass the Senate version" + } + ]) + + expect(html).toContain( + 'href="https://mapletestimony.org/conferences/194/student-learning-and-mental-health"' + ) + expect(html).toContain("Pass the Senate version") + expect(html).not.toContain("/bills/194/S2581") + expect(html).not.toContain("images_no-svgs") + }) + + it("keeps a bill testimony's link and position image", () => { + const html = render([ + { billId: "H1234", court: "194", position: "endorse" } + ]) + + expect(html).toContain('href="https://mapletestimony.org/bills/194/H1234"') + expect(html).toContain( + 'src="https://mapletestimony.org/email/images_no-svgs/endorse.jpg"' + ) + }) + + it("handles conference testimony in the sixth slot", () => { + const bill = { billId: "H1", court: "194", position: "oppose" } + const html = render([ + bill, + bill, + bill, + bill, + bill, + { + billId: "S2581", + court: "194", + conferenceId: "student-learning-and-mental-health", + stance: "Pass the Senate version" + } + ]) + + expect(html).toContain( + "/conferences/194/student-learning-and-mental-health" + ) + }) +}) diff --git a/functions/src/notifications/deliverNotifications.test.ts b/functions/src/notifications/deliverNotifications.test.ts index 79f9b5f7e..9213de33c 100644 --- a/functions/src/notifications/deliverNotifications.test.ts +++ b/functions/src/notifications/deliverNotifications.test.ts @@ -1,4 +1,4 @@ -import { buildDigestData } from "./deliverNotifications" +import { buildDigestData, CONFERENCE_STANCES } from "./deliverNotifications" import { Timestamp } from "../firebase" // Mock firebase module @@ -302,3 +302,60 @@ describe("buildDigestData — users", () => { expect(result.numUsersWithNewTestimony).toBe(1) }) }) + +describe("buildDigestData — conference testimony", () => { + it("links the conference and words the stance for a followed author", async () => { + const base = { + type: "testimony", + isBillMatch: false, + isUserMatch: true, + isBallotQuestionMatch: false, + header: "A Bill", + court: "194", + timestamp: now, + authorUid: "author1", + subheader: "Alice", + bodyText: "", + userRole: "user", + delivered: false, + ballotQuestionId: null, + ballotQuestionCourt: null + } + setupCollection([ + { + ...base, + billId: "S2581", + position: "passSenateVersion", + testimonyId: "t1", + conferenceId: "student-learning-and-mental-health" + }, + { + ...base, + billId: "H1234", + position: "endorse", + testimonyId: "t2", + conferenceId: null + } + ]) + + const result = await buildDigestData("uid1", now as any, "Weekly") + + expect(result.users[0].bills).toEqual([ + { + billId: "S2581", + court: "194", + conferenceId: "student-learning-and-mental-health", + stance: "Pass the Senate version" + }, + { billId: "H1234", court: "194", position: "endorse" } + ]) + }) +}) + +describe("CONFERENCE_STANCES", () => { + it("says what the site says", () => { + const { input } = require("../../../public/locales/en/conferences.json") + for (const [position, stance] of Object.entries(CONFERENCE_STANCES)) + expect(stance).toBe(input.position[position].long) + }) +}) diff --git a/functions/src/notifications/deliverNotifications.ts b/functions/src/notifications/deliverNotifications.ts index c45d645f7..f7c159c0b 100644 --- a/functions/src/notifications/deliverNotifications.ts +++ b/functions/src/notifications/deliverNotifications.ts @@ -12,12 +12,14 @@ import { TestimonySubmissionNotificationFields, Profile } from "./types" import { BallotQuestionDigest, BillDigest, + BillResult, NotificationEmailDigest, Position, UserDigest } from "./emailTypes" import { prepareHandlebars } from "../email/handlebarsHelpers" import { Frequency } from "../auth/types" +import { ConferencePosition } from "../testimony/types" const PROFILE_BATCH_SIZE = 50 const NUM_BILLS_TO_DISPLAY = 4 @@ -26,6 +28,14 @@ const NUM_TESTIMONIES_TO_DISPLAY = 6 const NUM_BALLOT_QUESTIONS_TO_DISPLAY = 4 const EMAIL_TEMPLATE_PATH = "../email/digestEmail.handlebars" +// The site's wording, conferences input.position.*.long; a test holds them equal. +export const CONFERENCE_STANCES: Record = { + passSomething: "Please pass something", + passSenateVersion: "Pass the Senate version", + passHouseVersion: "Pass the House version", + passNothing: "Don't pass anything" +} + const path = require("path") const getVerifiedUserEmail = async (uid: string) => { @@ -207,10 +217,16 @@ export const buildDigestData = async ( } if (notification.isUserMatch) { - const billResult = { + const billResult: BillResult = { billId: notification.billId, court: notification.court, - position: notification.position as Position + ...(notification.conferenceId + ? { + conferenceId: notification.conferenceId, + stance: + CONFERENCE_STANCES[notification.position as ConferencePosition] + } + : { position: notification.position as Position }) } if (usersById[notification.authorUid]) { const user = usersById[notification.authorUid] diff --git a/functions/src/notifications/emailTypes.ts b/functions/src/notifications/emailTypes.ts index 3ddf6fc99..ae6fe0bd8 100644 --- a/functions/src/notifications/emailTypes.ts +++ b/functions/src/notifications/emailTypes.ts @@ -12,8 +12,12 @@ export type Position = "endorse" | "neutral" | "oppose" export type BillResult = { billId: string court: string - position: Position -} +} & ( + | { position: Position } + // Testimony on a committee of conference links the conference and says its + // stance in words, since the position images are bill stances. + | { conferenceId: string; stance: string } +) export type UserDigest = { userId: string userName: string diff --git a/functions/src/notifications/populateTestimonySubmissionNotificationEvents.ts b/functions/src/notifications/populateTestimonySubmissionNotificationEvents.ts index 0c9594308..3f4cec732 100644 --- a/functions/src/notifications/populateTestimonySubmissionNotificationEvents.ts +++ b/functions/src/notifications/populateTestimonySubmissionNotificationEvents.ts @@ -53,6 +53,7 @@ export const populateTestimonySubmissionNotificationEvents = onDocumentWritten( ballotQuestionId: bqId, ballotQuestionCourt: bqCourt, + conferenceId: newData?.conferenceId ?? null, updateTime: Timestamp.now() } diff --git a/functions/src/notifications/publishNotifications.ts b/functions/src/notifications/publishNotifications.ts index fd84ea8d4..9b86f34db 100644 --- a/functions/src/notifications/publishNotifications.ts +++ b/functions/src/notifications/publishNotifications.ts @@ -74,7 +74,8 @@ const createNotificationFields = ( testimonyId: entity.testimonyId, userRole: entity.userRole, ballotQuestionId: entity.ballotQuestionId ?? null, - ballotQuestionCourt: entity.ballotQuestionCourt ?? null + ballotQuestionCourt: entity.ballotQuestionCourt ?? null, + conferenceId: entity.conferenceId ?? null }, createdAt: Timestamp.now() } @@ -140,7 +141,11 @@ export const publishNotifications = onDocumentWritten( ] : [ `testimony-${topic.userId}`, - `bill-${topic.billCourt}-${topic.billId}`, + // Conference testimony is kept off its bill, so the bill's + // followers don't hear of it. + ...(topic.conferenceId + ? [] + : [`bill-${topic.billCourt}-${topic.billId}`]), ...(topic.ballotQuestionId && topic.ballotQuestionCourt != null ? [ `ballot-question-${topic.ballotQuestionCourt}-${topic.ballotQuestionId}` diff --git a/functions/src/notifications/types.ts b/functions/src/notifications/types.ts index e748f24de..33c4aa5e3 100644 --- a/functions/src/notifications/types.ts +++ b/functions/src/notifications/types.ts @@ -26,6 +26,7 @@ export type TestimonySubmissionNotification = Notification & { testimonyVersion: number ballotQuestionId?: string | null ballotQuestionCourt?: number | null + conferenceId?: string | null } export interface TestimonySubmissionNotificationFields { @@ -48,6 +49,7 @@ export interface TestimonySubmissionNotificationFields { authorUid: string ballotQuestionId?: string | null ballotQuestionCourt?: number | null + conferenceId?: string | null } createdAt: FirebaseFirestore.Timestamp } diff --git a/functions/src/shared/conferences.ts b/functions/src/shared/conferences.ts new file mode 100644 index 000000000..40c9ba74b --- /dev/null +++ b/functions/src/shared/conferences.ts @@ -0,0 +1,6 @@ +import type { ConferenceStatus } from "../conferences/types" + +/** Testimony is taken while the committee is still deciding which version to + * report. */ +export const takesTestimony = (status: ConferenceStatus) => + status === "appointed" diff --git a/functions/src/shared/index.ts b/functions/src/shared/index.ts index c651f6893..2958296c6 100644 --- a/functions/src/shared/index.ts +++ b/functions/src/shared/index.ts @@ -1,2 +1,3 @@ +export * from "./conferences" export * from "./constants" export * from "./names" diff --git a/functions/src/testimony/deleteTestimony.ts b/functions/src/testimony/deleteTestimony.ts index fad333205..58c3e5791 100644 --- a/functions/src/testimony/deleteTestimony.ts +++ b/functions/src/testimony/deleteTestimony.ts @@ -3,8 +3,6 @@ import { onCall } from "firebase-functions/v2/https" import type { CallableRequest } from "firebase-functions/v2/https" import * as logger from "firebase-functions/logger" import { Record } from "runtypes" -import { BallotQuestion } from "../ballotQuestions/types" -import { Bill } from "../bills/types" import { checkAuth, checkRequest, @@ -15,8 +13,8 @@ import { } from "../common" import { db, FieldValue } from "../firebase" import { Attachments } from "./attachments" +import { targetCountsUpdate, targetOf } from "./target" import { DraftTestimony, Testimony } from "./types" -import { updateTestimonyCounts } from "./updateTestimonyCounts" const DeleteTestimonyRequest = Record({ uid: Id, @@ -67,10 +65,6 @@ class DeleteTestimonyTransaction { private publicationSnap!: DocumentSnapshot private publication!: Testimony - private billSnap!: DocumentSnapshot - private bill!: Bill - private ballotQuestion?: BallotQuestion - private ballotQuestionRef?: FirebaseFirestore.DocumentReference private draftSnap?: DocumentSnapshot constructor( @@ -86,27 +80,28 @@ class DeleteTestimonyTransaction { async run(): Promise { await this.loadPublication() if (!this.publicationSnap.exists) return { deleted: false } - await this.loadBill() - await this.loadBallotQuestion() - await this.loadDraft() - - const billUpdate: DocUpdate = { - ...(await this.resolveNewLatestTestimony()), - ...updateTestimonyCounts(this.bill, this.publication, undefined) - } + const target = targetOf(this.publication) + const [targetSnap, latest] = await Promise.all([ + this.t.get(target.ref), + target.kind === "bill" ? this.resolveNewLatestTestimony() : {}, + this.loadDraft() + ]) const draftUpdate: DocUpdate = { publishedVersion: FieldValue.delete() } - if (!this.publication.ballotQuestionId) { - this.t.update(this.billSnap.ref, billUpdate) - } - if (this.ballotQuestion && this.ballotQuestionRef) { - this.t.update( - this.ballotQuestionRef, - updateTestimonyCounts(this.ballotQuestion, this.publication, undefined) - ) + // A target that is gone (a removed ballot question, say) has no counts. + if (targetSnap.exists) { + this.t.update(target.ref, { + ...latest, + ...targetCountsUpdate( + target, + targetSnap.data(), + this.publication, + undefined + ) + }) } this.t.delete(this.publicationSnap.ref) if (this.draftSnap) this.t.update(this.draftSnap.ref, draftUpdate) @@ -128,20 +123,17 @@ class DeleteTestimonyTransaction { } } - private async loadBill() { - this.billSnap = await this.t.get( - db.doc( - `/generalCourts/${this.publication.court}/bills/${this.publication.billId}` - ) - ) - this.bill = Bill.checkWithDefaults(this.billSnap.data()) - } - private async loadDraft() { const result = await this.t.get( db .collection(`users/${this.uid}/draftTestimony`) .where("billId", "==", this.publication.billId) + .where( + "ballotQuestionId", + "==", + this.publication.ballotQuestionId ?? null + ) + .where("conferenceId", "==", this.publication.conferenceId ?? null) ) if (result.docs.length === 1) { @@ -149,17 +141,6 @@ class DeleteTestimonyTransaction { } } - private async loadBallotQuestion() { - const ballotQuestionId = this.publication.ballotQuestionId ?? null - if (!ballotQuestionId) return - - this.ballotQuestionRef = db.doc(`/ballotQuestions/${ballotQuestionId}`) - const snap = await this.t.get(this.ballotQuestionRef) - if (!snap.exists) return - - this.ballotQuestion = BallotQuestion.checkWithDefaults(snap.data()) - } - private async resolveNewLatestTestimony() { const result = await this.t.get( db @@ -167,6 +148,7 @@ class DeleteTestimonyTransaction { .where("billId", "==", this.publication.billId) .where("court", "==", this.publication.court) .where("ballotQuestionId", "==", null) + .where("conferenceId", "==", null) .orderBy("publishedAt", "desc") .limit(2) ) diff --git a/functions/src/testimony/positions.test.ts b/functions/src/testimony/positions.test.ts new file mode 100644 index 000000000..43cdd912f --- /dev/null +++ b/functions/src/testimony/positions.test.ts @@ -0,0 +1,42 @@ +import { DraftTestimony } from "./types" + +const draft = { + billId: "S2581", + court: 194, + content: "text", + attachmentId: null, + editReason: null, + ballotQuestionId: null +} + +describe("DraftTestimony position", () => { + it("takes a bill position on bill testimony", () => { + expect(DraftTestimony.guard({ ...draft, position: "endorse" })).toBe(true) + expect(DraftTestimony.guard({ ...draft, position: "passNothing" })).toBe( + false + ) + }) + + it("takes a conference position on conference testimony", () => { + const conference = { ...draft, conferenceId: "data-privacy" } + expect( + DraftTestimony.guard({ ...conference, position: "passSenateVersion" }) + ).toBe(true) + expect(DraftTestimony.guard({ ...conference, position: "oppose" })).toBe( + false + ) + }) +}) + +describe("DraftTestimony target", () => { + it("names a ballot question or a conference, not both", () => { + expect( + DraftTestimony.guard({ + ...draft, + position: "endorse", + ballotQuestionId: "bq-1", + conferenceId: "data-privacy" + }) + ).toBe(false) + }) +}) diff --git a/functions/src/testimony/publishTestimony.ts b/functions/src/testimony/publishTestimony.ts index 844ac504a..76638b679 100644 --- a/functions/src/testimony/publishTestimony.ts +++ b/functions/src/testimony/publishTestimony.ts @@ -1,17 +1,21 @@ -import { DocumentReference, DocumentSnapshot } from "@google-cloud/firestore" +import { + DocumentData, + DocumentReference, + DocumentSnapshot +} from "@google-cloud/firestore" import { onCall } from "firebase-functions/v2/https" import type { CallableRequest } from "firebase-functions/v2/https" import * as logger from "firebase-functions/logger" import { nanoid } from "nanoid" import { Record } from "runtypes" -import { BallotQuestion } from "../ballotQuestions/types" import { Bill } from "../bills/types" +import { Conference } from "../conferences/types" import { checkAuth, checkRequest, DocUpdate, fail, Id } from "../common" import { db, FieldValue, Timestamp } from "../firebase" -import { supportedGeneralCourts } from "../shared" +import { supportedGeneralCourts, takesTestimony } from "../shared" import { Attachments, PublishedAttachmentState } from "./attachments" +import { targetCountsUpdate, targetOf, TestimonyTarget } from "./target" import { DraftTestimony, Testimony } from "./types" -import { updateTestimonyCounts } from "./updateTestimonyCounts" const PublishTestimonyRequest = Record({ draftId: Id @@ -62,9 +66,10 @@ class PublishTestimonyTransaction { private draftSnap!: DocumentSnapshot private draft!: DraftTestimony private bqId!: string | null - private billSnap!: DocumentSnapshot private bill!: Bill - private ballotQuestion?: BallotQuestion + private conferenceId!: string | null + private target!: TestimonyTarget + private targetData?: DocumentData private publicationRef!: DocumentReference private currentPublication?: Testimony private profile?: any @@ -99,7 +104,8 @@ class PublishTestimonyTransaction { draftAttachmentId: this.attachments.draftId, fullName: this.profile?.fullName ?? "Anonymous", public: this.profile?.public ?? true, - ballotQuestionId: this.bqId + ballotQuestionId: this.bqId, + conferenceId: this.conferenceId } if (this.profile?.representative?.id) { newPublication.representativeId = this.profile.representative.id @@ -118,8 +124,7 @@ class PublishTestimonyTransaction { this.setPublication(newPublication) this.createArchive(newPublication) this.updateDraft(newPublication) - this.updateBill(newPublication) - this.updateBallotQuestion(newPublication) + this.updateTarget(newPublication) return { publicationId: this.publicationRef.id, @@ -147,32 +152,23 @@ class PublishTestimonyTransaction { this.t.update(this.draftSnap.ref, update) } - private updateBill(newPublication: Testimony) { - if (this.bqId) return - - const billTestimonyFields: DocUpdate = { - latestTestimonyAt: newPublication.publishedAt, - latestTestimonyId: this.publicationRef.id, - ...updateTestimonyCounts( - this.bill, - this.currentPublication, - newPublication - ) - } - this.t.update(this.billSnap.ref, billTestimonyFields) - } - - private updateBallotQuestion(newPublication: Testimony) { - if (!this.bqId || !this.ballotQuestion) return - - const ballotQuestionFields: DocUpdate = - updateTestimonyCounts( - this.ballotQuestion, + private updateTarget(newPublication: Testimony) { + const latest: DocUpdate = + this.target.kind === "bill" + ? { + latestTestimonyAt: newPublication.publishedAt, + latestTestimonyId: this.publicationRef.id + } + : {} + this.t.update(this.target.ref, { + ...latest, + ...targetCountsUpdate( + this.target, + this.targetData, this.currentPublication, newPublication ) - - this.t.update(db.doc(`/ballotQuestions/${this.bqId}`), ballotQuestionFields) + }) } private async resolveDraft() { @@ -195,13 +191,14 @@ class PublishTestimonyTransaction { await this.checkValidCourt(draft.court) const bqId = draft.ballotQuestionId ?? null + const conferenceId = draft.conferenceId ?? null + const target = targetOf(draft) const billRef = db.doc( `/generalCourts/${draft.court}/bills/${draft.billId}` ) - const bqRef = bqId !== null ? db.doc(`/ballotQuestions/${bqId}`) : null - const [billSnap, bqSnap] = await Promise.all([ + const [billSnap, targetSnap] = await Promise.all([ this.t.get(billRef), - bqRef ? this.t.get(bqRef) : Promise.resolve(null) + target.kind === "bill" ? null : this.t.get(target.ref) ]) if (!billSnap.exists) { @@ -210,22 +207,38 @@ class PublishTestimonyTransaction { `Draft testimony has invalid bill ID ${draft.billId}` ) } - if (bqSnap !== null && !bqSnap.exists) { + if (targetSnap && !targetSnap.exists) { throw fail( "failed-precondition", - `Draft testimony has invalid ballotQuestionId ${bqId}` + target.kind === "ballotQuestion" + ? `Draft testimony has invalid ballotQuestionId ${bqId}` + : `Draft testimony has invalid conferenceId ${conferenceId}` ) } + if (target.kind === "conference") { + const conference = Conference.checkWithDefaults(targetSnap!.data()) + if (conference.canonicalBillId !== draft.billId) { + throw fail( + "failed-precondition", + `Conference ${conferenceId} takes testimony on ${conference.canonicalBillId}, not ${draft.billId}` + ) + } + if (!takesTestimony(conference.status)) { + throw fail( + "failed-precondition", + `Conference ${conferenceId} is not accepting testimony (status ${conference.status})` + ) + } + } + this.draft = draft this.bqId = bqId + this.conferenceId = conferenceId + this.target = target + this.targetData = (targetSnap ?? billSnap).data() this.draftSnap = draftSnap - this.billSnap = billSnap this.bill = Bill.checkWithDefaults(billSnap.data()) - this.ballotQuestion = - bqSnap && bqSnap.exists - ? BallotQuestion.checkWithDefaults(bqSnap.data()) - : undefined } private async resolveProfile() { @@ -281,14 +294,15 @@ class PublishTestimonyTransaction { .where("billId", "==", this.draft.billId) .where("court", "==", this.draft.court) .where("ballotQuestionId", "==", this.bqId) + .where("conferenceId", "==", this.conferenceId) .orderBy("version", "desc") .limit(1) ) + const latest = archived.docs[0] - if (archived.size === 0) return INITIAL_VERSION + if (!latest) return INITIAL_VERSION - const data = archived.docs[0].data(), - nextVersion = Testimony.checkWithDefaults(data).version + 1 + const nextVersion = Testimony.checkWithDefaults(latest.data()).version + 1 if (nextVersion > MAX_VERSION) throw new Error( @@ -305,17 +319,18 @@ class PublishTestimonyTransaction { .where("billId", "==", this.draft.billId) .where("court", "==", this.draft.court) .where("ballotQuestionId", "==", this.bqId) + .where("conferenceId", "==", this.conferenceId) .limit(1) ) + const publication = publications.docs[0] - if (publications.size === 0) { + if (!publication) { this.publicationRef = db.doc( `/users/${this.uid}/publishedTestimony/${nanoid()}` ) } else { - const data = publications.docs[0].data() - this.currentPublication = Testimony.checkWithDefaults(data) - this.publicationRef = publications.docs[0].ref + this.currentPublication = Testimony.checkWithDefaults(publication.data()) + this.publicationRef = publication.ref } } diff --git a/functions/src/testimony/search.ts b/functions/src/testimony/search.ts index 0e3fc027d..4675d3346 100644 --- a/functions/src/testimony/search.ts +++ b/functions/src/testimony/search.ts @@ -42,7 +42,7 @@ export const { ], default_sorting_field: "publishedAt" }, - filter: data => !data.ballotQuestionId, + filter: data => !data.ballotQuestionId && !data.conferenceId, convert: data => { const validation = Testimony.validateWithDefaults(data) if (!validation.success) { diff --git a/functions/src/testimony/target.test.ts b/functions/src/testimony/target.test.ts new file mode 100644 index 000000000..bf898121e --- /dev/null +++ b/functions/src/testimony/target.test.ts @@ -0,0 +1,29 @@ +import { targetOf } from "./target" + +const ids = { court: 194, billId: "S2581" } + +describe("targetOf", () => { + it("targets the bill when no other target is named", () => { + const target = targetOf({ ...ids, ballotQuestionId: null }) + expect(target.kind).toBe("bill") + expect(target.ref.path).toBe("generalCourts/194/bills/S2581") + }) + + it("targets a ballot question", () => { + const target = targetOf({ ...ids, ballotQuestionId: "bq-1" }) + expect(target.kind).toBe("ballotQuestion") + expect(target.ref.path).toBe("ballotQuestions/bq-1") + }) + + it("targets a conference", () => { + const target = targetOf({ ...ids, conferenceId: "data-privacy" }) + expect(target.kind).toBe("conference") + expect(target.ref.path).toBe("generalCourts/194/conferences/data-privacy") + }) + + it("throws on a testimony naming two targets", () => { + expect(() => + targetOf({ ...ids, ballotQuestionId: "bq-1", conferenceId: "x" }) + ).toThrow("Testimony targets a ballot question or a conference, not both") + }) +}) diff --git a/functions/src/testimony/target.ts b/functions/src/testimony/target.ts new file mode 100644 index 000000000..d755ca652 --- /dev/null +++ b/functions/src/testimony/target.ts @@ -0,0 +1,61 @@ +import { DocumentData, DocumentReference } from "@google-cloud/firestore" +import { conferenceCountFields } from "../conferences/types" +import { db } from "../firebase" +import { billCountFields, oneTarget, Testimony } from "./types" +import { updateTestimonyCounts } from "./updateTestimonyCounts" + +/** What a testimony counts toward: its bill, a ballot question on it, or a + * committee of conference on it. */ +export type TestimonyTarget = { + kind: "bill" | "ballotQuestion" | "conference" + ref: DocumentReference +} + +type TargetIds = Pick< + Testimony, + "court" | "billId" | "ballotQuestionId" | "conferenceId" +> + +export function targetOf({ + court, + billId, + ballotQuestionId, + conferenceId +}: TargetIds): TestimonyTarget { + const one = oneTarget({ ballotQuestionId, conferenceId }) + if (one !== true) throw new Error(one) + if (ballotQuestionId) + return { + kind: "ballotQuestion", + ref: db.doc(`/ballotQuestions/${ballotQuestionId}`) + } + if (conferenceId) + return { + kind: "conference", + ref: db.doc(`/generalCourts/${court}/conferences/${conferenceId}`) + } + return { + kind: "bill", + ref: db.doc(`/generalCourts/${court}/bills/${billId}`) + } +} + +const countFieldsFor = { + bill: billCountFields, + ballotQuestion: billCountFields, + conference: conferenceCountFields +} + +/** The count fields to write on the target, from its current document. */ +export const targetCountsUpdate = ( + target: TestimonyTarget, + data: DocumentData | undefined, + currentPublication?: Pick, + newPublication?: Pick +) => + updateTestimonyCounts( + countFieldsFor[target.kind], + data ?? {}, + currentPublication, + newPublication + ) diff --git a/functions/src/testimony/types.ts b/functions/src/testimony/types.ts index cbd4f5875..b2905c2c5 100644 --- a/functions/src/testimony/types.ts +++ b/functions/src/testimony/types.ts @@ -16,10 +16,24 @@ import { Timestamp } from "../firebase" const maxTestimonyLength = 10_000 +/** Whether a bill should pass. */ +export type BillPosition = Static +export const BillPosition = Union(L("endorse"), L("oppose"), L("neutral")) +/** Which version a committee of conference should report, if any. */ +export type ConferencePosition = Static +export const ConferencePosition = Union( + L("passNothing"), + L("passSomething"), + L("passSenateVersion"), + L("passHouseVersion") +) +export type Position = Static +export const Position = Union(BillPosition, ConferencePosition) + const BaseTestimony = R({ billId: Id, court: Number, - position: Union(L("endorse"), L("oppose"), L("neutral")), + position: Position, content: RtString.withConstraint( s => s.length > 0 && s.length < maxTestimonyLength ), @@ -64,17 +78,60 @@ export const Testimony = withDefaults( } ) +/** The positions a testimony may take: conference testimony takes a + * conference position, all other testimony a bill position. */ +export const positionsFor = (conferenceId?: string | null) => + conferenceId ? ConferencePosition : BillPosition + +export const positionMatchesTarget = ({ + position, + conferenceId +}: { + position: Position + conferenceId?: string | null +}) => + positionsFor(conferenceId).guard(position) || + (conferenceId + ? "Conference testimony takes a conference position" + : "Bill testimony takes a bill position") + +/** A testimony is about one thing: its bill, a ballot question on it, or a + * conference on it. */ +export const oneTarget = ({ + ballotQuestionId, + conferenceId +}: { + ballotQuestionId?: string | null + conferenceId?: string | null +}) => + !(ballotQuestionId && conferenceId) || + "Testimony targets a ballot question or a conference, not both" + export type DraftTestimony = Static export const DraftTestimony = BaseTestimony.extend({ publishedVersion: Optional(Number) }) + .withConstraint(positionMatchesTarget) + .withConstraint(oneTarget) export const countsByPositions = { endorse: "endorseCount", neutral: "neutralCount", - oppose: "opposeCount" + oppose: "opposeCount", + passNothing: "passNothingCount", + passSomething: "passSomethingCount", + passSenateVersion: "passSenateVersionCount", + passHouseVersion: "passHouseVersionCount" } as const +/** The count fields the testimony triggers own on bills and ballot questions. */ +export const billCountFields = [ + "testimonyCount", + "endorseCount", + "neutralCount", + "opposeCount" +] as const + export const TestimonySearchRecord = R({ id: RtString, billId: RtString, @@ -82,7 +139,7 @@ export const TestimonySearchRecord = R({ * functions/src/bills/numberVariants.ts. */ billIdVariants: Array(RtString), court: Number, - position: Union(L("endorse"), L("oppose"), L("neutral")), + position: Position, content: RtString, authorUid: RtString, authorRole: RtString, diff --git a/functions/src/testimony/updateTestimonyCounts.test.ts b/functions/src/testimony/updateTestimonyCounts.test.ts index 68c76210d..d44ec1803 100644 --- a/functions/src/testimony/updateTestimonyCounts.test.ts +++ b/functions/src/testimony/updateTestimonyCounts.test.ts @@ -1,9 +1,12 @@ +import { conferenceCountFields } from "../conferences/types" +import { billCountFields } from "./types" import { updateTestimonyCounts } from "./updateTestimonyCounts" describe("updateTestimonyCounts", () => { it("increments counts for a new publication", () => { expect( updateTestimonyCounts( + billCountFields, { testimonyCount: 0, endorseCount: 0, @@ -24,6 +27,7 @@ describe("updateTestimonyCounts", () => { it("swaps counts correctly when a testimony changes position", () => { expect( updateTestimonyCounts( + billCountFields, { testimonyCount: 3, endorseCount: 1, @@ -44,6 +48,7 @@ describe("updateTestimonyCounts", () => { it("decrements counts for a deletion without going below zero", () => { expect( updateTestimonyCounts( + billCountFields, { testimonyCount: 1, endorseCount: 1, @@ -60,4 +65,71 @@ describe("updateTestimonyCounts", () => { opposeCount: 0 }) }) + + it("ignores fields outside the given set", () => { + expect( + updateTestimonyCounts( + billCountFields, + { + testimonyCount: 2, + endorseCount: 2, + neutralCount: 0, + opposeCount: 0, + latestTestimonyId: "x" + } as any, + { position: "endorse" }, + { position: "oppose" } + ) + ).toEqual({ + testimonyCount: 2, + endorseCount: 1, + neutralCount: 0, + opposeCount: 1 + }) + }) + + it("moves conference counts between the four stances", () => { + expect( + updateTestimonyCounts( + conferenceCountFields, + { + testimonyCount: 1, + passNothingCount: 0, + passSomethingCount: 0, + passSenateVersionCount: 1, + passHouseVersionCount: 0 + }, + { position: "passSenateVersion" }, + { position: "passHouseVersion" } + ) + ).toEqual({ + testimonyCount: 1, + passNothingCount: 0, + passSomethingCount: 0, + passSenateVersionCount: 0, + passHouseVersionCount: 1 + }) + }) + + it("throws on a position without a field in the set", () => { + expect(() => + updateTestimonyCounts( + billCountFields, + { testimonyCount: 0, endorseCount: 0, neutralCount: 0, opposeCount: 0 }, + undefined, + { position: "passNothing" } + ) + ).toThrow("No count field for position passNothing") + }) + + it("throws on an unknown position", () => { + expect(() => + updateTestimonyCounts( + billCountFields, + { testimonyCount: 1, endorseCount: 0, neutralCount: 0, opposeCount: 0 }, + { position: "bogus" as any }, + undefined + ) + ).toThrow("No count field for position bogus") + }) }) diff --git a/functions/src/testimony/updateTestimonyCounts.ts b/functions/src/testimony/updateTestimonyCounts.ts index 2970c13c0..0ce4f5e9f 100644 --- a/functions/src/testimony/updateTestimonyCounts.ts +++ b/functions/src/testimony/updateTestimonyCounts.ts @@ -1,38 +1,30 @@ import { countsByPositions, Testimony } from "./types" -export type TestimonyCountFields = { - testimonyCount: number - endorseCount: number - neutralCount: number - opposeCount: number -} +type CountField = + | "testimonyCount" + | (typeof countsByPositions)[keyof typeof countsByPositions] -/** Updates testimony-count fields based on changes to the user's testimony. */ -export function updateTestimonyCounts( - { - testimonyCount, - endorseCount, - neutralCount, - opposeCount - }: TestimonyCountFields, +/** Updates the given testimony-count fields based on changes to the user's + * testimony. Missing counts start at zero. Throws on a position whose count + * field is not among `fields`: draft validation keeps those out, so one here + * is a broken invariant. */ +export function updateTestimonyCounts( + fields: readonly F[], + counts: Partial>, currentPublication?: Pick, newPublication?: Pick -): TestimonyCountFields { - const update: TestimonyCountFields = { - testimonyCount, - endorseCount, - neutralCount, - opposeCount - } - if (currentPublication) { - const oldPosition = countsByPositions[currentPublication.position] - update[oldPosition] = Math.max(update[oldPosition] - 1, 0) - update.testimonyCount = Math.max(update.testimonyCount - 1, 0) - } - if (newPublication) { - const newPosition = countsByPositions[newPublication.position] - update[newPosition] += 1 - update.testimonyCount += 1 +): Record { + const update = Object.fromEntries( + fields.map(field => [field, counts[field] ?? 0]) + ) as Record + const adjust = (position: Testimony["position"], delta: number) => { + const field = countsByPositions[position] + if (!(field in update)) + throw new Error(`No count field for position ${position} on this target`) + for (const f of [field, "testimonyCount"]) + update[f] = Math.max(update[f] + delta, 0) } - return update + if (currentPublication) adjust(currentPublication.position, -1) + if (newPublication) adjust(newPublication.position, 1) + return update as Record } diff --git a/pages/conferences/[court]/[id].tsx b/pages/conferences/[court]/[id].tsx index 2b4275eaf..18a561bb8 100644 --- a/pages/conferences/[court]/[id].tsx +++ b/pages/conferences/[court]/[id].tsx @@ -28,7 +28,13 @@ export const getServerSideProps: GetServerSideProps = async ctx => { const [page, translations] = await Promise.all([ loadConferencePage(query.data.court, query.data.id), - serverSideTranslations(locale, ["auth", "common", "footer", "conferences"]) + serverSideTranslations(locale, [ + "auth", + "common", + "footer", + "conferences", + "testimony" + ]) ]) if (!page) return { notFound: true } diff --git a/pages/newsfeed.tsx b/pages/newsfeed.tsx index 38f6bda7a..8574e8df2 100644 --- a/pages/newsfeed.tsx +++ b/pages/newsfeed.tsx @@ -19,6 +19,7 @@ export async function getStaticProps({ locale }: any) { ...(await serverSideTranslations(locale, [ "auth", "common", + "conferences", "editProfile", "footer", "profile" diff --git a/pages/testimony/[...testimonyDetail].tsx b/pages/testimony/[...testimonyDetail].tsx index 894e00e52..d1e66802f 100644 --- a/pages/testimony/[...testimonyDetail].tsx +++ b/pages/testimony/[...testimonyDetail].tsx @@ -1,5 +1,6 @@ import { Testimony } from "components/db" import { dbService } from "components/db/api" +import { getConference } from "components/db/conferences" import { wrapper } from "components/store" import { pageDataLoaded, @@ -127,7 +128,8 @@ const fetchDocs = async (q: NonNullable>) => { authorUid, billId, court, - ballotQuestionId + ballotQuestionId, + conferenceId: testimony.conferenceId ?? undefined }) } else if (q.authorUid) { ;({ authorUid, billId, court, ballotQuestionId } = q) @@ -149,11 +151,14 @@ const fetchDocs = async (q: NonNullable>) => { author = await db.getProfile({ uid: authorUid }), ballotQuestion = ballotQuestionId ? await db.getBallotQuestion({ id: ballotQuestionId }) + : null, + conference = testimony.conferenceId + ? await getConference(court, testimony.conferenceId) : null if (!bill) return - return { bill, author, archive, testimony, ballotQuestion } + return { bill, author, archive, testimony, ballotQuestion, conference } } export const getServerSideProps = wrapper.getServerSideProps( @@ -194,6 +199,7 @@ export const getServerSideProps = wrapper.getServerSideProps( testimony: docs.testimony, bill: docs.bill, ballotQuestion: docs.ballotQuestion ?? null, + conference: docs.conference ?? null, author: docs.author ?? null, archive: docs.archive, version: q.version diff --git a/public/locales/en/common.json b/public/locales/en/common.json index fc9786495..cabd121f0 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -156,12 +156,15 @@ "actionUpdate": "has an action update from the ", "and": " and ", "notFollowEither": "You do not follow either", + "conferenceInput": " gave input to the conference committee on ", + "conferenceInputPost": "Input Posted ", "endorsed": " endorsed ", "endorsementPost": "Endorsement Posted ", "follow": "You follow ", "notFollow": "You do not follow ", "or": " or ", - "viewFullTestimony": "View Full Testimony" + "viewFullTestimony": "View Full Testimony", + "viewConferenceInput": "View on the Conference Page" }, "notInCommittee": "Bill not currently in committee", "noResults": "No Results", diff --git a/public/locales/en/conferences.json b/public/locales/en/conferences.json index 762c09b0f..c3d06c9ed 100644 --- a/public/locales/en/conferences.json +++ b/public/locales/en/conferences.json @@ -78,5 +78,96 @@ "text_pdf": "Read the PDF", "tabs_label": "Sections of this conference committee", "tab_committee": "Committee", - "tab_text": "Bill Text" + "tab_input": "Public Input", + "tab_text": "Bill Text", + "input": { + "share": "Share your input", + "title": "Public Input", + "all_users": "All users", + "all_accounts": "All Accounts", + "filter_by_account_type": "Filter by account type", + "account_individual": "Individuals", + "account_organization": "Organizations", + "account_government": "Gov Officials", + "type_individual": "Individual", + "type_organization": "Organization", + "type_government": "Government office", + "all_positions": "All positions", + "filter_by_position": "Filter by position", + "position_filtered": "Position: {{position}}. Change", + "clear_position": "Clear the {{position}} filter", + "position": { + "passSomething": { + "long": "Please pass something", + "short": "Pass something" + }, + "passSenateVersion": { + "long": "Pass the Senate version", + "short": "Senate version" + }, + "passHouseVersion": { + "long": "Pass the House version", + "short": "House version" + }, + "passNothing": { + "long": "Don't pass anything", + "short": "Pass nothing" + } + }, + "show_more": "Show more", + "show_less": "Show less", + "empty_title": "No public input yet", + "empty_body": "No public input has been submitted yet.", + "no_match_title": "Nothing matches these filters", + "no_match_body": "Try widening your selection to see more input on this topic.", + "clear_filters": "Clear Filters", + "previous_page": "Previous page", + "next_page": "Next page", + "page_of": "{{page}} of {{count}}", + "panel_title": "Add Your Input", + "close_panel": "Close Add Your Input", + "position_label": "Your position", + "body_label": "Your input", + "prompt": "What do you want lawmakers and other voters to know about this question?", + "saving": "Saving…", + "saved": "Saved", + "delete_draft": "Delete Draft", + "cancel": "Cancel", + "review": "Review and Post", + "review_title": "Review and Post", + "preview_label": "Preview", + "preview_on": "on {{subject}}", + "rules_label": "Before You Post", + "rule_public": "Posting is public and stays attached to your account.", + "rule_revise": "You can revise it later; earlier versions stay on the record.", + "conduct": "All posts are governed by our <1>Code of Conduct.", + "back": "Back to editing", + "post": "Post", + "empty_hint": "Write something first, then post it.", + "error": "Your input could not be saved. Try again in a moment.", + "yours_title": "Your Input", + "yours_on_record": "Your input is on the record.", + "yours_private": "This section is only visible to you. You can also find your post below in the public record.", + "view": "View submission", + "edit": "Edit", + "follow_user": "Follow user", + "unfollow_user": "Unfollow user", + "report": "Report", + "report_title": "Report Input", + "delete_wrong": "Submitted to the wrong conference committee", + "rescind": "You may request that your input be deleted by completing this form. Since MAPLE is an archive, deletion requests will only be granted if the input was submitted to the wrong conference committee, if it contained sensitive personal information, or if the user is below 18 years old.", + "more_actions": "More actions for {{name}}", + "modal_title": "Input for Conference Committee on {{subject}}", + "actions": "Actions", + "follow_account": "Follow This Account", + "unfollow_account": "Unfollow This Account", + "close": "Close", + "filters": "Filters", + "following": "Following", + "follow_all": "Follow all", + "anonymous": "Anonymous", + "descriptor_organization": "Organization account, verified", + "descriptor_individual": "Individual account", + "descriptor_government": "Legislator account" + } } diff --git a/scripts/README.md b/scripts/README.md index 32d54274b..178215621 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -28,20 +28,19 @@ yarn firebase-admin console --env local ### Scripts -#### `backfillTestimonyBallotQuestionId` +#### `backfillTestimonyScope` -Stamps `ballotQuestionId: null` onto every `publishedTestimony` and `archivedTestimony` document that is missing the field. +Stamps `ballotQuestionId: null` and `conferenceId: null` (replacing `backfillTestimonyBallotQuestionId`, which skipped drafts) onto every `publishedTestimony`, `archivedTestimony` and `draftTestimony` document missing either field. -**Why it exists:** The composite Firestore index used by `resolvePublication()` (which queries by `billId + court + ballotQuestionId`) excludes documents where an indexed field is absent. Without this backfill, re-publishing legacy testimony creates a duplicate record instead of updating the existing one. +**Why it exists:** Bill testimony is read by querying both fields `== null`, and Firestore cannot select on a missing field. Without this backfill, legacy testimony drops out of its bill's listing, and re-publishing it creates a duplicate instead of updating it. -**Idempotent:** Documents that already have the field set (to any value) are skipped, so it is safe to re-run. +**Idempotent:** Fields already set (to any value) are left alone. `--dry-run` prints the counts and writes nothing. Each write to a published testimony runs its triggers once (notification events, search sync, embeddings); the version and text are unchanged, so none of them notify or re-embed. -**When to run:** Once against each environment after deploying the ballot-questions feature. Can be deleted from the codebase once confirmed executed everywhere. +**When to run:** Per environment, dev then prod: after the indexes are built and before the functions and hosting deploy, then once more right after it, to catch what the old code wrote in between. Can be deleted once run everywhere. ```sh -yarn firebase-admin run-script backfillTestimonyBallotQuestionId --env local -yarn firebase-admin run-script backfillTestimonyBallotQuestionId --env dev -yarn firebase-admin run-script backfillTestimonyBallotQuestionId --env prod +yarn firebase-admin run-script backfillTestimonyScope --env dev --dry-run +yarn firebase-admin run-script backfillTestimonyScope --env dev ``` --- @@ -346,4 +345,4 @@ yarn setRole --env [args] ## CI test -The `integration_tests` job in `.github/workflows/repo-checks.yml` runs `backfillTestimonyBallotQuestionId` end-to-end via `yarn firebase-admin run-script` against the Firebase emulator to verify the CLI runner is working. This covers the full path: ts-node compilation, `run-script` dispatch, dynamic script loading, and Firestore writes. +The `integration_tests` job in `.github/workflows/repo-checks.yml` runs `backfillTestimonyScope` end-to-end via `yarn firebase-admin run-script` against the Firebase emulator to verify the CLI runner is working. This covers the full path: ts-node compilation, `run-script` dispatch, dynamic script loading, and Firestore writes. diff --git a/scripts/firebase-admin/backfillTestimonyBallotQuestionId.ts b/scripts/firebase-admin/backfillTestimonyBallotQuestionId.ts deleted file mode 100644 index 7090bc0d8..000000000 --- a/scripts/firebase-admin/backfillTestimonyBallotQuestionId.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - Backfills ballotQuestionId: null onto all existing publishedTestimony and - archivedTestimony documents that do not have the field set. - - Required: Firestore composite indexes exclude documents where indexed fields - are absent. resolvePublication() queries by billId + court + ballotQuestionId, - so legacy docs without the field would be duplicated on re-publish rather than - updated in place. - - Safe to re-run: documents already having the field (any value) are skipped. - Can be removed after running against all environments. -*/ -import { Script } from "./types" - -export const script: Script = async ({ db }) => { - const writer = db.bulkWriter() - let updated = 0, - skipped = 0 - - const [publishedSnap, archivedSnap] = await Promise.all([ - db.collectionGroup("publishedTestimony").select("ballotQuestionId").get(), - db.collectionGroup("archivedTestimony").select("ballotQuestionId").get() - ]) - - for (const [collName, snap] of [ - ["publishedTestimony", publishedSnap], - ["archivedTestimony", archivedSnap] - ] as const) { - console.log(`${collName}: ${snap.size} documents`) - snap.forEach(doc => { - if (!("ballotQuestionId" in doc.data())) { - writer.update(doc.ref, { ballotQuestionId: null }) - updated++ - } else { - skipped++ - } - }) - } - - await writer.close() - console.log(`Done. Updated: ${updated}, Skipped (already set): ${skipped}`) -} diff --git a/scripts/firebase-admin/backfillTestimonyScope.ts b/scripts/firebase-admin/backfillTestimonyScope.ts new file mode 100644 index 000000000..405f8cf0d --- /dev/null +++ b/scripts/firebase-admin/backfillTestimonyScope.ts @@ -0,0 +1,60 @@ +/* + Backfills ballotQuestionId: null and conferenceId: null onto every + publishedTestimony, archivedTestimony and draftTestimony document missing + either field. + + Required: bill testimony is read by querying both fields == null, and + Firestore cannot select on a missing field, so legacy docs would drop out of + the bill's listing, and re-publishing one would duplicate it rather than + update it in place. Replaces backfillTestimonyBallotQuestionId, which + skipped drafts. + + Safe to re-run: fields already set (to any value) are left alone. Pass + --dry-run to count without writing. Can be removed after running against all + environments. +*/ +import { Boolean, Optional, Record } from "runtypes" +import { Script } from "./types" + +const Args = Record({ dryRun: Optional(Boolean) }) + +const fields = ["ballotQuestionId", "conferenceId"] as const + +export const script: Script = async ({ db, args }) => { + const { dryRun = false } = Args.check(args) + const writer = db.bulkWriter() + const would = dryRun ? "would update" : "updated" + + const collNames = [ + "publishedTestimony", + "archivedTestimony", + "draftTestimony" + ] + const snaps = await Promise.all( + collNames.map(c => + db + .collectionGroup(c) + .select(...fields) + .get() + ) + ) + + collNames.forEach((collName, i) => { + let updated = 0 + snaps[i].forEach(doc => { + const data = doc.data() + const missing = fields.filter(f => !(f in data)) + if (!missing.length) return + if (!dryRun) + writer.update(doc.ref, Object.fromEntries(missing.map(f => [f, null]))) + updated++ + }) + console.log( + `${collName}: ${ + snaps[i].size + } documents, ${would} ${updated}, already set ${snaps[i].size - updated}` + ) + }) + + await writer.close() +} diff --git a/scripts/firebase-admin/seedLocal.ts b/scripts/firebase-admin/seedLocal.ts new file mode 100644 index 000000000..714c0e775 --- /dev/null +++ b/scripts/firebase-admin/seedLocal.ts @@ -0,0 +1,142 @@ +import * as fs from "fs" +import * as yaml from "js-yaml" +import * as path from "path" +import { Timestamp } from "../../functions/src/firebase" +import { createMemberSearchIndex } from "../../functions/src/members/createMemberSearchIndex" +import { script as syncConferences } from "./syncConferences" +import { Script } from "./types" + +/** + * Brings a fresh local emulator up with the conference data: the real bills and + * members every conference file names (fetched by the functions emulator's + * scrapers, as a scrape would), the member search index built from them, the + * conferences themselves, and some sample testimony on one conference. The + * verified test@example.com comes from the emulator's auth export. Local only. + * + * yarn emulators:start # in another terminal, from a clean start + * yarn firebase-admin run-script seedLocal --env local + */ +const COURT = 194 +const SAMPLE = "student-learning-and-mental-health" +const WAIT_MS = 180_000 + +const sample: [string, string, string, string][] = [ + [ + "Sam Okafor", + "user", + "passSenateVersion", + "The Senate version gives districts a year longer. That matters for small districts like mine." + ], + [ + "Rep. Ellis Park", + "legislator", + "passHouseVersion", + "As a member of the House I ask the committee to hold to the House text on enforcement dates." + ], + [ + "Dana Whitlock", + "user", + "passNothing", + "Neither bill does what its sponsors say. I would rather the conference let both lapse." + ], + [ + "Example Statewide Coalition", + "organization", + "passSomething", + "An organization account files under its own name. This one asks the conference to reach agreement without saying which chamber's text should win." + ], + [ + "Jordan Marsh", + "user", + "passHouseVersion", + "An individual writes a few sentences about why the outcome matters to them, and it appears here with their name and the date." + ], + [ + "Fight for the Future", + "organization", + "passSenateVersion", + "Our coalition continues to oppose any legislation that requires online age checks.\n\nHowever, the Senate bill, as amended, is the better of the two texts." + ], + [ + "Massachusetts Medical Society", + "organization", + "passSomething", + "Dear Members of the Conference Committee:\n\nOn behalf of the over 22,000 members of the Massachusetts Medical Society, I write to convey our support of legislation to establish reasonable limits on student smartphone use during the school day. We urge you to report a bill that includes clear exceptions for medical need." + ] +] + +export const script: Script = async ctx => { + const { env, db } = ctx + if (env !== "local") throw Error("only local supported") + + const dir = path.resolve(process.cwd(), "conferences", `${COURT}`) + const files = fs.readdirSync(dir).filter(f => f.endsWith(".yaml")) + const bills = new Set() + const members = new Set() + for (const f of files) { + const c: any = yaml.load(fs.readFileSync(path.join(dir, f), "utf8")) + bills.add(c.senateBillId).add(c.houseBillId) + c.conferees.forEach((m: any) => members.add(m.memberCode)) + } + + // The scrapers run in the functions emulator and write the real documents. + for (const [name, ids] of [ + ["bills", [...bills]], + ["members", [...members]] + ] as const) + await db.collection(`/scrapers/${name}/batches`).add({ court: COURT, ids }) + console.log(`queued ${bills.size} bills and ${members.size} members`) + + const want = [ + ...[...bills].map(id => `generalCourts/${COURT}/bills/${id}`), + ...[...members].map(id => `generalCourts/${COURT}/members/${id}`) + ] + const started = Date.now() + for (;;) { + const snaps = await db.getAll(...want.map(p => db.doc(p)), { + fieldMask: [] + }) + const missing = snaps.filter(s => !s.exists).length + if (!missing) break + if (Date.now() - started > WAIT_MS) + throw Error(`${missing} bills or members still missing after waiting`) + process.stdout.write(`\rwaiting on the scrapers: ${missing} left `) + await new Promise(r => setTimeout(r, 3000)) + } + console.log("\nbills and members are in") + + // Scheduled jobs never fire in the emulator, and triggerPubsubFunction does + // not reach them, so run the index job's handler here. + await createMemberSearchIndex.run({ scheduleTime: new Date().toISOString() }) + + await syncConferences(ctx) + + const conference = `generalCourts/${COURT}/conferences/${SAMPLE}` + const { canonicalBillId, title } = (await db.doc(conference).get()).data()! + const day = 86_400_000 + let at = Date.now() - sample.length * day + for (const [i, [name, role, position, content]] of sample.entries()) { + const uid = `seed-conference-${i}` + const id = `seed-conference-testimony-${i}` + at += day + await db.doc(`users/${uid}/publishedTestimony/${id}`).set({ + id, + authorUid: uid, + authorDisplayName: name, + fullName: name, + authorRole: role, + billId: canonicalBillId, + court: COURT, + billTitle: title, + position, + content, + version: 1, + public: true, + conferenceId: SAMPLE, + ballotQuestionId: null, + publishedAt: Timestamp.fromMillis(at), + updatedAt: Timestamp.fromMillis(at) + }) + } + console.log(`seeded ${sample.length} sample testimonies on ${SAMPLE}`) +} diff --git a/scripts/firebase-admin/sendTestEmail.ts b/scripts/firebase-admin/sendTestEmail.ts index ce9f3cde8..1c6dfc1c7 100644 --- a/scripts/firebase-admin/sendTestEmail.ts +++ b/scripts/firebase-admin/sendTestEmail.ts @@ -126,6 +126,12 @@ const billResults: BillResult[] = [ court: "194", position: "neutral" }, + { + billId: "S2581", + court: "194", + conferenceId: "student-learning-and-mental-health", + stance: "Pass the Senate version" + }, { billId: "H533", court: "194", diff --git a/stories/emailTemplates/NotificationDigestEmail.stories.tsx b/stories/emailTemplates/NotificationDigestEmail.stories.tsx index 1a8321b6b..dc3d1d5c5 100644 --- a/stories/emailTemplates/NotificationDigestEmail.stories.tsx +++ b/stories/emailTemplates/NotificationDigestEmail.stories.tsx @@ -87,6 +87,12 @@ const billResults: BillResult[] = [ court: "194", position: "neutral" }, + { + billId: "S2581", + court: "194", + conferenceId: "student-learning-and-mental-health", + stance: "Pass the Senate version" + }, { billId: "H533", court: "194", diff --git a/tests/integration/backfillTestimonyBallotQuestionId.test.ts b/tests/integration/backfillTestimonyBallotQuestionId.test.ts deleted file mode 100644 index 4feb74e41..000000000 --- a/tests/integration/backfillTestimonyBallotQuestionId.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { testDb, terminateFirebase } from "../testUtils" -import { script } from "../../scripts/firebase-admin/backfillTestimonyBallotQuestionId" - -afterAll(async () => { - await Promise.all([ - testDb - .doc("users/backfill-test-user/publishedTestimony/legacy-pub") - .delete(), - testDb - .doc("users/backfill-test-user/archivedTestimony/legacy-arch") - .delete(), - testDb - .doc("users/backfill-test-user2/publishedTestimony/has-null") - .delete(), - testDb - .doc("users/backfill-test-user2/publishedTestimony/has-value") - .delete() - ]) - await terminateFirebase() -}) - -it("sets ballotQuestionId: null on docs that are missing the field", async () => { - const pub = testDb - .collection("users") - .doc("backfill-test-user") - .collection("publishedTestimony") - .doc("legacy-pub") - const arch = testDb - .collection("users") - .doc("backfill-test-user") - .collection("archivedTestimony") - .doc("legacy-arch") - - await pub.set({ billId: "H1234", court: 193, content: "test" }) - await arch.set({ billId: "H1234", court: 193, version: 1 }) - - await script({ db: testDb, args: { env: "local", argv: [] } } as any) - - expect((await pub.get()).data()?.ballotQuestionId).toBeNull() - expect((await arch.get()).data()?.ballotQuestionId).toBeNull() -}) - -it("skips docs that already have ballotQuestionId set", async () => { - const pub1 = testDb - .collection("users") - .doc("backfill-test-user2") - .collection("publishedTestimony") - .doc("has-null") - const pub2 = testDb - .collection("users") - .doc("backfill-test-user2") - .collection("publishedTestimony") - .doc("has-value") - - await pub1.set({ ballotQuestionId: null, billId: "H1234", court: 193 }) - await pub2.set({ ballotQuestionId: "bq-123", billId: "H5678", court: 193 }) - - await script({ db: testDb, args: { env: "local", argv: [] } } as any) - - expect((await pub1.get()).data()?.ballotQuestionId).toBeNull() - expect((await pub2.get()).data()?.ballotQuestionId).toBe("bq-123") -}) diff --git a/tests/integration/backfillTestimonyCounts.test.ts b/tests/integration/backfillTestimonyCounts.test.ts index fb5ac68ef..e58da4d49 100644 --- a/tests/integration/backfillTestimonyCounts.test.ts +++ b/tests/integration/backfillTestimonyCounts.test.ts @@ -15,7 +15,7 @@ beforeEach(async () => { afterAll(terminateFirebase) -describe.skip("backfillTestimonyCounts", () => { +describe("backfillTestimonyCounts", () => { it("Calculates testimony counts", async () => { await setPublication("user-1", "t-1", { position: "endorse", @@ -37,6 +37,13 @@ describe.skip("backfillTestimonyCounts", () => { billId, court: currentGeneralCourt }) + // Conference testimony shares the bill id but not its counts. + await setPublication("user-5", "t-5", { + position: "passSenateVersion", + billId, + court: currentGeneralCourt, + conferenceId: "some-conference" + }) await testDb .doc(`/generalCourts/${currentGeneralCourt}/bills/${billId}`) @@ -76,7 +83,9 @@ describe.skip("backfillTestimonyCounts", () => { async function setPublication( uid: string, id: string, - testimony: PartialTestimony + testimony: PartialTestimony & { conferenceId?: string } ): Promise { - await testDb.doc(`/users/${uid}/publishedTestimony/${id}`).set(testimony) + await testDb + .doc(`/users/${uid}/publishedTestimony/${id}`) + .set({ ballotQuestionId: null, conferenceId: null, ...testimony }) } diff --git a/tests/integration/backfillTestimonyScope.test.ts b/tests/integration/backfillTestimonyScope.test.ts new file mode 100644 index 000000000..072504bcc --- /dev/null +++ b/tests/integration/backfillTestimonyScope.test.ts @@ -0,0 +1,60 @@ +import { testDb, terminateFirebase } from "../testUtils" +import { script } from "../../scripts/firebase-admin/backfillTestimonyScope" + +const user = testDb.collection("users").doc("backfill-scope-test-user") +const docs = { + pub: user.collection("publishedTestimony").doc("legacy-pub"), + arch: user.collection("archivedTestimony").doc("legacy-arch"), + draft: user.collection("draftTestimony").doc("legacy-draft"), + ballot: user.collection("publishedTestimony").doc("ballot-pub"), + conference: user.collection("draftTestimony").doc("conference-draft") +} + +const run = (args = {}) => + script({ db: testDb, args: { env: "local", argv: [], ...args } } as any) + +beforeEach(async () => { + await Promise.all([ + docs.pub.set({ billId: "H1234", court: 193 }), + docs.arch.set({ billId: "H1234", court: 193, version: 1 }), + docs.draft.set({ billId: "H1234", court: 193 }), + docs.ballot.set({ billId: "H1234", court: 193, ballotQuestionId: "bq-1" }), + docs.conference.set({ billId: "H1234", court: 193, conferenceId: "c-1" }) + ]) +}) + +afterAll(async () => { + await Promise.all(Object.values(docs).map(d => d.delete())) + await terminateFirebase() +}) + +it("sets both scope fields to null on docs missing them", async () => { + await run() + + for (const d of [docs.pub, docs.arch, docs.draft]) { + const data = (await d.get()).data() + expect(data).toHaveProperty("ballotQuestionId", null) + expect(data).toHaveProperty("conferenceId", null) + } +}) + +it("leaves fields that are already set", async () => { + await run() + + const ballot = (await docs.ballot.get()).data() + expect(ballot).toHaveProperty("ballotQuestionId", "bq-1") + expect(ballot).toHaveProperty("conferenceId", null) + const conference = (await docs.conference.get()).data() + expect(conference).toHaveProperty("ballotQuestionId", null) + expect(conference).toHaveProperty("conferenceId", "c-1") +}) + +it("writes nothing on a dry run", async () => { + await run({ dryRun: true }) + + for (const d of [docs.pub, docs.arch, docs.draft]) { + const data = (await d.get()).data() + expect(data).not.toHaveProperty("ballotQuestionId") + expect(data).not.toHaveProperty("conferenceId") + } +}) diff --git a/tests/integration/common.ts b/tests/integration/common.ts index 6ea279184..0a9c59489 100644 --- a/tests/integration/common.ts +++ b/tests/integration/common.ts @@ -181,7 +181,9 @@ export const createNewTestimony = async (uid: string, billId: string) => { court: 192, position: "oppose", content: "testimony content", - public: true + public: true, + ballotQuestionId: null, + conferenceId: null } try { diff --git a/tests/integration/exportedTestData/auth_export/accounts.json b/tests/integration/exportedTestData/auth_export/accounts.json index f932a8f3b..915e4f481 100644 --- a/tests/integration/exportedTestData/auth_export/accounts.json +++ b/tests/integration/exportedTestData/auth_export/accounts.json @@ -1 +1 @@ -{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"05lcy6YQrPvPRvVPlZFbCA0O81TF","lastLoginAt":"1682192783216","emailVerified":true,"email":"testadmin@example.com","salt":"fakeSaltQ0CeYd5UyEmOq5MWLcnP","passwordHash":"fakeHash:salt=fakeSaltQ0CeYd5UyEmOq5MWLcnP:password=password","passwordUpdatedAt":1682192769498,"validSince":"1682192769","createdAt":"1682192575545","providerUserInfo":[{"providerId":"password","email":"testadmin@example.com","federatedId":"testadmin@example.com","rawId":"testadmin@example.com","displayName":"","photoUrl":""}],"lastRefreshAt":"2023-04-22T19:46:23.216Z","customAttributes":"{\"role\":\"admin\"}","displayName":"","photoUrl":""},{"localId":"PbB7tQy18F9bQXfI3AonVmFfQU1B","createdAt":"1647962671353","lastLoginAt":"1649530941158","displayName":"test1","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltuknPVwAkolkrDbQLJkUa:password=password","salt":"fakeSaltuknPVwAkolkrDbQLJkUa","passwordUpdatedAt":1682192348137,"providerUserInfo":[{"providerId":"password","email":"test@example.com","federatedId":"test@example.com","rawId":"test@example.com","displayName":"test1","photoUrl":""}],"validSince":"1682192348","email":"test@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"},{"localId":"VBN6dg70PGsupNvayJ2TPHPgydHz","createdAt":"1647962690530","lastLoginAt":"1647962870753","displayName":"test2","photoUrl":"","passwordHash":"fakeHash:salt=fakeSalt2x7l7erotVwSgZtSrvE4:password=password","salt":"fakeSalt2x7l7erotVwSgZtSrvE4","passwordUpdatedAt":1682192348137,"providerUserInfo":[{"providerId":"password","email":"test3@example.com","federatedId":"test3@example.com","rawId":"test3@example.com","displayName":"test2","photoUrl":""}],"validSince":"1682192348","email":"test3@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"},{"localId":"d6ZFKTVH8i4hglyv42wz8QDaKKxw","createdAt":"1647962684429","lastLoginAt":"1647962684429","displayName":"test3","photoUrl":"","passwordHash":"fakeHash:salt=fakeSalt9WTUIwt7hSmUtHEWukrL:password=password","salt":"fakeSalt9WTUIwt7hSmUtHEWukrL","passwordUpdatedAt":1682192348138,"providerUserInfo":[{"providerId":"password","email":"test2@example.com","federatedId":"test2@example.com","rawId":"test2@example.com","displayName":"test3","photoUrl":""}],"validSince":"1682192348","email":"test2@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"},{"localId":"jLPye8OfwlzAEGO6h10hs5mmVIDE","createdAt":"1647962700251","lastLoginAt":"1647962870743","displayName":"test4","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltExpNl9RTLBTTOfrDMtdM:password=password","salt":"fakeSaltExpNl9RTLBTTOfrDMtdM","passwordUpdatedAt":1682192348138,"providerUserInfo":[{"providerId":"password","email":"test4@example.com","federatedId":"test4@example.com","rawId":"test4@example.com","displayName":"test4","photoUrl":""}],"validSince":"1682192348","email":"test4@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"}]} \ No newline at end of file +{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"05lcy6YQrPvPRvVPlZFbCA0O81TF","lastLoginAt":"1682192783216","emailVerified":true,"email":"testadmin@example.com","salt":"fakeSaltQ0CeYd5UyEmOq5MWLcnP","passwordHash":"fakeHash:salt=fakeSaltQ0CeYd5UyEmOq5MWLcnP:password=password","passwordUpdatedAt":1682192769498,"validSince":"1682192769","createdAt":"1682192575545","providerUserInfo":[{"providerId":"password","email":"testadmin@example.com","federatedId":"testadmin@example.com","rawId":"testadmin@example.com","displayName":"","photoUrl":""}],"lastRefreshAt":"2023-04-22T19:46:23.216Z","customAttributes":"{\"role\":\"admin\"}","displayName":"","photoUrl":""},{"localId":"PbB7tQy18F9bQXfI3AonVmFfQU1B","createdAt":"1647962671353","lastLoginAt":"1649530941158","displayName":"test1","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltuknPVwAkolkrDbQLJkUa:password=password","salt":"fakeSaltuknPVwAkolkrDbQLJkUa","passwordUpdatedAt":1682192348137,"providerUserInfo":[{"providerId":"password","email":"test@example.com","federatedId":"test@example.com","rawId":"test@example.com","displayName":"test1","photoUrl":""}],"validSince":"1682192348","email":"test@example.com","emailVerified":true,"disabled":false,"customAttributes":"{\"role\":\"user\"}"},{"localId":"VBN6dg70PGsupNvayJ2TPHPgydHz","createdAt":"1647962690530","lastLoginAt":"1647962870753","displayName":"test2","photoUrl":"","passwordHash":"fakeHash:salt=fakeSalt2x7l7erotVwSgZtSrvE4:password=password","salt":"fakeSalt2x7l7erotVwSgZtSrvE4","passwordUpdatedAt":1682192348137,"providerUserInfo":[{"providerId":"password","email":"test3@example.com","federatedId":"test3@example.com","rawId":"test3@example.com","displayName":"test2","photoUrl":""}],"validSince":"1682192348","email":"test3@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"},{"localId":"d6ZFKTVH8i4hglyv42wz8QDaKKxw","createdAt":"1647962684429","lastLoginAt":"1647962684429","displayName":"test3","photoUrl":"","passwordHash":"fakeHash:salt=fakeSalt9WTUIwt7hSmUtHEWukrL:password=password","salt":"fakeSalt9WTUIwt7hSmUtHEWukrL","passwordUpdatedAt":1682192348138,"providerUserInfo":[{"providerId":"password","email":"test2@example.com","federatedId":"test2@example.com","rawId":"test2@example.com","displayName":"test3","photoUrl":""}],"validSince":"1682192348","email":"test2@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"},{"localId":"jLPye8OfwlzAEGO6h10hs5mmVIDE","createdAt":"1647962700251","lastLoginAt":"1647962870743","displayName":"test4","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltExpNl9RTLBTTOfrDMtdM:password=password","salt":"fakeSaltExpNl9RTLBTTOfrDMtdM","passwordUpdatedAt":1682192348138,"providerUserInfo":[{"providerId":"password","email":"test4@example.com","federatedId":"test4@example.com","rawId":"test4@example.com","displayName":"test4","photoUrl":""}],"validSince":"1682192348","email":"test4@example.com","emailVerified":false,"disabled":false,"customAttributes":"{\"role\":\"user\"}"}]} \ No newline at end of file diff --git a/tests/integration/exportedTestData/firebase-export-metadata.json b/tests/integration/exportedTestData/firebase-export-metadata.json index 913a9ae45..5955c99c1 100644 --- a/tests/integration/exportedTestData/firebase-export-metadata.json +++ b/tests/integration/exportedTestData/firebase-export-metadata.json @@ -1,7 +1,7 @@ { "version": "11.16.0", "firestore": { - "version": "1.15.1", + "version": "1.22.0", "path": "firestore_export", "metadata_file": "firestore_export/firestore_export.overall_export_metadata" }, diff --git a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata index 7c1c7a624..eac59636d 100644 Binary files a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata and b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ diff --git a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-0 b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-0 index 73ae8ca18..1eb8980f8 100644 Binary files a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-0 and b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-0 differ diff --git a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-1 b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-1 index a9cf44192..d4427f614 100644 Binary files a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-1 and b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-1 differ diff --git a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-2 b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-2 index a7c4d72c7..46c6893da 100644 Binary files a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-2 and b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-2 differ diff --git a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-3 b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-3 index 9d7dd6842..ed975374b 100644 Binary files a/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-3 and b/tests/integration/exportedTestData/firestore_export/all_namespaces/all_kinds/output-3 differ diff --git a/tests/integration/exportedTestData/firestore_export/firestore_export.overall_export_metadata b/tests/integration/exportedTestData/firestore_export/firestore_export.overall_export_metadata index 9d09faa0b..a23ed5390 100644 Binary files a/tests/integration/exportedTestData/firestore_export/firestore_export.overall_export_metadata and b/tests/integration/exportedTestData/firestore_export/firestore_export.overall_export_metadata differ diff --git a/tests/integration/testimony.test.ts b/tests/integration/testimony.test.ts index dd6f6393b..0e39e836f 100644 --- a/tests/integration/testimony.test.ts +++ b/tests/integration/testimony.test.ts @@ -1,4 +1,5 @@ import { currentGeneralCourt } from "functions/src/shared" +import { FieldValue } from "firebase-admin/firestore" import { signOut, User } from "firebase/auth" import { doc, getDoc, setDoc, Timestamp, updateDoc } from "firebase/firestore" import { httpsCallable } from "firebase/functions" @@ -6,6 +7,7 @@ import { ref, uploadBytes } from "firebase/storage" import { nanoid } from "nanoid" import { auth, firestore, functions, storage } from "../../components/firebase" import { terminateFirebase, testDb, testStorage } from "../testUtils" +import { script as backfillTestimonyScope } from "../../scripts/firebase-admin/backfillTestimonyScope" import { createFakeBill, expectPermissionDenied, @@ -20,11 +22,19 @@ import { type BaseTestimony = { billId: string court: number - position: "endorse" | "oppose" | "neutral" + position: + | "endorse" + | "oppose" + | "neutral" + | "passNothing" + | "passSomething" + | "passSenateVersion" + | "passHouseVersion" content: string attachmentId: string | null | undefined editReason?: string ballotQuestionId?: string | null + conferenceId?: string | null } type DraftTestimony = BaseTestimony & { @@ -446,6 +456,17 @@ describe("publishTestimony", () => { expect(pubB.ballotQuestionId ?? null).toBeNull() }) + it("Refuses testimony on a ballot question and a conference", async () => { + const { draftId } = await createDraft(uid, billId, undefined, "bq-test-1") + await updateDoc(refs.draftTestimony(uid, draftId), { + conferenceId: "data-privacy", + position: "passNothing" + }) + await expect(publishTestimony({ draftId })).rejects.toThrow( + "Testimony targets a ballot question or a conference, not both" + ) + }) + it("Fails to publish testimony with a non-existent ballotQuestionId", async () => { const { draftId: bqDraftId } = await createDraft( uid, @@ -479,6 +500,236 @@ describe("publishTestimony", () => { expect(publication.ballotQuestionId).toBe("bq-test-1") }) }) + + describe("conferenceId", () => { + let conferenceId: string + beforeEach(async () => { + conferenceId = await createConference(billId) + }) + + it("Counts conference testimony on the conference, not the bill", async () => { + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + conferenceId, + "passSenateVersion" + ) + const res = await publishTestimony({ draftId: conferenceDraftId }) + + const publication = await getPublication(uid, res.data.publicationId) + expect(publication.conferenceId).toBe(conferenceId) + expect(publication.position).toBe("passSenateVersion") + + const conference = await getConference(conferenceId) + expect(conference.testimonyCount).toBe(1) + expect(conference.passSenateVersionCount).toBe(1) + + const bill = await getBill(billId) + expect(bill.testimonyCount).toBe(0) + expect(bill.latestTestimonyId).toBeUndefined() + }) + + it("Keeps bill and conference testimony on the same bill apart", async () => { + const resA = await publishTestimony({ draftId }) + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + conferenceId, + "passNothing" + ) + const resB = await publishTestimony({ draftId: conferenceDraftId }) + // Republishing the bill testimony finds its own publication again. + await updateDoc(refs.draftTestimony(uid, draftId), { + content: "updated content", + editReason: "edit reason" + }) + const resC = await publishTestimony({ draftId }) + + expect(resA.data.publicationId).not.toBe(resB.data.publicationId) + expect(resC.data.publicationId).toBe(resA.data.publicationId) + expect((await getPublication(uid, resC.data.publicationId)).version).toBe( + 2 + ) + expect((await getPublication(uid, resB.data.publicationId)).version).toBe( + 1 + ) + + const bill = await getBill(billId) + expect(bill.testimonyCount).toBe(1) + expect(bill.endorseCount).toBe(1) + expect(bill.latestTestimonyId).toBe(resA.data.publicationId) + }) + + it("Republishes backfilled legacy bill testimony in place", async () => { + const resA = await publishTestimony({ draftId }) + // Strip the field from everything the bill testimony wrote, as it was + // before conference testimony existed, then backfill it. + const legacy = [ + testDb.doc(paths.draftTestimony(uid, draftId)), + testDb.doc( + `/users/${uid}/publishedTestimony/${resA.data.publicationId}` + ), + ...( + await testDb + .collection(`/users/${uid}/archivedTestimony`) + .where("billId", "==", billId) + .get() + ).docs.map(d => d.ref) + ] + for (const ref of legacy) + await ref.update({ conferenceId: FieldValue.delete() }) + await backfillTestimonyScope({ + db: testDb, + args: { env: "local", argv: [] } + } as any) + + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + conferenceId, + "passNothing" + ) + await publishTestimony({ draftId: conferenceDraftId }) + await updateDoc(refs.draftTestimony(uid, draftId), { + content: "updated content", + editReason: "edit reason" + }) + const resB = await publishTestimony({ draftId }) + + expect(resB.data.publicationId).toBe(resA.data.publicationId) + const publication = await getPublication(uid, resB.data.publicationId) + expect(publication.version).toBe(2) + expect(publication.conferenceId).toBeNull() + }) + + it("Moves the conference counts when the stance changes", async () => { + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + conferenceId, + "passSenateVersion" + ) + const res1 = await publishTestimony({ draftId: conferenceDraftId }) + await updateDoc(refs.draftTestimony(uid, conferenceDraftId), { + position: "passHouseVersion", + editReason: "changed my mind" + }) + const res2 = await publishTestimony({ draftId: conferenceDraftId }) + + expect(res2.data.publicationId).toBe(res1.data.publicationId) + const conference = await getConference(conferenceId) + expect(conference.testimonyCount).toBe(1) + expect(conference.passSenateVersionCount).toBe(0) + expect(conference.passHouseVersionCount).toBe(1) + }) + + it("Fails the delete of a publication whose position does not fit its conference", async () => { + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + conferenceId, + "passSenateVersion" + ) + const res = await publishTestimony({ draftId: conferenceDraftId }) + const publicationPath = `/users/${uid}/publishedTestimony/${res.data.publicationId}` + await testDb.doc(publicationPath).update({ position: "endorse" }) + + await signInTestAdmin() + await expect( + deleteTestimony({ uid, publicationId: res.data.publicationId }) + ).rejects.toThrow() + await signOut(auth) + + expect((await testDb.doc(publicationPath).get()).exists).toBe(true) + const conference = await getConference(conferenceId) + expect(conference.testimonyCount).toBe(1) + expect(conference.passSenateVersionCount).toBe(1) + }) + + it("Refuses a bill position on conference testimony", async () => { + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + conferenceId, + "endorse" + ) + await expect( + publishTestimony({ draftId: conferenceDraftId }) + ).rejects.toThrow("Conference testimony takes a conference position") + }) + + it("Refuses a conference position on bill testimony", async () => { + await updateDoc(refs.draftTestimony(uid, draftId), { + position: "passNothing" + }) + await expect(publishTestimony({ draftId })).rejects.toThrow( + "Bill testimony takes a bill position" + ) + }) + + it("Refuses a conference that does not exist", async () => { + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + "nonexistent-conference", + "passNothing" + ) + await expect( + publishTestimony({ draftId: conferenceDraftId }) + ).rejects.toThrow("invalid conferenceId") + }) + + it.each(["reportFiled", "enacted", "dissolved"])( + "Refuses testimony on a conference that is %s", + async status => { + const closedId = await createConference(billId, status) + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + closedId, + "passNothing" + ) + await expect( + publishTestimony({ draftId: conferenceDraftId }) + ).rejects.toThrow("not accepting testimony") + expect((await getConference(closedId)).testimonyCount).toBe(0) + } + ) + + it("Refuses to republish once the conference has closed", async () => { + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + conferenceId, + "passNothing" + ) + await publishTestimony({ draftId: conferenceDraftId }) + await testDb + .doc( + `/generalCourts/${currentGeneralCourt}/conferences/${conferenceId}` + ) + .update({ status: "reportFiled" }) + await updateDoc(refs.draftTestimony(uid, conferenceDraftId), { + content: "updated content" + }) + await expect( + publishTestimony({ draftId: conferenceDraftId }) + ).rejects.toThrow("not accepting testimony") + }) + + it("Refuses testimony on a bill that is not the conference's", async () => { + const otherBillId = await createFakeBill() + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + otherBillId, + conferenceId, + "passNothing" + ) + await expect( + publishTestimony({ draftId: conferenceDraftId }) + ).rejects.toThrow(`takes testimony on ${billId}`) + }) + }) }) describe("deleteTestimony", () => { @@ -547,6 +798,90 @@ describe("deleteTestimony", () => { expect(archived.size).toBe(2) }) + it("Takes conference testimony off the conference's counts", async () => { + const conferenceId = await createConference(billId) + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + conferenceId, + "passSomething" + ) + const res = await publishTestimony({ draftId: conferenceDraftId }) + + await getSignedInAdmin() + await deleteTestimony({ uid, publicationId: res.data.publicationId }) + + const conference = await getConference(conferenceId) + expect(conference.testimonyCount).toBe(0) + expect(conference.passSomethingCount).toBe(0) + const draft = await testDb + .doc(paths.draftTestimony(uid, conferenceDraftId)) + .get() + expect(draft.data()!.publishedVersion).toBeUndefined() + }) + + it("Lets conference testimony be deleted after the conference closes", async () => { + const conferenceId = await createConference(billId) + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + conferenceId, + "passSomething" + ) + const res = await publishTestimony({ draftId: conferenceDraftId }) + await testDb + .doc(`/generalCourts/${currentGeneralCourt}/conferences/${conferenceId}`) + .update({ status: "dissolved" }) + + await getSignedInAdmin() + await deleteTestimony({ uid, publicationId: res.data.publicationId }) + + expect((await getConference(conferenceId)).testimonyCount).toBe(0) + }) + + it("Never makes conference testimony the bill's latest", async () => { + const conferenceId = await createConference(billId) + const resBill = await publishTestimony({ draftId }) + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + conferenceId, + "passSomething" + ) + await publishTestimony({ draftId: conferenceDraftId }) + + await getSignedInAdmin() + await deleteTestimony({ uid, publicationId: resBill.data.publicationId }) + + const bill = await getBill(billId) + expect(bill.testimonyCount).toBe(0) + expect(bill.latestTestimonyId).toBeUndefined() + expect((await getConference(conferenceId)).testimonyCount).toBe(1) + }) + + it("Skips newer conference testimony for the bill's latest", async () => { + const resA = await publishTestimony({ draftId }) + const { draftId: conferenceDraftId } = await createConferenceDraft( + uid, + billId, + await createConference(billId), + "passSomething" + ) + await publishTestimony({ draftId: conferenceDraftId }) + const { uid: uid2 } = await getSignedInAdmin() + const { draftId: draftId2 } = await createDraft(uid2, billId) + const resB = await publishTestimony({ draftId: draftId2 }) + expect((await getBill(billId)).latestTestimonyId).toBe( + resB.data.publicationId + ) + + await deleteTestimony({ uid: uid2, publicationId: resB.data.publicationId }) + + const bill = await getBill(billId) + expect(bill.testimonyCount).toBe(1) + expect(bill.latestTestimonyId).toBe(resA.data.publicationId) + }) + describe("attachments", () => { it("deletes attachments", async () => { let attachmentId = nanoid() @@ -615,6 +950,54 @@ async function createDraftAttachment(uid: string, id: string, content: string) { }) } +/** A conference whose canonical bill is `billId`, with its counts zeroed. It + * takes testimony only while `status` is appointed. */ +async function createConference(billId: string, status = "appointed") { + const id = `conference-${nanoid()}`.toLowerCase() + await testDb + .doc(`/generalCourts/${currentGeneralCourt}/conferences/${id}`) + .set({ + id, + court: currentGeneralCourt, + title: "Test conference", + senateBillId: billId, + houseBillId: "H1", + canonicalBillId: billId, + conferees: [], + status, + hearingIds: [], + differences: [], + similarities: [], + testimonyCount: 0, + passNothingCount: 0, + passSomethingCount: 0, + passSenateVersionCount: 0, + passHouseVersionCount: 0 + }) + return id +} + +async function getConference(id: string) { + const doc = await testDb + .doc(`/generalCourts/${currentGeneralCourt}/conferences/${id}`) + .get() + return doc.data() as any +} + +async function createConferenceDraft( + uid: string, + billId: string, + conferenceId: string, + position: BaseTestimony["position"] +) { + const { draftId } = await createDraft(uid, billId) + await updateDoc(refs.draftTestimony(uid, draftId), { + conferenceId, + position + }) + return { draftId } +} + async function createDraft( uid: string, billId: string, @@ -628,7 +1011,8 @@ async function createDraft( court, position: "endorse", attachmentId: null, - ballotQuestionId: ballotQuestionId ?? null + ballotQuestionId: ballotQuestionId ?? null, + conferenceId: null } await setDoc(refs.draftTestimony(uid, draftId), draft) diff --git a/tests/unit/conferenceLinks.test.tsx b/tests/unit/conferenceLinks.test.tsx index 6818d94b1..47e9dd051 100644 --- a/tests/unit/conferenceLinks.test.tsx +++ b/tests/unit/conferenceLinks.test.tsx @@ -23,6 +23,7 @@ const conference: ConferenceSummary = { title: "An Act relative to testing", senateBillId: "S2500", houseBillId: "H4000", + canonicalBillId: "S2500", status: "appointed" }