Skip to content

Notification preferences screen & pop-up control - #3913

Merged
markus-moser merged 27 commits into
2026.xfrom
feat/notification-subscriptions
Aug 21, 2026
Merged

Notification preferences screen & pop-up control#3913
markus-moser merged 27 commits into
2026.xfrom
feat/notification-subscriptions

Conversation

@markus-moser

@markus-moser markus-moser commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What this adds

The Studio-UI half of the notification subscription framework — pairs with pimcore/studio-backend-bundle#1959.

  • Preferences screen — per notification type, whether you're notified and through which channels. Fully API-driven: channel columns come from the reported channel list, so a bundle contributing a channel gets a column for free. A channel a type can't use shows a dash, not a disabled switch.
  • Extension seamsDynamicTypeNotificationRegistry (custom toast/row rendering) and DynamicTypeNotificationChannelRegistry (channel-column presentation). Both follow the existing DynamicTypeRegistryAbstract, are re-exported through the SDK, and are optional — anything unregistered falls back to today's rendering.
  • Pop-up suppression — the Mercure handler toasts only when the recipient wants one; the bell and unread count still update ("don't interrupt me", not "hide this"). Compared strictly against false, so an older backend keeps always-toast.
  • Profile menu — the unread count moves onto the avatar and a gear beside Notifications opens the preferences.

Also folds in two bell fixes: the unread count now decrements on read/delete (both bubbles read notificationGetUnreadCount, which the collection-cache update never touched), and toast View expands the row even when the bell is already open.

Notes

🤖 Generated with Claude Code

markus-moser and others added 7 commits July 20, 2026 17:59
Two extension seams for the notification framework, both inert until a backend
supplies the matching fields, so this can land on its own.

Pop-up suppression: the Mercure handler now raises the toast only when the
recipient wants one. Cache invalidation and the unread count stay unconditional
— turning pop-ups off means "do not interrupt me", never "hide this from me",
so the notification still reaches the bell. The check is strictly against false
so that a backend predating the field keeps the previous always-toast
behaviour.

Type specific rendering: DynamicTypeNotificationRegistry lets a bundle render
its own notifications in the toast and the expanded row instead of a bare title
and sender. Both render methods may return null, so a definition can enrich only
the detail view. Unregistered types fall back to today's rendering, which is why
nothing changes here until a bundle opts in.

DynamicTypeNotificationChannelRegistry does the same for delivery channel
columns. It exists because the API can only report a channel's id and
translation key — naming an icon from the frontend's library is not its job.
Registering is optional: an unknown channel still renders a usable column with a
generic icon and the API's label, which is what lets a bundle ship a channel
without also shipping frontend code.

Both follow the existing DynamicTypeRegistryAbstract pattern and are re-exported
through the SDK for bundle use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets a user decide, per notification type, whether they are notified and through
which channels. On an installation with no contributing bundle that is a single
row — the catch-all every untyped notification falls into — offering the one
control that was missing: whether notifications interrupt you, or simply wait in
the bell.

The table is built from the API rather than from anything hardcoded here. Columns
come from the reported channel list, so a bundle contributing a delivery channel
gets a column with no change to this screen. A channel a type cannot use renders
as a dash rather than a disabled switch: a switch, even a dead one, suggests
somebody could turn it on and invites a support request for something that is
structurally unavailable. There is no "locked by administrator" state to render,
because a channel an administrator disables is absent from the API entirely and
its column never appears.

Draft state deserves a note. Channel sets are compared without regard to order,
since serialising them would report a phantom change and leave Save enabled
forever. Unsubscribing clears channels locally to mirror what the server stores,
so the UI never shows a state that is about to be overwritten. And seeding is
keyed on content rather than array identity — keyed on identity, a caller
rebuilding the array on render re-seeded endlessly and discarded edits
mid-interaction.

The profile menu gains the unread count on the avatar itself, using the query
that already feeds the menu and is kept current by the Mercure handler, so it is
live without extra fetching. The gear beside Notifications opens this screen.
Send moves into the bell view, where it sits beside the notifications it
produces rather than in a menu people open every day.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The unread badge on the avatar and the count pill in the profile menu both read
off the notificationGetUnreadCount query. Marking a notification as read updated
the read flag in the collection cache but never touched that query, so the number
only ever went up (from Mercure) and never came back down. It now decrements when
a notification goes from unread to read, and when an unread notification is
deleted.

The decrement is guarded on the actual unread-to-read transition, captured before
the flag is flipped, so re-opening an already-read notification leaves the count
alone. Verified against the running app: opening a notification took both bubbles
from 3 to 2, matching the database.

