Skip to content

Announcements: target specific teams, rich body, and an in-app toast - #8239

Draft
dimitrieh wants to merge 6 commits into
mainfrom
announcement-audience-and-toast
Draft

Announcements: target specific teams, rich body, and an in-app toast#8239
dimitrieh wants to merge 6 commits into
mainfrom
announcement-audience-and-toast

Conversation

@dimitrieh

@dimitrieh dimitrieh commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Description

The Notifications Hub could send a plain text announcement to whole team types, with the whole card as its only affordance. Moving a named set of teams onto a new product needs narrower targeting and a message that can explain itself.

Raised as a proposal, so opening as a draft.

  • Audience is one thing or the other. An explicit choice between teams of a type and specific teams, with only that mode's controls rendered. Previously team type and billing state appeared twice on the same screen, once as audience selectors and again as filters on the team table.
  • Specific teams come from a table. Filterable and paginated, with row checkboxes and bulk actions: select every team matching the current filter, select the visible page, clear. Selection is owned outside the table so it survives searching, filtering and paging. New admin route returns the ids for a filter so selecting a thousand teams is one request, sharing its where clause with the teams list so the two cannot drift.
  • Richer body. Optional markdown, an embedded YouTube video, and a button with its own label and url. The admin form previews through the same component recipients see. Video and button urls are normalised and validated server side, so the embed src is built from a validated id rather than typed text.
  • Bottom right toast. Unread announcements surface without opening the drawer, and clear when it is opened. A rich announcement's card is not itself a link, since the body owns its links, video and button. A plain announcement keeps the existing card wide click.

Suspended teams are excluded from selection: they are filtered out when an announcement is addressed, so offering them built a selection that could never receive anything.

ff-data-table gains an optional checked prop. Left unset it owns its selection exactly as before.

Context: #8230

Screenshots

pr-1-hub-form-and-preview pr-2-audience-team-type-mode pr-3-specific-teams-bulk-select pr-4-toast-bottom-right pr-5-toast-beside-expert-drawer pr-6-drawer-rich-and-plain

Test plan

Verified against a local platform with 63 teams across three team types

Backend, unit tested:

  • Audience by explicit team list, by team type, and by billing state; recipients deduped when one user owns many selected teams
  • Ids endpoint matches the teams list for the same filter, filters by type, name and state, and is admin only
  • Video link accepted from watch, share, shorts, live and bare id forms; other hosts and non http schemes rejected
  • Card link, to.url and button url reject anything that can execute or resolve off platform, including targets hiding a host behind a stripped tab

Browser:

  • Select all matching, then narrow the filter: selection holds while the matching count drops, and unchecking one row leaves the rest
  • Switching audience mode clears the other side, so a stale selection cannot be sent
  • Markdown, video playback and the button all work inside the toast and the drawer card
  • Toast clears when the notifications drawer opens; sits beside an open right drawer at several widths, and holds back when there is no room for it
  • Clicking a rich card's body neither navigates nor marks it read

Related Issue(s)

None. Raised from a question about informing teams in app when they are moved onto a new product.

Checklist

  • I have read the contribution guidelines
  • Suitable unit/system level tests have been added and they pass
  • Documentation has been updated
    • Upgrade instructions
    • Configuration details
    • Concepts
  • Changes flowforge.yml?
    • Issue/PR raised on FlowFuse/helm to update ConfigMap Template
    • Issue/PR raised on FlowFuse/CloudProject to update values for Staging/Production
  • Link to Changelog Entry PR, or note why one is not needed. Draft proposal, so no changelog entry yet.

Labels

  • Includes a DB migration? -> add the area:migration label

@dimitrieh

Copy link
Copy Markdown
Contributor Author

Two pre-existing issues found while reviewing this branch. Both predate it and are left untouched here, noting them so they are not lost.

Generic.vue renders an announcement body through v-html with no sanitiser. A plain announcement's message goes straight into v-html in frontend/src/components/notifications/Generic.vue, so admin authored markup is rendered unfiltered. The new rich renderer added here does sanitise, which makes the untouched plain path the weaker of the two.

A Cypress selector will break if a fixture grows. test/e2e/frontend/cypress/tests/admin/notifications-hub.spec.js does cy.get('[data-el^="audience-teamType-"]').click(), which matches every team type row and fails on more than one match. It passes only because the CE fixture seeds a single team type.

@dimitrieh
dimitrieh force-pushed the announcement-audience-and-toast branch from 90405fb to e916913 Compare August 21, 2026 14:45
@dimitrieh

Copy link
Copy Markdown
Contributor Author

@allthedoll can you take a look here?

This is a request from sales to help with the migration of pro/starter on FlowFuse Cloud. It builds out the notification mechanism we already have to be able to target specific teams and accept markdown inputs/rich text formatting so we can put in a youtube embed, links to documentation, and/or a button to reach out to sales. Check screenshots in the body of this PR.

Happy to discuss this proposal next week. Ideally, need a dev to review this. This can happen any time just before the migration.

@dimitrieh
dimitrieh requested a review from allthedoll August 21, 2026 19:27
The Notifications Hub could only address whole team types with a plain
text message whose only affordance was a card-wide link. Migrating a
named set of teams onto a new product needs narrower targeting and a
message that can explain itself.

- audience: search teams by name and target them explicitly; the team
  type filter is ignored when specific teams are selected
- body: optional markdown, a validated YouTube embed, and a button with
  its own label and url; the admin form previews exactly what is sent
