Repository: cyverse-de/sonora
Pull request #628: Global Alerts
Author: login: ianmcorvidae
Created: 2025-04-01T20:38:33Z | Merged: 2025-05-09T18:40:22Z

I think this is ready for review, finally.

I'm using our included "warning" colors for the bar rather than a harsh red, now. Markdown is supported for formatting the header text, which allows adding things like links if desired as well.


Diff:
diff --git a/public/static/locales/en/alerts-admin.json b/public/static/locales/en/alerts-admin.json
new file mode 100644
index 000000000..afd86f397
--- /dev/null
+++ b/public/static/locales/en/alerts-admin.json
@@ -0,0 +1,6 @@
+{
+    "alertText": "Alert Text",
+    "startDate": "Start Date",
+    "endDate": "End Date",
+    "none": "None"
+}
diff --git a/public/static/locales/en/common.json b/public/static/locales/en/common.json
index 8cd9694d6..00208adb8 100644
--- a/public/static/locales/en/common.json
+++ b/public/static/locales/en/common.json
@@ -4,6 +4,7 @@
     "add": "Add",
     "admin": "Admin",
     "all": "All",
+    "alerts": "Alerts",
     "analyses": "Analyses",
     "apply": "Apply",
     "apps": "Apps",
diff --git a/src/components/alerts/admin/AlertsAdmin.js b/src/components/alerts/admin/AlertsAdmin.js
new file mode 100644
index 000000000..d258df246
--- /dev/null
+++ b/src/components/alerts/admin/AlertsAdmin.js
@@ -0,0 +1,222 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Skeleton,
+    Stack,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormMultilineTextField from "components/forms/FormMultilineTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Stack
+                        direction="row"
+                        spacing={2}
+                        alignItems="flex-start"
+                        justifyContent="flex-start"
+                    >
+                        <Field
+                            component={FormMultilineTextField}
+                            name="alertText"
+                            label={t("alerts-admin:alertText")}
+                            required={true}
+                            sx={{ minWidth: "50ch", width: 0 }}
+                        />
+                        <Field
+                            component={FormTimestampField}
+                            name="startDate"
+                            helperText={t("alerts-admin:startDate")}
+                            required={false}
+                            sx={{ minWidth: "25ch" }}
+                        />
+                        <Field
+                            component={FormTimestampField}
+                            name="endDate"
+                            helperText={t("alerts-admin:endDate")}
+                            required={true}
+                            sx={{ minWidth: "25ch" }}
+                        />
+                        <Button
+                            type="submit"
+                            variant="contained"
+                            color="primary"
+                            startIcon={<AddIcon />}
+                        >
+                            {t("common:add")}
+                        </Button>
+                    </Stack>
+                </Form>
+            )}
+        </Formik>
+    );
+};
+
+const AlertsEditor = (props) => {
+    const [alertsList, setAlertsList] = useState([]);
+
+    const queryClient = useQueryClient();
+
+    const { t } = useTranslation(["alerts-admin", "common"]);
+
+    function updateAlerts(queryresp) {
+        setAlertsList(queryresp?.alerts || []);
+    }
+
+    const { isFetching: isFetchingList, error: loadingError } = useQuery({
+        queryKey: ALL_ALERTS_QUERY_KEY,
+        queryFn: allAlerts,
+        enabled: true,
+        onSuccess: updateAlerts,
+    });
+
+    const {
+        mutate: addAlertMutation,
+        error: addAlertError,
+        isLoading: isAddingAlert,
+    } = useMutation(addAlert, {
+        onSuccess: (createdAlert) => {
+            queryClient.invalidateQueries(ALL_ALERTS_QUERY_KEY);
+            queryClient.invalidateQueries(ACTIVE_ALERTS_QUERY_KEY);
+        },
+    });
+
+    const {
+        mutate: removeAlertMutation,
+        error: removeAlertError,
+        isLoading: isRemovingAlert,
+    } = useMutation(removeAlert, {
+        onSuccess: (resp) => {
+            queryClient.invalidateQueries(ALL_ALERTS_QUERY_KEY);
+            queryClient.invalidateQueries(ACTIVE_ALERTS_QUERY_KEY);
+        },
+    });
+
+    const isLoading = isQueryLoading([
+        isFetchingList,
+        isAddingAlert,
+        isRemovingAlert,
+    ]);
+
+    const error = loadingError || addAlertError || removeAlertError;
+
+    function formatAlert(formValues) {
+        const { alertText, startDate, endDate } = formValues;
+        const alertSpec = {
+            "alert-text": alertText,
+            "end-date": endDate
+                ? formatDateObject(new Date(endDate), dateConstants.ISO_8601)
+                : undefined,
+            "start-date": startDate
+                ? formatDateObject(new Date(startDate), dateConstants.ISO_8601)
+                : undefined,
+        };
+        return alertSpec;
+    }
+
+    function handleSubmit(formValues) {
+        addAlertMutation(formatAlert(formValues));
+    }
+
+    return isLoading ? (
+        <Skeleton
+            variant="rectangular"
+            animation="wave"
+            height={300}
+            width="100%"
+        />
+    ) : error ? (
+        <WrappedErrorHandler errorObject={error} />
+    ) : (
+        <Paper>
+            <AddAlertForm t={t} handleSubmit={handleSubmit} />
+            <TableContainer>
+                <Table>
+                    <TableHead>
+                        <TableRow>
+                            <TableCell>{t("alerts-admin:alertText")}</TableCell>
+                            <TableCell>{t("alerts-admin:startDate")}</TableCell>
+                            <TableCell>{t("alerts-admin:endDate")}</TableCell>
+                            <TableCell>{t("common:delete")}</TableCell>
+                        </TableRow>
+                    </TableHead>
+                    <TableBody>
+                        {alertsList.map((alertData, index) => {
+                            return (
+                                <TableRow key={index}>
+                                    <TableCell>{alertData["alert"]}</TableCell>
+                                    <TableCell>
+                                        {alertData["start_date"] ||
+                                            t("alerts-admin:none")}
+                                    </TableCell>
+                                    <TableCell>
+                                        {alertData["end_date"]}
+                                    </TableCell>
+                                    <TableCell>
+                                        <IconButton
+                                            onClick={(event) => {
+                                                event.stopPropagation();
+                                                event.preventDefault();
+
+                                                removeAlertMutation({
+                                                    "end-date":
+                                                        alertData["end_date"],
+                                                    "alert-text":
+                                                        alertData["alert"],
+                                                });
+                                            }}
+                                            size="large"
+                                        >
+                                            <DeleteIcon />
+                                        </IconButton>
+                                    </TableCell>
+                                </TableRow>
+                            );
+                        })}
+                    </TableBody>
+                </Table>
+            </TableContainer>
+        </Paper>
+    );
+};
+
+export default AlertsEditor;
diff --git a/src/components/layout/AdminDrawerItems.js b/src/components/layout/AdminDrawerItems.js
index a64d8fe0b..9672beee0 100644
--- a/src/components/layout/AdminDrawerItems.js
+++ b/src/components/layout/AdminDrawerItems.js
@@ -165,6 +165,21 @@ export default function AdminDrawerItems(props) {
                     {open && <ListItemText>{t("vice")}</ListItemText>}
                 </ListItem>
             </Tooltip>
+
+            <Tooltip title={t("alerts")} placement="right" arrow>
+                <ListItem
+                    button
+                    className={cx(classes.nested, classes.listItem)}
+                    onClick={() =>
+                        router.push("/" + NavigationConstants.ADMIN + "/alerts")
+                    }
+                >
+                    <ListItemIcon>
+                        <LabelImportantIcon className={classes.icon} />
+                    </ListItemIcon>
+                    {open && <ListItemText>{t("alerts")}</ListItemText>}
+                </ListItem>
+            </Tooltip>
         </>
     );
 }
diff --git a/src/components/layout/AppBar.js b/src/components/layout/AppBar.js
index 2a2f1d49f..178d91b76 100644
--- a/src/components/layout/AppBar.js
+++ b/src/components/layout/AppBar.js
@@ -20,6 +20,11 @@ import { useUserProfile } from "contexts/userProfile";
 import { useBootstrapInfo } from "contexts/bootstrap";
 import { intercomLogout } from "common/intercom";
 
