Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ad8d7a9
feat(announcements): add persistent BANNER notice type for maintenanc…
JamieRuderman Jul 21, 2026
5a08b9f
fix(announcements): render banner as full-bleed bar with structured t…
JamieRuderman Jul 21, 2026
600fc19
feat(admin): add notice management pages for creating and scheduling …
JamieRuderman Jul 21, 2026
5156663
fix(admin): use standard back button header on notice detail page
JamieRuderman Jul 21, 2026
c895294
fix(admin): show notices detail in a resizable side panel and use glo…
JamieRuderman Jul 21, 2026
baeae7f
feat(admin): restore status chips and detail columns in notices list,…
JamieRuderman Jul 21, 2026
73dd0bd
refactor(admin): wrap all admin routes in a single Panel for consiste…
JamieRuderman Jul 21, 2026
7d31af3
fix(admin): default notices row height with subtitle as its own column
JamieRuderman Jul 21, 2026
f8e388a
fix(admin): label the notice preview field Subtitle consistently
JamieRuderman Jul 21, 2026
53400c0
feat(admin): add notice image field and live card preview for non-ban…
JamieRuderman Jul 21, 2026
2daefd0
refactor(announcements): use body for banner text and drop the unused…
JamieRuderman Jul 21, 2026
619b58e
chore: remove temporary preview harness from version control
JamieRuderman Jul 21, 2026
b050aca
fix(admin): refresh the notices list from the global refresh button
JamieRuderman Jul 21, 2026
0fb703a
feat(admin): group notices by status with newest edits first
JamieRuderman Jul 21, 2026
1c8f950
fix(admin): use the service enable checkbox pattern for notices
JamieRuderman Jul 21, 2026
a60217c
fix(announcements): use info severity for the banner
JamieRuderman Jul 21, 2026
edd2c24
feat(admin): give each notice type its own list icon
JamieRuderman Jul 21, 2026
ff7d100
feat(announcements): add warning and danger banner types
JamieRuderman Jul 21, 2026
b3d7c6a
fix(admin): keep the until column consistent when no end date is set
JamieRuderman Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions frontend/src/buttons/RefreshButton/RefreshButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const RefreshButton: React.FC<ButtonProps> = props => {
const adminUsersPage = useRouteMatch('/admin/users')
const adminPartnersPage = useRouteMatch('/admin/partners')
const adminEnterpriseLicensesPage = useRouteMatch('/admin/enterprise-licenses')
const adminNoticesPage = useRouteMatch('/admin/notices')
const scriptingPage = useRouteMatch(['/script', '/scripts', '/runs'])
const runsPage = useRouteMatch<{ fileID?: string }>('/runs/:fileID?')
const scriptPage = useRouteMatch('/script')
Expand Down Expand Up @@ -127,6 +128,11 @@ export const RefreshButton: React.FC<ButtonProps> = props => {
} else if (adminEnterpriseLicensesPage) {
title = 'Refresh enterprise customers'
methods.push(async () => await dispatch.adminEnterpriseLicenses.fetch())

// admin notices pages
} else if (adminNoticesPage) {
title = 'Refresh notices'
methods.push(async () => await dispatch.adminNotices.fetch())
}

const refresh = async () => {
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/AdminSidebarNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ export const AdminSidebarNav: React.FC = () => {
</ListItemIcon>
<ListItemText primary="Enterprise" />
</ListItemButton>

<ListItemButton
dense
selected={currentPath.includes('/admin/notices')}
onClick={() => handleNavClick('/admin/notices')}
>
<ListItemIcon>
<Icon name="bullhorn" size="md" />
</ListItemIcon>
<ListItemText primary="Notices" />
</ListItemButton>
</List>
)
}
53 changes: 53 additions & 0 deletions frontend/src/components/AnnouncementBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { Box, Button } from '@mui/material'
import { State } from '../store'
import { selectBannerAnnouncements } from '../selectors/announcements'
import { Notice } from './Notice'
import { bannerSeverity } from '../helpers/noticeHelper'

export const AnnouncementBanner: React.FC = () => {
const banners = useSelector((state: State) => selectBannerAnnouncements(state))

// Re-check `until` on every render rather than in the memoized selector. The server already
// filters expired notices, but `announcements` is persisted, so an offline or failed fetch
// would otherwise leave an expired banner pinned with no way to dismiss it.
const now = new Date()
const active = banners.filter(banner => !banner.until || banner.until > now)

if (!active.length) return null

return (
<Box sx={{ width: '100%' }}>
{active.map(banner => (
<Notice
key={banner.id}
severity={bannerSeverity(banner.type)}
fullWidth
solid
// Square corners so it reads as a bar rather than a card — the app panel in
// RemoteHeader is `overflow: hidden` and rounded, so it clips the top corners for us.
sx={{ borderRadius: 0 }}
button={
banner.link ? (
// The theme's Button default resolves `inherit` to grayDark, which is unreadable on
// the solid severity background — pin it to the same white the banner text uses.
<Button href={banner.link} size="small" target="_blank" sx={{ color: 'alwaysWhite.main' }}>
Learn more
</Button>
) : undefined
}
>
{/*
Bind the two lines structurally rather than relying on the notice author to hand-write
`<strong>`/`<em>`. `title` is NOT NULL so a banner always has a heading, and `body`
supplies the smaller line. Notice styles `strong` as the title and `em` as a smaller
block subtitle.
*/}
<strong>{banner.title}</strong>
{banner.body && <em dangerouslySetInnerHTML={{ __html: String(banner.body) }} />}
</Notice>
))}
</Box>
)
}
2 changes: 2 additions & 0 deletions frontend/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { Page } from '../pages/Page'
import { Logo } from '@common/brand/Logo'
import { ViewAsBanner } from './ViewAsBanner'
import { AnnouncementDialog } from './AnnouncementDialog'
import { AnnouncementBanner } from './AnnouncementBanner'

export const App: React.FC = () => {
const { insets } = useSafeArea()
Expand Down Expand Up @@ -110,6 +111,7 @@ export const App: React.FC = () => {
return (
<Page>
<ViewAsBanner />
<AnnouncementBanner />
<PersistGate persistor={persistor} loading={<LoadingMessage message="Restoring state..." />}>
<Box
sx={{
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/helpers/noticeHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NoticeProps } from '../components/Notice'

// Banner severity is encoded in the notice type rather than a separate column. Keep every
// banner-vs-card test going through `isBannerType` — a missed check silently leaks a banner into
// the announcements list, the unread badge and the full-screen dialog.
export const BANNER_TYPES: INoticeType[] = ['BANNER', 'BANNER_WARN', 'BANNER_DANGER']

export const isBannerType = (type?: INoticeType) => !!type && BANNER_TYPES.includes(type)

export const bannerSeverity = (type?: INoticeType): NoticeProps['severity'] => {
switch (type) {
case 'BANNER_DANGER':
return 'error'
case 'BANNER_WARN':
return 'warning'
default:
return 'info'
}
}
89 changes: 89 additions & 0 deletions frontend/src/models/adminNotices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { createModel } from '@rematch/core'
import { graphQLAllNotices } from '../services/graphQLRequest'
import { graphQLCreateNotice, graphQLDeleteNotice, graphQLUpdateNotice } from '../services/graphQLMutation'
import type { RootModel } from '.'

type AdminNoticesState = {
notices: IAdminNotice[]
loading: boolean
saving: boolean
initialized: boolean
}

const defaultState: AdminNoticesState = {
notices: [],
loading: false,
saving: false,
initialized: false,
}

export default createModel<RootModel>()({
state: defaultState,
effects: dispatch => ({
async fetch() {
dispatch.adminNotices.set({ loading: true })
const response = await graphQLAllNotices()
if (response === 'ERROR') {
dispatch.adminNotices.set({ loading: false })
return
}
const notices = parse(response?.data?.data?.allNotices)
dispatch.adminNotices.set({ notices, loading: false, initialized: true })
},

async create(notice: INoticeInput) {
dispatch.adminNotices.set({ saving: true })
const response = await graphQLCreateNotice(notice)
dispatch.adminNotices.set({ saving: false })
if (response === 'ERROR') return false
await dispatch.adminNotices.fetch()
return true
},

async update(params: { id: string; notice: INoticeInput }) {
dispatch.adminNotices.set({ saving: true })
const response = await graphQLUpdateNotice(params.id, params.notice)
dispatch.adminNotices.set({ saving: false })
if (response === 'ERROR') return false
await dispatch.adminNotices.fetch()
return true
},

async remove(id: string) {
dispatch.adminNotices.set({ saving: true })
const response = await graphQLDeleteNotice(id)
dispatch.adminNotices.set({ saving: false })
if (response === 'ERROR') return false
await dispatch.adminNotices.fetch()
return true
},
}),
reducers: {
reset() {
return { ...defaultState }
},
set(state, params: Partial<AdminNoticesState>) {
Object.keys(params).forEach(key => (state[key] = params[key]))
return state
},
},
})

function parse(all?: any[]): IAdminNotice[] {
if (!all) return []
return all.map(n => ({
id: n.id,
type: n.type,
title: n.title,
body: n.body || '',
preview: n.preview || undefined,
image: n.image || undefined,
link: n.link || undefined,
stage: n.stage || undefined,
language: n.language || undefined,
enabled: !!n.enabled,
from: n.from ? new Date(n.from) : undefined,
until: n.until ? new Date(n.until) : undefined,
modified: n.modified ? new Date(n.modified) : undefined,
}))
}
2 changes: 2 additions & 0 deletions frontend/src/models/announcements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default createModel<RootModel>()({
link
type
modified
until
read
}
}`
Expand All @@ -51,6 +52,7 @@ export default createModel<RootModel>()({
link: n.link,
type: n.type,
modified: new Date(n.modified),
until: n.until ? new Date(n.until) : undefined,
read: n.read ? new Date(n.read) : undefined,
}))
},
Expand Down
1 change: 1 addition & 0 deletions frontend/src/models/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export default createModel<RootModel>()({
dispatch.adminUsers.reset()
dispatch.adminPartners.reset()
dispatch.adminEnterpriseLicenses.reset()
dispatch.adminNotices.reset()

cloudSync.reset()
dispatch.accounts.set({ activeId: undefined })
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import agents from './agents'
import { adminPartners } from './adminPartners'
import { adminUsers } from './adminUsers'
import { adminEnterpriseLicenses } from './adminEnterpriseLicenses'
import adminNotices from './adminNotices'
import announcements from './announcements'
import applicationTypes from './applicationTypes'
import auth from './auth'
Expand Down Expand Up @@ -39,6 +40,7 @@ export interface RootModel extends Models<RootModel> {
adminPartners: typeof adminPartners
adminUsers: typeof adminUsers
adminEnterpriseLicenses: typeof adminEnterpriseLicenses
adminNotices: typeof adminNotices
announcements: typeof announcements
applicationTypes: typeof applicationTypes
auth: typeof auth
Expand Down Expand Up @@ -75,6 +77,7 @@ export const models: RootModel = {
adminPartners,
adminUsers,
adminEnterpriseLicenses,
adminNotices,
announcements,
applicationTypes,
auth,
Expand Down
118 changes: 118 additions & 0 deletions frontend/src/pages/AdminNoticesPage/AdminNoticeDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React, { useEffect } from 'react'
import { useHistory, useParams } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import { Box, Typography } from '@mui/material'
import { Dispatch, State } from '../../store'
import { Container } from '../../components/Container'
import { Confirm } from '../../components/Confirm'
import { Title } from '../../components/Title'
import { IconButton } from '../../buttons/IconButton'
import { Gutters } from '../../components/Gutters'
import { LoadingMessage } from '../../components/LoadingMessage'
import { Notice } from '../../components/Notice'
import { spacing } from '../../styling'
import { AdminNoticeForm } from './AdminNoticeForm'

const LIST_ROUTE = '/admin/notices'

type Props = {
// Only shown when the list panel is hidden — otherwise the list is already on screen and the
// global header back button covers going up.
showBackArrow?: boolean
}

export const AdminNoticeDetailPanel: React.FC<Props> = ({ showBackArrow }) => {
const { noticeId } = useParams<{ noticeId?: string }>()
const history = useHistory()
const dispatch = useDispatch<Dispatch>()
const notices = useSelector((state: State) => state.adminNotices.notices)
const saving = useSelector((state: State) => state.adminNotices.saving)
const initialized = useSelector((state: State) => state.adminNotices.initialized)
const [removeOpen, setRemoveOpen] = React.useState(false)

const isNew = noticeId === 'new'
const notice = isNew ? undefined : notices.find(n => n.id === noticeId)

// Deep linking straight to an edit URL has no list to read from yet.
useEffect(() => {
if (!initialized) dispatch.adminNotices.fetch()
}, [initialized])

const handleBack = () => history.push(LIST_ROUTE)

const handleSave = async (input: INoticeInput) => {
const saved = isNew
? await dispatch.adminNotices.create(input)
: await dispatch.adminNotices.update({ id: noticeId as string, notice: input })
if (saved) handleBack()
}

const handleRemove = async () => {
setRemoveOpen(false)
if (notice && (await dispatch.adminNotices.remove(notice.id))) handleBack()
}

const header = (
<Box>
{showBackArrow && (
<Box
sx={{
height: 45,
display: 'flex',
alignItems: 'center',
paddingX: `${spacing.md}px`,
marginTop: `${spacing.sm}px`,
}}
>
<IconButton icon="chevron-left" title="Back to Notices" onClick={handleBack} size="md" color="grayDarker" />
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingRight: 2 }}>
<Typography variant="h2" sx={{ padding: 2 }}>
<Title>{isNew ? 'New notice' : 'Edit notice'}</Title>
</Typography>
{!isNew && notice && (
<IconButton icon="trash" title="Delete notice" onClick={() => setRemoveOpen(true)} size="md" />
)}
</Box>
</Box>
)

if (!isNew && !notice)
return initialized ? (
<Container gutterBottom header={header}>
<Gutters>
<Notice severity="error" fullWidth>
Notice not found
<em>It may have been deleted by someone else.</em>
</Notice>
</Gutters>
</Container>
) : (
<LoadingMessage message="Loading notice..." />
)

return (
<Container gutterBottom bodyProps={{ verticalOverflow: true }} header={header}>
<AdminNoticeForm
key={notice?.id || 'new'}
notice={notice}
saving={saving}
onCancel={handleBack}
onSave={handleSave}
/>
<Confirm
open={removeOpen}
title="Delete notice?"
onConfirm={handleRemove}
onDeny={() => setRemoveOpen(false)}
action="Delete"
color="error"
>
<Typography variant="body2">
"{notice?.title}" will be permanently deleted. This cannot be undone.
</Typography>
</Confirm>
</Container>
)
}
Loading
Loading