- video and button urls are normalised and validated server side, so the
  embed src is built from a known-good id rather than admin input
- rich announcements render in a card that is not itself a link, because
  the body owns its links, video and button; plain announcements keep the
  existing card-wide behaviour
- unread announcements also surface as a bottom-right toast, cleared when
  the notifications drawer is opened

Also fixes the billing state audience filter, which only applied when a
team type filter was set.
Building an audience of hundreds of teams out of thousands does not work
through a search-and-add box: the admin needs to see the teams, filter
them, and select in bulk.

- the audience is now a filterable, paginated table of teams with row
  checkboxes, reusing the filters from the admin teams list
- batch actions: select every team matching the current filter, select
  the rows currently loaded, and clear the selection
- selection is owned outside the table so it survives searching,
  filtering and paging; unchecking the header box now clears only the
  rows on screen instead of the whole selection
- new admin route returns the ids of every team matching a filter, so
  selecting all of them is one request rather than paging the list; it
  shares its where-clause with the teams list so the two cannot drift
- the id list and the addressable audience share one cap, and a
  truncated select-all says so rather than silently selecting a prefix

ff-data-table grows an optional `checked` prop. Left unset it owns its
selection exactly as before.
- the toast stack sits to the left of an open right drawer instead of
  under it, measuring the drawer so it follows a drag-resize and the
  open and close animations rather than assuming a width
- the stack scrolls itself when it is taller than the viewport; two
  announcements carrying video used to run off the top of the screen
- the close button overlays the header instead of taking a grid column,
  which was reserving a dead strip down the full height of the body
- team picker: fifty rows a page, breathing room around the load-more
  control, and the bulk action reads "Select visible" rather than naming
  a count that was easy to misread as the whole matching set
The audience was expressed twice on one screen: team type and billing
state as selectors, and the same two again as filters on the team table.
An explicit choice replaces the conflict, and only that mode's controls
render, so the greyed-out checkboxes and the "overridden by" note are
gone. Audience moves to a full-width band directly above the table,
because as a short third column it left a large gap between the choice
and the list it applies to.

Review findings addressed:

- link validation is now shared by the button and the whole-card link,
  and rejects targets that only look like an in-app path ('//host' and
  '/\host' both resolve off-platform despite the leading slash). The
  card link previously reached an href unvalidated, so a plain
  announcement could carry a javascript: url
- the controlled selection on ff-data-table compared object identity,
  which can never match across a prop round-trip, so the two watchers
  bounced updates off each other. It compares contents now
- an uncontrolled table clears its whole selection again when the header
  box is unticked, so the existing consumer keeps the behaviour it had
- opening the notifications drawer dismisses every unread announcement,
  not just the two on screen, which used to let the next two slide in
  over the open drawer
- the toast stack sits below the dialog layer, so a modal and its
  backdrop cover it instead of competing with it
- suspended teams are no longer selectable: they are filtered out when
  the announcement is addressed, so offering them built a selection that
  could never receive anything
- announcements render markdown through a plain renderer; the shared one
  emits code-block and table furniture whose styling and copy handler
  live only in the Expert chat
- an in-app button routes instead of reloading the whole app
- the video preview checks the host, as the server does, rather than
  claiming parity it did not have
- the toast tracks the viewport width instead of reading a
  non-reactive value inside a computed
Second review of the previous commit found the hardened link check was
still bypassable and the toast layering traded one bug for another.

- reject tab, newline and carriage return in a link. The URL parser
  strips them before parsing, so '/<tab>/host' reached the browser as an
  off-platform '//host' while passing the in-app path check
- validate `to.url` as well. The front-end prefers `to` over `url` and
  opens it directly, so hardening only `url` left the same sink open
  under another name
- the toast stack stays below the dialog layer, but a drawer wider than
  the space beside it would have buried it with no way to reach it.
  Rather than pick a layer to lose to, hold the announcements back while
  there is no room and show them once the drawer is out of the way
- the announcement body checks the links it renders. The admin preview
  draws whatever has been typed so far, which the server has not seen
- match the server's video check exactly, protocol and path included,
  rather than claiming a parity the preview did not have
- enforce every field length the API enforces. An over-long button label
  used to fail with a bare "Bad Request" and no indication of the field
- cap the card link length, as its siblings already were
- drop a duplicated suspended clause when filtering active teams
The new admin route and the announcement body properties change the
generated OpenAPI types, which CI checks for drift.

Generated through the same path CI uses. Running `npm run generate:types`
against a local flowforge.local.yml instead boots without the tables
feature and drops its routes from the spec, which would have removed
valid types rather than adding the new ones.
@dimitrieh
dimitrieh force-pushed the announcement-audience-and-toast branch from e916913 to f5acc27 Compare August 24, 2026 08:47
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.35484% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.04%. Comparing base (d3419bb) to head (f5acc27).

Files with missing lines Patch % Lines
forge/db/models/Team.js 83.33% 2 Missing ⚠️
forge/db/models/User.js 60.00% 2 Missing ⚠️
forge/lib/teamFilters.js 90.00% 2 Missing ⚠️
forge/lib/announcements.js 98.14% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8239      +/-   ##
==========================================
+ Coverage   75.94%   76.04%   +0.09%     
==========================================
  Files         445      447       +2     
  Lines       23990    24094     +104     
  Branches     6404     6443      +39     
==========================================
+ Hits        18220    18323     +103     
- Misses       5770     5771       +1     
Flag Coverage Δ
backend 76.04% <94.35%> (+0.09%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant