diff --git a/code/lib/manager-api/src/modules/notifications.ts b/code/lib/manager-api/src/modules/notifications.ts index 3358dd67a241..a8ee3195b6b7 100644 --- a/code/lib/manager-api/src/modules/notifications.ts +++ b/code/lib/manager-api/src/modules/notifications.ts @@ -1,4 +1,5 @@ import type { API_Notification } from '@storybook/types'; +import partition from 'lodash/partition.js'; import type { ModuleFn } from '../lib/types'; export interface SubState { @@ -25,26 +26,24 @@ export interface SubAPI { export const init: ModuleFn = ({ store }) => { const api: SubAPI = { - addNotification: (notification) => { - // Get rid of it if already exists - api.clearNotification(notification.id); - - const { notifications } = store.getState(); - - store.setState({ notifications: [...notifications, notification] }); + addNotification: (newNotification) => { + store.setState(({ notifications }) => { + const [existing, others] = partition(notifications, (n) => n.id === newNotification.id); + existing.forEach((notification) => { + if (notification.onClear) notification.onClear({ dismissed: false, timeout: false }); + }); + return { notifications: [...others, newNotification] }; + }); }, - clearNotification: (id) => { - const { notifications } = store.getState(); - - const notification = notifications.find((n) => n.id === id); - - if (notification) { - store.setState({ notifications: notifications.filter((n) => n.id !== id) }); - if (notification.onClear) { - notification.onClear({ dismissed: false }); - } - } + clearNotification: (notificationId) => { + store.setState(({ notifications }) => { + const [matching, others] = partition(notifications, (n) => n.id === notificationId); + matching.forEach((notification) => { + if (notification.onClear) notification.onClear({ dismissed: false, timeout: false }); + }); + return { notifications: others }; + }); }, }; diff --git a/code/lib/manager-api/src/tests/notifications.test.js b/code/lib/manager-api/src/tests/notifications.test.js index 51a53be2b1e3..8d2005db6837 100644 --- a/code/lib/manager-api/src/tests/notifications.test.js +++ b/code/lib/manager-api/src/tests/notifications.test.js @@ -2,34 +2,33 @@ import { describe, it, expect, vi } from 'vitest'; import { init as initNotifications } from '../modules/notifications'; describe('notifications API', () => { - it('allows adding notifications', () => { - const store = { - getState: () => ({ - notifications: [], - }), - setState: vi.fn(), - }; + const store = { + state: { notifications: [] }, + getState: () => store.state, + setState: (update) => { + if (typeof update === 'function') { + store.state = update(store.state); + } else { + store.state = update; + } + }, + }; + it('allows adding notifications', () => { const { api } = initNotifications({ store }); api.addNotification({ id: '1' }); - expect(store.setState).toHaveBeenCalledWith({ + expect(store.getState()).toEqual({ notifications: [{ id: '1' }], }); }); it('allows removing notifications', () => { - const store = { - getState: () => ({ - notifications: [{ id: '1' }, { id: '2' }, { id: '3' }], - }), - setState: vi.fn(), - }; - + store.setState({ notifications: [{ id: '1' }, { id: '2' }, { id: '3' }] }); const { api } = initNotifications({ store }); api.clearNotification('2'); - expect(store.setState).toHaveBeenCalledWith({ + expect(store.getState()).toEqual({ notifications: [{ id: '1' }, { id: '3' }], }); }); diff --git a/code/lib/types/src/modules/api.ts b/code/lib/types/src/modules/api.ts index 1ccc98cc9e24..481139e71792 100644 --- a/code/lib/types/src/modules/api.ts +++ b/code/lib/types/src/modules/api.ts @@ -115,9 +115,20 @@ export interface API_SidebarOptions { interface OnClearOptions { /** - * True when the user dismissed the notification. + * True when the user manually dismissed the notification. */ dismissed: boolean; + /** + * True when the notification timed out after the set duration. + */ + timeout: boolean; +} + +interface OnClickOptions { + /** + * Function to dismiss the notification. + */ + onDismiss: () => void; } /** @@ -130,14 +141,16 @@ interface DeprecatedIconType { } export interface API_Notification { id: string; - link: string; content: { headline: string; subHeadline?: string | any; }; + duration?: number; + link?: string; // TODO: Remove DeprecatedIconType in 9.0 icon?: React.ReactNode | DeprecatedIconType; onClear?: (options: OnClearOptions) => void; + onClick?: (options: OnClickOptions) => void; } type API_Versions = Record; diff --git a/code/ui/manager/src/components/notifications/NotificationItem.stories.tsx b/code/ui/manager/src/components/notifications/NotificationItem.stories.tsx index 5d58559b465b..af4c7fcc8e57 100644 --- a/code/ui/manager/src/components/notifications/NotificationItem.stories.tsx +++ b/code/ui/manager/src/components/notifications/NotificationItem.stories.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { action } from '@storybook/addon-actions'; import { LocationProvider } from '@storybook/router'; import type { Meta, StoryObj } from '@storybook/react'; import NotificationItem from './NotificationItem'; @@ -7,6 +8,7 @@ import { BookIcon as BookIconIcon, FaceHappyIcon, } from '@storybook/icons'; +import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; const meta = { component: NotificationItem, @@ -24,13 +26,16 @@ const meta = { ), ], excludeStories: /.*Data$/, + args: { + onDismissNotification: () => {}, + }, } satisfies Meta; export default meta; type Story = StoryObj; -const onClear = () => {}; -const onDismissNotification = () => {}; +const onClear = fn(action('onClear')); +const onClick = fn(action('onClick')); export const Simple: Story = { args: { @@ -40,29 +45,68 @@ export const Simple: Story = { content: { headline: 'Storybook cool!', }, - link: '/some/path', }, - onDismissNotification, + }, +}; + +export const Timeout: Story = { + args: { + notification: { + id: 'Timeout', + onClear, + onClick, + content: { + headline: 'Storybook cool!', + }, + duration: 3000, + }, + }, + play: async ({ args }) => { + await waitFor( + () => { + expect(args.notification.onClear).toHaveBeenCalledWith({ dismissed: false, timeout: true }); + }, + { + timeout: 4000, + } + ); }, }; export const LongHeadline: Story = { args: { - ...Simple.args, notification: { id: '2', onClear, content: { headline: 'This is a long message that extends over two lines!', }, - link: '/some/path', + link: undefined, + }, + }, +}; + +export const Clickable: Story = { + args: { + notification: { + id: 'Clickable', + onClear, + onClick, + content: { + headline: 'Storybook cool!', + }, }, }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const [button] = await canvas.findAllByRole('button'); + await userEvent.click(button); + await expect(args.notification.onClick).toHaveBeenCalledWith({ onDismiss: expect.anything() }); + }, }; export const Link: Story = { args: { - ...Simple.args, notification: { id: '3', onClear, @@ -76,7 +120,6 @@ export const Link: Story = { export const LinkIconWithColor: Story = { args: { - ...Simple.args, notification: { id: '4', onClear, @@ -91,7 +134,6 @@ export const LinkIconWithColor: Story = { export const LinkIconWithColorSubHeadline: Story = { args: { - ...Simple.args, notification: { id: '5', onClear, @@ -107,7 +149,6 @@ export const LinkIconWithColorSubHeadline: Story = { export const BookIcon: Story = { args: { - ...Simple.args, notification: { id: '6', onClear, @@ -115,14 +156,13 @@ export const BookIcon: Story = { headline: 'Storybook has a book icon!', }, icon: , - link: '/some/path', + link: undefined, }, }, }; export const StrongSubHeadline: Story = { args: { - ...Simple.args, notification: { id: '7', onClear, @@ -131,14 +171,13 @@ export const StrongSubHeadline: Story = { subHeadline: Strong subHeadline, }, icon: , - link: '/some/path', + link: undefined, }, }, }; export const StrongEmphasizedSubHeadline: Story = { args: { - ...Simple.args, notification: { id: '8', onClear, @@ -151,14 +190,13 @@ export const StrongEmphasizedSubHeadline: Story = { ), }, icon: , - link: '/some/path', + link: undefined, }, }, }; export const BookIconSubHeadline: Story = { args: { - ...Simple.args, notification: { id: '9', onClear, @@ -167,14 +205,13 @@ export const BookIconSubHeadline: Story = { subHeadline: 'Find out more!', }, icon: , - link: '/some/path', + link: undefined, }, }, }; export const BookIconLongSubHeadline: Story = { args: { - ...Simple.args, notification: { id: '10', onClear, @@ -184,14 +221,13 @@ export const BookIconLongSubHeadline: Story = { 'Find out more! by clicking on on buttons and downloading some applications. Find out more! by clicking on buttons and downloading some applications', }, icon: , - link: '/some/path', + link: undefined, }, }, }; export const AccessibilityIcon: Story = { args: { - ...Simple.args, notification: { id: '11', onClear, @@ -200,14 +236,13 @@ export const AccessibilityIcon: Story = { subHeadline: 'It is here!', }, icon: , - link: '/some/path', + link: undefined, }, }, }; export const AccessibilityGoldIcon: Story = { args: { - ...Simple.args, notification: { id: '12', onClear, @@ -216,14 +251,13 @@ export const AccessibilityGoldIcon: Story = { subHeadline: 'It is gold!', }, icon: , - link: '/some/path', + link: undefined, }, }, }; export const AccessibilityGoldIconLongHeadLineNoSubHeadline: Story = { args: { - ...Simple.args, notification: { id: '13', onClear, @@ -231,14 +265,13 @@ export const AccessibilityGoldIconLongHeadLineNoSubHeadline: Story = { headline: 'Storybook notifications has a accessibility icon it can be any color!', }, icon: , - link: '/some/path', + link: undefined, }, }, }; export const WithOldIconFormat: Story = { args: { - ...Simple.args, notification: { id: '13', onClear, @@ -249,7 +282,7 @@ export const WithOldIconFormat: Story = { name: 'accessibility', color: 'gold', }, - link: '/some/path', + link: undefined, }, }, }; diff --git a/code/ui/manager/src/components/notifications/NotificationItem.tsx b/code/ui/manager/src/components/notifications/NotificationItem.tsx index 0028f87749cf..d47f539a3f1c 100644 --- a/code/ui/manager/src/components/notifications/NotificationItem.tsx +++ b/code/ui/manager/src/components/notifications/NotificationItem.tsx @@ -1,28 +1,69 @@ import type { FC, SyntheticEvent } from 'react'; -import React from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { type State } from '@storybook/manager-api'; import { Link } from '@storybook/router'; -import { styled, useTheme } from '@storybook/theming'; +import { keyframes, styled, useTheme } from '@storybook/theming'; import type { IconsProps } from '@storybook/components'; import { IconButton, Icons } from '@storybook/components'; import { transparentize } from 'polished'; import { CloseAltIcon } from '@storybook/icons'; -const Notification = styled.div(({ theme }) => ({ - position: 'relative', - display: 'flex', - padding: 15, - width: 280, - borderRadius: 4, - alignItems: 'center', +const slideIn = keyframes({ + '0%': { + opacity: 0, + transform: 'translateY(30px)', + }, + '100%': { + opacity: 1, + transform: 'translateY(0)', + }, +}); - background: theme.base === 'light' ? 'hsla(203, 50%, 20%, .97)' : 'hsla(203, 30%, 95%, .97)', - boxShadow: `0 2px 5px 0 rgba(0,0,0,0.05), 0 5px 15px 0 rgba(0,0,0,0.1)`, - color: theme.color.inverseText, - textDecoration: 'none', -})); +const grow = keyframes({ + '0%': { + width: '0%', + }, + '100%': { + width: '100%', + }, +}); + +const Notification = styled.div<{ duration?: number }>( + ({ theme }) => ({ + position: 'relative', + display: 'flex', + padding: 15, + width: 280, + borderRadius: 4, + alignItems: 'center', + + animation: `${slideIn} 500ms`, + background: theme.base === 'light' ? 'hsla(203, 50%, 20%, .97)' : 'hsla(203, 30%, 95%, .97)', + boxShadow: `0 2px 5px 0 rgba(0,0,0,0.05), 0 5px 15px 0 rgba(0,0,0,0.1)`, + color: theme.color.inverseText, + textDecoration: 'none', + overflow: 'hidden', + }), + ({ duration, theme }) => + duration && { + '&::after': { + content: '""', + display: 'block', + position: 'absolute', + bottom: 0, + left: 0, + height: 3, + background: theme.color.secondary, + animation: `${grow} ${duration}ms linear forwards reverse`, + }, + } +); const NotificationWithInteractiveStates = styled(Notification)(() => ({ + cursor: 'pointer', + border: 'none', + outline: 'none', + textAlign: 'left', transition: 'all 150ms ease-out', transform: 'translate3d(0, 0, 0)', '&:hover': { @@ -37,9 +78,10 @@ const NotificationWithInteractiveStates = styled(Notification)(() => ({ }, '&:focus': { boxShadow: - '0 1px 3px 0 rgba(30,167,253,0.5), 0 2px 5px 0 rgba(0,0,0,0.05), 0 5px 15px 0 rgba(0,0,0,0.1)', + 'rgba(2,156,253,1) 0 0 0 1px inset, 0 1px 3px 0 rgba(30,167,253,0.5), 0 2px 5px 0 rgba(0,0,0,0.05), 0 5px 15px 0 rgba(0,0,0,0.1)', }, })); +const NotificationButton = NotificationWithInteractiveStates.withComponent('button'); const NotificationLink = NotificationWithInteractiveStates.withComponent(Link); const NotificationIconWrapper = styled.div(() => ({ @@ -121,6 +163,7 @@ const DismissNotificationItem: FC<{ title="Dismiss notification" onClick={(e: SyntheticEvent) => { e.preventDefault(); + e.stopPropagation(); onDismiss(); }} > @@ -135,22 +178,50 @@ export const NotificationItemSpacer = styled.div({ const NotificationItem: FC<{ notification: State['notifications'][0]; onDismissNotification: (id: string) => void; -}> = ({ notification: { content, link, onClear, id, icon }, onDismissNotification }) => { - const dismissNotificationItem = () => { +}> = ({ + notification: { content, duration, link, onClear, onClick, id, icon }, + onDismissNotification, +}) => { + const onTimeout = useCallback(() => { onDismissNotification(id); - if (onClear) { - onClear({ dismissed: true }); - } - }; - return link ? ( - - - - - ) : ( - + if (onClear) onClear({ dismissed: false, timeout: true }); + }, [onDismissNotification, onClear]); + + const timer = useRef(null); + useEffect(() => { + if (!duration) return; + timer.current = setTimeout(onTimeout, duration); + return () => clearTimeout(timer.current); + }, [duration, onTimeout]); + + const onDismiss = useCallback(() => { + clearTimeout(timer.current); + onDismissNotification(id); + if (onClear) onClear({ dismissed: true, timeout: false }); + }, [onDismissNotification, onClear]); + + if (link) { + return ( + + + + + ); + } + + if (onClick) { + return ( + onClick({ onDismiss })}> + + + + ); + } + + return ( + - + ); };