From 955e6344acda8ffc307bff4a58b39405f75e9078 Mon Sep 17 00:00:00 2001 From: Christian Maddox Date: Tue, 26 Nov 2024 08:59:05 -0500 Subject: [PATCH] feat: Static Templates (#567) * Added modules and struct for fetching static templates. * Added endpoint for getting static templates. * Added client fetch for static templates. * Added a page for static templates table. * Fix line breaks. * Render static templates in a table. * Adjusted UI to allow selecting a template. * Save message_type to DB. * Clear appropriate state when clearing selected template. * Use new column for template linking. * Add IDs to templates. * Keep message_type as an Enum. * Add template and alert to show response. * Serialize alert if not nil. * Simplified return. * Remove unused endpoint. * Move cache seeding logic. * Tests and credo. * Ignore sobelow false positive. * Remove unused imports. * Fix message type formatting. * Added an archived field to static templates. * Added tests for static templates. * Move static template file from server to client. * Revert some unnecessarily changed code. * Address feedback from QA. * Address PR feedback. --- .../new-pa-message/new-pa-message-page.scss | 88 ++++----- .../new-pa-message/static-template-page.scss | 93 ++++++++++ assets/css/screenplay.scss | 1 + assets/css/variables.scss | 2 + .../Dashboard/EditPaMessage/EditPaMessage.tsx | 4 + .../Dashboard/PaMessageForm/MainForm.tsx | 167 +++++++++++------- .../Dashboard/PaMessageForm/PaMessageForm.tsx | 49 ++++- .../PaMessageForm/StaticTemplatePage.tsx | 117 ++++++++++++ .../Dashboard/PaMessageForm/types.ts | 1 + assets/js/models/pa_message.ts | 4 + assets/js/models/static_template.ts | 9 + assets/static/static_templates.json | 146 +++++++++++++++ lib/screenplay/alerts/cache.ex | 2 +- lib/screenplay/pa_messages/pa_message.ex | 12 +- .../20241115154029_add_template_id_column.exs | 9 + test/screenplay/alerts/cache_test.exs | 31 +--- test/screenplay/pa_messages_test.exs | 50 +----- .../pa_messages_api_controller_test.exs | 27 ++- test/support/alerts_cache_helpers.ex | 55 ++++++ 19 files changed, 660 insertions(+), 207 deletions(-) create mode 100644 assets/css/dashboard/new-pa-message/static-template-page.scss create mode 100644 assets/js/components/Dashboard/PaMessageForm/StaticTemplatePage.tsx create mode 100644 assets/js/models/static_template.ts create mode 100644 assets/static/static_templates.json create mode 100644 priv/repo/migrations/20241115154029_add_template_id_column.exs create mode 100644 test/support/alerts_cache_helpers.ex diff --git a/assets/css/dashboard/new-pa-message/new-pa-message-page.scss b/assets/css/dashboard/new-pa-message/new-pa-message-page.scss index 18a66522a..974bbd85c 100644 --- a/assets/css/dashboard/new-pa-message/new-pa-message-page.scss +++ b/assets/css/dashboard/new-pa-message/new-pa-message-page.scss @@ -258,31 +258,25 @@ } } - .unassociated-alert-header { - margin-top: 40px; - font-size: 20px; - font-weight: 500; - - button { - font-size: 20px; - margin-bottom: 14px; - } - } - - .associated-alert-header { + .associated-alert-header, + .selected-template-header, + .alert-template-header { margin: 40px 0px 0px 16px; font-size: 20px; font-weight: 500; + } - button { + .associated-alert-header, + .selected-template-header, + .alert-template-container { + .clear-button { + padding: 0; color: $text-secondary; - margin-left: 16px; - margin-bottom: 6px; - width: auto; - padding: 0px; - height: 24px; + margin: auto 0 auto 16px; } + } + .associated-alert-header { .effect-period, .alert-ended { margin-top: 4px; @@ -296,57 +290,39 @@ } } - .associate-alert-button { - color: $text-link-primary; - font-family: Inter; - font-size: 20; - font-weight: 500; - height: 30px; - margin-bottom: 8px; - } + .alert-template-container { + display: flex; + align-items: center; + padding: 0; - .unassociated-alert-header { - margin-top: 40px; - font-size: 20px; - font-weight: 500; + .associate-alert-button { + padding-left: 0px; + } - button { + .associate-alert-button, + .psa-emergency-button { + height: 30px; + display: flex; + align-items: center; + margin: 0; + color: $text-link-primary; font-size: 20px; - margin-bottom: 14px; + font-weight: 500; + text-decoration: none; } } - .associated-alert-header { - margin: 40px 0px 0px 16px; + .unassociated-alert-header { + margin-top: 40px; font-size: 20px; font-weight: 500; button { - color: $text-secondary; - margin-left: 16px; - margin-bottom: 6px; - width: auto; - padding: 0px; - height: 24px; - } - - .effect-period { - margin-top: 4px; - font-size: 14px; - font-weight: 400; - width: 100%; + font-size: 20px; + margin-bottom: 14px; } } - .associate-alert-button { - color: $text-link-primary; - font-family: Inter; - font-size: 20; - font-weight: 500; - height: 30px; - margin-bottom: 8px; - } - input[type="time"], input[type="date"] { position: relative; diff --git a/assets/css/dashboard/new-pa-message/static-template-page.scss b/assets/css/dashboard/new-pa-message/static-template-page.scss new file mode 100644 index 000000000..4d2c6d611 --- /dev/null +++ b/assets/css/dashboard/new-pa-message/static-template-page.scss @@ -0,0 +1,93 @@ +.static-template-page { + padding: 24px 48px; + + .header-container { + padding-right: 48px; + max-width: 1380px; + max-height: 48px; + margin-bottom: 40px; + + .header, + .cancel-button-col, + .cancel-button { + height: 48px; + } + + .cancel-button-col { + flex: none; + width: 100px; + + .cancel-button { + color: $text-link-primary; + text-decoration: none; + + &:hover { + color: $text-link-primary; + background-color: $cool-gray-15; + } + } + } + } + + .static-template-page-body { + .filter-group-col { + max-width: 200px; + margin-right: 48px; + } + + .static-template-table-container { + max-width: 1096px; + + .table-header { + font-size: 32px; + } + + .static-template-table { + width: 100%; + + .header-row { + height: 55px; + vertical-align: bottom; + border-bottom: 1px solid $border-primary; + + .message-column-header { + padding-bottom: 8px; + padding-left: 16px; + } + } + + .template-row { + background-color: $cool-gray-15; + border-bottom: 1px solid $divider-secondary; + + .title { + font-weight: 700; + } + + .message-cell { + padding: 8px 16px; + + .message { + white-space: pre-line; + } + } + + .select-button-cell { + vertical-align: top; + padding: 16px 38px 0 16px; + + .select-button { + padding: 0; + color: $text-secondary; + } + } + + &:hover { + background-color: $cool-gray-20; + cursor: pointer; + } + } + } + } + } +} diff --git a/assets/css/screenplay.scss b/assets/css/screenplay.scss index b462a2b01..b84468c82 100644 --- a/assets/css/screenplay.scss +++ b/assets/css/screenplay.scss @@ -47,6 +47,7 @@ $form-feedback-invalid-color: $text-error; @import "dashboard/toast.scss"; @import "dashboard/kebab-menu.scss"; @import "dashboard/filter-group.scss"; +@import "dashboard/new-pa-message/static-template-page.scss"; html { font-size: 16px; diff --git a/assets/css/variables.scss b/assets/css/variables.scss index cb3720377..99e327ef5 100644 --- a/assets/css/variables.scss +++ b/assets/css/variables.scss @@ -68,3 +68,5 @@ $text-button-blue-hover-color: #c1e4ff14; $text-button-grey-hover-color: #f8f9fa14; $alert-yellow: #ffdd00; $accessibility-blue: #165c96; + +$divider-secondary: #495057; diff --git a/assets/js/components/Dashboard/EditPaMessage/EditPaMessage.tsx b/assets/js/components/Dashboard/EditPaMessage/EditPaMessage.tsx index cc343ffe8..022579d16 100644 --- a/assets/js/components/Dashboard/EditPaMessage/EditPaMessage.tsx +++ b/assets/js/components/Dashboard/EditPaMessage/EditPaMessage.tsx @@ -6,6 +6,7 @@ import PaMessageForm from "../PaMessageForm"; import { updateExistingPaMessage } from "Utils/api"; import { Alert } from "Models/alert"; import { AudioPreview } from "Components/PaMessageForm/types"; +import { STATIC_TEMPLATES } from "Components/PaMessageForm/StaticTemplatePage"; const useAlert = (id: string | null | undefined) => { const { data: alerts, isLoading } = useSWR>( @@ -104,6 +105,9 @@ const EditPaMessage = ({ paMessage, alert }: Props) => { onErrorsChange={setErrors} defaultValues={paMessage} defaultAlert={alert ?? paMessage.alert_id} + defaultTemplate={STATIC_TEMPLATES.find( + (t) => t.id === paMessage.template_id, + )} defaultAudioState={AudioPreview.Reviewed} paused={paMessage.paused} onSubmit={async (data) => { diff --git a/assets/js/components/Dashboard/PaMessageForm/MainForm.tsx b/assets/js/components/Dashboard/PaMessageForm/MainForm.tsx index 6000057dd..5a36fd290 100644 --- a/assets/js/components/Dashboard/PaMessageForm/MainForm.tsx +++ b/assets/js/components/Dashboard/PaMessageForm/MainForm.tsx @@ -20,6 +20,8 @@ import { getAlertEarliestStartLatestEnd } from "../../../util"; import { AudioPreview, Page } from "./types"; import SelectedSignsByRouteTags from "./SelectedSignsByRouteTags"; import { Place } from "Models/place"; +import { StaticTemplate } from "Models/static_template"; +import { MessageType } from "Models/pa_message"; const MAX_TEXT_LENGTH = 2000; @@ -54,6 +56,8 @@ interface Props { audioState: AudioPreview; hide: boolean; paused: boolean; + selectedTemplate: StaticTemplate | null; + onClearSelectedTemplate: () => void; } const MainForm = ({ @@ -87,17 +91,12 @@ const MainForm = ({ audioState, setAudioState, paused, + selectedTemplate, + onClearSelectedTemplate, }: Props) => { const navigate = useNavigate(); const [validated, setValidated] = useState(false); - const priorityToIntervalMap: { [priority: number]: string } = { - 1: "1", - 2: "4", - 3: "10", - 4: "12", - }; - const previewAudio = () => { if (audioState === AudioPreview.Playing) return; @@ -154,6 +153,8 @@ const MainForm = ({ onClearAssociatedAlert={onClearAssociatedAlert} navigateTo={navigateTo} setEndWithEffectPeriod={setEndWithEffectPeriod} + selectedTemplate={selectedTemplate} + onClearSelectedTemplate={onClearSelectedTemplate} />
When
@@ -301,10 +302,7 @@ const MainForm = ({ { - setInterval(priorityToIntervalMap[priority]); - setPriority(priority); - }} + onSelectPriority={setPriority} /> @@ -348,6 +346,7 @@ const MainForm = ({ { setVisualText(text); if (audioState !== AudioPreview.Unreviewed) { @@ -363,7 +362,9 @@ const MainForm = ({ - {typeof associatedAlert === "string" ? ( -
- Alert has ended and is no longer available. -
- ) : ( - formatActivePeriod(associatedAlert.active_period) - )} - - - ) : ( - <> - - - (Optional) + const formatMessageType = (messageType: MessageType) => { + switch (messageType) { + case "psa": + return "PSA"; + case "emergency": + return "Emergency"; + default: + return ""; + } + }; + + if (associatedAlert) { + return ( + +
+ Associated Alert: Alert ID{" "} + {typeof associatedAlert === "string" + ? associatedAlert + : associatedAlert.id} + + {typeof associatedAlert === "string" ? ( +
+ Alert has ended and is no longer available. +
+ ) : ( + formatActivePeriod(associatedAlert.active_period) + )} +
- -
- Linking will allow you to share end time with alert, and import - location and message. + ); + } else if (selectedTemplate) { + return ( + +
+ Template:{" "} + {`${formatMessageType(selectedTemplate.type)} - ${selectedTemplate.title}`} +
- - ); + ); + } else { + return ( + +
+ + | + + (Optional) +
+
+ ); + } }; export default MainForm; diff --git a/assets/js/components/Dashboard/PaMessageForm/PaMessageForm.tsx b/assets/js/components/Dashboard/PaMessageForm/PaMessageForm.tsx index 50a744453..959b4193e 100644 --- a/assets/js/components/Dashboard/PaMessageForm/PaMessageForm.tsx +++ b/assets/js/components/Dashboard/PaMessageForm/PaMessageForm.tsx @@ -1,14 +1,17 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import moment, { type Moment } from "moment"; import MainForm from "./MainForm"; import { AudioPreview, Page } from "./types"; import SelectStationsAndZones from "./SelectStationsAndZones"; import AssociateAlert from "./AssociateAlert"; +import StaticTemplatePage from "./StaticTemplatePage"; import { Alert, InformedEntity } from "Models/alert"; import { usePlacesWithPaEss } from "Hooks/usePlacesWithPaEss"; import Toast from "Components/Toast"; import { busRouteIdsAtPlaces, getRouteIdsForSign } from "../../../util"; import fp from "lodash/fp"; +import { StaticTemplate } from "Models/static_template"; +import { MessageType } from "Models/pa_message"; interface PaMessageFormData { alert_id: string | null; @@ -20,6 +23,8 @@ interface PaMessageFormData { interval_in_minutes: number; visual_text: string; audio_text: string; + message_type: MessageType; + template_id: number | null; } interface Props { @@ -31,6 +36,7 @@ interface Props { errors: string[]; defaultValues?: Partial; defaultAlert?: Alert | string | null; + defaultTemplate?: StaticTemplate | null; defaultAudioState?: AudioPreview; paused: boolean; } @@ -44,11 +50,13 @@ const PaMessageForm = ({ onSubmit, defaultValues, defaultAlert, + defaultTemplate, defaultAudioState, paused, }: Props) => { const [page, setPage] = useState(Page.MAIN); const now = moment(); + const defaultPriority = 2; const [associatedAlert, setAssociatedAlert] = useState( () => { @@ -75,7 +83,7 @@ const PaMessageForm = ({ return defaultValues?.days_of_week ?? [1, 2, 3, 4, 5, 6, 7]; }); const [priority, setPriority] = useState(() => { - return defaultValues?.priority ?? 2; + return defaultValues?.priority ?? defaultPriority; }); const [interval, setInterval] = useState(() => { return defaultValues?.interval_in_minutes @@ -98,6 +106,8 @@ const PaMessageForm = ({ const [audioState, setAudioState] = useState( () => defaultAudioState ?? AudioPreview.Unreviewed, ); + const [selectedTemplate, setSelectedTemplate] = + useState(defaultTemplate ?? null); const onClearAssociatedAlert = () => { setEndDateTime(moment(startDateTime).add(1, "hour")); @@ -105,6 +115,14 @@ const PaMessageForm = ({ setEndWithEffectPeriod(false); }; + const onClearSelectedTemplate = () => { + setSelectedTemplate(null); + setVisualText(""); + setPhoneticText(""); + setAudioState(AudioPreview.Unreviewed); + setPriority(defaultPriority); + }; + const onImportMessage = (alertMessage: string) => { if (audioState !== AudioPreview.Unreviewed) setAudioState(AudioPreview.Outdated); @@ -141,6 +159,16 @@ const PaMessageForm = ({ setSignIds(fp.uniq(importedSigns)); }; + useEffect(() => { + const priorityToIntervalMap: { [priority: number]: string } = { + 1: "1", + 2: "4", + 3: "10", + 4: "12", + }; + setInterval(priorityToIntervalMap[priority]); + }, [priority]); + return (
{[Page.STATIONS, Page.ZONES].includes(page) && ( @@ -230,6 +262,19 @@ const PaMessageForm = ({ onCancel={() => setPage(Page.MAIN)} /> )} + {page === Page.TEMPLATES && ( + setPage(Page.MAIN)} + onSelect={(template) => { + setSelectedTemplate(template); + setVisualText(template.visual_text); + setPhoneticText(template.audio_text); + setPriority(template.type === "psa" ? 4 : 1); + setAudioState(AudioPreview.Reviewed); + setPage(Page.MAIN); + }} + /> + )} void; + onSelect: (template: StaticTemplate) => void; +} + +type TemplateType = "psa" | "emergency"; + +export const STATIC_TEMPLATES = _staticTemplates as StaticTemplate[]; + +const StaticTemplatePage = ({ onCancel, onSelect }: Props) => { + const [selectedTemplateType, setSelectedTemplateType] = + useState("psa"); + + return ( +
+ + + Select template + + + + + + + + setSelectedTemplateType(templateType as TemplateType) + } + filters={[ + { label: "PSAs", value: "psa" }, + { label: "Emergency", value: "emergency" }, + ]} + /> + + + template.type === selectedTemplateType, + )} + selectedTemplateType={selectedTemplateType} + onSelect={onSelect} + /> + + + +
+ ); +}; + +interface StaticTemplateTableProps { + templates: StaticTemplate[]; + selectedTemplateType: TemplateType; + onSelect: (template: StaticTemplate) => void; +} + +const StaticTemplateTable = ({ + templates, + selectedTemplateType, + onSelect, +}: StaticTemplateTableProps) => { + return ( +
+
+ {selectedTemplateType === "psa" ? "PSAs" : "Emergency"} +
+ + + + + + + + + {templates.map((template) => { + return ( + onSelect(template)} + > + + + + ); + })} + +
Message
+
{template.title}
+
{template.visual_text}
+
+ +
+
+ ); +}; + +export default StaticTemplatePage; diff --git a/assets/js/components/Dashboard/PaMessageForm/types.ts b/assets/js/components/Dashboard/PaMessageForm/types.ts index 01f83f290..4565ecf7f 100644 --- a/assets/js/components/Dashboard/PaMessageForm/types.ts +++ b/assets/js/components/Dashboard/PaMessageForm/types.ts @@ -10,4 +10,5 @@ export enum Page { STATIONS = "stations", ALERTS = "alerts", ZONES = "zones", + TEMPLATES = "templates", } diff --git a/assets/js/models/pa_message.ts b/assets/js/models/pa_message.ts index f30bcc308..ef22d5f59 100644 --- a/assets/js/models/pa_message.ts +++ b/assets/js/models/pa_message.ts @@ -13,8 +13,12 @@ export interface PaMessage { saved: boolean; inserted_at: string; updated_at: string; + message_type: MessageType; + template_id: number | null; } +export type MessageType = null | "psa" | "emergency"; + export type NewPaMessageBody = Omit< PaMessage, "id" | "paused" | "saved" | "inserted_at" | "updated_at" diff --git a/assets/js/models/static_template.ts b/assets/js/models/static_template.ts new file mode 100644 index 000000000..24c2ee7ca --- /dev/null +++ b/assets/js/models/static_template.ts @@ -0,0 +1,9 @@ +import { type MessageType } from "./pa_message"; + +export interface StaticTemplate { + id: number; + title: string; + visual_text: string; + audio_text: string; + type: MessageType; +} diff --git a/assets/static/static_templates.json b/assets/static/static_templates.json new file mode 100644 index 000000000..26ccda505 --- /dev/null +++ b/assets/static/static_templates.json @@ -0,0 +1,146 @@ +[ + { + "id": 1, + "title": "Bicycles (English)", + "visual_text": "To make space for other passengers, bicycles are not permitted on trains during rush hour, or at any time of day on the Green Line. For details visit mbta.com/bikes.", + "audio_text": "To make space for other passengers, bicycles are not permitted on trains during rush hour, or at any time of day on the Green Line. For details visit mbta.com/bikes.", + "type": "psa", + "archived": false + }, + { + "id": 2, + "title": "Backpacks (English)", + "visual_text": "To make space for other passengers and speed up boarding, please take off your backpack before entering the train and hold it at your side.", + "audio_text": "To make space for other passengers and speed up boarding, please take off your backpack before entering the train and hold it at your side.", + "type": "psa", + "archived": false + }, + { + "id": 3, + "title": "Track Fires (English)", + "visual_text": "Delays due to fires are often caused by trash on the tracks. Please help us keep our stations clean by throwing away or recycling unwanted items.", + "audio_text": "Delays due to fires are often caused by trash on the tracks. Please help us keep our stations clean by throwing away or recycling unwanted items.", + "type": "psa", + "archived": false + }, + { + "id": 4, + "title": "Bicycles (English + Spanish)", + "visual_text": "To make space for other passengers, bicycles are not permitted on trains during rush hour, or at any time of day on the Green Line. For details visit mbta.com/bikes.\n\nPara dejar espacio para otros pasajeros, bicicletas no son permitidas en los trenes durante hora pico y nunca son permitidas en la linea verde. Para mas informacion, visite mbta.com/bikes", + "audio_text": "To make space for other passengers, bicycles are not permitted on trains during rush hour, or at any time of day on the Green Line. For details visit mbta.com/bikes.\n\n Para dejar espacio para otros pasajeros, bicicletas no son permitidas en los trenes durante hora pico y nunca son permitidas en la línea verde. Para más información, visite [mbta.com/bikes](http://mbta.com/bikes) ", + "type": "psa", + "archived": false + }, + { + "id": 5, + "title": "Backpacks (English + Spanish)", + "visual_text": "To make space for other passengers and speed up boarding, please take off your backpack before entering the train and hold it at your side.\n\nPara dejar espacio para otros pasajeros y acelerar el embarque, por favor quitese la mochila y mantengala a su lado.", + "audio_text": "To make space for other passengers and speed up boarding, please take off your backpack before entering the train and hold it at your side.\n\n Para dejar espacio para otros pasajeros y acelerar el embarque, por favor quítese la mochila y manténgala a su lado. ", + "type": "psa", + "archived": false + }, + { + "id": 6, + "title": "Track Fires (English + Spanish)", + "visual_text": "Delays due to fires are often caused by trash on the tracks. Please help us keep our stations clean by throwing away or recycling unwanted items.\n\nRetrasos debidos a incendios, muchas veces son causados por basura en las rieles. Por favor, ayudenos a mantener nuestras estaciones limpias tirando o reciclando sus articulos no deseados.", + "audio_text": "Delays due to fires are often caused by trash on the tracks. Please help us keep our stations clean by throwing away or recycling unwanted items.\n\n Retrasos debidos a incendios, muchas veces son causados por basura en las rieles. Por favor, ayúdenos a mantener nuestras estaciones limpias tirando o reciclando sus artículos no deseados. ", + "type": "psa", + "archived": false + }, + { + "id": 7, + "title": "Safety: See something, say something", + "visual_text": "Safety Reminder: If you see something, say something. Call Transit Police at 617-222-1212", + "audio_text": "Safety Reminder: If you see something, say something. Call Transit Police at 617-222-1212", + "type": "psa", + "archived": false + }, + { + "id": 8, + "title": "Safety: Don't scroll and stroll", + "visual_text": "Safety Reminder: Don't scroll and stroll. Please pay attention to your surroundings and other riders.", + "audio_text": "Safety Reminder: Don't scroll and stroll. Please pay attention to your surroundings and other riders.", + "type": "psa", + "archived": false + }, + { + "id": 9, + "title": "Safety: Do not run car to car", + "visual_text": "Safety Reminder: Do not run from car to car. It can cause injuries to yourself and others.", + "audio_text": "Safety Reminder: Do not run from car to car. It can cause injuries to yourself and others.", + "type": "psa", + "archived": false + }, + { + "id": 10, + "title": "Safety: Closing doors", + "visual_text": "Safety Reminder: Please watch for closing doors for your own safety.", + "audio_text": "Safety Reminder: Please watch for closing doors for your own safety.", + "type": "psa", + "archived": false + }, + { + "id": 11, + "title": "Safety: Follow instructions", + "visual_text": "Safety Reminder: In the event of an emergency, follow instructions from MBTA personnel for your safety.", + "audio_text": "Safety Reminder: In the event of an emergency, follow instructions from MBTA personnel for your safety.", + "type": "psa", + "archived": false + }, + { + "id": 12, + "title": "Safety: Don't throw trash", + "visual_text": "Do not throw trash on the tracks. It can cause track fires and risk your safety.", + "audio_text": "Safety Reminder: Do not throw trash on the tracks. It can cause track fires and risk your safety.", + "type": "psa", + "archived": false + }, + { + "id": 13, + "title": "Emergency in station", + "visual_text": "Attention Passengers. An emergency situation has been reported, please leave the station through the nearest exit.", + "audio_text": "Attention Passengers. An emergency situation has been reported, please leave the station through the nearest exit.", + "type": "emergency", + "archived": false + }, + { + "id": 14, + "title": "Emergency outside station", + "visual_text": "Attention Passengers. An emergency situation has been reported outside the station. Please remain in the station until advised otherwise by MBTA or emergency personnel.", + "audio_text": "Attention Passengers. An emergency situation has been reported outside the station. Please remain in the station until advised otherwise by MBTA or emergency personnel.", + "type": "emergency", + "archived": false + }, + { + "id": 15, + "title": "Emergency follow instructions", + "visual_text": "Attention Passengers. An emergency situation has been reported. Please follow the instructions of MBTA or emergency personnel.", + "audio_text": "Attention Passengers. An emergency situation has been reported. Please follow the instructions of MBTA or emergency personnel.", + "type": "emergency", + "archived": false + }, + { + "id": 16, + "title": "Stand behind line", + "visual_text": "Attention Passengers. Please stand behind the yellow safety line, a train is approaching the station.", + "audio_text": "Attention Passengers. Please stand behind the yellow safety line, a train is approaching the station.", + "type": "emergency", + "archived": false + }, + { + "id": 17, + "title": "Emergency situation ended", + "visual_text": "Attention Passengers. The emergency situation has ended. It is now safe to exit the station.", + "audio_text": "Attention Passengers. The emergency situation has ended. It is now safe to exit the station.", + "type": "emergency", + "archived": false + }, + { + "id": 18, + "title": "Emergency TEST", + "visual_text": "This is a test of the emergency messaging system. This is not an emergency, this is a test.", + "audio_text": "This is a test of the emergency messaging system. This is not an emergency, this is a test.", + "type": "emergency", + "archived": false + } +] diff --git a/lib/screenplay/alerts/cache.ex b/lib/screenplay/alerts/cache.ex index dcea501af..7c5b08e8c 100644 --- a/lib/screenplay/alerts/cache.ex +++ b/lib/screenplay/alerts/cache.ex @@ -24,7 +24,7 @@ defmodule Screenplay.Alerts.Cache do @doc """ Retrieves an alert struct given an alert ID. """ - @spec alert(String.t()) :: Alert.t() + @spec alert(String.t()) :: Alert.t() | nil def alert(alert_id) do case :ets.match(:alerts, {alert_id, :"$1"}) do [[alert]] -> alert diff --git a/lib/screenplay/pa_messages/pa_message.ex b/lib/screenplay/pa_messages/pa_message.ex index 3a90f9860..83b3ec474 100644 --- a/lib/screenplay/pa_messages/pa_message.ex +++ b/lib/screenplay/pa_messages/pa_message.ex @@ -7,6 +7,8 @@ defmodule Screenplay.PaMessages.PaMessage do @derive {Jason.Encoder, except: [:__meta__]} + @type message_type :: nil | :psa | :emergency + @type t() :: %__MODULE__{ alert_id: String.t() | nil, start_datetime: DateTime.t(), @@ -19,7 +21,8 @@ defmodule Screenplay.PaMessages.PaMessage do audio_text: String.t(), paused: boolean() | nil, saved: boolean() | nil, - message_type: String.t() | nil, + message_type: message_type(), + template_id: non_neg_integer() | nil, inserted_at: DateTime.t(), updated_at: DateTime.t() } @@ -36,7 +39,8 @@ defmodule Screenplay.PaMessages.PaMessage do field(:audio_text, :string) field(:paused, :boolean) field(:saved, :boolean) - field(:message_type, :string) + field(:message_type, Ecto.Enum, values: [nil, :psa, :emergency]) + field(:template_id, :integer) timestamps(type: :utc_datetime) end @@ -54,7 +58,9 @@ defmodule Screenplay.PaMessages.PaMessage do :visual_text, :audio_text, :paused, - :saved + :saved, + :message_type, + :template_id ]) |> validate_required([ :start_datetime, diff --git a/priv/repo/migrations/20241115154029_add_template_id_column.exs b/priv/repo/migrations/20241115154029_add_template_id_column.exs new file mode 100644 index 000000000..0e29dd013 --- /dev/null +++ b/priv/repo/migrations/20241115154029_add_template_id_column.exs @@ -0,0 +1,9 @@ +defmodule Screenplay.Repo.Migrations.AddTemplateIdColumn do + use Ecto.Migration + + def change do + alter table("pa_message") do + add :template_id, :integer + end + end +end diff --git a/test/screenplay/alerts/cache_test.exs b/test/screenplay/alerts/cache_test.exs index a4554cb12..4b3854561 100644 --- a/test/screenplay/alerts/cache_test.exs +++ b/test/screenplay/alerts/cache_test.exs @@ -1,37 +1,12 @@ defmodule Screenplay.Alerts.CacheTest do use ExUnit.Case + alias Screenplay.AlertsCacheHelpers alias Screenplay.Alerts.{Alert, Cache} - defp alert_json(id) do - %{ - "id" => id, - "attributes" => %{ - "active_period" => [], - "created_at" => nil, - "updated_at" => nil, - "cause" => nil, - "effect" => nil, - "header" => nil, - "informed_entity" => [], - "lifecycle" => nil, - "severity" => nil, - "timeframe" => nil, - "url" => nil, - "description" => nil - } - } - end - test "It polls an API and updates the store" do - get_json_fn = fn "/alerts", %{"include" => "routes"} -> - {:ok, %{"data" => [alert_json("1")], "included" => []}} - end - - {:ok, fetcher} = - start_supervised({Cache, get_json_fn: get_json_fn, update_interval_ms: 10_000}) - - send(fetcher, :fetch) + now = DateTime.utc_now() + AlertsCacheHelpers.seed_alerts_cache(1, now, now) _ = await_updated() assert [%Alert{id: "1"}] = Cache.alerts() diff --git a/test/screenplay/pa_messages_test.exs b/test/screenplay/pa_messages_test.exs index bd1401093..3b4cbdb98 100644 --- a/test/screenplay/pa_messages_test.exs +++ b/test/screenplay/pa_messages_test.exs @@ -3,6 +3,7 @@ defmodule Screenplay.PaMessagesTest do import Screenplay.Factory + alias Screenplay.AlertsCacheHelpers alias Screenplay.PaMessages alias Screenplay.PaMessages.PaMessage @@ -41,19 +42,7 @@ defmodule Screenplay.PaMessagesTest do test "returns messages linked to an existing alert" do now = ~U[2024-05-01T05:00:00Z] - get_json_fn = fn "/alerts", %{"include" => "routes"} -> - {:ok, - %{ - "data" => [ - alert_json("1", ~U[2024-05-01T04:00:00Z], ~U[2024-05-01T06:00:00Z]), - alert_json("2", ~U[2024-04-01T04:00:00Z], ~U[2024-04-01T06:00:00Z]) - ], - "included" => [] - }} - end - - {:ok, fetcher} = start_supervised({Screenplay.Alerts.Cache, get_json_fn: get_json_fn}) - send(fetcher, :fetch) + AlertsCacheHelpers.seed_alerts_cache(2, ~U[2024-05-01T04:00:00Z], ~U[2024-05-01T06:00:00Z]) insert(:pa_message, %{ alert_id: "1", @@ -97,18 +86,7 @@ defmodule Screenplay.PaMessagesTest do test "returns messages linked to an existing alert using custom schedule" do now = ~U[2024-05-01T12:00:00Z] - get_json_fn = fn "/alerts", %{"include" => "routes"} -> - {:ok, - %{ - "data" => [ - alert_json("1", ~U[2024-05-01T04:00:00Z], ~U[2024-05-01T06:00:00Z]) - ], - "included" => [] - }} - end - - {:ok, fetcher} = start_supervised({Screenplay.Alerts.Cache, get_json_fn: get_json_fn}) - send(fetcher, :fetch) + AlertsCacheHelpers.seed_alerts_cache(1, ~U[2024-05-01T04:00:00Z], ~U[2024-05-01T06:00:00Z]) insert(:pa_message, %{ id: 1, @@ -219,26 +197,4 @@ defmodule Screenplay.PaMessagesTest do assert {:error, %Ecto.Changeset{}} = PaMessages.update_message(pa_message, %{sign_ids: []}) end end - - defp alert_json(id, start_dt, end_dt) do - %{ - "id" => id, - "attributes" => %{ - "active_period" => [ - %{"start" => DateTime.to_iso8601(start_dt), "end" => DateTime.to_iso8601(end_dt)} - ], - "created_at" => nil, - "updated_at" => nil, - "cause" => nil, - "effect" => nil, - "header" => nil, - "informed_entity" => [], - "lifecycle" => nil, - "severity" => nil, - "timeframe" => nil, - "url" => nil, - "description" => nil - } - } - end end diff --git a/test/screenplay_web/controllers/pa_messages_api_controller_test.exs b/test/screenplay_web/controllers/pa_messages_api_controller_test.exs index b755ccbfd..ff1297010 100644 --- a/test/screenplay_web/controllers/pa_messages_api_controller_test.exs +++ b/test/screenplay_web/controllers/pa_messages_api_controller_test.exs @@ -1,17 +1,15 @@ defmodule ScreenplayWeb.PaMessagesApiControllerTest do use ScreenplayWeb.ConnCase + alias Screenplay.AlertsCacheHelpers alias Screenplay.Places.{Cache, Place} alias Screenplay.Places.Place.PaEssScreen import Screenplay.Factory setup_all do - get_json_fn = fn "alerts", %{"include" => "routes"} -> - {:ok, %{"data" => [], "included" => []}} - end + AlertsCacheHelpers.seed_alerts_cache(2, ~U[2024-05-01T04:00:00Z], ~U[2024-05-01T06:00:00Z]) - start_supervised!({Screenplay.Alerts.Cache, get_json_fn: get_json_fn}) start_supervised!(Screenplay.Places.Cache) :ok @@ -365,10 +363,23 @@ defmodule ScreenplayWeb.PaMessagesApiControllerTest do audio_text: "Audio Text" }) - assert %{"id" => 1} = - conn - |> get("/api/pa-messages/1") - |> json_response(200) + assert %{"id" => 1} = conn |> get("/api/pa-messages/1") |> json_response(200) + end + + @tag :authenticated_pa_message_admin + test "returns the PA message with the given ID and its associated alert", %{conn: conn} do + insert(:pa_message, %{ + id: 1, + start_datetime: ~U[2024-05-01T01:00:00Z], + end_datetime: ~U[2024-05-01T13:00:00Z], + days_of_week: [1, 2, 3, 4, 5, 6, 7], + inserted_at: ~U[2024-05-01T01:00:00Z], + visual_text: "Visual Text", + audio_text: "Audio Text", + alert_id: "1" + }) + + assert %{"id" => 1} = conn |> get("/api/pa-messages/1") |> json_response(200) end @tag :authenticated_pa_message_admin diff --git a/test/support/alerts_cache_helpers.ex b/test/support/alerts_cache_helpers.ex new file mode 100644 index 000000000..8c6be35a1 --- /dev/null +++ b/test/support/alerts_cache_helpers.ex @@ -0,0 +1,55 @@ +defmodule Screenplay.AlertsCacheHelpers do + @moduledoc """ + Helper functions for seeding Screenplay.Alerts.Cache. + """ + + use ExUnit.CaseTemplate + + alias Screenplay.Alerts.Cache, as: AlertsCache + + def seed_alerts_cache(num_alerts, start_dt, end_dt) do + get_json_fn = fn "/alerts", %{"include" => "routes"} -> + {:ok, + %{ + "data" => + Enum.map( + 1..num_alerts, + &alert_json( + Integer.to_string(&1), + start_dt, + end_dt + ) + ), + "included" => [] + }} + end + + {:ok, fetcher} = start_supervised({AlertsCache, get_json_fn: get_json_fn}) + send(fetcher, :fetch) + end + + defp alert_json(id, start_dt, end_dt) do + start_iso8601 = DateTime.to_iso8601(start_dt) + end_iso8601 = DateTime.to_iso8601(end_dt) + + %{ + "id" => id, + "attributes" => %{ + "active_period" => [ + %{"start" => start_iso8601, "end" => end_iso8601} + ], + "created_at" => start_iso8601, + "updated_at" => start_iso8601, + "cause" => nil, + "effect" => nil, + "header" => nil, + "informed_entity" => [], + "lifecycle" => nil, + "severity" => nil, + "timeframe" => nil, + "url" => nil, + "description" => nil + } + } + end +end