Also caps the preferences table at a sensible max-width and gives the type
description a readable measure, so on a wide editor the toggles sit next to the
type they belong to instead of drifting to the far edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n the bell is open

The Send action lives in both the profile menu and the bell view again. It was
moved out of the menu during the 5a rework; it now sits next to the settings
gear there as before, and also stays in the bell — having it in both places is
intended.

"View" in a notification toast now expands the related row even when the bell is
already open. It only worked from a closed bell because opening the widget
mounted the list fresh and the row read its expanded state from the widget
config once. When the widget is already open the container memoises its rendered
component, so an updated config never reaches the list. A small UI slice carries
the expand request instead — the rows subscribe to it directly, which works
regardless of whether the widget was already open. A token on the request means
viewing the same row again, after collapsing it by hand, re-expands it.

Verified against the running app: with the bell already open, clicking View on a
toast expanded exactly that row and left the others collapsed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SonarCloud's quality gate flagged two S2004 issues — functions nested more than
four levels deep — where the cell onChange handlers sat inside the group, type
and channel .map() loops. Pulling the per-type row into its own component resets
the nesting and reads better; behaviour is unchanged and the view tests still
pass through it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@markus-moser markus-moser added this to the 2026.3.0 milestone Jul 21, 2026
markus-moser and others added 3 commits July 21, 2026 13:21
The column-header row and the per-group heading rows carried the same fill,
so the two read as one repeated band rather than two levels of hierarchy.
Give the header its own identity (a neutral fill plus bold weight as the
table's header) and make the group headings flat uppercase section labels
with a divider. Flat typographic labels separate the sections more clearly
than a second fill shade would.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A custom getDetailContent replaced the whole default block, so a definition
silently lost the notification's attachment. Give it control instead:

- getDetailContent receives a slots argument carrying the rendered attachment
  (or null), so a definition can position it inside its own template.
- appendsAttachment() (default true) decides whether the host still renders the
  attachment below the custom content — so a definition that ignores attachments
  never drops one, while one that places slots.attachment itself returns false to
  avoid a duplicate (or to hide it).

The plain fallback is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
markus-moser and others added 3 commits August 18, 2026 12:43
The channel labels and the catch-all's row labels are decided by the backend —
SubscriptionService composes the channel keys, GeneralNotificationDescriptor
hardcodes its own — and studio-backend-bundle#1959 now ships them in all seven
locales. Keeping copies here meant one repository owning the string and another
owning the translation, with nothing failing in either if they drifted.

The four notification.type.general.* copies were already dead: #1959 renamed them
to notifications.type.general.* to match the rest of the domain, so nothing asked
for these names any more.

The ~14 notifications.settings.* keys stay: the screen composes those itself, and
they are not sent by the API. That includes notifications.settings.group.general
— group headings are built frontend-side from the descriptor's group value.

Labels come from item.translationKey / channel.translationKey in the API
response, so no component changes. The remaining key strings in
notification-settings-view.test.tsx are fixtures under a t: (key) => key mock and
never touch the catalogue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	assets/build/api/docs.jsonopenapi.json
#	build-dist/build-50c1decfabce.zip
#	build-dist/build-d723cc4c5b90.zip
#	build-dist/build-fc863ace9b68.zip
@pimcore-deployments

pimcore-deployments commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

🌐 Translations synced automatically

The key changes below were generated for all target languages, passed the
mechanical validation gate (key parity, order, placeholders, types, plurals),
and are being committed to this branch by the workflow (check the run status
if the commit is not visible).
Please review the generated translations.

Added (15):

  • notifications.settings.label
  • notifications.settings.description
  • notifications.settings.description-popup-only
  • notifications.settings.column.type
  • notifications.settings.column.subscribed
  • notifications.settings.not-available
  • notifications.settings.save
  • notifications.settings.saved
  • notifications.settings.discard
  • notifications.settings.discard.title
  • notifications.settings.discard.confirm
  • notifications.settings.discard.keep-editing
  • notifications.settings.unsaved-changes_one
  • notifications.settings.unsaved-changes_other
  • notifications.settings.group.general

ℹ️ Pre-existing translation errors (not from this PR)

12 validation error(s) predating this PR are still present. They are not blocking it; clearing them needs another run or a dedicated backfill.

markus-moser and others added 12 commits August 18, 2026 15:52
The forward merge left docs.jsonopenapi.json unresolvable by picking a side: the
PR's copy carried the notification endpoints but was 230 commits stale, and
2026.x's was current but had no notification endpoints at all, so re-running the
codegen would have deleted the settings screen's API.

Taken from the live backend instead — /api/docs/json?locale=en with #1959 and
current 2026.x in place — overlaid onto mainline's spec rather than replacing it.
A developer box is not an oracle for anything outside #1959: this one contributes
82 paths from three unreleased local bundles, and carries an OLDER
backend-power-tools whose UpdateTheme schema is missing the `makeDefault`
property mainline already documents, so a wholesale copy would have silently
dropped it.

The result adds exactly one path and six schemas, updates NotificationMinimal for
popup + payload, removes nothing, and every $ref resolves. Running
`npm run build-api-client` over it regenerates only notifications-slice.gen.ts and
leaves every other slice untouched, which is the check that the overlay is
consistent with the rest of the spec.

That regeneration also picks up the summaries the PR's committed client was
missing: it had been generated against a backend where the studio_api_docs keys
were not resolving, so its JSDoc read
"notification_get_subscriptions_success_response" instead of the English.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The email column offered a switch that stored fine and then delivered nothing when
the user has no email address on their account — indistinguishable from a broken
channel, and it cost a real debugging session to find that was all it was.

The backend now reports per channel why it cannot reach the caller
(unavailableReasonKey on NotificationAvailableChannel), so the column carries a ⓘ
with the reason. The switches stay live: the preference is real and starts working
the moment an address exists, so disabling or hiding them would be the wrong fix.

Spec and client regenerated from the backend carrying the new field; only
notifications-slice.gen.ts changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Replace `message.payload as any` with the existing NotificationMessagePayload
  type (Partial in shouldHandle, which runs on every message; full in the popup
  handler, reached only after shouldHandle confirmed the shape). Exported the
  type so both sites share it.
- Log the previously-silent catches in useOptimisticUpdate — the cache update
  stays best-effort, but a failure is no longer swallowed.

Verified: tsc (0 new errors), eslint clean, notification jest suites pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Save now seeds the draft from the mutation response, so a save the server
normalises back to the current state still clears the dirty flag instead of
leaving Save enabled forever. The seeding effect keys on the last observed
items rather than the last seeded state, so the stale query data does not undo
that in the window before the refetch lands. Adds a success message.

isUnreadInCache now filters on the notificationGetCollection key, matching the
write loop below it, rather than scanning every cached query.

Also: Tooltip via the Studio wrapper, memoised registry lookup in the detail
row, no shadowed `entry`, and the comments cut back to the repo norm (11% ->
5% of source lines; the explanatory blocks above test cases are gone).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The indicator had no sizing of its own, so antd's default grew it with each
digit and a two-digit count read as a stretched pill against the 26px avatar.
Fixes it the way the menu-item badge already does: a fixed circle with the
font stepping down, capped at 99+.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@markus-moser
markus-moser marked this pull request as ready for review August 21, 2026 08:59
Copilot AI balanced review requested due to automatic review settings August 21, 2026 08:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds API-driven notification preferences, extensible notification renderers, popup suppression, and unread-count synchronization.

Changes:

  • Adds notification preference UI and translations.
  • Introduces notification/channel dynamic-type registries and SDK exports.
  • Updates popup, expansion, menu, and unread-count behavior.

Reviewed changes

Copilot reviewed 44 out of 46 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
translations/studio.sv.yaml Adds Swedish settings translations.
translations/studio.no.yaml Adds Norwegian settings translations.
translations/studio.it.yaml Adds Italian settings translations.
translations/studio.fr.yaml Adds French settings translations.
translations/studio.es.yaml Adds Spanish settings translations.
translations/studio.en.yaml Adds English settings translations.
translations/studio.de.yaml Adds German settings translations.
assets/js/src/sdk/modules/notifications/index.ts Exports notification extension APIs.
assets/js/src/core/modules/notifications/utils/notification-payload.ts Normalizes notification payloads.
assets/js/src/core/modules/notifications/utils/notification-payload.test.ts Tests payload normalization.
assets/js/src/core/modules/notifications/settings/notification-settings.styles.ts Styles the preferences screen.
assets/js/src/core/modules/notifications/settings/notification-settings-view.tsx Renders API-driven settings columns.
assets/js/src/core/modules/notifications/settings/notification-settings-view.test.tsx Tests settings presentation.
assets/js/src/core/modules/notifications/settings/notification-settings-toolbar.tsx Adds save/discard controls.
assets/js/src/core/modules/notifications/settings/notification-settings-row.tsx Renders preference rows.
assets/js/src/core/modules/notifications/settings/notification-settings-container.tsx Connects settings to the API.
assets/js/src/core/modules/notifications/settings/notification-settings-cell.tsx Renders channel switches or fallbacks.
assets/js/src/core/modules/notifications/settings/hooks/use-notification-settings-draft.ts Manages preference draft state.
assets/js/src/core/modules/notifications/settings/hooks/use-notification-settings-draft.test.ts Tests draft behavior.
assets/js/src/core/modules/notifications/notifications-view.tsx Adds notification sending access.
assets/js/src/core/modules/notifications/notifications-ui-slice.ts Stores row-expansion requests.
assets/js/src/core/modules/notifications/notifications-ui-slice.test.ts Tests expansion requests.
assets/js/src/core/modules/notifications/notifications-slice.gen.ts Adds generated subscription endpoints.
assets/js/src/core/modules/notifications/notifications-slice-enhanced.ts Adds preference cache tags.
assets/js/src/core/modules/notifications/notification-popup/notification-popup.tsx Carries type-specific popup data.
assets/js/src/core/modules/notifications/notification-popup/notification-popup-item.tsx Supports custom popup renderers.
assets/js/src/core/modules/notifications/notification-popup/notification-popup-content.tsx Dispatches row-expansion requests.
assets/js/src/core/modules/notifications/notification-popup/handlers/notification-message-handler.tsx Applies popup preferences.
assets/js/src/core/modules/notifications/notification-popup/handlers/notification-message-handler.test.ts Tests popup suppression.
assets/js/src/core/modules/notifications/notification-detail.tsx Supports custom detail renderers.
assets/js/src/core/modules/notifications/index.tsx Registers settings and channel types.
assets/js/src/core/modules/notifications/hooks/use-optimistic-update.tsx Synchronizes unread counts.
assets/js/src/core/modules/notifications/hooks/use-optimistic-update.test.ts Tests unread-cache detection.
assets/js/src/core/modules/notifications/hooks/use-notification-detail.tsx Consumes expansion requests.
assets/js/src/core/modules/notifications/dynamic-types/registry/dynamic-type-notification-registry.tsx Adds notification renderer registry.
assets/js/src/core/modules/notifications/dynamic-types/registry/dynamic-type-notification-registry.test.ts Tests both registries.
assets/js/src/core/modules/notifications/dynamic-types/registry/dynamic-type-notification-channel-registry.tsx Adds channel registry.
assets/js/src/core/modules/notifications/dynamic-types/definitions/notification-channels.tsx Defines built-in channel presentations.
assets/js/src/core/modules/notifications/dynamic-types/definitions/dynamic-type-abstract-notification.tsx Defines notification rendering API.
assets/js/src/core/modules/notifications/dynamic-types/definitions/dynamic-type-abstract-notification-channel.tsx Defines channel presentation API.
assets/js/src/core/modules/app/base-layout/user-menu/user-menu.tsx Adds avatar badge and settings action.
assets/js/src/core/app/config/services/service-ids.ts Adds notification service IDs.
assets/js/src/core/app/config/services/index.ts Binds notification services.
assets/js/src/core/app/api/pimcore/tags.ts Adds preference cache tags.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread assets/js/src/core/modules/app/base-layout/user-menu/user-menu.tsx
Comment thread assets/js/src/core/modules/notifications/settings/notification-settings-view.tsx Outdated
Comment thread assets/js/src/core/modules/notifications/settings/notification-settings.styles.ts Outdated
Comment thread assets/js/src/core/modules/notifications/hooks/use-notification-detail.tsx Outdated
markus-moser and others added 2 commits August 21, 2026 11:27
- Unread badge stayed stale after "Remove all": notificationGetUnreadCount
  kept the generated "Notifications" tag while the list/delete-all use the
  NOTIFICATIONS tag; override it to share the same tag.
- Settings load failure showed a blank panel: surface the query's error via
  trackError, matching the notification list.
- Channel translationKey override (documented on the abstract) was ignored
  by the header label; resolve the registry key first, mirroring the icon.
- Unsupported-channel cell was an inaccessible em dash: give it a spelled-out
  accessible name and a keyboard-focusable, tooltip-reachable trigger.
- Expand request was never cleared, re-expanding a viewed row on every bell
  reopen; clear it once consumed (+ reducer test).
- Preference controls stayed live while saving; disable them via a saving flag.
- Settings table clipped extra channel columns; scroll instead of clip.
- Require studio-backend-bundle ^2026.3 so the subscriptions endpoint exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@markus-moser
markus-moser merged commit 7472c5d into 2026.x Aug 21, 2026
24 checks passed
@markus-moser
markus-moser deleted the feat/notification-subscriptions branch August 21, 2026 09:51
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants