Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d0293be
Honour notification pop-up preferences and allow type specific rendering
markus-moser Jul 20, 2026
b5dc4c8
Add the notification preferences screen
markus-moser Jul 20, 2026
7c3d398
Decrement the unread count on read, and bound the settings table width
markus-moser Jul 21, 2026
bbb141c
Keep Send in the profile menu, and expand the viewed notification whe…
markus-moser Jul 21, 2026
5b38759
Automatic frontend build
markus-moser Jul 21, 2026
9750e37
Extract the notification settings row to fix a SonarCloud critical
markus-moser Jul 21, 2026
86d69e0
Automatic frontend build
markus-moser Jul 21, 2026
e3a5f98
Differentiate the settings header row from the group headings
markus-moser Jul 21, 2026
804f43e
Automatic frontend build
markus-moser Jul 21, 2026
8e76c05
Let a notification renderer place or defer the attachment
markus-moser Jul 21, 2026
e7bd77b
Drop the notification keys studio-backend now ships
markus-moser Aug 18, 2026
c4e0cc5
Merge remote-tracking branch 'origin/2026.x' into local/pr3913-live
markus-moser Aug 18, 2026
2269207
Automatic frontend build
markus-moser Aug 18, 2026
4b53258
Regenerate the API spec and client against the merged backend
markus-moser Aug 18, 2026
ac67401
Sync translations with studio.en.yaml [automated]
markus-moser Aug 18, 2026
cfa4122
Automatic frontend build
markus-moser Aug 18, 2026
5600b0b
Explain a channel that cannot reach the account
markus-moser Aug 18, 2026
a1024e5
Automatic frontend build
markus-moser Aug 18, 2026
c9e80e5
Review nits: type the Mercure payload, log the optimistic-update catches
markus-moser Aug 20, 2026
ed9d0a7
Automatic frontend build
markus-moser Aug 20, 2026
9d0258f
Address review: dirty-after-save, save feedback, scoped unread scan
markus-moser Aug 20, 2026
de7fa29
Sync translations with studio.en.yaml [automated]
markus-moser Aug 20, 2026
b9ca435
Automatic frontend build
pimcore-deployments Aug 20, 2026
309d348
Keep the avatar badge circular as the unread count grows
markus-moser Aug 21, 2026
d08af43
Automatic frontend build
markus-moser Aug 21, 2026
1d56f3f
Address code-review findings on the notification preferences PR
markus-moser Aug 21, 2026
fce2d8b
Automatic frontend build
markus-moser Aug 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
2 changes: 1 addition & 1 deletion assets/build/api/docs.jsonopenapi.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions assets/js/src/core/app/api/pimcore/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const tagNames = {
NOTES_AND_EVENTS: 'NOTES_AND_EVENTS',
NOTIFICATIONS: 'NOTIFICATIONS',
NOTIFICATION_DETAILS: 'NOTIFICATION_DETAILS',
NOTIFICATION_PREFERENCES: 'NOTIFICATION_PREFERENCES',
AVAILABLE_TAGS: 'AVAILABLE_TAGS',
SETTINGS_ADMIN: 'SETTINGS_ADMIN',
WEBSITE_SETTINGS: 'WEBSITE_SETTINGS',
Expand Down Expand Up @@ -149,6 +150,7 @@ export const providingTags = {
ELEMENT_NOTES_AND_EVENTS: (elementType: ElementType, id: number) => [getElementDetailTag(elementType, id), getElementSpecificTag(tagNames.NOTES_AND_EVENTS, elementType, id)],
NOTIFICATIONS: () => [tagNames.NOTIFICATIONS],
NOTIFICATION_DETAIL: (id: number) => [{ type: tagNames.NOTIFICATION_DETAILS, id }],
NOTIFICATION_PREFERENCES: () => [tagNames.NOTIFICATION_PREFERENCES],
AVAILABLE_TAGS: () => [tagNames.AVAILABLE_TAGS],
ELEMENT_TAGS: (elementType: ElementType, id: number) => [getElementDetailTag(elementType, id), getElementSpecificTag(tagNames.ELEMENT_TAGS, elementType, id)],
ROLE: () => [tagNames.ROLE],
Expand Down Expand Up @@ -238,6 +240,7 @@ export const invalidatingTags = {
NOTES_AND_EVENTS_DETAIL: (id: number) => [{ type: tagNames.NOTES_AND_EVENTS, id }],
NOTIFICATION_DETAIL: (id: number) => [{ type: tagNames.NOTIFICATION_DETAILS, id }],
NOTIFICATIONS: () => [tagNames.NOTIFICATIONS],
NOTIFICATION_PREFERENCES: () => [tagNames.NOTIFICATION_PREFERENCES],
ELEMENT_NOTES_AND_EVENTS: (elementType: ElementType, id: number) => [getElementSpecificTag(tagNames.NOTES_AND_EVENTS, elementType, id)],
AVAILABLE_TAGS: () => [tagNames.AVAILABLE_TAGS],
ELEMENT_TAGS: (elementType: ElementType, id: number) => [getElementSpecificTag(tagNames.ELEMENT_TAGS, elementType, id)],
Expand Down
9 changes: 9 additions & 0 deletions assets/js/src/core/app/config/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,9 @@ import { DynamicTypeFieldDefinitionObjectbricks } from '@Pimcore/modules/field-d
import { DynamicTypeFieldDefinitionClassificationstore } from '@Pimcore/modules/field-definitions/dynamic-types/types/data/classificationstore/dynamic-type-field-definition-classificationstore'
import { DynamicTypeFieldDefinitionLocalizedfields } from '@Pimcore/modules/field-definitions/dynamic-types/types/data/localizedfields/dynamic-type-field-definition-localizedfields'
import { DynamicTypeGDPRProviderRegistry } from '@Pimcore/modules/gdpr-data-extractor/dynamic-types/registry/dynamic-type-gdpr-provider-registry'
import { DynamicTypeNotificationRegistry } from '@Pimcore/modules/notifications/dynamic-types/registry/dynamic-type-notification-registry'
import { DynamicTypeNotificationChannelRegistry } from '@Pimcore/modules/notifications/dynamic-types/registry/dynamic-type-notification-channel-registry'
import { DynamicTypeNotificationChannelEmail, DynamicTypeNotificationChannelPopup } from '@Pimcore/modules/notifications/dynamic-types/definitions/notification-channels'
import { DynamicTypeDataObjectGDPRProvider } from '@Pimcore/modules/gdpr-data-extractor/dynamic-types/definitions/dynamic-type-data-object-gdpr-provider'
import { DynamicTypeAssetsGDPRProvider } from '@Pimcore/modules/gdpr-data-extractor/dynamic-types/definitions/dynamic-type-assets-gdpr-provider'
import { DynamicTypeUsersGDPRProvider } from '@Pimcore/modules/gdpr-data-extractor/dynamic-types/definitions/dynamic-type-users-gdpr-provider'
Expand Down Expand Up @@ -860,6 +863,12 @@ container.bind(serviceIds['DynamicTypes/VideoTransformation/SetFramerate']).to(S
container.bind(serviceIds['DynamicTypes/VideoTransformation/ColorChannelMixer']).to(ColorChannelMixerVideoTransformationType).inSingletonScope()
container.bind(serviceIds['DynamicTypes/VideoTransformation/Mute']).to(MuteVideoTransformationType).inSingletonScope()

// Notifications
container.bind(serviceIds['DynamicTypes/NotificationRegistry']).to(DynamicTypeNotificationRegistry).inSingletonScope()
container.bind(serviceIds['DynamicTypes/NotificationChannelRegistry']).to(DynamicTypeNotificationChannelRegistry).inSingletonScope()
container.bind(serviceIds['DynamicTypes/NotificationChannel/Popup']).to(DynamicTypeNotificationChannelPopup).inSingletonScope()
container.bind(serviceIds['DynamicTypes/NotificationChannel/Email']).to(DynamicTypeNotificationChannelEmail).inSingletonScope()

// GDPR Provider
container.bind(serviceIds['DynamicTypes/GDPRProviderRegistry']).to(DynamicTypeGDPRProviderRegistry).inSingletonScope()
container.bind(serviceIds['DynamicTypes/GDPRProvider/DataObjects']).to(DynamicTypeDataObjectGDPRProvider).inSingletonScope()
Expand Down
4 changes: 4 additions & 0 deletions assets/js/src/core/app/config/services/service-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const dynamicTypeRegistriesServiceIds = {
'DynamicTypes/WidgetEditor/WidgetTypeRegistry': 'DynamicTypes/WidgetEditor/WidgetTypeRegistry',
'DynamicTypes/FieldDefinitionRegistry': 'DynamicTypes/FieldDefinitionRegistry',
'DynamicTypes/GDPRProviderRegistry': 'DynamicTypes/GDPRProviderRegistry',
'DynamicTypes/NotificationRegistry': 'DynamicTypes/NotificationRegistry',
'DynamicTypes/NotificationChannelRegistry': 'DynamicTypes/NotificationChannelRegistry',
'DynamicTypes/NotificationChannel/Popup': 'DynamicTypes/NotificationChannel/Popup',
'DynamicTypes/NotificationChannel/Email': 'DynamicTypes/NotificationChannel/Email',
'DynamicTypes/TransformationDynamicTypeRegistry': 'image-thumbnails.transformation-dynamic-type-registry',
'DynamicTypes/TransformationFieldCollectionRegistry': 'image-thumbnails.transformation-field-collection-registry',
'DynamicTypes/Transformation/Cover': 'DynamicTypes/Transformation/Cover',
Expand Down
76 changes: 58 additions & 18 deletions assets/js/src/core/modules/app/base-layout/user-menu/user-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
*/

import { Badge } from '@Pimcore/components/badge/badge'
import { Button } from '@Pimcore/components/button/button'
import { Dropdown, type DropdownMenuProps } from '@Pimcore/components/dropdown/dropdown'
import { Icon } from '@Pimcore/components/icon/icon'
import trackError, { ApiError } from '@Pimcore/modules/app/error-handler'
import { useLogoutMutation } from '@Pimcore/modules/auth/authorization-api-slice.gen'
import { isAllowed } from '@Pimcore/modules/auth/permission-helper'
import { NOTIFICATIONS } from '@Pimcore/modules/notifications'
import { NOTIFICATIONS, NOTIFICATION_SETTINGS } from '@Pimcore/modules/notifications'
import { IconButton } from '@Pimcore/components/icon-button/icon-button'
import { Button } from '@Pimcore/components/button/button'
import { Flex } from '@Pimcore/components/flex/flex'
import { SendNotificationModal } from '@Pimcore/modules/notifications/send-notification/send-notification-modal'
import { useWidgetManager } from '@sdk/modules/widget-manager'
import { theme } from 'antd'
Expand All @@ -36,9 +38,9 @@ export const UserMenu = ({ className }: IUserMenuProps): React.JSX.Element => {
const { t } = useTranslation()
const { styles } = useStyle()
const { token } = theme.useToken()
const [sendModal, setSendModal] = useState<boolean>(false)
const [logout] = useLogoutMutation()
const { openMainWidget } = useWidgetManager()
const [sendModal, setSendModal] = useState<boolean>(false)
const user = useUser()
const { getUserImageById, updateUserImageInState } = useUserHelper()
const { data } = useNotificationGetUnreadCountQuery(undefined, {
Expand Down Expand Up @@ -92,7 +94,8 @@ export const UserMenu = ({ className }: IUserMenuProps): React.JSX.Element => {
showZero
styles={ {
indicator: {
background: token.colorPrimary,
// Always shown here, but a zero goes grey rather than wearing the accent colour.
background: notificationCount > 0 ? token.colorPrimary : token.colorTextQuaternary,
width: 20,
height: 20,
minWidth: 20,
Expand All @@ -109,18 +112,33 @@ export const UserMenu = ({ className }: IUserMenuProps): React.JSX.Element => {
</div>,
onClick: () => { openMainWidget(NOTIFICATIONS) },
hidden: !isAllowed(UserPermission.Notifications),
extra: isAllowed(UserPermission.SendNotifications)
? (
<Button
className={ 'user-menu__item-extra' }
// Both actions open something other than the bell, so each stops the row's own onClick.
extra: (
<Flex
align={ 'center' }
gap={ 'mini' }
>
{isAllowed(UserPermission.SendNotifications) && (
<Button
className={ 'user-menu__item-extra' }
onClick={ (e) => {
e.stopPropagation()
setSendModal(true)
} }
size={ 'small' }
>{t('user-menu.notification.send')}</Button>
)}
<IconButton
icon={ { value: 'settings' } }
onClick={ (e) => {
e.stopPropagation()
setSendModal(true)
openMainWidget(NOTIFICATION_SETTINGS)
Comment thread
markus-moser marked this conversation as resolved.
} }
size={ 'small' }
>{t('user-menu.notification.send')}</Button>
)
: null
title={ t('notifications.settings.label') }
type={ 'text' }
/>
</Flex>
)
},
{
key: 'myprofile',
Expand All @@ -145,11 +163,33 @@ export const UserMenu = ({ className }: IUserMenuProps): React.JSX.Element => {
overlayStyle={ { minWidth: 275 } }
trigger={ ['click'] }
>
<Avatar
data-testid="user-menu-avatar"
size={ 26 }
src={ user?.hasImage && user?.image != null ? user?.image : undefined }
/>
{/* No showZero: permanently on screen, so it must go quiet at nothing to report. */}
<Badge
count={ notificationCount }
data-testid="user-menu-avatar-badge"
overflowCount={ 99 }
size={ 'small' }
styles={ {
indicator: {
// Fixed circle: the default indicator grows with each digit, which reads as a
// stretched pill against a 26px avatar. The font steps down instead.
width: 16,
height: 16,
minWidth: 16,
lineHeight: '16px',
borderRadius: '50%',
fontSize: notificationCount > 9 ? 9 : 10,
fontWeight: 'normal',
padding: 0
}
} }
>
<Avatar
data-testid="user-menu-avatar"
size={ 26 }
src={ user?.hasImage && user?.image != null ? user?.image : undefined }
/>
</Badge>
</Dropdown>

<SendNotificationModal
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

import { injectable } from 'inversify'
import { DynamicTypeAbstract } from '@Pimcore/modules/element/dynamic-types/registry/dynamic-type-registry-abstract'

/**
* Presentation for a delivery channel column in the notification preferences. Optional — an
* unregistered channel still renders a column, with a generic icon and the API's label.
*/
@injectable()
export abstract class DynamicTypeAbstractNotificationChannel extends DynamicTypeAbstract {
/** Matches the channel id reported by the API, e.g. `email`. */
abstract readonly id: string

/** Icon name from the icon library, shown in the column header. */
abstract readonly icon: string

/** Overrides the API's translation key. */
readonly translationKey?: string
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

import React from 'react'
import { injectable } from 'inversify'
import { DynamicTypeAbstract } from '@Pimcore/modules/element/dynamic-types/registry/dynamic-type-registry-abstract'

export interface NotificationRenderProps {
/** Matches the notification's `type`, e.g. `collab.mention`. */
type: string
title: string
sender: string | null
/** Parsed `payload`; null when a notification carries none. */
payload: Record<string, unknown> | null
}

export interface NotificationDetailRenderProps extends NotificationRenderProps {
/** Only available once the detail has been fetched. */
message?: string | null
}

export interface NotificationDetailSlots {
/** Place it yourself, or ignore it and let the host append it below (see appendsAttachment). */
attachment: React.JSX.Element | null
}

/**
* Renders a specific notification type. Either method may return null to fall back to the host's
* plain title-and-sender rendering, so a definition can enrich only the detail view.
*/
@injectable()
export abstract class DynamicTypeAbstractNotification extends DynamicTypeAbstract {
/** The notification type id this definition renders. */
abstract readonly id: string

/** Content for the toast; the host supplies the chrome and the action that opens the bell. */
getPopupContent (props: NotificationRenderProps): React.JSX.Element | null {
return null
}

/** Content for the expanded row in the notification list. */
getDetailContent (props: NotificationDetailRenderProps, slots: NotificationDetailSlots): React.JSX.Element | null {
return null
}

/** Whether the host appends the attachment below custom detail content. */
appendsAttachment (): boolean {
return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

import { injectable } from 'inversify'
import { DynamicTypeAbstractNotificationChannel } from './dynamic-type-abstract-notification-channel'

/** Channel id the backend reserves for the in-app toast preference. */
export const POPUP_CHANNEL_ID = 'popup'

@injectable()
export class DynamicTypeNotificationChannelPopup extends DynamicTypeAbstractNotificationChannel {
readonly id = POPUP_CHANNEL_ID
readonly icon = 'monitor'
}

// Registered here rather than per bundle: the icon is a frontend asset and email is common
// enough that every contributing bundle would otherwise repeat it.
@injectable()
export class DynamicTypeNotificationChannelEmail extends DynamicTypeAbstractNotificationChannel {
readonly id = 'email'
readonly icon = 'email'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

import { injectable } from 'inversify'
import { DynamicTypeRegistryAbstract } from '@Pimcore/modules/element/dynamic-types/registry/dynamic-type-registry-abstract'
import { type DynamicTypeAbstractNotificationChannel } from '../definitions/dynamic-type-abstract-notification-channel'

/** Channel presentation keyed by channel id; the column set itself comes from the API. */
@injectable()
export class DynamicTypeNotificationChannelRegistry
extends DynamicTypeRegistryAbstract<DynamicTypeAbstractNotificationChannel> {}
Loading
Loading