diff --git a/assets/css/dashboard/button-image.scss b/assets/css/dashboard/button-image.scss index 54591156..0c043fd4 100644 --- a/assets/css/dashboard/button-image.scss +++ b/assets/css/dashboard/button-image.scss @@ -6,7 +6,7 @@ flex-direction: column; height: 268px; border-radius: 16px; - background: #2b3741; + background: $bg-elevation-4; border-color: transparent; flex: 1 1 306px; align-items: center; diff --git a/assets/css/dashboard/configure-screens-workflow-page.scss b/assets/css/dashboard/configure-screens-workflow-page.scss index 0c06e265..55fef0da 100644 --- a/assets/css/dashboard/configure-screens-workflow-page.scss +++ b/assets/css/dashboard/configure-screens-workflow-page.scss @@ -34,7 +34,7 @@ } .screens-table-body tr.screen-row:not(:first-child) { - border-top-color: #2b3741; + border-top-color: $bg-elevation-4; border-top-width: 1px; } diff --git a/assets/css/dashboard/new-pa-message-page/new-pa-message-page.scss b/assets/css/dashboard/new-pa-message/new-pa-message-page.scss similarity index 97% rename from assets/css/dashboard/new-pa-message-page/new-pa-message-page.scss rename to assets/css/dashboard/new-pa-message/new-pa-message-page.scss index 284ee4c4..45c3eec1 100644 --- a/assets/css/dashboard/new-pa-message-page/new-pa-message-page.scss +++ b/assets/css/dashboard/new-pa-message/new-pa-message-page.scss @@ -6,13 +6,6 @@ margin: 0; } - &__header { - font-size: 40px; - font-weight: 500; - line-height: 48px; - text-align: left; - } - &__associate-alert-subtext { color: $text-secondary; } diff --git a/assets/css/dashboard/new-pa-message/new-pa-message.scss b/assets/css/dashboard/new-pa-message/new-pa-message.scss new file mode 100644 index 00000000..8f51b297 --- /dev/null +++ b/assets/css/dashboard/new-pa-message/new-pa-message.scss @@ -0,0 +1,33 @@ +@import "./new-pa-message-page.scss"; +@import "./select-stations-page.scss"; +@import "./selected-stations-summary.scss"; + +.new-pa-message, +.select-stations-page-modal { + height: 100%; + + .header { + font-size: 40px; + font-weight: 500; + line-height: 48px; + text-align: left; + } + + .cancel-button { + background-color: transparent; + color: $text-link-primary; + border: none; + } + + .submit-button { + background-color: $button-primary; + color: $button-secondary; + } +} + +.select-stations-page-modal { + .modal-content { + color: $text-primary; + background-color: $dark-bg; + } +} diff --git a/assets/css/dashboard/new-pa-message/select-stations-page.scss b/assets/css/dashboard/new-pa-message/select-stations-page.scss new file mode 100644 index 00000000..7d9dfd45 --- /dev/null +++ b/assets/css/dashboard/new-pa-message/select-stations-page.scss @@ -0,0 +1,186 @@ +.select-stations-page { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: hidden; + + .header { + display: flex; + justify-content: space-between; + margin: 24px 40px 0 40px; + + .buttons { + display: flex; + align-items: center; + + .btn { + height: 38px; + } + } + } + + .container-fluid { + display: flex; + margin-top: 40px; + padding: 0; + overflow-x: auto; + overflow-y: hidden; + flex-grow: 0; + scrollbar-color: $theme-secondary $bg-elevation-4; + + .form-check { + display: flex; + padding: 0; + + .form-check-input { + height: 16px; + width: 16px; + margin-left: 0; + margin-right: 8px; + } + + .form-check-label { + flex-grow: 1; + } + } + + .col { + background-color: $cool-gray-20; + border-radius: 0px 0px 8px 8px; + margin-right: 24px; + display: flex; + flex-direction: column; + + &:first-child { + margin-left: 40px; + } + + &:last-child { + margin-right: 40px; + } + + .col-content { + padding: 8px 16px; + } + } + + .title { + padding: 8px 16px; + border-radius: 8px 8px 0px 0px; + width: 100%; + height: 40px; + font-size: 20px; + font-weight: 500; + line-height: 24px; + } + + .station-groups-col { + min-width: 273px; + + .title { + background-color: $cool-gray-40; + } + + .route-groups { + padding: 0 16px; + + &:nth-child(2) { + padding-top: 16px; + } + + &:last-child { + padding-bottom: 16px; + } + + .station-group { + &:not(:last-child) { + margin-bottom: 24px; + } + } + } + + .col-content { + height: 100%; + overflow-y: auto; + padding-left: 0; + } + + .group-title { + color: $text-secondary; + } + + hr { + margin-top: 24px; + margin-bottom: 8px; + } + } + + .route-col { + min-width: 304px; + + &:not(&--green) { + height: fit-content; + max-height: 100%; + + .col-content { + flex-basis: content; + overflow-y: auto; + } + } + + .station-name { + margin-bottom: 6px; + } + + &--green { + .title { + background-color: $mbta-green; + height: fit-content; + } + + .branches-col { + overflow-y: auto; + height: 100%; + + .title { + border-radius: 0; + background-color: $bg-elevation-4; + font-size: 16px; + } + } + } + + &--red, + &--mattapan { + .title { + background-color: $mbta-red; + } + } + + &--orange { + .title { + background-color: $mbta-orange; + } + } + + &--blue { + .title { + background-color: $mbta-blue; + } + } + + &--silver { + .title { + background-color: $mbta-silver; + } + } + + &--bus { + .title { + color: $text-invert; + background-color: $mbta-bus; + } + } + } + } +} diff --git a/assets/css/dashboard/new-pa-message/selected-stations-summary.scss b/assets/css/dashboard/new-pa-message/selected-stations-summary.scss new file mode 100644 index 00000000..f2201833 --- /dev/null +++ b/assets/css/dashboard/new-pa-message/selected-stations-summary.scss @@ -0,0 +1,80 @@ +.selected-stations-summary { + display: flex; + align-items: center; + height: 52px; + border-radius: 8px; + background-color: $cool-gray-30; + margin: 40px 40px 0 40px; + padding: 15px 18px; + font-weight: 500; + font-size: 20px; + line-height: 30px; + + .label { + margin-right: 8px; + } + + span { + color: $text-secondary; + } + + .geo-alt-fill-icon { + margin-right: 7px; + } + + .selected-group-tag { + height: 36px; + padding: 6px 12px; + border: 1px solid $text-secondary; + border-radius: 4px; + font-weight: 500; + font-size: 16px; + line-height: 24px; + margin-right: 4px; + display: flex; + align-items: center; + + &--green { + background-color: $mbta-green; + } + &--red { + background-color: $mbta-red; + } + &--orange { + background-color: $mbta-orange; + } + &--blue { + background-color: $mbta-blue; + } + &--mattapan { + background-color: $mbta-red; + } + &--silver { + background-color: $mbta-silver; + } + &--bus { + background-color: $mbta-bus; + color: $text-invert; + fill: $text-invert; + } + + button { + height: 24px; + width: 16px; + margin-left: 4px; + padding: 0; + background-color: transparent; + border: none; + + &.bus { + svg { + fill: $text-invert; + } + } + + .bi-x { + display: block; + } + } + } +} diff --git a/assets/css/dashboard/pa-messages-page.scss b/assets/css/dashboard/pa-messages-page.scss index fefb7a08..030d7086 100644 --- a/assets/css/dashboard/pa-messages-page.scss +++ b/assets/css/dashboard/pa-messages-page.scss @@ -72,11 +72,11 @@ } th { - border-bottom: 1px solid #495057; + border-bottom: 1px solid $theme-secondary; } td { - border-bottom: 1px solid #2b3741; + border-bottom: 1px solid $bg-elevation-4; } &__start-end { diff --git a/assets/css/screenplay.scss b/assets/css/screenplay.scss index afa08628..41e23815 100644 --- a/assets/css/screenplay.scss +++ b/assets/css/screenplay.scss @@ -23,7 +23,7 @@ $form-check-input-border: 1px solid #8b9198; @import "dashboard/bottom-action-bar.scss"; @import "dashboard/configure-screens-workflow-page.scss"; @import "dashboard/pa-messages-page.scss"; -@import "dashboard/new-pa-message-page/new-pa-message-page.scss"; +@import "dashboard/new-pa-message/new-pa-message.scss"; @import "place-row.scss"; @import "places-action-bar.scss"; @import "filter-dropdown.scss"; @@ -99,6 +99,7 @@ input[type="button"] { .h5 { font-weight: 700; font-size: 16px; + line-height: 24px; } .body { diff --git a/assets/css/variables.scss b/assets/css/variables.scss index eb1a7764..99b1c81c 100644 --- a/assets/css/variables.scss +++ b/assets/css/variables.scss @@ -33,6 +33,7 @@ $mbta-red: #da291c; $mbta-blue: #003da5; $mbta-orange: #ed8b00; $mbta-bus: #ffc72c; +$mbta-silver: #6c777e; $sidebar-width: 108px; @@ -41,6 +42,7 @@ $text-disabled: #c6c6c9; $text-secondary: #c1c7ce; $theme-primary: #384956; +$theme-secondary: #495057; $border-primary: #8b9198; @@ -53,3 +55,5 @@ $dark-bg: #0f1417; $text-link-primary: #c1e4ff; $text-invert: #212529; $bg-border-disabled: #353b41; + +$bg-elevation-4: #2b3741; diff --git a/assets/js/components/App.tsx b/assets/js/components/App.tsx index 1bfa49ee..6f7df974 100644 --- a/assets/js/components/App.tsx +++ b/assets/js/components/App.tsx @@ -20,9 +20,7 @@ const SelectScreenTypeComponent = React.lazy( () => import("Components/PermanentConfiguration/SelectScreenType"), ); const PaMessagesPage = React.lazy(() => import("Components/PaMessagesPage")); -const NewPaMessagePage = React.lazy( - () => import("Components/NewPaMessagePage"), -); +const NewPaMessage = React.lazy(() => import("Components/NewPaMessage")); class AppRoutes extends React.Component { render() { @@ -50,7 +48,7 @@ class AppRoutes extends React.Component { } /> } /> - } /> + } /> diff --git a/assets/js/components/Dashboard/NewPaMessage/NewPaMessage.tsx b/assets/js/components/Dashboard/NewPaMessage/NewPaMessage.tsx new file mode 100644 index 00000000..c1629cf2 --- /dev/null +++ b/assets/js/components/Dashboard/NewPaMessage/NewPaMessage.tsx @@ -0,0 +1,65 @@ +import React, { useState } from "react"; +import moment from "moment"; +import { Page } from "./types"; + +import NewPaMessagePage from "./NewPaMessagePage"; +import SelectStationsPage from "./SelectStationsPage"; +import { Modal } from "react-bootstrap"; + +const NewPaMessage = () => { + const [page, setPage] = useState(Page.NEW); + const now = moment(); + + const [startDate, setStartDate] = useState(now.format("L")); + const [startTime, setStartTime] = useState(now.format("HH:mm")); + const [endDate, setEndDate] = useState(now.format("L")); + const [endTime, setEndTime] = useState(now.add(1, "hour").format("HH:mm")); + const [days, setDays] = useState([1, 2, 3, 4, 5, 6, 7]); + const [priority, setPriority] = useState(2); + const [interval, setInterval] = useState("4"); + const [visualText, setVisualText] = useState(""); + const [phoneticText, setPhoneticText] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + + return ( +
+ {page === Page.NEW && ( + + )} + setPage(Page.NEW)} + > + + +
+ ); +}; + +export default NewPaMessage; diff --git a/assets/js/components/Dashboard/NewPaMessagePage.tsx b/assets/js/components/Dashboard/NewPaMessage/NewPaMessagePage.tsx similarity index 87% rename from assets/js/components/Dashboard/NewPaMessagePage.tsx rename to assets/js/components/Dashboard/NewPaMessage/NewPaMessagePage.tsx index ff753936..93ca9617 100644 --- a/assets/js/components/Dashboard/NewPaMessagePage.tsx +++ b/assets/js/components/Dashboard/NewPaMessage/NewPaMessagePage.tsx @@ -1,5 +1,5 @@ /* eslint-disable jsx-a11y/media-has-caption */ -import React, { useEffect, useState } from "react"; +import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; import { Alert, Button, @@ -9,12 +9,11 @@ import { Form, Row, } from "react-bootstrap"; -import DaysPicker from "./DaysPicker"; +import DaysPicker from "Components/DaysPicker"; import PriorityPicker from "Components/PriorityPicker"; import IntervalPicker from "Components/IntervalPicker"; import MessageTextBox from "Components/MessageTextBox"; import { useNavigate } from "react-router-dom"; -import moment from "moment"; import { ArrowRightShort, CheckCircleFill, @@ -24,6 +23,8 @@ import { } from "react-bootstrap-icons"; import cx from "classnames"; +import { Page } from "./types"; + const MAX_TEXT_LENGTH = 2000; enum AudioPreview { @@ -33,23 +34,57 @@ enum AudioPreview { Outdated, } -const NewPaMessagePage = () => { - const now = moment(); +interface Props { + days: number[]; + endDate: string; + endTime: string; + errorMessage: string; + interval: string; + navigateTo: (page: Page) => void; + phoneticText: string; + priority: number; + setDays: Dispatch>; + setEndDate: Dispatch>; + setEndTime: Dispatch>; + setErrorMessage: Dispatch>; + setInterval: Dispatch>; + setPhoneticText: Dispatch>; + setPriority: Dispatch>; + setStartDate: Dispatch>; + setStartTime: Dispatch>; + setVisualText: Dispatch>; + startDate: string; + startTime: string; + visualText: string; +} - const [startDate, setStartDate] = useState(now.format("L")); - const [startTime, setStartTime] = useState(now.format("HH:mm")); - const [endDate, setEndDate] = useState(now.format("L")); - const [endTime, setEndTime] = useState(now.add(1, "hour").format("HH:mm")); - const [days, setDays] = useState([1, 2, 3, 4, 5, 6, 7]); - const [priority, setPriority] = useState(2); - const [interval, setInterval] = useState("4"); - const [visualText, setVisualText] = useState(""); - const [phoneticText, setPhoneticText] = useState(""); +const NewPaMessagePage = ({ + days, + endDate, + endTime, + errorMessage, + interval, + navigateTo, + phoneticText, + priority, + setDays, + setEndDate, + setEndTime, + setErrorMessage, + setInterval, + setPhoneticText, + setPriority, + setStartDate, + setStartTime, + setVisualText, + startDate, + startTime, + visualText, +}: Props) => { + const navigate = useNavigate(); const [audioState, setAudioState] = useState( AudioPreview.Unreviewed, ); - const [errorMessage, setErrorMessage] = useState(""); - const navigate = useNavigate(); const priorityToIntervalMap: { [priority: number]: string } = { 1: "1", @@ -91,7 +126,7 @@ const NewPaMessagePage = () => { event.preventDefault(); }} > -
New PA/ESS message
+
New PA/ESS message
diff --git a/assets/js/components/Dashboard/NewPaMessage/RouteColumn.tsx b/assets/js/components/Dashboard/NewPaMessage/RouteColumn.tsx new file mode 100644 index 00000000..dc20ae05 --- /dev/null +++ b/assets/js/components/Dashboard/NewPaMessage/RouteColumn.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import fp from "lodash/fp"; +import type { Place } from "Models/place"; +import { sortByStationOrder } from "../../../util"; +import { Form } from "react-bootstrap"; + +const RouteColumn = ({ + label, + routeIds, + orderingRouteId = routeIds[0], + places, + value, + onChange, + reverse = false, +}: { + label: string; + routeIds: string[]; + orderingRouteId?: string; + places: Place[]; + value: string[]; + onChange: (signIds: string[]) => void; + reverse?: boolean; +}) => { + const signsIdsAtRoutes = places.flatMap((place) => + place.screens + .filter( + (screen) => fp.intersection(routeIds, screen.route_ids).length > 0, + ) + .map((screen) => screen.id), + ); + + return ( + <> +
+ { + if (evt.target.checked) { + onChange(fp.union(signsIdsAtRoutes, value)); + } else { + onChange(fp.without(signsIdsAtRoutes, value)); + } + }} + checked={signsIdsAtRoutes.every((signId) => value.includes(signId))} + /> +
+
+ {sortByStationOrder(places, orderingRouteId, reverse).map((place) => { + const signIdsAtPlace = place.screens + .filter( + (screen) => + fp.intersection(routeIds, screen.route_ids).length > 0, + ) + .map((screen) => screen.id); + return ( +
+ { + if (evt.target.checked) { + onChange(fp.union(signIdsAtPlace, value)); + } else { + onChange(fp.without(signIdsAtPlace, value)); + } + }} + checked={value.some((signId) => + signIdsAtPlace.includes(signId), + )} + /> +
+ ); + })} +
+ + ); +}; + +export default RouteColumn; diff --git a/assets/js/components/Dashboard/NewPaMessage/SelectStationsPage.tsx b/assets/js/components/Dashboard/NewPaMessage/SelectStationsPage.tsx new file mode 100644 index 00000000..b67d7f18 --- /dev/null +++ b/assets/js/components/Dashboard/NewPaMessage/SelectStationsPage.tsx @@ -0,0 +1,299 @@ +import React, { useMemo, useState } from "react"; +import { Button, Container, Form } from "react-bootstrap"; +import fp from "lodash/fp"; +import { useScreenplayContext } from "Hooks/useScreenplayContext"; +import type { Place } from "Models/place"; +import RouteColumn from "./RouteColumn"; +import { GREEN_LINE_ROUTES, SILVER_LINE_ROUTES } from "Constants/constants"; +import StationGroupCheckbox from "./StationGroupCheckbox"; +import SelectedStationsSummary from "./SelectedStationsSummary"; +import { + GL_CENTRAL_SUBWAY, + GL_D_BRANCH, + GL_E_BRANCH, + GLX, + ORANGE_NORTH, + ORANGE_SOUTH, + RED_ASHMONT_BRANCH, + RED_BRAINTREE_BRANCH, + RED_TRUNK, +} from "./StationGroups"; +import { Page } from "./types"; + +const usePlacesWithPaEss = () => { + const { places } = useScreenplayContext(); + return useMemo( + () => + places + .map((place) => ({ + ...place, + screens: place.screens.filter((screen) => screen.type === "pa_ess"), + })) + .filter((place: Place) => place.screens.length > 0), + [places], + ); +}; + +const BASE_ROUTE_NAME_TO_ROUTE_IDS: { [key: string]: string[] } = { + "Green-B": ["Green-B"], + "Green-C": ["Green-C"], + "Green-D": ["Green-D"], + "Green-E": ["Green-E"], + Red: ["Red"], + Orange: ["Orange"], + Blue: ["Blue"], + Mattapan: ["Mattapan"], + Silver: SILVER_LINE_ROUTES, +}; + +const ROUTE_TO_CLASS_NAMES_MAP: { [key: string]: string } = { + Red: "route-col--red", + Orange: "route-col--orange", + Blue: "route-col--blue", + Mattapan: "route-col--mattapan", + Silver: "route-col--silver", +}; + +interface Props { + navigateTo: (page: Page) => void; +} + +const SelectStationsPage = ({ navigateTo }: Props) => { + const [signsIds, setSignsIds] = useState([]); + const places = usePlacesWithPaEss(); + if (places.length === 0) return null; + + const allRoutes = fp.uniq( + places.flatMap((place) => + place.screens.flatMap((screen) => screen.route_ids ?? []), + ), + ); + + const busRoutes = fp.without( + Object.values(BASE_ROUTE_NAME_TO_ROUTE_IDS).flat(), + allRoutes, + ); + + const routeNameToRouteIds: { [key: string]: string[] } = { + ...BASE_ROUTE_NAME_TO_ROUTE_IDS, + Bus: busRoutes, + }; + + const placesByRoute = places.reduce<{ [key: string]: Array }>( + (acc, place) => { + place.routes.forEach((route) => { + const groupedRoutes = routeNameToRouteIds[route]; + if ( + place.screens.some( + (screen) => + fp.intersection(groupedRoutes, screen.route_ids).length > 0, + ) + ) { + acc[route] = [...(acc[route] || []), place]; + } + }); + return acc; + }, + {}, + ); + + const greenLineSignIds = fp.uniq( + GREEN_LINE_ROUTES.flatMap((route) => placesByRoute[route]).flatMap( + (place) => + place.screens + .filter( + (screen) => + fp.intersection(GREEN_LINE_ROUTES, screen.route_ids).length > 0, + ) + .map((screen) => screen.id), + ), + ); + + return ( +
+
+
Select Stations
+
+ + +
+
+ + +
+
Station Groups
+
+
+
Green line
+ + + + +
+
+
+
Red line
+ + + +
+
+
+
Orange line
+ + +
+
+
+
+ { + if (evt.target.checked) { + setSignsIds(fp.union(greenLineSignIds, signsIds)); + } else { + setSignsIds(fp.without(greenLineSignIds, signsIds)); + } + }} + checked={greenLineSignIds.every((signId) => + signsIds.includes(signId), + )} + /> + +
+ {["B", "C", "D", "E"].map((branch) => { + // It's possible to check a station on one branch even though not all signs are checked. + // If you want to instead select all branches when selecting a station, + // pass GREEN_LINE_ROUTES to routes and pass route to orderingRoute + const route = `Green-${branch}`; + return ( + + ); + })} +
+
+ + {Object.keys(ROUTE_TO_CLASS_NAMES_MAP).map((route) => ( +
+ +
+ ))} + +
+ +
+
+
+ ); +}; + +export default SelectStationsPage; diff --git a/assets/js/components/Dashboard/NewPaMessage/SelectedStationsSummary.tsx b/assets/js/components/Dashboard/NewPaMessage/SelectedStationsSummary.tsx new file mode 100644 index 00000000..d26a18d8 --- /dev/null +++ b/assets/js/components/Dashboard/NewPaMessage/SelectedStationsSummary.tsx @@ -0,0 +1,139 @@ +import { Place } from "Models/place"; +import { Screen } from "Models/screen"; +import React, { useMemo } from "react"; +import { GeoAltFill, X } from "react-bootstrap-icons"; +import fp from "lodash/fp"; +import { SILVER_LINE_ROUTES } from "Constants/constants"; +import { classWithModifier } from "../../../util"; +import pluralize from "pluralize"; +interface Props { + value: string[]; + onChange: (signIds: string[]) => void; + places: Place[]; + busRoutes: string[]; +} + +const SelectedGroupTag = ({ + numPlaces, + routeId, + onClick, +}: { + numPlaces: number; + routeId: string; + onClick: () => void; +}) => { + if (numPlaces === 0) return null; + return ( +
+ {routeId}: {pluralize("Station", numPlaces, true)} + +
+ ); +}; + +const SelectedStationsSummary = ({ + value, + places, + busRoutes, + onChange, +}: Props) => { + const placesWithSelectedScreens = useMemo(() => { + return fp.flow( + fp.map((place: Place) => { + return { + ...place, + screens: place.screens.filter((screen) => + fp.includes(screen.id, value), + ), + }; + }), + fp.filter((place) => place.screens.length > 0), + )(places); + }, [value]); + + const removeSelectedScreens = (filterFn: (screen: Screen) => boolean) => { + const selectedScreens = fp.flow( + fp.flatMap((place: Place) => place.screens), + fp.filter(filterFn), + fp.map((screen) => screen.id), + )(placesWithSelectedScreens); + + onChange(fp.without(selectedScreens, value)); + }; + + return ( +
+ +
Stations selected:
+ {value.length === 0 ? ( + None + ) : ( + <> + {["Green", "Red", "Orange", "Blue", "Mattapan"].map((routeId) => ( + + place.screens.some((screen) => + screen.route_ids?.some((r) => r.startsWith(routeId)), + ), + ).length + } + routeId={routeId} + onClick={() => { + removeSelectedScreens( + (screen) => + screen.route_ids?.some((r) => r.startsWith(routeId)) ?? + false, + ); + }} + /> + ))} + + place.screens.some((screen) => + screen.route_ids?.some((routeId) => + SILVER_LINE_ROUTES.includes(routeId), + ), + ), + ).length + } + routeId="Silver" + onClick={() => { + removeSelectedScreens( + (screen) => + fp.intersection(SILVER_LINE_ROUTES, screen.route_ids).length > + 0, + ); + }} + /> + + place.screens.some((screen) => + screen.route_ids?.some((routeId) => + busRoutes.includes(routeId), + ), + ), + ).length + } + routeId="Bus" + onClick={() => { + removeSelectedScreens( + (screen) => + fp.intersection(busRoutes, screen.route_ids).length > 0, + ); + }} + /> + + )} +
+ ); +}; + +export default SelectedStationsSummary; diff --git a/assets/js/components/Dashboard/NewPaMessage/StationGroupCheckbox.tsx b/assets/js/components/Dashboard/NewPaMessage/StationGroupCheckbox.tsx new file mode 100644 index 00000000..5ede59b8 --- /dev/null +++ b/assets/js/components/Dashboard/NewPaMessage/StationGroupCheckbox.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import fp from "lodash/fp"; +import { Place } from "Models/place"; +import { Form } from "react-bootstrap"; + +interface Props { + title: string; + label: string; + places: Place[]; + routes: string[]; + stations: string[]; + value: string[]; + onChange: (signIds: string[]) => void; +} + +const StationGroupCheckbox = ({ + title, + label, + places, + routes, + stations, + value, + onChange, +}: Props) => { + const signIds = places + .filter((place) => stations.includes(place.id)) + .flatMap((place) => + place.screens + .filter( + (screen) => fp.intersection(routes, screen.route_ids).length > 0, + ) + .map((screen) => screen.id), + ); + + return ( +
+
{title}
+ { + if (evt.target.checked) { + onChange(fp.union(signIds, value)); + } else { + onChange(fp.without(signIds, value)); + } + }} + checked={signIds.every((signId) => value.includes(signId))} + /> +
+ ); +}; + +export default StationGroupCheckbox; diff --git a/assets/js/components/Dashboard/NewPaMessage/StationGroups.ts b/assets/js/components/Dashboard/NewPaMessage/StationGroups.ts new file mode 100644 index 00000000..18410f93 --- /dev/null +++ b/assets/js/components/Dashboard/NewPaMessage/StationGroups.ts @@ -0,0 +1,101 @@ +export const GL_CENTRAL_SUBWAY = [ + "place-north", + "place-haecl", + "place-gover", + "place-pktrm", + "place-boyls", + "place-armnl", + "place-coecl", + "place-hymnl", + "place-kencl", +]; + +export const GL_D_BRANCH = [ + "place-kencl", + "place-fenwy", + "place-longw", + "place-bvmnl", + "place-brkhl", + "place-bcnfd", + "place-rsmnl", + "place-chhil", + "place-newto", + "place-newtn", + "place-eliot", + "place-waban", + "place-woodl", + "place-river", +]; + +export const GL_E_BRANCH = ["place-prmnl", "place-symcl", "place-longw"]; + +export const GLX = [ + "place-mdftf", + "place-balsq", + "place-mgngl", + "place-gilmn", + "place-esomr", + "place-unsqu", + "place-lech", + "place-spmnl", + "place-north", +]; + +export const RED_BRAINTREE_BRANCH = [ + "place-jfk", + "place-nqncy", + "place-wlsta", + "place-qnctr", + "place-qamnl", + "place-brntn", +]; + +export const RED_ASHMONT_BRANCH = [ + "place-jfk", + "place-shmnl", + "place-fldcr", + "place-smmnl", + "place-asmnl", +]; + +export const RED_TRUNK = [ + "place-alfcl", + "place-davis", + "place-portr", + "place-harsq", + "place-cntsq", + "place-knncl", + "place-chmnl", + "place-pktrm", + "place-dwnxg", + "place-sstat", + "place-brdwy", + "place-andrw", + "place-jfk", +]; + +export const ORANGE_NORTH = [ + "place-ogmnl", + "place-mlmnl", + "place-welln", + "place-astao", + "place-sull", + "place-ccmnl", + "place-north", +]; + +export const ORANGE_SOUTH = [ + "place-haecl", + "place-state", + "place-dwnxg", + "place-chncl", + "place-tumnl", + "place-bbsta", + "place-masta", + "place-rugg", + "place-rcmnl", + "place-jaksn", + "place-sbmnl", + "place-grnst", + "place-forhl", +]; diff --git a/assets/js/components/Dashboard/NewPaMessage/index.ts b/assets/js/components/Dashboard/NewPaMessage/index.ts new file mode 100644 index 00000000..00c9838f --- /dev/null +++ b/assets/js/components/Dashboard/NewPaMessage/index.ts @@ -0,0 +1 @@ +export { default } from "./NewPaMessage"; diff --git a/assets/js/components/Dashboard/NewPaMessage/types.ts b/assets/js/components/Dashboard/NewPaMessage/types.ts new file mode 100644 index 00000000..56df92fe --- /dev/null +++ b/assets/js/components/Dashboard/NewPaMessage/types.ts @@ -0,0 +1,5 @@ +export enum Page { + NEW = "new", + STATIONS = "stations", + // ZONES = 'zones' +} diff --git a/assets/js/constants/constants.ts b/assets/js/constants/constants.ts index 1d08af73..20a06809 100644 --- a/assets/js/constants/constants.ts +++ b/assets/js/constants/constants.ts @@ -68,3 +68,5 @@ export const STATUSES = [ ]; export const BASE_URL = "/api/takeover_tool"; + +export const GREEN_LINE_ROUTES = ["Green-B", "Green-C", "Green-D", "Green-E"]; diff --git a/assets/js/models/screen.ts b/assets/js/models/screen.ts index 0a9922c0..ce690742 100644 --- a/assets/js/models/screen.ts +++ b/assets/js/models/screen.ts @@ -7,4 +7,5 @@ export interface Screen { label?: string; location?: string; hidden?: boolean; + route_ids?: string[]; } diff --git a/assets/js/util.ts b/assets/js/util.ts index ae553151..631537fa 100644 --- a/assets/js/util.ts +++ b/assets/js/util.ts @@ -207,6 +207,8 @@ export const sortByStationOrder = ( ) => { const stationOrder = STATION_ORDER_BY_LINE[filteredLine.toLowerCase()]; + if (!stationOrder) return places; + const stationOrderToIndex = Object.fromEntries( stationOrder.map((station, i) => [station.name.toLowerCase(), i]), ); diff --git a/assets/package-lock.json b/assets/package-lock.json index afe0e781..ccc69fa6 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -10,12 +10,14 @@ "@heroicons/react": "^2.1.4", "@sentry/fullstory": "^3.0.0", "@sentry/react": "^8.13.0", + "@types/pluralize": "^0.0.33", "bootstrap": "^5.1.3", "classnames": "^2.5.1", "date-fns": "^3.6.0", "lodash": "^4.17.21", "moment": "^2.29.4", "moment-timezone": "^0.5.45", + "pluralize": "^8.0.0", "react": "^17.0.2", "react-bootstrap": "^2.10.4", "react-bootstrap-icons": "^1.8.2", @@ -3393,6 +3395,12 @@ "integrity": "sha512-xegpDuR+z0UqG9fwHqNoy3rI7JDlvaPh2TY47Fl80oq6g+hXT+c/LEuE43X48clZ6lOfANl5WrPur9fYO1RJ/w==", "dev": true }, + "node_modules/@types/pluralize": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.33.tgz", + "integrity": "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.12", "license": "MIT" @@ -10673,6 +10681,15 @@ "node": ">=8" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "dev": true, diff --git a/assets/package.json b/assets/package.json index 17554570..3dc6ef9f 100644 --- a/assets/package.json +++ b/assets/package.json @@ -17,12 +17,14 @@ "@heroicons/react": "^2.1.4", "@sentry/fullstory": "^3.0.0", "@sentry/react": "^8.13.0", + "@types/pluralize": "^0.0.33", "bootstrap": "^5.1.3", "classnames": "^2.5.1", "date-fns": "^3.6.0", "lodash": "^4.17.21", "moment": "^2.29.4", "moment-timezone": "^0.5.45", + "pluralize": "^8.0.0", "react": "^17.0.2", "react-bootstrap": "^2.10.4", "react-bootstrap-icons": "^1.8.2", diff --git a/config/test.exs b/config/test.exs index 714e60ef..0a88445f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -37,7 +37,10 @@ config :ueberauth_oidcc, config :screenplay, Screenplay.Repo, adapter: Ecto.Adapters.Postgres, database: "screenplay_test", - pool: Ecto.Adapters.SQL.Sandbox + pool: Ecto.Adapters.SQL.Sandbox, + username: "postgres", + password: "postgres", + hostname: "localhost" # Print only warnings and errors during test config :logger, level: :warning diff --git a/scripts/build_places.exs b/scripts/build_places.exs old mode 100644 new mode 100755 index 05fe4cf5..3e352d5f --- a/scripts/build_places.exs +++ b/scripts/build_places.exs @@ -1,4 +1,6 @@ +#!/usr/bin/env mix run # Script to populate `places_and_screens.json`. +require Logger {opts, _, _} = System.argv() @@ -100,14 +102,18 @@ end # Get live config from S3 config = if environment do + Logger.info("Fetching config from S3") {:ok, body} = get_config.("mbta-ctd-config", "screens/screens-#{environment}.json") body else + Logger.info("Reading config from file") File.read!(local_path) end parsed = Jason.decode!(config) +Logger.info("Formatting screens") + formatted_screens = parsed["screens"] # Certain screens (test screens, ones we've configured for non-MBTA locations) are intentionally @@ -342,6 +348,7 @@ params = "filter[id]" => Enum.map_join(bus_stops_with_screens, ",", fn {stop_id, _} -> stop_id end) }) +Logger.info("Fetching info for bus stops with screens") # Get stop info for bus stops with screens url = "https://api-v3.mbta.com/stops?#{params}" %{status_code: 200, body: body} = HTTPoison.get!(url, headers) @@ -369,6 +376,7 @@ bus_stops = %{id: id, name: name, screens: screens_at_stop} end) +Logger.info("Fetching all parent stations") # Go get all parent stations url = "https://api-v3.mbta.com/stops?filter[location_type]=1" %{status_code: 200, body: body} = HTTPoison.get!(url, headers) @@ -376,6 +384,8 @@ parsed = Jason.decode!(body) %{"data" => data} = parsed +Logger.info("Transforming data, fetching each stop as needed") + contents = data # Transform data @@ -423,31 +433,37 @@ contents = end) end) # Get the routes at each stop - |> Enum.map(fn %{id: id} = stop -> - # Not a big fan of this. Goes through each station one by one. - # Could not figure out how to get stops with route info all in one query. - url = "https://api-v3.mbta.com/routes?filter[stop]=#{id}" - %{status_code: 200, body: body} = HTTPoison.get!(url, headers) - %{"data" => data} = Jason.decode!(body) - - data - |> Enum.map(fn - %{"attributes" => %{"short_name" => "SL" <> _}} -> "Silver" - %{"id" => "CR-" <> _} -> "CR" - # Bus edge case I found in the data. - %{"id" => "34E"} -> "Bus" - %{"id" => route_id} -> route_id - end) - |> format_bus_routes.() - |> Enum.dedup() - |> sort_routes.() - |> add_routes_to_stops.(stop) - end) + |> Task.async_stream( + fn %{id: id} = stop -> + # Not a big fan of this. Goes through each station one by one. + # Could not figure out how to get stops with route info all in one query. + url = "https://api-v3.mbta.com/routes?filter[stop]=#{id}" + %{status_code: 200, body: body} = HTTPoison.get!(url, headers) + %{"data" => data} = Jason.decode!(body) + + data + |> Enum.map(fn + %{"attributes" => %{"short_name" => "SL" <> _}} -> "Silver" + %{"id" => "CR-" <> _} -> "CR" + # Bus edge case I found in the data. + %{"id" => "34E"} -> "Bus" + %{"id" => route_id} -> route_id + end) + |> format_bus_routes.() + |> Enum.dedup() + |> sort_routes.() + |> add_routes_to_stops.(stop) + end, + ordered: false + ) + |> Enum.map(fn {:ok, result} -> result end) # Get rid of CR stops with no screens |> Enum.reject(fn %{id: id, routes: routes, screens: screens} -> (String.starts_with?(id, "place-CM-") or cr_or_bus_only?.(routes)) and length(screens) == 0 end) +Logger.info("Fetching realtiem_signs config from GitHub") + # Because the realtime_signs config lives in the realtime_signs repo, go get it so we are always reading the latest. url = "https://api.github.com/repos/mbta/realtime_signs/contents/priv/signs.json" %{status_code: 200, body: body} = HTTPoison.get!(url, headers) @@ -485,15 +501,14 @@ stop_ids = |> Enum.map(fn %{"stop_id" => stop_id} -> stop_id end) |> Enum.uniq() +Logger.info("Refetching all stops") params = URI.encode_query(%{"filter[id]" => Enum.join(stop_ids, ",")}) url = "https://api-v3.mbta.com/stops?#{params}" %{status_code: 200, body: body} = HTTPoison.get!(url) -stops_parsed = Jason.decode!(body) +%{"data" => stops_parsed} = Jason.decode!(body) -%{"data" => data} = stops_parsed - -platform_to_stop_map = - Enum.map(data, fn %{"id" => id} = stop -> +stops_to_parent_station_ids = + Enum.map(stops_parsed, fn %{"id" => id} = stop -> {id, get_in(stop, [ "relationships", @@ -504,10 +519,10 @@ platform_to_stop_map = end) |> Enum.into(%{}) -get_first_not_nil = fn sources -> +get_first_parent_station = fn sources -> sources |> Enum.map(fn %{"stop_id" => platform_id} -> - platform_to_stop_map[platform_id] + stops_to_parent_station_ids[platform_id] end) |> Enum.uniq() |> Enum.reject(&is_nil/1) @@ -517,45 +532,60 @@ end {:ok, labels} = File.read("scripts/paess_labels.json") labels = Jason.decode!(labels) -# Get all countdown clocks -pa_ess_screens = - parsed - |> Enum.group_by( - fn - %{"source_config" => %{"sources" => sources}} -> - get_first_not_nil.(sources) +get_sources = fn + %{"source_config" => %{"sources" => sources}} -> + sources - %{"source_config" => [%{"sources" => top_sources}, %{"sources" => bottom_sources}]} -> - get_first_not_nil.(top_sources ++ bottom_sources) + %{"source_config" => [%{"sources" => top_sources}, %{"sources" => bottom_sources}]} -> + top_sources ++ bottom_sources - %{"sources" => sources} -> - get_first_not_nil.(sources) + %{"sources" => sources} -> + sources - %{"configs" => configs} -> - Enum.flat_map(configs, fn %{"sources" => sources} -> sources end) |> get_first_not_nil.() + %{"configs" => configs} -> + Enum.flat_map(configs, & &1["sources"]) - %{"top_configs" => top_configs, "bottom_configs" => bottom_configs} -> - (Enum.flat_map(top_configs, fn %{"sources" => sources} -> sources end) ++ - Enum.flat_map(bottom_configs, fn %{"sources" => sources} -> sources end)) - |> get_first_not_nil.() + %{"top_configs" => top_configs, "bottom_configs" => bottom_configs} -> + Enum.flat_map(top_configs, & &1["sources"]) ++ + Enum.flat_map(bottom_configs, & &1["sources"]) - %{ - "top_sources" => top_sources, - "bottom_sources" => bottom_sources - } -> - get_first_not_nil.(top_sources ++ bottom_sources) + %{ + "top_sources" => top_sources, + "bottom_sources" => bottom_sources + } -> + top_sources ++ bottom_sources +end + +get_routes_for_pa_ess = fn config -> + for source <- get_sources.(config), + route <- source["routes"] || List.wrap(source["route_id"]), + uniq: true do + route + end +end + +Logger.info("Getting all countdown clocks") +# Get all countdown clocks +pa_ess_screens = + parsed + |> Enum.group_by( + fn config -> + config + |> get_sources.() + |> get_first_parent_station.() end, fn %{ "id" => id, "pa_ess_loc" => station_code, "text_zone" => zone - } -> + } = config -> %{ id: id, station_code: station_code, zone: zone, type: "pa_ess", - label: labels["#{station_code}-#{zone}"] + label: labels["#{station_code}-#{zone}"], + route_ids: get_routes_for_pa_ess.(config) } end ) @@ -567,4 +597,10 @@ merged_paess = Map.put(place, :screens, screens ++ (pa_ess_screens[id] || [])) end) -File.write!("priv/config/places_and_screens.json", Jason.encode!(merged_paess), [:binary]) +Logger.info("Writing result to priv/config/places_and_screens.json") + +File.write!("priv/config/places_and_screens.json", Jason.encode!(merged_paess, pretty: true), [ + :binary +]) + +Logger.info("Done!")