diff --git a/shell/assets/images/content/README.md b/shell/assets/images/content/README.md new file mode 100644 index 00000000000..ba5b4862986 --- /dev/null +++ b/shell/assets/images/content/README.md @@ -0,0 +1,5 @@ +# Built-in icon images that can be used for Dynamic Content + +These are referenced in the dynamic content package with the '~' prefix. + +Light theme mode images are in this folder. The version of the image for dark theme mode should be in the `dark` sub-folder. \ No newline at end of file diff --git a/shell/assets/images/content/cloud-native.svg b/shell/assets/images/content/cloud-native.svg new file mode 100644 index 00000000000..c43e65519b0 --- /dev/null +++ b/shell/assets/images/content/cloud-native.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/shell/assets/images/content/dark/cloud-native.svg b/shell/assets/images/content/dark/cloud-native.svg new file mode 100644 index 00000000000..956f763fa5a --- /dev/null +++ b/shell/assets/images/content/dark/cloud-native.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/shell/assets/images/content/dark/shield.svg b/shell/assets/images/content/dark/shield.svg new file mode 100644 index 00000000000..d3a186c8781 --- /dev/null +++ b/shell/assets/images/content/dark/shield.svg @@ -0,0 +1,59 @@ + + diff --git a/shell/assets/images/content/dark/suse.svg b/shell/assets/images/content/dark/suse.svg new file mode 100644 index 00000000000..96e27b1fcb0 --- /dev/null +++ b/shell/assets/images/content/dark/suse.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/shell/assets/images/content/shield.svg b/shell/assets/images/content/shield.svg new file mode 100644 index 00000000000..b45a3189f1e --- /dev/null +++ b/shell/assets/images/content/shield.svg @@ -0,0 +1,59 @@ + + diff --git a/shell/assets/images/content/suse.svg b/shell/assets/images/content/suse.svg new file mode 100644 index 00000000000..2f92f49ccb0 --- /dev/null +++ b/shell/assets/images/content/suse.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml index 3b38b044baa..4a6f0369c35 100644 --- a/shell/assets/translations/en-us.yaml +++ b/shell/assets/translations/en-us.yaml @@ -2804,6 +2804,10 @@ drainNode: custom: "Give up after:" dynamicContent: + action: + close: Hide and Mark Announcement as read + openPrimary: Open announcement's primary action + openSecondary: Open announcement's secondary action newRelease: title: A new Rancher release is available! message: Rancher {version} has been released! diff --git a/shell/components/DynamicContent/DynamicContentBanner.vue b/shell/components/DynamicContent/DynamicContentBanner.vue new file mode 100644 index 00000000000..68d59d85dfb --- /dev/null +++ b/shell/components/DynamicContent/DynamicContentBanner.vue @@ -0,0 +1,102 @@ + + + + diff --git a/shell/components/DynamicContent/DynamicContentCloseButton.vue b/shell/components/DynamicContent/DynamicContentCloseButton.vue new file mode 100644 index 00000000000..ba3faa5f0ff --- /dev/null +++ b/shell/components/DynamicContent/DynamicContentCloseButton.vue @@ -0,0 +1,42 @@ + + + diff --git a/shell/components/DynamicContent/DynamicContentIcon.vue b/shell/components/DynamicContent/DynamicContentIcon.vue new file mode 100644 index 00000000000..2cbfd28eee9 --- /dev/null +++ b/shell/components/DynamicContent/DynamicContentIcon.vue @@ -0,0 +1,132 @@ + + + + diff --git a/shell/components/DynamicContent/DynamicContentPanel.vue b/shell/components/DynamicContent/DynamicContentPanel.vue new file mode 100644 index 00000000000..41a67ded1a6 --- /dev/null +++ b/shell/components/DynamicContent/DynamicContentPanel.vue @@ -0,0 +1,112 @@ + + + + diff --git a/shell/components/DynamicContent/content.ts b/shell/components/DynamicContent/content.ts new file mode 100644 index 00000000000..c04bf19ec1c --- /dev/null +++ b/shell/components/DynamicContent/content.ts @@ -0,0 +1,78 @@ +/** + * Composable to provide access to an announcement + */ + +import { computed, ComputedRef } from 'vue'; +import { useStore } from 'vuex'; +import { Notification, StoredNotification, NotificationAction } from '@shell/types/notifications'; +import { useRouter } from 'vue-router'; + +export type Styles = { [key: string]: string }; + +export interface UseDynamicInput { + dynamicContent: ComputedRef; + primaryButtonStyle: ComputedRef; + styles: ComputedRef; + invokeAction: (action: NotificationAction) => void; +} + +export interface DynamicInputProps { + location?: string; +} + +export const useDynamicContent = (props: DynamicInputProps, defaultLocation: string): UseDynamicInput => { + const store = useStore(); + const router = useRouter(); + + // Return the first un-read hidden notification for the given location + const dynamicContent = computed(() => { + const location = props.location || defaultLocation; + const hiddenUnreadNotificationsForLocation: Notification[] = store.getters['notifications/hidden'].filter((n: StoredNotification) => !n.read && n.data?.location === location); + + return hiddenUnreadNotificationsForLocation.length > 0 ? hiddenUnreadNotificationsForLocation[0] : undefined; + }); + + const styles = computed(() => { + const parts = dynamicContent?.value?.data?.style?.trim().split(',') || []; + const res: Styles = {}; + + parts.forEach((part: string) => { + const kv = part.split(':'); + + if (kv.length === 2) { + res[kv[0].trim()] = kv[1].trim(); + } + }); + + return res; + }); + + const primaryButtonStyle = computed(() => { + const buttonStyle = styles.value.btn === 'link' ? 'tertiary' : styles.value.btn || 'primary'; + + return `role-${ buttonStyle }`; + }); + + // Invoke action (typically from either the primary or secondary buttons of a notification) + // This can open a URL in a new tab OR navigate to an application route + const invokeAction = (action: NotificationAction) => { + if (action.target) { + window.open(action.target, '_blank'); + } else if (action.route) { + try { + router.push(action.route); + } catch (e) { + console.error('Error navigating to route for the notification action', e); // eslint-disable-line no-console + } + } else { + console.error('Notification action must either specify a "target" or a "route"'); // eslint-disable-line no-console + } + }; + + return { + dynamicContent, + invokeAction, + primaryButtonStyle, + styles + }; +}; diff --git a/shell/components/nav/NotificationCenter/index.vue b/shell/components/nav/NotificationCenter/index.vue index d36fc37556f..ee4d58153af 100644 --- a/shell/components/nav/NotificationCenter/index.vue +++ b/shell/components/nav/NotificationCenter/index.vue @@ -13,7 +13,8 @@ import { } from '@components/RcDropdown'; const store = useStore(); -const allNotifications = computed(() => store.getters['notifications/all']); +// We don't want any hidden notifications showing in the notification center (these are shown elsewhere, e.g. home page dynamic content announcements) +const allNotifications = computed(() => store.getters['notifications/visible']); const unreadLevelClass = computed(() => { return store.getters['notifications/unreadCount'] === 0 ? '' : 'unread'; }); diff --git a/shell/pages/home.vue b/shell/pages/home.vue index 996725756fe..7b57bd14e03 100644 --- a/shell/pages/home.vue +++ b/shell/pages/home.vue @@ -7,6 +7,8 @@ import PaginatedResourceTable from '@shell/components/PaginatedResourceTable.vue import { BadgeState } from '@components/BadgeState'; import CommunityLinks from '@shell/components/CommunityLinks.vue'; import SingleClusterInfo from '@shell/components/SingleClusterInfo.vue'; +import DynamicContentBanner from '@shell/components/DynamicContent/DynamicContentBanner.vue'; +import DynamicContentPanel from '@shell/components/DynamicContent/DynamicContentPanel.vue'; import { mapGetters, mapState } from 'vuex'; import { MANAGEMENT, CAPI, COUNT } from '@shell/config/types'; import { NAME as MANAGER } from '@shell/config/product/manager'; @@ -47,6 +49,8 @@ export default defineComponent({ SingleClusterInfo, TabTitle, ResourceTable, + DynamicContentBanner, + DynamicContentPanel, }, mixins: [PageHeaderActions, Preset], @@ -610,6 +614,7 @@ export default defineComponent({ pref-key="welcomeBanner" data-testid="home-banner-graphic" /> +
@@ -936,7 +941,10 @@ export default defineComponent({
- +
+ + +
diff --git a/shell/store/notifications.ts b/shell/store/notifications.ts index 7b873f46c09..bb0b25d0672 100644 --- a/shell/store/notifications.ts +++ b/shell/store/notifications.ts @@ -1,6 +1,12 @@ import { md5 } from '@shell/utils/crypto'; import { randomStr } from '@shell/utils/string'; -import { EncryptedNotification, Notification, NotificationHandlerExtensionName, StoredNotification } from '@shell/types/notifications'; +import { + EncryptedNotification, + Notification, + NotificationLevel, + NotificationHandlerExtensionName, + StoredNotification +} from '@shell/types/notifications'; import { encrypt, decrypt, deriveKey } from '@shell/utils/crypto/encryption'; /** @@ -66,7 +72,8 @@ async function saveEncryptedNotification(getters: any, notification: Notificatio primaryAction: notification.primaryAction, secondaryAction: notification.secondaryAction, preference: notification.preference, - handlerName: notification.handlerName + handlerName: notification.handlerName, + data: notification.data, }; const localStorageKey = getters['localStorageKey']; @@ -110,15 +117,23 @@ export const getters = { return state.notifications; }, + visible: (state: NotificationsStore) => { + return state.notifications.filter((n) => n.level !== NotificationLevel.Hidden); + }, + + hidden: (state: NotificationsStore) => { + return state.notifications.filter((n) => n.level === NotificationLevel.Hidden); + }, + item: (state: NotificationsStore) => { return (id: string) => { return state.notifications.find((i) => i.id === id); }; }, - // Count of unread notifications + // Count of unread notifications - only considers visible notifications unreadCount: (state: NotificationsStore) => { - return state.notifications.filter((n) => !n.read).length; + return state.notifications.filter((n) => !n.read && n.level !== NotificationLevel.Hidden).length; }, /** @@ -194,9 +209,10 @@ export const mutations = { syncIndex(state); }, + // Only mark visible notifications as read via mark all markAllRead(state: NotificationsStore) { state.notifications.forEach((notification) => { - if (!notification.read) { + if (!notification.read && notification.level !== NotificationLevel.Hidden) { notification.read = true; } }); diff --git a/shell/types/notifications/index.ts b/shell/types/notifications/index.ts index 257114f89f7..29db0ffd459 100644 --- a/shell/types/notifications/index.ts +++ b/shell/types/notifications/index.ts @@ -14,6 +14,7 @@ export enum NotificationLevel { Success, // eslint-disable-line no-unused-vars Warning, // eslint-disable-line no-unused-vars Error, // eslint-disable-line no-unused-vars + Hidden, // eslint-disable-line no-unused-vars } /** @@ -52,6 +53,8 @@ export type EncryptedNotification = { // Handler to be associated with this notification that can invoke additional behaviour when the notification changes // This is the name of the handler (the handlers are added as extensions). Notifications are persisted in the store, so can't use functions. handlerName?: string; + // Additional data to be stored with the notification (optional) + data?: any; }; /** diff --git a/shell/utils/dynamic-content/announcement.ts b/shell/utils/dynamic-content/announcement.ts index 51e1b993d69..eb4961f7992 100644 --- a/shell/utils/dynamic-content/announcement.ts +++ b/shell/utils/dynamic-content/announcement.ts @@ -15,10 +15,15 @@ import { DynamicContentAnnouncementHandlerName } from './notification-handler'; // Prefixes used in the notifications IDs created here export const ANNOUNCEMENT_PREFIX = 'announcement-'; +const TARGET_NOTIFICATION_CENTER = 'notification'; +const TARGET_HOME_PAGE = 'homepage'; +const ALLOWED_TARGETS = [TARGET_NOTIFICATION_CENTER, TARGET_HOME_PAGE]; + const ALLOWED_NOTIFICATIONS: Record = { announcement: NotificationLevel.Announcement, info: NotificationLevel.Info, warning: NotificationLevel.Warning, + homepage: NotificationLevel.Hidden, }; /** @@ -47,64 +52,89 @@ export async function processAnnouncements(context: Context, announcements: Anno return; } + if (!announcement.id) { + logger.error(`No ID For announcement - not going to add a notification for the announcement`); + + return; + } + // Check type const targetSplit = announcement.target.split('/'); + const target = targetSplit[0]; - if (targetSplit[0] === 'notification') { - // Show a notification - const subType = targetSplit.length === 2 ? targetSplit[1] : 'announcement'; + // Make sure that the target is supported + if (ALLOWED_TARGETS.includes(target)) { + let level = NotificationLevel.Announcement; + let data: any = {}; - // Because 0 is a falsy, see if we find something of type number to check for existence - if (typeof ALLOWED_NOTIFICATIONS[subType] !== 'number') { - logger.error(`Announcement notification type ${ subType } is not supported`); - } else { - logger.info(`Going to add a notification for announcement ${ announcement.target }`); + if (target === TARGET_NOTIFICATION_CENTER) { + // Show a notification + const subType = targetSplit.length === 2 ? targetSplit[1] : 'announcement'; - if (!announcement.id) { - logger.error(`No ID For announcement - not going to add a notification for the announcement`); + if (!(subType in ALLOWED_NOTIFICATIONS)) { + logger.error(`Announcement notification type ${ subType } is not supported`); return; } - // We should check if the notification already exists - const id = `${ ANNOUNCEMENT_PREFIX }${ announcement.id }`; - const existing = getters['notifications/item'](id); + level = ALLOWED_NOTIFICATIONS[subType]; + } else if (target === TARGET_HOME_PAGE) { + level = NotificationLevel.Hidden; + data = { + icon: announcement.icon, + location: targetSplit.length === 2 ? targetSplit[1] : 'banner', + }; - // Check if the pref for 'read announcements' has the id - const pref = getters['prefs/get'](READ_ANNOUNCEMENTS) || ''; - const prefExists = pref.split(',').includes(announcement.id); + if (announcement.style) { + data.style = announcement.style; + } + } - if (existing || prefExists) { - logger.info(`Not adding announcement with ID ${ id } as it already exists or has been read previously (title: ${ announcement.title })`); + logger.info(`Going to add a notification for announcement ${ announcement.target }`); - return; - } - const notification: Notification = { - id, - level: ALLOWED_NOTIFICATIONS[subType], - title: announcement.title, - message: announcement.message, - handlerName: DynamicContentAnnouncementHandlerName, - }; + // We should check if the notification already exists + const id = `${ ANNOUNCEMENT_PREFIX }${ announcement.id }`; + const existing = getters['notifications/item'](id); - if (announcement.cta?.primary) { - notification.primaryAction = { - label: announcement.cta.primary.action, - target: announcement.cta.primary.link, - }; - } + // Check if the pref for 'read announcements' has the id + const pref = getters['prefs/get'](READ_ANNOUNCEMENTS) || ''; + const prefExists = pref.split(',').includes(announcement.id); - if (announcement.cta?.secondary) { - notification.secondaryAction = { - label: announcement.cta.secondary.action, - target: announcement.cta.secondary.link, - }; - } + if (existing || prefExists) { + logger.info(`Not adding announcement with ID ${ id } as it already exists or has been read previously (title: ${ announcement.title })`); + + return; + } - logger.info(`Adding announcement with ID ${ id } (title: ${ announcement.title })`); + const notification: Notification = { + id, + level, + title: announcement.title, + message: announcement.message, + handlerName: DynamicContentAnnouncementHandlerName, + }; - await dispatch('notifications/add', notification); + if (data && Object.keys(data).length > 0) { + notification.data = data; + } + + if (announcement.cta?.primary) { + notification.primaryAction = { + label: announcement.cta.primary.action, + target: announcement.cta.primary.link, + }; } + + if (announcement.cta?.secondary) { + notification.secondaryAction = { + label: announcement.cta.secondary.action, + target: announcement.cta.secondary.link, + }; + } + + logger.info(`Adding announcement with ID ${ id } (title: ${ announcement.title }, target: ${ announcement.target })`); + + await dispatch('notifications/add', notification); } else { logger.error(`Announcement type ${ announcement.target } is not supported`); } diff --git a/shell/utils/dynamic-content/types.d.ts b/shell/utils/dynamic-content/types.d.ts index af1bd3ebb00..d8cfe9bc639 100644 --- a/shell/utils/dynamic-content/types.d.ts +++ b/shell/utils/dynamic-content/types.d.ts @@ -106,6 +106,8 @@ export type CallToAction = { * - `notification/announcement` - Shown with `Announcement` level in the Notification Center * - `notification/info` - Shown with `Info` level in the Notification Center * - `notification/warning` - Shown with `Warning` level in the Notification Center + * - `homepage/banner` - Shown on the home page as a banner beneath the main banner + * - `homepage/rhs` - Shown on the home page as a panel beneath the right-hand side links panel * */ export type Announcement = { @@ -115,10 +117,28 @@ export type Announcement = { target: string; // Where the announcement should be shown version?: string; // Version or semver expression for when to show this announcement audience?: 'admin' | 'all'; // Audience - show for just Admins or for all users + icon?: string; cta?: { primary: CallToAction, // Must have a primary call to action, if we have a cta field secondary?: CallToAction, - } + }, + style?: string; // Styling information that will be interpreted by the rendering component +}; + +/** + * Icon information + */ +export type AnnouncementNotificationIconData = { + light: string; // Light mode icon/image + dark?: string; // Light mode icon/image +}; + +/** + * Custom data for announcements stored with the notification + */ +export type AnnouncementNotificationData = { + icon?: AnnouncementNotificationIconData; // Icon/Image to show + location: string; // Location of the announcement in the UI }; /**