Notification preferences screen & pop-up control - #3913
Conversation
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>
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>
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
🌐 Translations synced automaticallyThe key changes below were generated for all target languages, passed the Added (15):
ℹ️ 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. |
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>
There was a problem hiding this comment.
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.
- 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>
|



What this adds
The Studio-UI half of the notification subscription framework — pairs with pimcore/studio-backend-bundle#1959.
DynamicTypeNotificationRegistry(custom toast/row rendering) andDynamicTypeNotificationChannelRegistry(channel-column presentation). Both follow the existingDynamicTypeRegistryAbstract, are re-exported through the SDK, and are optional — anything unregistered falls back to today's rendering.false, so an older backend keeps always-toast.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
tscand eslint clean; verified against a running app.2026.x); the two ship together.🤖 Generated with Claude Code