+import {
+    activeAlerts,
+    ACTIVE_ALERTS_QUERY_KEY,
+} from "serviceFacades/notifications";
+
 import {
     getUserProfile,
     useBootStrap,
@@ -74,6 +79,8 @@ import {
 } from "serviceFacades/dashboard";
 import { getUserQuota } from "common/resourceUsage";
 
+import markdownToHtml from "components/utils/markdownToHtml";
+
 import useBreakpoints from "./useBreakpoints";
 
 // hidden in xsDown
@@ -127,6 +134,8 @@ function DEAppBar(props) {
         !!config?.subscriptions?.enforce
     );
     const [dataUsagePercentage, setDataUsagePercentage] = useState(0);
+    const [activeAlertsList, setActiveAlertsList] = useState([]);
+
     const { isSmUp, isSmDown } = useBreakpoints();
 
     if (activeView === NavigationConstants.APPS) {
@@ -159,6 +168,28 @@ function DEAppBar(props) {
         refetchInterval: profileRefetchInterval,
     });
 
+    function updateAlerts(queryresp) {
+        const alertsMdPromises = queryresp?.alerts.map((alertMap) =>
+            markdownToHtml(alertMap.alert)
+        );
+        Promise.all(alertsMdPromises).then((values) => {
+            if (
+                values.length !== activeAlertsList.length ||
+                !values.every((el, idx) => activeAlertsList[idx] === el)
+            ) {
+                setActiveAlertsList(values || []);
+            }
+        });
+    }
+
+    useQuery({
+        queryKey: ACTIVE_ALERTS_QUERY_KEY,
+        queryFn: activeAlerts,
+        enabled: true,
+        onSuccess: updateAlerts,
+        refetchInterval: 120000,
+    });
+
     useEffect(() => {
         if (clientConfig) {
             let config = { ...clientConfig };
@@ -402,6 +433,19 @@ function DEAppBar(props) {
                     [classes.appBarShift]: open,
                 })}
             >
+                {activeAlertsList?.map((text) => {
+                    return (
+                        <Toolbar
+                            key={text}
+                            variant="dense"
+                            className={classes.alertBar}
+                        >
+                            <Typography
+                                dangerouslySetInnerHTML={{ __html: text }}
+                            />
+                        </Toolbar>
+                    );
+                })}
                 <Toolbar>
                     {!isSmUp && (
                         <>
diff --git a/src/components/layout/styles.js b/src/components/layout/styles.js
index d7549a36a..8916cc791 100644
--- a/src/components/layout/styles.js
+++ b/src/components/layout/styles.js
@@ -24,6 +24,10 @@ const LayoutStyles = (theme) => ({
             }),
         },
     },
+    alertBar: {
+        backgroundColor: "#FFC972",
+        color: "#713916",
+    },
     drawerIcon: {
         flexShrink: 0,
         marginRight: theme.spacing(3.5),
diff --git a/src/pages/admin/alerts.js b/src/pages/admin/alerts.js
new file mode 100644
index 000000000..ae0719a1a
--- /dev/null
+++ b/src/pages/admin/alerts.js
@@ -0,0 +1,32 @@
+import React, { useState } from "react";
+
+import { serverSideTranslations } from "next-i18next/serverSideTranslations";
+
+import { i18n, RequiredNamespaces } from "i18n";
+import { useUserProfile } from "contexts/userProfile";
+import NotAuthorized from "components/error/NotAuthorized";
+
+import AlertsEditor from "components/alerts/admin/AlertsAdmin";
+
+export default function AlertsAdminPage() {
+    const profile = useUserProfile()[0];
+    if (!profile?.admin) {
+        return <NotAuthorized />;
+    } else {
+        return <AlertsEditor />;
+    }
+}
+
+export async function getServerSideProps({ locale }) {
+    const title = i18n.t("common:alerts");
+
+    return {
+        props: {
+            title,
+            ...(await serverSideTranslations(locale, [
+                "alerts-admin",
+                ...RequiredNamespaces,
+            ])),
+        },
+    };
+}
diff --git a/src/server/api/notifications.js b/src/server/api/notifications.js
index a13d5d938..1dd56de81 100644
--- a/src/server/api/notifications.js
+++ b/src/server/api/notifications.js
@@ -65,5 +65,52 @@ export default function notificationsRouter() {
         })
     );
 
+    logger.info("************ Adding Alerts handlers **********");
+    logger.info("adding the GET /alerts/all handler");
+    api.get(
+        "/alerts/all",
+        auth.authnTokenMiddleware,
+        terrainHandler({
+            method: "GET",
+            pathname: "/alerts/all",
+        })
+    );
+
+    logger.info("adding the GET /alerts/active handler");
+    api.get(
+        "/alerts/active",
+        auth.authnTokenMiddleware,
+        terrainHandler({
+            method: "GET",
+            pathname: "/alerts/active",
+        })
+    );
+
+    logger.info("adding the POST /admin/alerts handler");
+    api.post(
+        "/admin/alerts",
+        auth.authnTokenMiddleware,
+        terrainHandler({
+            method: "POST",
+            pathname: "/admin/alerts",
+            headers: {
+                "Content-Type": "application/json",
+            },
+        })
+    );
+
+    logger.info("adding the DELETE /admin/alerts handler");
+    api.delete(
+        "/admin/alerts",
+        auth.authnTokenMiddleware,
+        terrainHandler({
+            method: "DELETE",
+            pathname: "/admin/alerts",
+            headers: {
+                "Content-Type": "application/json",
+            },
+        })
+    );
+
     return api;
 }
diff --git a/src/serviceFacades/notifications.js b/src/serviceFacades/notifications.js
index 9f11485a0..2ae338593 100644
--- a/src/serviceFacades/notifications.js
+++ b/src/serviceFacades/notifications.js
@@ -5,6 +5,8 @@ import callApi from "../common/callApi";
 
 const NOTIFICATIONS_MESSAGES_QUERY_KEY = "fetchNotificationsMessagesKey";
 const NOTIFICATIONS_MARK_ALL_SEEN_KEY = "markAllSeenKey";
+const ACTIVE_ALERTS_QUERY_KEY = "fetchActiveAlerts";
+const ALL_ALERTS_QUERY_KEY = "fetchAllAlerts";
 
 function getNotifications({ filter, orderBy, order, limit, offset }) {
     return callApi({
@@ -43,11 +45,47 @@ function deleteNotifications(ids) {
     });
 }
 
+function allAlerts() {
+    return callApi({
+        endpoint: "/api/alerts/all",
+        method: "GET",
+    });
+}
+
+function activeAlerts() {
+    return callApi({
+        endpoint: "/api/alerts/active",
+        method: "GET",
+    });
+}
+
+function addAlert(alert_spec) {
+    return callApi({
+        endpoint: "/api/admin/alerts",
+        method: "POST",
+        body: alert_spec,
+    });
+}
+
+function removeAlert(alert_spec) {
+    return callApi({
+        endpoint: "/api/admin/alerts",
+        method: "DELETE",
+        body: alert_spec,
+    });
+}
+
 export {
     NOTIFICATIONS_MESSAGES_QUERY_KEY,
     NOTIFICATIONS_MARK_ALL_SEEN_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    ALL_ALERTS_QUERY_KEY,
     deleteNotifications,
     getNotifications,
     markSeen,
     markAllSeen,
+    allAlerts,
+    activeAlerts,
+    addAlert,
+    removeAlert,
 };
diff --git a/stories/AlertsAdmin.stories.js b/stories/AlertsAdmin.stories.js
new file mode 100644
index 000000000..034a312c4
--- /dev/null
+++ b/stories/AlertsAdmin.stories.js
@@ -0,0 +1,63 @@
+import React from "react";
+import AlertsEditor from "../src/components/alerts/admin/AlertsAdmin";
+import { mockAxios } from "./axiosMock";
+
+const emptyAlerts = { alerts: [] };
+const nonEmptyAlerts = {
+    alerts: [
+        {
+            end_date: "2024-01-01T00:00:00Z",
+            alert: "This is an alert in the past.",
+        },
+        {
+            end_date: "2100-01-01T00:00:00Z",
+            alert: "This is a testing alert far in the future.",
+        },
+    ],
+};
+
+const alertsAdminTestTemplate = (args) => {
+    const { mockAllAlerts } = args;
+    mockAxios.onGet("/api/alerts/all").reply(200, mockAllAlerts);
+    mockAxios.onPost("/api/admin/alerts").reply((config) => {
+        const {
+            "alert-text": alert,
+            "start-date": start_date,
+            "end-date": end_date,
+        } = JSON.parse(config.data);
+        mockAllAlerts.alerts.push({ alert, start_date, end_date });
+        return [201, {}];
+    });
+    mockAxios.onDelete("/api/admin/alerts").reply((config) => {
+        const d = JSON.parse(config.data);
+        const deleteIndex = mockAllAlerts.alerts.findIndex(
+            (alert) =>
+                alert.alert === d["alert-text"] &&
+                alert.end_date === d["end-date"]
+        );
+        if (deleteIndex !== -1) {
+            mockAllAlerts.alerts.splice(deleteIndex, 1);
+        }
+        return deleteIndex !== -1 ? [200, {}] : [500, {}];
+    });
+    return <AlertsEditor />;
+};
+
+export const NormalView = alertsAdminTestTemplate.bind({});
+NormalView.args = {
+    mockAllAlerts: emptyAlerts,
+};
+
+export const HasAlertsView = alertsAdminTestTemplate.bind({});
+HasAlertsView.args = {
+    mockAllAlerts: nonEmptyAlerts,
+};
+
+export default {
+    title: "Alerts / Alerts Editor",
+    component: AlertsEditor,
+    parameters: {
+        // This is the max delay allowed.
+        chromatic: { delay: 15000 },
+    },
+};
diff --git a/stories/AppBar.stories.js b/stories/AppBar.stories.js
index 99307e8fb..0df1abf37 100644
--- a/stories/AppBar.stories.js
+++ b/stories/AppBar.stories.js
@@ -135,6 +135,16 @@ const bag_data = {
     },
 };
 
+const emptyAlerts = { alerts: [] };
+const nonEmptyAlerts = {
+    alerts: [
+        {
+            end_date: "2100-01-01T00:00:00Z",
+            alert: "This is a *testing* alert, with some *formatting* and a [link](https://cyverse.org).",
+        },
+    ],
+};
+
 const appBarTestTemplate = (args) => {
     const {
         mockUserProfile,
@@ -144,6 +154,7 @@ const appBarTestTemplate = (args) => {
         mockRunningViceJobs,
         mockInstantLaunches,
         mockUsageSummary,
+        mockGlobalAlerts,
     } = args;
 
     mockAxios.onGet("/api/profile").reply(200, mockUserProfile);
@@ -183,6 +194,7 @@ const appBarTestTemplate = (args) => {
             },
         ],
     });
+    mockAxios.onGet("/api/alerts/active").reply(200, mockGlobalAlerts);
 
     return (
         <UserProfileProvider>
@@ -202,6 +214,7 @@ NormalView.args = {
     mockRunningViceJobs: runningViceJobs,
     mockInstantLaunches: instantLaunchNavDrawerMock,
     mockUsageSummary: usageSummaryResponse,
+    mockGlobalAlerts: emptyAlerts,
 };
 
 export const ComputeLimitExceeded = appBarTestTemplate.bind({});
@@ -213,6 +226,7 @@ ComputeLimitExceeded.args = {
     mockRunningViceJobs: runningViceJobs,
     mockInstantLaunches: instantLaunchNavDrawerMock,
     mockUsageSummary: usageSummaryComputeLimitExceededResponse,
+    mockGlobalAlerts: emptyAlerts,
 };
 
 export const DataUsage50percent = appBarTestTemplate.bind({});
@@ -224,6 +238,7 @@ DataUsage50percent.args = {
     mockRunningViceJobs: runningViceJobs,
     mockInstantLaunches: instantLaunchNavDrawerMock,
     mockUsageSummary: usageSummaryDiskUsage50percentResponse,
+    mockGlobalAlerts: emptyAlerts,
 };
 
 export const DataUsage75percent = appBarTestTemplate.bind({});
@@ -235,6 +250,7 @@ DataUsage75percent.args = {
     mockRunningViceJobs: runningViceJobs,
     mockInstantLaunches: instantLaunchNavDrawerMock,
     mockUsageSummary: usageSummaryDiskUsage75percentResponse,
+    mockGlobalAlerts: emptyAlerts,
 };
 
 export const DataUsage100percent = appBarTestTemplate.bind({});
@@ -246,6 +262,19 @@ DataUsage100percent.args = {
     mockRunningViceJobs: runningViceJobs,
     mockInstantLaunches: instantLaunchNavDrawerMock,
     mockUsageSummary: usageSummaryDiskUsage100percentResponse,
+    mockGlobalAlerts: emptyAlerts,
+};
+
+export const HasGlobalAlerts = appBarTestTemplate.bind({});
+HasGlobalAlerts.args = {
+    mockUserProfile: mockUser,
+    mockBootstrapResponse: bootStrap,
+    mockNotificationsData: notificationsData,
+    mockBagData: bag_data,
+    mockRunningViceJobs: runningViceJobs,
+    mockInstantLaunches: instantLaunchNavDrawerMock,
+    mockUsageSummary: usageSummaryResponse,
+    mockGlobalAlerts: nonEmptyAlerts,
 };
 
 export default {
diff --git a/stories/vice/admin/VICEAdmin.stories.js b/stories/vice/admin/VICEAdmin.stories.js
index 4b25d0149..db98f363b 100644
--- a/stories/vice/admin/VICEAdmin.stories.js
+++ b/stories/vice/admin/VICEAdmin.stories.js
@@ -11,6 +11,7 @@ import {
     instantLaunchMetadata,
     instantLaunchMappings,
     publicQuicklaunches,
+    filetypeList,
 } from "./mocks";
 
 export default {
@@ -34,5 +35,6 @@ export const VICEAdminTest = () => {
     mockAxios
         .onGet(/\/api\/instantlaunches\/quicklaunches\/public.*/)
         .reply(200, publicQuicklaunches);
+    mockAxios.onGet("/api/filetypes/type-list").reply(200, filetypeList);
     return <VICEAdmin />;
 };
diff --git a/stories/vice/admin/mocks.js b/stories/vice/admin/mocks.js
index 687923cae..6e38b3f33 100644
--- a/stories/vice/admin/mocks.js
+++ b/stories/vice/admin/mocks.js
@@ -7006,3 +7006,62 @@ export const publicQuicklaunches = [
         },
     },
 ];
+
+export const filetypeList = {
+    types: [
+        "ace",
+        "bam",
+        "bash",
+        "bed",
+        "bedgz",
+        "bigbed",
+        "bigwig",
+        "blast",
+        "bowtie",
+        "clustalw",
+        "codata",
+        "csh",
+        "csv",
+        "embl",
+        "fasta",
+        "fastq",
+        "fastxy",
+        "game",
+        "gcg",
+        "gcgblast",
+        "gcgfasta",
+        "gde",
+        "genbank",
+        "genscan",
+        "gff",
+        "gtf",
+        "hmmer",
+        "ht-analysis-path-list",
+        "jar",
+        "mase",
+        "mega",
+        "msf",
+        "multi-input-path-list",
+        "newick",
+        "nexml",
+        "nexus",
+        "pdf",
+        "perl",
+        "phylip",
+        "phyloxml",
+        "pir",
+        "prodom",
+        "python",
+        "rsf",
+        "selex",
+        "sff",
+        "sh",
+        "stockholm",
+        "swiss",
+        "tcsh",
+        "tsv",
+        "vcf",
+        "vcfgz",
+        "zip",
+    ],
+};


Reviews:
user: login: psarando
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T23:49:48Z
user: login: ianmcorvidae
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T17:22:58Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces a global alerts feature with new API endpoints, UI updates, and Storybook stories to support both viewing and administrating alerts.  
- Adds a filetype list to support the alerts feature in mocks and related stories.  
- Integrates new alerts API endpoints into the service facade and server routes.  
- Implements UI components for displaying alerts (in the AppBar) and administrating alerts (in AlertsAdmin).

### Reviewed Changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| stories/vice/admin/mocks.js | Adds filetypeList constant used to simulate file types in the story mocking data. |
| stories/vice/admin/VICEAdmin.stories.js | Integrates filetypeList in the quicklaunch story for API simulation. |
| stories/AppBar.stories.js | Adds test cases for global alerts using empty and non-empty alerts mocks. |
| stories/AlertsAdmin.stories.js | Provides Storybook stories for the AlertsAdmin component. |
| src/serviceFacades/notifications.js | Introduces new API functions (activeAlerts, allAlerts, addAlert, removeAlert) for alerts. |
| src/server/api/notifications.js | Adds new REST endpoints for alerts, including GET, POST and DELETE handlers. |
| src/pages/admin/alerts.js | Creates an admin page for alerts with proper authorization check. |
| src/components/layout/styles.js | Defines styling for the alert bar using the theme’s warning palette. |
| src/components/layout/AppBar.js | Updates the AppBar to display active alerts using markdown-to-HTML conversion. |
| src/components/layout/AdminDrawerItems.js | Adds a navigation item for alerts within the admin drawer. |
| src/components/alerts/admin/AlertsAdmin.js | Implements an AlertsAdmin component for managing alerts via add/delete operations. |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/components/layout/AdminDrawerItems.js:169**
* [nitpick] Consider capitalizing the tooltip text to 'Alerts' for a consistent and user-friendly UI.
```
<Tooltip title={"alerts"} placement="right" arrow>
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-05T17:25:16Z
user: login: slr71
body: I didn't catch any issues.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-05T19:56:41Z
user: login: psarando
body: Looks good overall 👍 

I did catch some issues with i18n messages, plus I had a few other suggestions.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-06T00:55:34Z
user: login: ianmcorvidae
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T18:54:28Z
user: login: ianmcorvidae
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T19:03:46Z
user: login: ianmcorvidae
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T19:05:58Z
user: login: ianmcorvidae
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T20:51:33Z
user: login: psarando
body: LGTM 👍 

I had a few more suggestions and nitpicks, but nothing blocking, so feel free to tweak what you want, but we can :shipit: for now and do more improvements in a future PR(s).
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T02:20:58Z
user: login: ianmcorvidae
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T16:31:39Z
user: login: ianmcorvidae
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T16:33:54Z
user: login: ianmcorvidae
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T16:47:26Z
user: login: ianmcorvidae
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T19:49:00Z
user: login: psarando
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T21:58:23Z
user: login: psarando
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T22:03:54Z
user: login: psarando
body: LGTM 🎉 👍 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-08T22:04:08Z

Review comments:
diff_hunk: @@ -0,0 +1,74 @@
+import React from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import { Skeleton } from "@mui/material";
+
+import FormTextField from "components/forms/FormTextField";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Field
+                        component={FormTextField}
+                        name="alertText"
+                        required={true}
+                    />
+                    <Field
+                        component={FormTextField}
+                        name="endDate"
+                        required={true}
+                    />
+                    <Field
+                        component={FormTextField}
+                        name="startDate"
+                        required={false}
+                    />
+                </Form>
+            )}
+        </Formik>
path: src/components/alerts/admin/AlertsAdmin.js
user: login: psarando
body: 👍 
Also consider using the https://github.com/cyverse-de/sonora/blob/main/src/components/forms/FormTimestampField.js for the start and end date fields (see https://github.com/cyverse-de/sonora/blob/main/src/components/subscriptions/edit/EditSubscription.js for example).
created_at: 2025-04-15T23:49:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,74 @@
+import React from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import { Skeleton } from "@mui/material";
+
+import FormTextField from "components/forms/FormTextField";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Field
+                        component={FormTextField}
+                        name="alertText"
+                        required={true}
+                    />
+                    <Field
+                        component={FormTextField}
+                        name="endDate"
+                        required={true}
+                    />
+                    <Field
+                        component={FormTextField}
+                        name="startDate"
+                        required={false}
+                    />
+                </Form>
+            )}
+        </Formik>
path: src/components/alerts/admin/AlertsAdmin.js
user: login: ianmcorvidae
body: Ah, good! I had failed to find that before, but looks like it'll do well for that. Thanks!
created_at: 2025-04-25T17:22:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,270 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Typography,
+    Skeleton,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+import { makeStyles } from "tss-react/mui";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const useStyles = makeStyles()((theme) => ({
+    flexContainer: {
+        marginTop: 0,
+        marginLeft: theme.spacing(1),
+        marginBottom: theme.spacing(4),
+        marginRight: theme.spacing(2),
+        display: "flex",
+        flexWrap: "wrap",
+
+        "& .MuiTextField-root": {
+            width: 0,
+            margin: theme.spacing(1),
+            minWidth: "25ch",
+        },
+    },
+    flexItem: {
+        marginTop: 0,
+        marginBottom: 0,
+        marginRight: theme.spacing(2),
+        marginLeft: theme.spacing(2),
+    },
+}));
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    const { classes } = useStyles();
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form className={classes.flexContainer}>
+                    <Field
+                        component={FormTextField}
+                        name="alertText"
+                        label="Alert Text"
+                        required={true}
+                        className={classes.flexItem}
+                    />
+                    <Field
+                        component={FormTimestampField}
+                        name="startDate"
+                        helperText="Start Date"
+                        required={false}
+                        className={classes.flexItem}
+                    />
+                    <Field
+                        component={FormTimestampField}
+                        name="endDate"
+                        helperText="End Date"
+                        required={true}
+                        className={classes.flexItem}
+                    />
+                    <Button
+                        type="submit"
+                        variant="contained"
+                        color="primary"
+                        startIcon={<AddIcon />}
+                    >
+                        {t("common:add")}
+                    </Button>
+                </Form>
+            )}
+        </Formik>
+    );
+};
+
+const AlertsEditor = (props) => {
+    const [alertsList, setAlertsList] = useState([]);
+
+    const queryClient = useQueryClient();
+
+    const { t } = useTranslation(["common"]);
+
+    function updateAlerts(queryresp) {
+        setAlertsList(queryresp?.alerts || []);
+    }
+
+    const {
+        isLoading: isLoadingList,
+        isFetching: isFetchingList,
+        error: loadingError,
+        data,
+    } = useQuery({
+        queryKey: ALL_ALERTS_QUERY_KEY,
+        queryFn: allAlerts,
+        enabled: true,
+        onSuccess: updateAlerts,
+    });
+
+    const {
+        mutate: addAlertMutation,
+        error: addAlertError,
+        isLoading: isAddingAlert,
+    } = useMutation(addAlert, {
+        onSuccess: (createdAlert) => {
+            queryClient.invalidateQueries(ALL_ALERTS_QUERY_KEY);
+        },
+    });
+
+    const {
+        mutate: removeAlertMutation,
+        error: removeAlertError,
+        isLoading: isRemovingAlert,
+    } = useMutation(removeAlert, {
+        onSuccess: (resp) => {
+            queryClient.invalidateQueries(ALL_ALERTS_QUERY_KEY);
+        },
+    });
+
+    const isMutating = isQueryLoading([isAddingAlert, isRemovingAlert]);
+
+    const error = loadingError || addAlertError || removeAlertError;
+
+    function formatAlert(formValues) {
+        const { alertText, startDate, endDate } = formValues;
+        const alertSpec = {
+            "alert-text": alertText,
+            "end-date": endDate
+                ? formatDateObject(new Date(endDate), dateConstants.ISO_8601)
+                : undefined,
+            "start-date": startDate
+                ? formatDateObject(new Date(startDate), dateConstants.ISO_8601)
+                : undefined,
+        };
+        return alertSpec;
+    }
+
+    function handleSubmit(formValues) {
+        addAlertMutation(formatAlert(formValues));
+    }
+
+    return (
+        <div>
+            {isLoadingList ? (
+                <Skeleton
+                    variant="rectangular"
+                    animation="wave"
+                    height={300}
+                    width="100%"
+                />
+            ) : error ? (
+                <WrappedErrorHandler errorObject={error} />
+            ) : (
+                <Paper>
+                    {isMutating ? (
+                        <Skeleton
+                            variant="rectangular"
+                            animation="wave"
+                            height={150}
+                            width="100%"
+                        />
+                    ) : (
+                        <AddAlertForm t={t} handleSubmit={handleSubmit} />
+                    )}
+                    {isFetchingList || isMutating ? (
+                        <Skeleton
+                            variant="rectangular"
+                            animation="wave"
+                            height={200}
+                            width="100%"
+                        />
+                    ) : (
+                        <TableContainer>
+                            <Table>
+                                <TableHead>
+                                    <TableRow>
+                                        <TableCell>Start Date</TableCell>
+                                        <TableCell>End Date</TableCell>
+                                        <TableCell>Text</TableCell>
+                                        <TableCell>Delete</TableCell>
+                                    </TableRow>
+                                </TableHead>
+                                <TableBody>
+                                    {alertsList.map((alertData, index) => {
+                                        return (
+                                            <TableRow key={index}>
path: src/components/alerts/admin/AlertsAdmin.js
user: login: Copilot
body: [nitpick] Consider using a unique identifier as the key for TableRow instead of the array index to improve rendering performance when the list order changes.
```suggestion
                                    {alertsList.map((alertData) => {
                                        return (
                                            <TableRow key={`${alertData.start_date}-${alertData.alert}`}>
```
created_at: 2025-05-05T17:25:15Z
author_association: NONE
diff_hunk: @@ -159,6 +168,28 @@ function DEAppBar(props) {
         refetchInterval: profileRefetchInterval,
     });
 
+    function updateAlerts(queryresp) {
path: src/components/layout/AppBar.js
user: login: Copilot
body: [nitpick] Consider extracting the updateAlerts logic into a shared utility to avoid duplication between the AppBar and AlertsAdmin components.
created_at: 2025-05-05T17:25:16Z
author_association: NONE
path: src/pages/admin/alerts.js
user: login: psarando
body: This needs the `getServerSideProps` function in order to properly init i18n messages in this page, and it could use a custom `title` for browser history accessibility and usability (see other admin pages for examples).

created_at: 2025-05-05T21:21:52Z
author_association: MEMBER
diff_hunk: @@ -165,6 +165,21 @@ export default function AdminDrawerItems(props) {
                     {open && <ListItemText>{t("vice")}</ListItemText>}
                 </ListItem>
             </Tooltip>
+
+            <Tooltip title={"alerts"} placement="right" arrow>
+                <ListItem
+                    button
+                    className={cx(classes.nested, classes.listItem)}
+                    onClick={() =>
+                        router.push("/" + NavigationConstants.ADMIN + "/alerts")
+                    }
+                >
+                    <ListItemIcon>
+                        <LabelImportantIcon className={classes.icon} />
+                    </ListItemIcon>
+                    {open && <ListItemText>{"alerts"}</ListItemText>}
+                </ListItem>
+            </Tooltip>
path: src/components/layout/AdminDrawerItems.js
user: login: psarando
body: The `alerts` text in this list item and tooltip title should come from the `common` i18n messages.
created_at: 2025-05-05T21:53:46Z
author_association: MEMBER
diff_hunk: @@ -34,5 +35,6 @@ export const VICEAdminTest = () => {
     mockAxios
         .onGet(/\/api\/instantlaunches\/quicklaunches\/public.*/)
         .reply(200, publicQuicklaunches);
+    mockAxios.onGet("/api/filetypes/type-list").reply(200, filetypeList);
path: stories/vice/admin/VICEAdmin.stories.js
user: login: psarando
body: 🙌 
created_at: 2025-05-05T21:58:04Z
author_association: MEMBER
path: src/pages/admin/alerts.js
user: login: psarando
body: This is how the page looks in dev mode without i18n set up:
![Admin Alerts page](https://github.com/user-attachments/assets/dad803e7-5f94-40ed-b3a2-62684afc36b4)

created_at: 2025-05-05T22:50:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,66 @@
+import React from "react";
+import AlertsEditor from "../src/components/alerts/admin/AlertsAdmin";
+import { mockAxios } from "./axiosMock";
+
+//import testConfig from "./configMock";
path: stories/AlertsAdmin.stories.js
user: login: psarando
body: Looks like this line can be removed now?
created_at: 2025-05-05T22:51:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,66 @@
+import React from "react";
+import AlertsEditor from "../src/components/alerts/admin/AlertsAdmin";
+import { mockAxios } from "./axiosMock";
+
+//import testConfig from "./configMock";
+
+const emptyAlerts = { alerts: [] };
+const nonEmptyAlerts = {
+    alerts: [
+        {
+            end_date: "2024-01-01T00:00:00Z",
+            alert: "This is an alert in the past.",
+        },
+        {
+            end_date: "2100-01-01T00:00:00Z",
+            alert: "This is a testing alert far in the future.",
+        },
+    ],
+};
+
+const alertsAdminTestTemplate = (args) => {
+    const { mockAllAlerts } = args;
+    //mockAxios.onGet("/api/...").reply(200, mockDataSpec)
path: stories/AlertsAdmin.stories.js
user: login: psarando
body: This line can also be removed now?
created_at: 2025-05-05T22:51:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,66 @@
+import React from "react";
+import AlertsEditor from "../src/components/alerts/admin/AlertsAdmin";
+import { mockAxios } from "./axiosMock";
+
+//import testConfig from "./configMock";
+
+const emptyAlerts = { alerts: [] };
+const nonEmptyAlerts = {
+    alerts: [
+        {
+            end_date: "2024-01-01T00:00:00Z",
+            alert: "This is an alert in the past.",
+        },
+        {
+            end_date: "2100-01-01T00:00:00Z",
+            alert: "This is a testing alert far in the future.",
+        },
+    ],
+};
+
+const alertsAdminTestTemplate = (args) => {
+    const { mockAllAlerts } = args;
+    //mockAxios.onGet("/api/...").reply(200, mockDataSpec)
+    mockAxios.onGet("/api/alerts/all").reply(200, mockAllAlerts);
+    mockAxios.onPost("/api/admin/alerts").reply((config) => {
+        const {
+            "alert-text": alert,
+            "start-date": start_date,
+            "end-date": end_date,
+        } = JSON.parse(config.data);
+        mockAllAlerts.alerts.push({ alert, start_date, end_date });
+        return [201, {}];
path: stories/AlertsAdmin.stories.js
user: login: psarando
body: 👍 
created_at: 2025-05-05T22:52:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,66 @@
+import React from "react";
+import AlertsEditor from "../src/components/alerts/admin/AlertsAdmin";
+import { mockAxios } from "./axiosMock";
+
+//import testConfig from "./configMock";
+
+const emptyAlerts = { alerts: [] };
+const nonEmptyAlerts = {
+    alerts: [
+        {
+            end_date: "2024-01-01T00:00:00Z",
+            alert: "This is an alert in the past.",
+        },
+        {
+            end_date: "2100-01-01T00:00:00Z",
+            alert: "This is a testing alert far in the future.",
+        },
+    ],
+};
+
+const alertsAdminTestTemplate = (args) => {
+    const { mockAllAlerts } = args;
+    //mockAxios.onGet("/api/...").reply(200, mockDataSpec)
+    mockAxios.onGet("/api/alerts/all").reply(200, mockAllAlerts);
+    mockAxios.onPost("/api/admin/alerts").reply((config) => {
+        const {
+            "alert-text": alert,
+            "start-date": start_date,
+            "end-date": end_date,
+        } = JSON.parse(config.data);
+        mockAllAlerts.alerts.push({ alert, start_date, end_date });
+        return [201, {}];
+    });
+    mockAxios.onDelete("/api/admin/alerts").reply((config) => {
+        const d = JSON.parse(config.data);
+        const deleteIndex = mockAllAlerts.alerts.findIndex(
+            (alert) =>
+                alert.alert === d["alert-text"] &&
+                alert.end_date === d["end-date"]
+        );
+        if (deleteIndex !== -1) {
+            mockAllAlerts.alerts.splice(deleteIndex, 1);
+        }
+        return deleteIndex !== -1 ? [200, {}] : [500, {}];
path: stories/AlertsAdmin.stories.js
user: login: psarando
body: 👍 
created_at: 2025-05-05T22:58:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,270 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Typography,
path: src/components/alerts/admin/AlertsAdmin.js
user: login: psarando
body: My IDE says `Typography` is not used.
created_at: 2025-05-05T22:58:50Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,270 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Typography,
+    Skeleton,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
path: src/components/alerts/admin/AlertsAdmin.js
user: login: psarando
body: Looks like `ACTIVE_ALERTS_QUERY_KEY` is also not in use.
created_at: 2025-05-05T22:59:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,270 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Typography,
+    Skeleton,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+import { makeStyles } from "tss-react/mui";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const useStyles = makeStyles()((theme) => ({
+    flexContainer: {
+        marginTop: 0,
+        marginLeft: theme.spacing(1),
+        marginBottom: theme.spacing(4),
+        marginRight: theme.spacing(2),
+        display: "flex",
+        flexWrap: "wrap",
+
+        "& .MuiTextField-root": {
+            width: 0,
+            margin: theme.spacing(1),
+            minWidth: "25ch",
+        },
+    },
+    flexItem: {
+        marginTop: 0,
+        marginBottom: 0,
+        marginRight: theme.spacing(2),
+        marginLeft: theme.spacing(2),
+    },
+}));
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    const { classes } = useStyles();
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form className={classes.flexContainer}>
+                    <Field
+                        component={FormTextField}
+                        name="alertText"
+                        label="Alert Text"
+                        required={true}
+                        className={classes.flexItem}
+                    />
path: src/components/alerts/admin/AlertsAdmin.js
user: login: psarando
body: Rather than making custom styles with `tss-react/mui` for the Form and Fields, the Fields can be wrapped in a [Stack](https://v5.mui.com/material-ui/react-stack/) (or a [Grid](https://v5.mui.com/material-ui/react-grid/) for a combination of row and column layouts), which has some [simple props to control flex layout](https://v5.mui.com/material-ui/react-stack/#interactive-demo), and styles can be further tweaked on each component with the [sx prop](https://mui.com/system/getting-started/the-sx-prop/), if needed.

We installed [tss-react for backwards compatibility with MUI v4 styles](https://v5.mui.com/material-ui/integrations/interoperability/#jss-tss), but I personally feel the `sx` prop, and the Stack and Grid props, are simpler.
created_at: 2025-05-05T23:47:52Z
author_association: MEMBER
diff_hunk: @@ -165,6 +165,21 @@ export default function AdminDrawerItems(props) {
                     {open && <ListItemText>{t("vice")}</ListItemText>}
                 </ListItem>
             </Tooltip>
+
+            <Tooltip title={"alerts"} placement="right" arrow>
+                <ListItem
+                    button
+                    className={cx(classes.nested, classes.listItem)}
+                    onClick={() =>
+                        router.push("/" + NavigationConstants.ADMIN + "/alerts")
+                    }
+                >
+                    <ListItemIcon>
+                        <LabelImportantIcon className={classes.icon} />
+                    </ListItemIcon>
+                    {open && <ListItemText>{"alerts"}</ListItemText>}
+                </ListItem>
+            </Tooltip>
path: src/components/layout/AdminDrawerItems.js
user: login: ianmcorvidae
body: Ah, thanks. Forgot to go back to the layout and i18n it after I (mostly, heh) did it on the component itself.
created_at: 2025-05-06T18:54:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,270 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Typography,
+    Skeleton,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
path: src/components/alerts/admin/AlertsAdmin.js
user: login: ianmcorvidae
body: Ah, good catch. I should actually be invalidating that query when the lists of alerts change, so the person editing will see the correct stuff at the top of their own screen right away.
created_at: 2025-05-06T19:03:46Z
author_association: MEMBER
path: src/pages/admin/alerts.js
user: login: ianmcorvidae
body: I think I've now got this set up properly, hopefully.
created_at: 2025-05-06T19:05:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,270 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Typography,
+    Skeleton,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+import { makeStyles } from "tss-react/mui";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const useStyles = makeStyles()((theme) => ({
+    flexContainer: {
+        marginTop: 0,
+        marginLeft: theme.spacing(1),
+        marginBottom: theme.spacing(4),
+        marginRight: theme.spacing(2),
+        display: "flex",
+        flexWrap: "wrap",
+
+        "& .MuiTextField-root": {
+            width: 0,
+            margin: theme.spacing(1),
+            minWidth: "25ch",
+        },
+    },
+    flexItem: {
+        marginTop: 0,
+        marginBottom: 0,
+        marginRight: theme.spacing(2),
+        marginLeft: theme.spacing(2),
+    },
+}));
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    const { classes } = useStyles();
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form className={classes.flexContainer}>
+                    <Field
+                        component={FormTextField}
+                        name="alertText"
+                        label="Alert Text"
+                        required={true}
+                        className={classes.flexItem}
+                    />
path: src/components/alerts/admin/AlertsAdmin.js
user: login: ianmcorvidae
body: Yeah, that's looking a lot cleaner. I'm pushing that up shortly.
created_at: 2025-05-06T20:51:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,253 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Skeleton,
+    Stack,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Stack
+                        direction="row"
+                        spacing={2}
+                        alignItems="flex-start"
+                        justifyContent="flex-start"
+                    >
+                        <Field
+                            component={FormTextField}
+                            name="alertText"
+                            label="Alert Text"
+                            required={true}
+                            sx={{ minWidth: "25ch", width: 0 }}
+                        />
+                        <Field
+                            component={FormTimestampField}
+                            name="startDate"
+                            helperText="Start Date"
+                            required={false}
+                            sx={{ minWidth: "25ch" }}
+                        />
+                        <Field
+                            component={FormTimestampField}
+                            name="endDate"
+                            helperText="End Date"
+                            required={true}
+                            sx={{ minWidth: "25ch" }}
+                        />
+                        <Button
+                            type="submit"
+                            variant="contained"
+                            color="primary"
+                            startIcon={<AddIcon />}
+                        >
+                            {t("common:add")}
+                        </Button>
+                    </Stack>
+                </Form>
+            )}
+        </Formik>
+    );
+};
+
+const AlertsEditor = (props) => {
+    const [alertsList, setAlertsList] = useState([]);
+
+    const queryClient = useQueryClient();
+
+    const { t } = useTranslation(["common"]);
+
+    function updateAlerts(queryresp) {
+        setAlertsList(queryresp?.alerts || []);
+    }
+
+    const {
+        isLoading: isLoadingList,
+        isFetching: isFetchingList,
+        error: loadingError,
+        data,
path: src/components/alerts/admin/AlertsAdmin.js
user: login: psarando
body: My IDE says `data` is not in use.
created_at: 2025-05-07T01:36:50Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,253 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Skeleton,
+    Stack,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Stack
+                        direction="row"
+                        spacing={2}
+                        alignItems="flex-start"
+                        justifyContent="flex-start"
+                    >
+                        <Field
+                            component={FormTextField}
+                            name="alertText"
+                            label="Alert Text"
+                            required={true}
+                            sx={{ minWidth: "25ch", width: 0 }}
+                        />
+                        <Field
+                            component={FormTimestampField}
+                            name="startDate"
+                            helperText="Start Date"
+                            required={false}
+                            sx={{ minWidth: "25ch" }}
+                        />
+                        <Field
+                            component={FormTimestampField}
+                            name="endDate"
+                            helperText="End Date"
+                            required={true}
+                            sx={{ minWidth: "25ch" }}
+                        />
+                        <Button
+                            type="submit"
+                            variant="contained"
+                            color="primary"
+                            startIcon={<AddIcon />}
+                        >
+                            {t("common:add")}
+                        </Button>
+                    </Stack>
+                </Form>
+            )}
+        </Formik>
+    );
+};
+
+const AlertsEditor = (props) => {
+    const [alertsList, setAlertsList] = useState([]);
+
+    const queryClient = useQueryClient();
+
+    const { t } = useTranslation(["common"]);
+
+    function updateAlerts(queryresp) {
+        setAlertsList(queryresp?.alerts || []);
+    }
+
+    const {
+        isLoading: isLoadingList,
+        isFetching: isFetchingList,
+        error: loadingError,
+        data,
+    } = useQuery({
+        queryKey: ALL_ALERTS_QUERY_KEY,
+        queryFn: allAlerts,
+        enabled: true,
+        onSuccess: updateAlerts,
+    });
+
+    const {
+        mutate: addAlertMutation,
+        error: addAlertError,
+        isLoading: isAddingAlert,
+    } = useMutation(addAlert, {
+        onSuccess: (createdAlert) => {
+            queryClient.invalidateQueries(ALL_ALERTS_QUERY_KEY);
+            queryClient.invalidateQueries(ACTIVE_ALERTS_QUERY_KEY);
+        },
+    });
+
+    const {
+        mutate: removeAlertMutation,
+        error: removeAlertError,
+        isLoading: isRemovingAlert,
+    } = useMutation(removeAlert, {
+        onSuccess: (resp) => {
+            queryClient.invalidateQueries(ALL_ALERTS_QUERY_KEY);
+            queryClient.invalidateQueries(ACTIVE_ALERTS_QUERY_KEY);
+        },
+    });
+
+    const isMutating = isQueryLoading([isAddingAlert, isRemovingAlert]);
+
+    const error = loadingError || addAlertError || removeAlertError;
+
+    function formatAlert(formValues) {
+        const { alertText, startDate, endDate } = formValues;
+        const alertSpec = {
+            "alert-text": alertText,
+            "end-date": endDate
+                ? formatDateObject(new Date(endDate), dateConstants.ISO_8601)
+                : undefined,
+            "start-date": startDate
+                ? formatDateObject(new Date(startDate), dateConstants.ISO_8601)
+                : undefined,
+        };
+        return alertSpec;
+    }
+
+    function handleSubmit(formValues) {
+        addAlertMutation(formatAlert(formValues));
+    }
+
+    return (
+        <div>
path: src/components/alerts/admin/AlertsAdmin.js
user: login: psarando
body: Forgot to mention this nitpick in my last review: Since every conditional inside this `div` is returning only a single, root node, then this wrapping `<div>{...}</div>` is actually not required.
created_at: 2025-05-07T01:40:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,253 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Skeleton,
+    Stack,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Stack
+                        direction="row"
+                        spacing={2}
+                        alignItems="flex-start"
+                        justifyContent="flex-start"
+                    >
+                        <Field
+                            component={FormTextField}
+                            name="alertText"
+                            label="Alert Text"
+                            required={true}
+                            sx={{ minWidth: "25ch", width: 0 }}
+                        />
+                        <Field
+                            component={FormTimestampField}
+                            name="startDate"
+                            helperText="Start Date"
+                            required={false}
+                            sx={{ minWidth: "25ch" }}
+                        />
+                        <Field
+                            component={FormTimestampField}
+                            name="endDate"
+                            helperText="End Date"
+                            required={true}
+                            sx={{ minWidth: "25ch" }}
+                        />
+                        <Button
+                            type="submit"
+                            variant="contained"
+                            color="primary"
+                            startIcon={<AddIcon />}
+                        >
+                            {t("common:add")}
+                        </Button>
+                    </Stack>
+                </Form>
+            )}
+        </Formik>
+    );
+};
+
+const AlertsEditor = (props) => {
+    const [alertsList, setAlertsList] = useState([]);
+
+    const queryClient = useQueryClient();
+
+    const { t } = useTranslation(["common"]);
+
+    function updateAlerts(queryresp) {
+        setAlertsList(queryresp?.alerts || []);
+    }
+
+    const {
+        isLoading: isLoadingList,
+        isFetching: isFetchingList,
+        error: loadingError,
+        data,
+    } = useQuery({
+        queryKey: ALL_ALERTS_QUERY_KEY,
+        queryFn: allAlerts,
+        enabled: true,
+        onSuccess: updateAlerts,
+    });
+
+    const {
+        mutate: addAlertMutation,
+        error: addAlertError,
+        isLoading: isAddingAlert,
+    } = useMutation(addAlert, {
+        onSuccess: (createdAlert) => {
+            queryClient.invalidateQueries(ALL_ALERTS_QUERY_KEY);
+            queryClient.invalidateQueries(ACTIVE_ALERTS_QUERY_KEY);
+        },
+    });
+
+    const {
+        mutate: removeAlertMutation,
+        error: removeAlertError,
+        isLoading: isRemovingAlert,
+    } = useMutation(removeAlert, {
+        onSuccess: (resp) => {
+            queryClient.invalidateQueries(ALL_ALERTS_QUERY_KEY);
+            queryClient.invalidateQueries(ACTIVE_ALERTS_QUERY_KEY);
+        },
+    });
+
+    const isMutating = isQueryLoading([isAddingAlert, isRemovingAlert]);
+
+    const error = loadingError || addAlertError || removeAlertError;
+
+    function formatAlert(formValues) {
+        const { alertText, startDate, endDate } = formValues;
+        const alertSpec = {
+            "alert-text": alertText,
+            "end-date": endDate
+                ? formatDateObject(new Date(endDate), dateConstants.ISO_8601)
+                : undefined,
+            "start-date": startDate
+                ? formatDateObject(new Date(startDate), dateConstants.ISO_8601)
+                : undefined,
+        };
+        return alertSpec;
+    }
+
+    function handleSubmit(formValues) {
+        addAlertMutation(formatAlert(formValues));
+    }
+
+    return (
+        <div>
+            {isLoadingList ? (
+                <Skeleton
+                    variant="rectangular"
+                    animation="wave"
+                    height={300}
+                    width="100%"
+                />
+            ) : error ? (
+                <WrappedErrorHandler errorObject={error} />
+            ) : (
+                <Paper>
+                    {isFetchingList || isMutating ? (
+                        <Skeleton
+                            variant="rectangular"
+                            animation="wave"
+                            height={150}
+                            width="100%"
+                        />
+                    ) : (
+                        <AddAlertForm t={t} handleSubmit={handleSubmit} />
+                    )}
+                    {isFetchingList || isMutating ? (
+                        <Skeleton
path: src/components/alerts/admin/AlertsAdmin.js
user: login: psarando
body: While testing this out in storybook, I never noticed these inner skeletons, and it looked like everything was always masked by the first skeleton.

So we could probably do away with these inner skeletons and throw everything into the `isQueryLoading` in order to display just the top skeleton.
created_at: 2025-05-07T01:51:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,253 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Skeleton,
+    Stack,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Stack
+                        direction="row"
+                        spacing={2}
+                        alignItems="flex-start"
+                        justifyContent="flex-start"
+                    >
+                        <Field
+                            component={FormTextField}
+                            name="alertText"
+                            label="Alert Text"
+                            required={true}
+                            sx={{ minWidth: "25ch", width: 0 }}
+                        />
path: src/components/alerts/admin/AlertsAdmin.js
user: login: psarando
body: This can wait for a future PR, but this text field is kind of small, especially if the message gets kind of long with markdown formatting and links.

It might be helpful to allow this text field to stretch full-width across the top of everything else, and a Markdown preview (maybe with the yellow background) might be useful too.

The existing alerts listing could also use Markdown rendering.

Also, there isn't enough room for all these fields to say side-by-side in mobile view. I'm wondering if a Grid might allow them to flow / wrap in small screens.

---
Long text and a URL:

![Long text and a URL](https://github.com/user-attachments/assets/e30a4029-e0d2-4b3f-be15-9a968da15f2d)

---
Mobile view:

![Mobile view](https://github.com/user-attachments/assets/9e63c159-85db-46ee-8a68-cce0ec5218a9)


created_at: 2025-05-07T02:19:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,253 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Skeleton,
+    Stack,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Stack
+                        direction="row"
+                        spacing={2}
+                        alignItems="flex-start"
+                        justifyContent="flex-start"
+                    >
+                        <Field
+                            component={FormTextField}
+                            name="alertText"
+                            label="Alert Text"
+                            required={true}
+                            sx={{ minWidth: "25ch", width: 0 }}
+                        />
+                        <Field
+                            component={FormTimestampField}
+                            name="startDate"
+                            helperText="Start Date"
+                            required={false}
+                            sx={{ minWidth: "25ch" }}
+                        />
+                        <Field
+                            component={FormTimestampField}
+                            name="endDate"
+                            helperText="End Date"
+                            required={true}
+                            sx={{ minWidth: "25ch" }}
+                        />
+                        <Button
+                            type="submit"
+                            variant="contained"
+                            color="primary"
+                            startIcon={<AddIcon />}
+                        >
+                            {t("common:add")}
+                        </Button>
+                    </Stack>
+                </Form>
+            )}
+        </Formik>
+    );
+};
+
+const AlertsEditor = (props) => {
+    const [alertsList, setAlertsList] = useState([]);
+
+    const queryClient = useQueryClient();
+
+    const { t } = useTranslation(["common"]);
+
+    function updateAlerts(queryresp) {
+        setAlertsList(queryresp?.alerts || []);
+    }
+
+    const {
+        isLoading: isLoadingList,
+        isFetching: isFetchingList,
+        error: loadingError,
+        data,
+    } = useQuery({
+        queryKey: ALL_ALERTS_QUERY_KEY,
+        queryFn: allAlerts,
+        enabled: true,
+        onSuccess: updateAlerts,
+    });
+
+    const {
+        mutate: addAlertMutation,
+        error: addAlertError,
+        isLoading: isAddingAlert,
+    } = useMutation(addAlert, {
+        onSuccess: (createdAlert) => {
+            queryClient.invalidateQueries(ALL_ALERTS_QUERY_KEY);
+            queryClient.invalidateQueries(ACTIVE_ALERTS_QUERY_KEY);
+        },
+    });
+
+    const {
+        mutate: removeAlertMutation,
+        error: removeAlertError,
+        isLoading: isRemovingAlert,
+    } = useMutation(removeAlert, {
+        onSuccess: (resp) => {
+            queryClient.invalidateQueries(ALL_ALERTS_QUERY_KEY);
+            queryClient.invalidateQueries(ACTIVE_ALERTS_QUERY_KEY);
+        },
+    });
+
+    const isMutating = isQueryLoading([isAddingAlert, isRemovingAlert]);
+
+    const error = loadingError || addAlertError || removeAlertError;
+
+    function formatAlert(formValues) {
+        const { alertText, startDate, endDate } = formValues;
+        const alertSpec = {
+            "alert-text": alertText,
+            "end-date": endDate
+                ? formatDateObject(new Date(endDate), dateConstants.ISO_8601)
+                : undefined,
+            "start-date": startDate
+                ? formatDateObject(new Date(startDate), dateConstants.ISO_8601)
+                : undefined,
+        };
+        return alertSpec;
+    }
+
+    function handleSubmit(formValues) {
+        addAlertMutation(formatAlert(formValues));
+    }
+
+    return (
+        <div>
path: src/components/alerts/admin/AlertsAdmin.js
user: login: ianmcorvidae
body: Ah, good catch. I hadn't previously had the last branch wrapped in a `Paper` and forgot to remove the div after.
created_at: 2025-05-08T16:31:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,253 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Skeleton,
+    Stack,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Stack
+                        direction="row"
+                        spacing={2}
+                        alignItems="flex-start"
+                        justifyContent="flex-start"
+                    >
+                        <Field
+                            component={FormTextField}
+                            name="alertText"
+                            label="Alert Text"
+                            required={true}
+                            sx={{ minWidth: "25ch", width: 0 }}
+                        />
+                        <Field
+                            component={FormTimestampField}
+                            name="startDate"
+                            helperText="Start Date"
+                            required={false}
+                            sx={{ minWidth: "25ch" }}
+                        />
+                        <Field
+                            component={FormTimestampField}
+                            name="endDate"
+                            helperText="End Date"
+                            required={true}
+                            sx={{ minWidth: "25ch" }}
+                        />
+                        <Button
+                            type="submit"
+                            variant="contained"
+                            color="primary"
+                            startIcon={<AddIcon />}
+                        >
+                            {t("common:add")}
+                        </Button>
+                    </Stack>
+                </Form>
+            )}
+        </Formik>
+    );
+};
+
+const AlertsEditor = (props) => {
+    const [alertsList, setAlertsList] = useState([]);
+
+    const queryClient = useQueryClient();
+
+    const { t } = useTranslation(["common"]);
+
+    function updateAlerts(queryresp) {
+        setAlertsList(queryresp?.alerts || []);
+    }
+
+    const {
+        isLoading: isLoadingList,
+        isFetching: isFetchingList,
+        error: loadingError,
+        data,
+    } = useQuery({
+        queryKey: ALL_ALERTS_QUERY_KEY,
+        queryFn: allAlerts,
+        enabled: true,
+        onSuccess: updateAlerts,
+    });
+
+    const {
+        mutate: addAlertMutation,
+        error: addAlertError,
+        isLoading: isAddingAlert,
+    } = useMutation(addAlert, {
+        onSuccess: (createdAlert) => {
+            queryClient.invalidateQueries(ALL_ALERTS_QUERY_KEY);
+            queryClient.invalidateQueries(ACTIVE_ALERTS_QUERY_KEY);
+        },
+    });
+
+    const {
+        mutate: removeAlertMutation,
+        error: removeAlertError,
+        isLoading: isRemovingAlert,
+    } = useMutation(removeAlert, {
+        onSuccess: (resp) => {
+            queryClient.invalidateQueries(ALL_ALERTS_QUERY_KEY);
+            queryClient.invalidateQueries(ACTIVE_ALERTS_QUERY_KEY);
+        },
+    });
+
+    const isMutating = isQueryLoading([isAddingAlert, isRemovingAlert]);
+
+    const error = loadingError || addAlertError || removeAlertError;
+
+    function formatAlert(formValues) {
+        const { alertText, startDate, endDate } = formValues;
+        const alertSpec = {
+            "alert-text": alertText,
+            "end-date": endDate
+                ? formatDateObject(new Date(endDate), dateConstants.ISO_8601)
+                : undefined,
+            "start-date": startDate
+                ? formatDateObject(new Date(startDate), dateConstants.ISO_8601)
+                : undefined,
+        };
+        return alertSpec;
+    }
+
+    function handleSubmit(formValues) {
+        addAlertMutation(formatAlert(formValues));
+    }
+
+    return (
+        <div>
+            {isLoadingList ? (
+                <Skeleton
+                    variant="rectangular"
+                    animation="wave"
+                    height={300}
+                    width="100%"
+                />
+            ) : error ? (
+                <WrappedErrorHandler errorObject={error} />
+            ) : (
+                <Paper>
+                    {isFetchingList || isMutating ? (
+                        <Skeleton
+                            variant="rectangular"
+                            animation="wave"
+                            height={150}
+                            width="100%"
+                        />
+                    ) : (
+                        <AddAlertForm t={t} handleSubmit={handleSubmit} />
+                    )}
+                    {isFetchingList || isMutating ? (
+                        <Skeleton
path: src/components/alerts/admin/AlertsAdmin.js
user: login: ianmcorvidae
body: Yeah, I think you're right. I'd initially done a thing where it masked different things depending on what was loading, but in the end it doesn't make much sense to show the form without the list (which is what usually happened) or vice versa, so it's all ended up using the same stuff. I'll fold it all into one isQueryLoading.
created_at: 2025-05-08T16:33:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,253 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Skeleton,
+    Stack,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Stack
+                        direction="row"
+                        spacing={2}
+                        alignItems="flex-start"
+                        justifyContent="flex-start"
+                    >
+                        <Field
+                            component={FormTextField}
+                            name="alertText"
+                            label="Alert Text"
+                            required={true}
+                            sx={{ minWidth: "25ch", width: 0 }}
+                        />
path: src/components/alerts/admin/AlertsAdmin.js
user: login: ianmcorvidae
body: Hm, yeah, good thoughts. I was definitely struggling with the formatting stuff and how to get it working nicely.

With the text box, I just had a thought and I'm not sure how hard it would be, but maybe it would work well to have that field be the sort of expanding text field that among other places is used for GitHub comments. Then we can show it fairly small while it's empty, but let it expand downward as users type stuff. I do think it could stand to be wider as well though; I went with the same width as the timestamps because initially it was stretching so wide it was making _those_ fields unusable. At the least I can probably make it a bit wider.

Adding the markdown rendering to the table display wouldn't be too difficult I think, that's a good idea. I'm less sure how to do it cleanly within the table but I'll think about it. I think you're right, just not sure how to do it, haha.

For a Grid, I guess what we'd do is change where it does lines depending on breakpoints. That's probably feasible, but I might leave it for this PR as you suggest, since the formatting stuff has been my kryptonite on this PR a bit, haha.
created_at: 2025-05-08T16:47:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,253 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Skeleton,
+    Stack,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Stack
+                        direction="row"
+                        spacing={2}
+                        alignItems="flex-start"
+                        justifyContent="flex-start"
+                    >
+                        <Field
+                            component={FormTextField}
+                            name="alertText"
+                            label="Alert Text"
+                            required={true}
+                            sx={{ minWidth: "25ch", width: 0 }}
+                        />
path: src/components/alerts/admin/AlertsAdmin.js
user: login: ianmcorvidae
body: I realized we've already got FormMultilineTextField, so I put that in there along with doubling the min-width. It's still not good on mobile, but I think that's a problem for another time.
created_at: 2025-05-08T19:49:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,253 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Skeleton,
+    Stack,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Stack
+                        direction="row"
+                        spacing={2}
+                        alignItems="flex-start"
+                        justifyContent="flex-start"
+                    >
+                        <Field
+                            component={FormTextField}
+                            name="alertText"
+                            label="Alert Text"
+                            required={true}
+                            sx={{ minWidth: "25ch", width: 0 }}
+                        />
path: src/components/alerts/admin/AlertsAdmin.js
user: login: psarando
body: The `FormMultilineTextField` is also just a convenience wrapper around a `FormTextField` with a `multiline` prop set:
https://github.com/cyverse-de/sonora/blob/c1e49799889a244cd2255423d083c666407a790b/src/components/forms/FormMultilineTextField.js#L7-L9
So this field could still use `component={FormTextField}` with a `multiline` prop added, but without the `minRows={3}` (or maybe `minRows={1}` would be required, I can't remember). Then it would look like a single-line text field and expand as required; but this `FormMultilineTextField` LGTM, too 👍 
created_at: 2025-05-08T21:58:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,253 @@
+import React, { useState } from "react";
+
+import { useQueryClient, useMutation, useQuery } from "react-query";
+
+import {
+    Button,
+    IconButton,
+    Paper,
+    Table,
+    TableContainer,
+    TableHead,
+    TableCell,
+    TableRow,
+    TableBody,
+    Skeleton,
+    Stack,
+} from "@mui/material";
+
+import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
+
+import FormTextField from "components/forms/FormTextField";
+import FormTimestampField from "components/forms/FormTimestampField";
+
+import WrappedErrorHandler from "components/error/WrappedErrorHandler";
+
+import { useTranslation } from "i18n";
+import { Field, Form, Formik } from "formik";
+
+import {
+    allAlerts,
+    ALL_ALERTS_QUERY_KEY,
+    ACTIVE_ALERTS_QUERY_KEY,
+    addAlert,
+    removeAlert,
+} from "serviceFacades/notifications";
+
+import isQueryLoading from "components/utils/isQueryLoading";
+import { formatDateObject } from "components/utils/DateFormatter";
+import dateConstants from "components/utils/dateConstants";
+
+const initialValues = {
+    startDate: "",
+    endDate: "",
+    alertText: "",
+};
+
+const AddAlertForm = ({ t, handleSubmit }) => {
+    return (
+        <Formik initialValues={initialValues} onSubmit={handleSubmit}>
+            {(props) => (
+                <Form>
+                    <Stack
+                        direction="row"
+                        spacing={2}
+                        alignItems="flex-start"
+                        justifyContent="flex-start"
+                    >
+                        <Field
+                            component={FormTextField}
+                            name="alertText"
+                            label="Alert Text"
+                            required={true}
+                            sx={{ minWidth: "25ch", width: 0 }}
+                        />
path: src/components/alerts/admin/AlertsAdmin.js
user: login: psarando
body: I didn't explicitly mention it, but I guess I was envisioning the form fields stacked in a column, rather than a single row. That would allow the Alert text field to expand full-width. Although I do like the start and end dates side-by-side (especially with their current layout), so they could be wrapped in an inner Stack with `direction="row"`. Then a Markdown preview could go between the Alert text field and the date fields.

But all this tweaking can be a for a future PR.
created_at: 2025-05-08T22:03:54Z
author_association: MEMBER

PR comments:
user: login: ianmcorvidae
created_at: 2025-05-05T17:29:55Z
body: ![image](https://github.com/user-attachments/assets/db581c06-a55b-4321-b276-0746a48a5160)

This is what the header looks like, in storybook.
author_association: MEMBER
user: login: ianmcorvidae
created_at: 2025-05-08T16:48:49Z
body: Side note: while poking around I remembered there's a group of things I forgot to add i18n to, so I'll be updating that. Mariah also had one more graphical update for the color of the alert in the header (a slightly less eye-searing orange rather than the "warning" yellow color), which I'll integrate.
author_association: MEMBER
user: login: ianmcorvidae
created_at: 2025-05-08T19:50:18Z
body: Okay. Maybe finally up to a final mergeable state!
author_association: MEMBER
user: login: ianmcorvidae
created_at: 2025-05-09T18:40:16Z
body: Thanks for the reviews and rereviews! This one took me a while, but merging now.
author_association: MEMBER