diff --git a/components/notifications/.npmignore b/components/notifications/.npmignore new file mode 100644 index 00000000..2b07fde8 --- /dev/null +++ b/components/notifications/.npmignore @@ -0,0 +1,3 @@ +src +rollup.config.js +tsconfig.json diff --git a/components/notifications/README.md b/components/notifications/README.md new file mode 100644 index 00000000..349d0710 --- /dev/null +++ b/components/notifications/README.md @@ -0,0 +1,245 @@ +# `@byndyusoft-ui/notifications` + + +## Installation + +```sh +npm i @byndyusoft-ui/notifications +# or +yarn add @byndyusoft-ui/notifications +``` + + +## Usage + +```tsx +import React from 'react'; +import NotificationsManager, {useNotifications, INotificationData} from '@byndyusoft-ui/notifications'; + + +const SomeComponent = () => { + const {success} = useNotifications(); + + const onShowSuccessNotification = () => { + success({ + title: 'Some string', + message: 'Some ReactNode...', + footer: 'Some ReactNode...', + }) + } + + return ( + + ) +} + +const NotificationComponent = ({data}: INotificationData) => ( +
+ {data.title} + {data?.message &&
{data.message}
} + {data?.footer &&
{data.footer}
} + {data?.isClosable && ( + + )} +
+) + +const App = () => { + return ( +
+ + +
+ ) +} + +export default App; +``` + +# API + +## NotificationsManager + +### Props + +| Props | Type | Default | Description | +|------------------------------|-------------------------------|----------------|--------------------------------------------------------------------------------------| +| position | `TNotificationPosition` | "top-right" | One of top-right, top-center, top-left, bottom-right, bottom-center, bottom-left | +| duration | number | 5000 | Duration in ms to close the notification. | +| isClosable | boolean | true | Allows manual closing of the notification. | +| isAutoClosable | boolean | true | Automatically closes the notification after the specified duration. | +| isPauseWhenPageHidden | boolean | true | Pauses the notification timer when the page is hidden. | +| isPauseOnHover | boolean | true | Pauses the notification timer on hover. | +| isCloseOnClick | boolean | false | Closes the notification when clicked. | +| isNewestOnTop | boolean | false | Displays new notifications on top of older ones. | +| limit | number | - | The maximum number of notifications displayed simultaneously at a specific position. | +| renderNotification | `TNotificationRender` | - | Component for rendering notifications. | +| renderNotificationComponents | `TNotificationRecordRender` | - | Object with components for rendering different types of notifications. | +| offset | `TPlatformValue` | 24 | Offset for web and mobile versions. | +| gap | `TPlatformValue` | 8 | Gap between notifications for web and mobile versions. | +| width | string \| number | 356 | Width of the notifications. | +| className | string | - | Class for the notification list container. | +| classNameItem | string | - | Class for each notification item. | +| style | CSSProperties | - | Style object for the notification list container. | +| styleItem | CSSProperties | - | Style object for each notification item. | + +### Usages + +```tsx +import NotificationsManager from '@byndyusoft-ui/notifications'; + + + + renderNotificationComponents={{ + success: SuccessNotification, + danger: DangerNotification, + info: InfoNotification, + warning: WarningNotification, + ordinary: OrdinaryNotification, + }} +/> +``` + +> #### Notes +> - `renderNotificationComponents` allows you to set a component personally for each `theme` of notification (e.g., `success`, `danger`, `info`, `warning`, `ordinary`). +> - If you use `renderNotification`, then `renderNotificationComponents` will override the components for all notification themes. + +## Emitter (actions from useNotifications) + +### Params create + +> When displaying a notification , the options are inherited from the container. Notification options supersede Notifications Manager props + +| Options | Type | Description | +|----------------|-------------------------|---------------------------------------------------------------------| +| id | string \| number | Identifier for the notification. | +| position | `TNotificationPosition` | Position of the notification. | +| duration | number | Duration in ms to close notification | +| title | string | Title of the notification. | +| message | ReactNode | Main content of the notification. | +| footer | ReactNode | Footer content of the notification. | +| isClosable | boolean | Allows manual closing of the notification. | +| isAutoClosable | boolean | Automatically closes the notification after the specified duration. | +| isCloseOnClick | boolean | Closes the notification when clicked. | +| render | `TNotificationRender` | Component for rendering the notification. | +| theme | `TNotificationTheme` | Theme of the notification. | +| afterClose | () => void | Callback function to be executed after the notification is closed. | +| className | string | Class name for the notifications component. | +| classNameItem | CSSProperties | Class for the notification container. | +| style | CSSProperties | Style object for the notifications component. | +| styleItem | CSSProperties | Style object for the notification container. | + +### Usages + +```js +import { useNotifications } from '@byndyusoft-ui/notifications'; + + +const options = { + position: "top-left", + duration: 3000, + title: 'Some string', + message: 'Some ReactNode...', + footer: 'Some ReactNode...', + isClosable: true, + isAutoClosable: true, + isCloseOnClick: false, + className: 'notification', + style: {/* ...styles notification */ }, + classNameItem: 'contaider-item', + styleItem: {/* ...styles contaider*/ }, + afterClose: () => console.log("afterClose"), +}; + +const notifications = useNotifications() + +// create new +notifications.success(options); +notifications.danger(options); +notifications.info(options); +notifications.warning(options); +notifications.ordinary(options); +notifications.custom(({ data, index }) =>
{data.title}
, { /* ...options */ }); +// update by id +notifications.update('id', options); +// deleting with animation +notifications.dismiss('id'); +notifications.dismissAll(); +// deleting without animation +notifications.remove('id'); +notifications.removeAll(); +``` + +### Types + +#### TNotificationPosition + +```ts +type TNotificationPosition = + | 'top-left' + | 'top-right' + | 'bottom-left' + | 'bottom-right' + | 'top-center' + | 'bottom-center'; +``` + +#### TNotificationRender + +```ts +interface INotificationData { + data: INotificationsItem; + index: number; +} + +type TNotificationRender = ((params: INotificationData) => ReactNode) | ReactNode; +``` + +#### TNotificationRecordRender + +```ts +type TNotificationRecordRender = Partial, TNotificationRender>>; +``` + +#### TPlatformValue + +```ts +type TPlatformValue = + | string + | number + | { + web: string | number; + mobile: string | number; +}; +``` + +#### TNotificationTheme +```ts +type TNotificationTheme = 'success' | 'info' | 'warning' | 'danger' | 'custom' | 'ordinary'; +``` diff --git a/components/notifications/package.json b/components/notifications/package.json new file mode 100644 index 00000000..0d2fca0b --- /dev/null +++ b/components/notifications/package.json @@ -0,0 +1,40 @@ +{ + "name": "@byndyusoft-ui/notifications", + "version": "0.0.1", + "description": "Byndyusoft UI Portal React Component", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "react", + "notifications" + ], + "author": "", + "homepage": "", + "license": "ISC", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "clean": "rimraf dist", + "lint": "eslint src --config ../../eslint.config.js", + "test": "jest --config ../../jest.config.js --roots components/notifications/src" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@byndyusoft-ui/types": "^0.1.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17", + "@byndyusoft-ui/pub-sub": "^0.0.1" + } +} diff --git a/components/notifications/rollup.config.js b/components/notifications/rollup.config.js new file mode 100644 index 00000000..231a2aca --- /dev/null +++ b/components/notifications/rollup.config.js @@ -0,0 +1,15 @@ +import typescript from '@rollup/plugin-typescript'; +import baseConfig from '../../rollup.base.config'; + +export default { + ...baseConfig, + input: ['src/index.ts'], + plugins: [ + ...baseConfig.plugins, + typescript({ + tsconfig: './tsconfig.json', + module: 'ESNext', + exclude: ['src/**/*.stories.tsx', 'src/__tests__/*', 'node_modules'] + }) + ] +}; diff --git a/components/notifications/src/Notifications.types.ts b/components/notifications/src/Notifications.types.ts new file mode 100644 index 00000000..2cdadcb2 --- /dev/null +++ b/components/notifications/src/Notifications.types.ts @@ -0,0 +1,111 @@ +import { CSSProperties, ReactNode } from 'react'; +import { Callback } from '@byndyusoft-ui/types'; + +export type TNotificationItemId = string | number; + +export type TNotificationTheme = 'success' | 'info' | 'warning' | 'danger' | 'custom' | 'ordinary'; + +export type TNotificationPosition = + | 'top-left' + | 'top-right' + | 'bottom-left' + | 'bottom-right' + | 'top-center' + | 'bottom-center'; + +export interface INotification { + theme?: TNotificationTheme; + title?: string; + message?: ReactNode; + footer?: ReactNode; + isClosable?: boolean; + onClose?: Callback; + className?: string; + style?: CSSProperties; +} + +export interface INotificationData { + data: INotificationsItem; + index: number; +} + +export type TNotificationRender = ((params: INotificationData) => ReactNode) | ReactNode; + +export type TNotificationRecordRender = Partial, TNotificationRender>>; + +export interface INotificationsItem extends INotification { + id: TNotificationItemId; + position?: TNotificationPosition; + duration?: number; + isClosable?: boolean; + isAutoClosable?: boolean; + dismiss?: boolean; + isCloseOnClick?: boolean; + render?: TNotificationRender; + afterClose?: Callback; + classNameItem?: string; + styleItem?: CSSProperties; +} + +export type TCreateNotificationParams = Partial>; + +export type TThemedNotificationParams = Omit, 'theme' | 'render' | 'onClose' | 'dismiss'>; + +export type TCustomNotificationParams = Partial< + Omit +>; + +export type TPlatformValue = + | string + | number + | { + web: string | number; + mobile: string | number; + }; + +export interface INotificationsManagerProps { + position?: TNotificationPosition; + duration?: number; + isClosable?: boolean; + isAutoClosable?: boolean; + isPauseWhenPageHidden?: boolean; + isPauseOnHover?: boolean; + isCloseOnClick?: boolean; + isNewestOnTop?: boolean; + limit?: number; + renderNotification?: TNotificationRender; + renderNotificationComponents?: TNotificationRecordRender; + offset?: TPlatformValue; + gap?: TPlatformValue; + width?: string | number; + className?: string; + classNameItem?: string; + style?: CSSProperties; + styleItem?: CSSProperties; +} + +export interface IUseNotificationsStateParams { + position: TNotificationPosition; + isCloseOnClick: boolean; + isClosable: boolean; + limit?: number; + isNewestOnTop?: boolean; + renderNotification?: TNotificationRender; + renderNotificationComponents?: TNotificationRecordRender; +} + +export interface INotificationsItemProps { + children: ReactNode; + duration: number; + position: TNotificationPosition; + removeNotification: () => void; + dismissNotification: () => void; + theme?: TNotificationTheme; + isAutoClosable?: boolean; + dismiss?: boolean; + isPauseToRemove?: boolean; + onClick?: () => void; + afterClose?: () => void; + className?: string; + style?: CSSProperties; +} diff --git a/components/notifications/src/NotificationsManager.module.css b/components/notifications/src/NotificationsManager.module.css new file mode 100644 index 00000000..26ee5367 --- /dev/null +++ b/components/notifications/src/NotificationsManager.module.css @@ -0,0 +1,78 @@ +.notifications { + margin: 0; + padding: 0; + position: fixed; + z-index: 99999; + display: flex; + flex-direction: column; + gap: var(--gap-mobile); + width: calc(100% - var(--offset-mobile) * 2); + list-style-type: none; +} + +.top-left, +.top-right, +.top-center { + top: var(--offset-mobile); + left: var(--offset-mobile); + right: var(--offset-mobile); + transform: none; +} + +.bottom-left, +.bottom-right, +.bottom-center { + bottom: var(--offset-mobile); + left: var(--offset-mobile); + right: var(--offset-mobile); + transform: none; +} + +@media (min-width: 768px) { + .notifications { + width: var(--width); + gap: var(--gap); + } + + .top-left { + top: var(--offset); + left: var(--offset); + right: auto; + transform: none; + } + + .top-right { + top: var(--offset); + right: var(--offset); + left: auto; + transform: none; + } + + .bottom-left { + bottom: var(--offset); + left: var(--offset); + right: auto; + transform: none; + } + + .bottom-right { + bottom: var(--offset); + right: var(--offset); + left: auto; + transform: none; + } + + .top-center { + top: var(--offset); + left: 50%; + right: auto; + transform: translateX(-50%); + } + + .bottom-center { + bottom: var(--offset); + left: 50%; + right: auto; + transform: translateX(-50%); + } +} diff --git a/components/notifications/src/NotificationsManager.tsx b/components/notifications/src/NotificationsManager.tsx new file mode 100644 index 00000000..a631cde1 --- /dev/null +++ b/components/notifications/src/NotificationsManager.tsx @@ -0,0 +1,111 @@ +import React, { CSSProperties, useState } from 'react'; +import Portal from '@byndyusoft-ui/portal'; +import { normalizeCssValue, normalizePlatformValue } from './utilities'; +import { NotificationsItem } from './partials/NotificationsItem'; +import { useIsDocumentHidden } from './hooks/useDocumentVisibility'; +import { INotificationsManagerProps, TNotificationPosition } from './Notifications.types'; +import { useNotificationsManager } from './hooks/useNotificationsManager'; +import { cn } from './utilities'; +import { NOTIFICATION_LIFETIME, OFFSET, POSITION, NOTIFICATION_WIDTH, GAP } from './constants'; +import styles from './NotificationsManager.module.css'; + +const NotificationsManager = (props: INotificationsManagerProps) => { + const { + position = POSITION, + duration = NOTIFICATION_LIFETIME, + limit, + renderNotification, + renderNotificationComponents, + isPauseWhenPageHidden = true, + isPauseOnHover = true, + isClosable = true, + isAutoClosable = true, + isCloseOnClick = false, + isNewestOnTop = false, + gap, + offset, + width, + className, + classNameItem, + style, + styleItem + } = props; + + const [focusedPosition, setFocusedPosition] = useState(null); // Хранение текущей позиции + + const { + notifications, + possiblePositions, + onClickAndClose, + dismissNotification, + removeNotification, + renderNotificationItem, + prepareNotifications + } = useNotificationsManager({ + position, + isCloseOnClick, + isClosable, + isNewestOnTop, + limit, + renderNotification, + renderNotificationComponents + }); + + const isDocumentHidden = useIsDocumentHidden(); + + if (!notifications?.length) return null; + + return ( + + {possiblePositions?.map((position, positionIndex) => { + const preparedNotifications = prepareNotifications(position, positionIndex); + + if (!preparedNotifications?.length) return null; + + return ( +
    setFocusedPosition(position)} + onMouseLeave={() => setFocusedPosition(null)} + > + {preparedNotifications?.map((item, itemIndex) => ( + + {renderNotificationItem(item, itemIndex)} + + ))} +
+ ); + })} +
+ ); +}; + +export default NotificationsManager; diff --git a/components/notifications/src/__stories__/Notifications.docs.mdx b/components/notifications/src/__stories__/Notifications.docs.mdx new file mode 100644 index 00000000..9549f4f4 --- /dev/null +++ b/components/notifications/src/__stories__/Notifications.docs.mdx @@ -0,0 +1,11 @@ +import { Meta, Markdown, Canvas, Source, ArgsTable } from '@storybook/blocks'; +import Readme from '../../README.md'; +import * as NotificationsStories from './Notifications.stories'; + + + +{Readme} + +# Story + + diff --git a/components/notifications/src/__stories__/Notifications.stories.module.css b/components/notifications/src/__stories__/Notifications.stories.module.css new file mode 100644 index 00000000..6d9a384f --- /dev/null +++ b/components/notifications/src/__stories__/Notifications.stories.module.css @@ -0,0 +1,93 @@ +.container { + display: flex; + align-items: center; + flex-direction: column; +} + +.row { + display: flex; + align-items: center; + gap: 8px; +} + +.col { + display: flex; + flex-direction: column; + gap: 6px; +} + +.row > button { + border-radius: 8px; + border: 1px solid transparent; + padding: 6px 12px; + font-size: 1em; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: border-color 0.25s; + white-space: nowrap; +} +.row > button:hover { + border-color: #646cff; +} + +.row > .button_active { + background: #646cff; + color: white; +} + +.notification { + box-sizing: border-box; + position: relative; + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + border-radius: 6px; + color: black; +} + +.notification.success { + background: #48ffc8; +} + +.notification.danger { + background: #ff484e; +} + +.notification.info { + background: #48b9ff; +} + +.notification.warning { + background: #ff8b48; +} + +.notification.ordinary { + background: #d7d7d7; +} + +.notification_close_btn { + box-sizing: border-box; + position: absolute; + top: 4px; + right: 4px; + border: none; + background: none; + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: center; +} + +.code { + padding: 10px; + height: 250px; + border-radius: 5px; + overflow-x: auto; + font-family: 'Courier New, Courier, monospace', serif; + font-size: 16px; + background-color: #e6e6e6; + color: #333; +} diff --git a/components/notifications/src/__stories__/Notifications.stories.tsx b/components/notifications/src/__stories__/Notifications.stories.tsx new file mode 100644 index 00000000..34457877 --- /dev/null +++ b/components/notifications/src/__stories__/Notifications.stories.tsx @@ -0,0 +1,260 @@ +import React, { ChangeEvent, Dispatch, SetStateAction, useState } from 'react'; +import { StoryObj } from '@storybook/react'; +import NotificationsManager, { useNotifications, INotificationData } from '..'; +import { TNotificationPosition, TNotificationTheme } from '../Notifications.types'; +import styles from './Notifications.stories.module.css'; + +const positions: TNotificationPosition[] = [ + 'top-right', + 'top-left', + 'top-center', + 'bottom-right', + 'bottom-left', + 'bottom-center' +]; +const themes: TNotificationTheme[] = ['success', 'danger', 'info', 'warning', 'ordinary', 'custom']; + +const NotificationComponent = ({ data }: INotificationData): JSX.Element => { + return ( +
+ {data.title} + {data?.message &&
{data.message}
} + {data?.footer &&
{data.footer}
} + {data?.isClosable && ( + + )} +
+ ); +}; + +const Template = (): JSX.Element => { + const [activePosition, setActivePosition] = useState('top-right'); + const [activeTheme, setActiveTheme] = useState('ordinary'); + const [duration, setDuration] = useState(5000); + const [limit, setLimit] = useState(0); + const [isCloseOnClick, setIsCloseOnClick] = useState(false); + const [isPauseWhenPageHidden, setIsPauseWhenPageHidden] = useState(true); + const [isPauseOnHover, setIsPauseOnHover] = useState(true); + const [isNewestOnTop, setIsNewestOnTop] = useState(false); + const [isClosable, setIsClosable] = useState(true); + const [isAutoClosable, setIsAutoClosable] = useState(true); + + const { custom, create, dismissAll } = useNotifications(); + + const handleChangeInputNumber = (set: Dispatch>) => (e: ChangeEvent) => { + const numericValue = e.target.value.replace(/[^0-9]/g, ''); + set(Number(numericValue)); + }; + + const onShowNotification = () => { + if (activeTheme === 'custom') { + custom(({ index }) =>
{index} | Custom notification
, { + position: activePosition, + duration, + isCloseOnClick, + isAutoClosable + }); + } else { + create({ + position: activePosition, + theme: activeTheme, + title: `🔔 Notification ${activeTheme}`, + message: 'Notification message', + footer: new Date().toDateString(), + duration, + isCloseOnClick, + isClosable, + isAutoClosable + }); + } + }; + + const renderEmitterCode = () => { + const baseCode = (type?: string) => `const { ${type} } = useNotifications(); + +${type}({ + title: \`🔔 Notification ${activeTheme}\`, + message: 'Notification message', + footer: new Date().toDateString(), + position: ${activePosition}, + delay: ${duration}, + isCloseOnClick: ${isCloseOnClick} + isClosable: ${isClosable} + isAutoClosable: ${isAutoClosable} +});`; + + if (activeTheme === 'custom') { + return `const { custom } = useNotifications(); + +custom(({data}) =>
{data.id} | Custom notification
, + { + position: ${activePosition}, + duration: ${duration}, + isCloseOnClick: ${isCloseOnClick} + isAutoClosable: ${isAutoClosable} + } +);`; + } + + return baseCode(activeTheme); + }; + + return ( +
+

Notifications

+ + + +
+
+

Notifications Container

+
+                        
+                            {``}
+                        
+                    
+
+
+

Notification Emitter

+
+                        {renderEmitterCode()}
+                    
+
+
+ +
+
+

Position

+
+ {positions.map(position => ( + + ))} +
+
+
+

Theme

+
+ {themes.map(theme => ( + + ))} +
+
+
+

Options

+ + + + + + + + + +
+
+ + +
+
+
+
+ ); +}; + +export const NotificationsStory: StoryObj = { + name: 'Notifications story', + render: Template, + args: {} +}; + +export default { + title: 'components/Notifications' +}; diff --git a/components/notifications/src/__tests__/NotificationsManager.tests.tsx b/components/notifications/src/__tests__/NotificationsManager.tests.tsx new file mode 100644 index 00000000..74f0de37 --- /dev/null +++ b/components/notifications/src/__tests__/NotificationsManager.tests.tsx @@ -0,0 +1,290 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, waitFor } from '@testing-library/react'; +import { INotificationData, INotificationsManagerProps, TThemedNotificationParams } from '../Notifications.types'; +import NotificationsManager, { useNotifications } from '../index'; +import { TIME_BEFORE_UNMOUNT } from '../constants'; + +const BUTTON_NAMES = { + CREATE: 'Create notification', + UPDATE: 'Update notification', + SUCCESS: 'Show success', + DANGER: 'Show danger', + INFO: 'Show info', + WARNING: 'Show warning', + ORDINARY: 'Show ordinary', + CUSTOM: 'Show custom', + REMOVE: 'Remove notification', + REMOVE_ALL: 'Remove all notifications', + DISMISS: 'Dismiss notification', + DISMISS_ALL: 'Dismiss all notifications' +}; + +const NotificationComponent = ({ data }: INotificationData) => ( +
+

{data.title}

+ {data?.message &&
{data.message}
} + {data?.footer &&
{data.footer}
} + {data?.isClosable && ( + + )} +
+); + +const ActionsComponent = (paramsEmitter: TThemedNotificationParams = {}) => { + const { create, update, success, danger, info, warning, ordinary, custom, remove, removeAll, dismiss, dismissAll } = + useNotifications(); + return ( +
+ + + + + + + + + + + + +
+ ); +}; + +const setup = (props: INotificationsManagerProps = {}, paramsEmitter: TThemedNotificationParams = {}) => { + return render( +
+ + +
+ ); +}; + +describe('NotificationsManager', () => { + afterEach(() => { + jest.clearAllTimers(); + }); + + test('create notification', async () => { + const { getByRole, getByTestId } = setup(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.CREATE })); + + expect(getByTestId('notification-item')).toBeInTheDocument(); + expect(getByRole('status')).toBeInTheDocument(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL })); + }); + + test('updates notification', async () => { + const { getByRole } = setup(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.CREATE })); + + expect(getByRole('heading', { level: 3 })).toHaveTextContent('Title'); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.UPDATE })); + + expect(getByRole('heading', { level: 3 })).toHaveTextContent('New title'); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL })); + }); + + test('adds `success` notification', async () => { + const { getByRole } = setup(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.SUCCESS })); + + expect(getByRole('status')).toBeInTheDocument(); + expect(getByRole('heading')).toHaveTextContent('Success'); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL })); + }); + + test('adds `info` notification', async () => { + const { getByRole } = setup(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.INFO })); + + expect(getByRole('status')).toBeInTheDocument(); + expect(getByRole('heading')).toHaveTextContent('Info'); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL })); + }); + + test('adds `warning` notification', async () => { + const { getByRole } = setup(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.WARNING })); + + expect(getByRole('status')).toBeInTheDocument(); + expect(getByRole('heading')).toHaveTextContent('Warning'); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL })); + }); + + test('adds `ordinary` notification', async () => { + const { getByRole } = setup(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.ORDINARY })); + + expect(getByRole('status')).toBeInTheDocument(); + expect(getByRole('heading')).toHaveTextContent('Ordinary'); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL })); + }); + + test('adds `danger` notification', async () => { + const { getByRole } = setup(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DANGER })); + + expect(getByRole('alert')).toBeInTheDocument(); + expect(getByRole('heading')).toHaveTextContent('Danger'); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL })); + }); + + test('adds `custom` notification', async () => { + const { getByRole, getByText, queryByTestId } = setup(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.CUSTOM })); + + expect(getByText('Custom')).toBeInTheDocument(); + expect(queryByTestId('notification-item')).not.toBeInTheDocument(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL })); + }); + + test('dismiss notification', async () => { + const { getByRole, queryByRole } = setup(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.CREATE })); + + expect(getByRole('status')).toBeInTheDocument(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DISMISS })); + + await waitFor( + () => { + expect(queryByRole('status')).not.toBeInTheDocument(); + }, + { timeout: TIME_BEFORE_UNMOUNT + 1 } + ); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL })); + }); + + test('dismiss all notifications', async () => { + const { getByRole, getAllByRole, queryAllByRole } = setup(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DANGER })); + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.SUCCESS })); + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.WARNING })); + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DANGER })); + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DANGER })); + + expect(getAllByRole('status')).toHaveLength(2); + expect(getAllByRole('alert')).toHaveLength(3); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DISMISS_ALL })); + + await waitFor( + () => { + expect(queryAllByRole('status')).toHaveLength(0); + expect(queryAllByRole('alert')).toHaveLength(0); + }, + { timeout: TIME_BEFORE_UNMOUNT + 1 } + ); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL })); + }); + + test('remove notification', async () => { + const { getByRole, queryByRole } = setup(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.CREATE })); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE })); + + expect(queryByRole('status')).not.toBeInTheDocument(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL })); + }); + + test('remove all notifications', async () => { + const { getByRole, getAllByRole, queryAllByRole } = setup(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.SUCCESS })); + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.WARNING })); + + expect(getAllByRole('status')).toHaveLength(2); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE_ALL })); + + expect(queryAllByRole('status')).toHaveLength(0); + }); + + test('creates and closes custom notification', async () => { + const { getByRole, getByText, queryByRole, getByLabelText } = setup( + {}, + { + title: 'test-title', + message: 'test-message', + footer: 'test-footer', + isClosable: true + } + ); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.SUCCESS })); + + expect(getByRole('status')).toBeInTheDocument(); + expect(getByText('test-title')).toBeInTheDocument(); + expect(getByText('test-message')).toBeInTheDocument(); + expect(getByText('test-footer')).toBeInTheDocument(); + expect(getByLabelText('close-notification')).toBeInTheDocument(); + + await userEvent.click(getByRole('button', { name: 'close-notification' })); + + await waitFor(() => { + expect(queryByRole('status')).not.toBeInTheDocument(); + }); + }); + test('limits the number of notifications displayed', async () => { + const limit = 2; + const { getByRole, getAllByRole, queryByRole } = setup({ limit }); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.CREATE })); + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.ORDINARY })); + + expect(getAllByRole('status')).toHaveLength(2); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.DANGER })); + + expect(getAllByRole('status')).toHaveLength(2); + expect(queryByRole('alert')).not.toBeInTheDocument(); + + await userEvent.click(getByRole('button', { name: BUTTON_NAMES.REMOVE })); + + expect(getAllByRole('status')).toHaveLength(1); + expect(getAllByRole('alert')).toHaveLength(1); + expect(queryByRole('alert')).toBeInTheDocument(); + }); +}); diff --git a/components/notifications/src/constants.ts b/components/notifications/src/constants.ts new file mode 100644 index 00000000..5f6d74f5 --- /dev/null +++ b/components/notifications/src/constants.ts @@ -0,0 +1,6 @@ +export const POSITION = 'top-right' +export const GAP = 8; +export const OFFSET = 24; +export const NOTIFICATION_WIDTH = 356; +export const NOTIFICATION_LIFETIME = 5000; +export const TIME_BEFORE_UNMOUNT = 300; diff --git a/components/notifications/src/hooks/useDocumentVisibility.ts b/components/notifications/src/hooks/useDocumentVisibility.ts new file mode 100644 index 00000000..ee79b013 --- /dev/null +++ b/components/notifications/src/hooks/useDocumentVisibility.ts @@ -0,0 +1,15 @@ +import React from 'react'; + +export const useIsDocumentHidden = () => { + const [isDocumentHidden, setIsDocumentHidden] = React.useState(document.hidden); + + React.useEffect(() => { + const callback = () => { + setIsDocumentHidden(document.hidden); + }; + document.addEventListener('visibilitychange', callback); + return () => document.removeEventListener('visibilitychange', callback); + }, []); + + return isDocumentHidden; +}; diff --git a/components/notifications/src/hooks/useNotificationsActions.ts b/components/notifications/src/hooks/useNotificationsActions.ts new file mode 100644 index 00000000..a8e67c34 --- /dev/null +++ b/components/notifications/src/hooks/useNotificationsActions.ts @@ -0,0 +1,16 @@ +import { notificationService } from '../services/notifications.service'; + +export const useNotificationsActions = () => ({ + create: notificationService.create, + update: notificationService.update, + success: notificationService.success, + danger: notificationService.danger, + info: notificationService.info, + warning: notificationService.warning, + ordinary: notificationService.ordinary, + custom: notificationService.custom, + remove: notificationService.remove, + removeAll: notificationService.removeAll, + dismiss: notificationService.dismiss, + dismissAll: notificationService.dismissAll +}); diff --git a/components/notifications/src/hooks/useNotificationsManager.ts b/components/notifications/src/hooks/useNotificationsManager.ts new file mode 100644 index 00000000..49c02c7f --- /dev/null +++ b/components/notifications/src/hooks/useNotificationsManager.ts @@ -0,0 +1,133 @@ +import { ReactNode, useEffect, useMemo, useState } from 'react'; +import { Callback } from '@byndyusoft-ui/types'; +import { + INotificationsItem, + TNotificationItemId, + TNotificationPosition, + IUseNotificationsStateParams, + TNotificationRender +} from '../Notifications.types'; +import { subscriber } from '../services/notificationsPubSub.service'; +import { notificationService } from '../services/notifications.service'; + +export const useNotificationsManager = (params: IUseNotificationsStateParams) => { + const { + position, + limit, + isCloseOnClick, + isClosable, + isNewestOnTop, + renderNotification, + renderNotificationComponents + } = params; + + const [notifications, setNotifications] = useState>([]); + + const possiblePositions: Array = useMemo(() => { + const currentPositions = [position].concat( + notifications + .filter((n): n is INotificationsItem & { position: TNotificationPosition } => Boolean(n?.position)) + .map(n => n.position) + ); + return Array.from(new Set(currentPositions)); + }, [notifications, position]); + + const cuttingByLimit = (items: Array, isNewestOnTop?: boolean): Array => { + if (!limit) return items; + + return isNewestOnTop ? items.slice(-limit) : items.slice(0, limit); + }; + + const onClickAndClose = (itemId: TNotificationItemId, isCloseOnClickItem?: boolean): Callback | undefined => { + if (isCloseOnClickItem === false || !isCloseOnClick) return undefined; + + return () => notificationService.dismiss(itemId); + }; + + const dismissNotification = (itemId: TNotificationItemId) => (): void => { + notificationService.dismiss(itemId); + }; + + const removeNotification = (itemId: TNotificationItemId) => (): void => { + notificationService.remove(itemId); + }; + + const executeRender = ( + render: TNotificationRender | undefined, + data: INotificationsItem, + index: number + ): ReactNode | null => { + return typeof render === 'function' ? render({ data, index }) : render || null; + }; + + const prepareNotificationProps = (item: INotificationsItem): INotificationsItem => { + return { + ...item, + isClosable: item?.isClosable || isClosable, + onClose: () => { + notificationService.dismiss(item.id); + } + }; + }; + + const renderNotificationItem = (notificationItem: INotificationsItem, index: number): ReactNode | null => { + const prepareItem = prepareNotificationProps(notificationItem); + + if (prepareItem.render) { + return executeRender(prepareItem.render, prepareItem, index); + } + + if ( + typeof renderNotificationComponents === 'object' && + prepareItem.theme && + prepareItem.theme !== 'custom' && + renderNotificationComponents[prepareItem.theme] + ) { + return executeRender(renderNotificationComponents[prepareItem.theme], prepareItem, index); + } + + if (renderNotification) { + return executeRender(renderNotification, prepareItem, index); + } + + return null; + }; + + const getNotificationsByPosition = ( + positionItem: TNotificationPosition, + positionIndex: number + ): Array => + notifications.filter( + notification => (!notification.position && positionIndex === 0) || notification.position === positionItem + ); + + const prepareNotifications = ( + position: TNotificationPosition, + positionIndex: number + ): Array => { + const notificationsByPosition = getNotificationsByPosition(position, positionIndex); + + return cuttingByLimit( + isNewestOnTop ? notificationsByPosition.reverse() : notificationsByPosition, + isNewestOnTop + ); + }; + + useEffect(() => { + const listener = (newNotifications: Array) => { + setNotifications([...newNotifications]); + }; + + return subscriber(listener); + }, [setNotifications]); + + return { + possiblePositions, + notifications, + onClickAndClose, + dismissNotification, + removeNotification, + renderNotificationItem, + prepareNotifications + }; +}; diff --git a/components/notifications/src/index.ts b/components/notifications/src/index.ts new file mode 100644 index 00000000..21f93202 --- /dev/null +++ b/components/notifications/src/index.ts @@ -0,0 +1,5 @@ +export { default } from './NotificationsManager'; + +export type { INotificationData } from './Notifications.types'; +export { default as NotificationsManager } from './NotificationsManager'; +export { useNotificationsActions as useNotifications } from './hooks/useNotificationsActions'; diff --git a/components/notifications/src/partials/NotificationsItem.module.css b/components/notifications/src/partials/NotificationsItem.module.css new file mode 100644 index 00000000..347f7eaa --- /dev/null +++ b/components/notifications/src/partials/NotificationsItem.module.css @@ -0,0 +1,144 @@ +.notification_item { + overflow: hidden; + position: relative; + opacity: 1; + height: auto; +} + +.notification_item.pointer { + cursor: pointer; +} + +.animation_bottom_left, +.animation_top_left { + animation: slideInRight 0.3s ease-out forwards; +} + +.animation_bottom_left.hidden, +.animation_top_left.hidden { + animation: slideOutLeft 0.2s ease-in forwards, collapseHeight 0.2s ease-out 0.1s forwards; +} + +.animation_bottom_right, +.animation_top_right { + animation: slideInLeft 0.3s ease-out forwards; +} + +.animation_bottom_right.hidden, +.animation_top_right.hidden { + animation: slideOutRight 0.2s ease-out forwards, collapseHeight 0.2s ease-out 0.1s forwards; +} + +.animation_top_center { + animation: slideInBottom 0.2s ease-out forwards; +} + +.animation_top_center.hidden { + animation: slideOutTop 0.2s ease-out forwards, collapseHeight 0.2s ease-out 0.1s forwards; +} + +.animation_bottom_center { + animation: slideInTop 0.3s ease-out forwards; +} + +.animation_bottom_center.hidden { + animation: slideOutBottom 0.2s ease-out forwards, collapseHeight 0.2s ease-out 0.1s forwards; +} + + +@keyframes collapseHeight { + 0% { + max-height: 800px; + } + 100% { + max-height: 0; + } +} + +@keyframes slideInLeft { + 0% { + transform: translateX(100%); + opacity: 0; + } + 100% { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOutRight { + 0% { + transform: translateX(0); + opacity: 1; + } + 100% { + transform: translateX(100%); + opacity: 0; + } +} + +@keyframes slideInRight { + 0% { + transform: translateX(-100%); + opacity: 0; + } + 100% { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOutLeft { + 0% { + transform: translateX(0); + opacity: 1; + } + 100% { + transform: translateX(-100%); + opacity: 0; + } +} + +@keyframes slideInBottom { + 0% { + transform: translateY(-100%); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes slideOutTop { + 0% { + transform: translateY(0); + opacity: 1; + } + 100% { + transform: translateY(-100%); + opacity: 0; + } +} + +@keyframes slideInTop { + 0% { + transform: translateY(100%); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes slideOutBottom { + 0% { + transform: translateY(0); + opacity: 1; + } + 100% { + transform: translateY(100%); + opacity: 0; + } +} diff --git a/components/notifications/src/partials/NotificationsItem.tsx b/components/notifications/src/partials/NotificationsItem.tsx new file mode 100644 index 00000000..b1015f2d --- /dev/null +++ b/components/notifications/src/partials/NotificationsItem.tsx @@ -0,0 +1,103 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { TIME_BEFORE_UNMOUNT } from '../constants'; +import { cn } from '../utilities'; +import { INotificationsItemProps, TNotificationPosition } from '../Notifications.types'; +import styles from './NotificationsItem.module.css'; + +const animationClasses: Record = { + 'bottom-left': styles.animation_bottom_left, + 'top-left': styles.animation_top_left, + 'bottom-right': styles.animation_bottom_right, + 'top-right': styles.animation_top_right, + 'top-center': styles.animation_top_center, + 'bottom-center': styles.animation_bottom_center +}; + +export const NotificationsItem = (props: INotificationsItemProps) => { + const [isFadingOut, setIsFadingOut] = useState(false); + + const { + dismiss, + children, + position, + theme, + duration, + isAutoClosable, + isPauseToRemove, + dismissNotification, + removeNotification, + className, + style, + onClick, + afterClose + } = props; + + const timerStartRef = useRef(0); + const remainingTime = useRef(duration); + + const closeNotification = useCallback(() => { + setIsFadingOut(true); + afterClose?.(); + + setTimeout(() => { + removeNotification(); + }, TIME_BEFORE_UNMOUNT); + }, []); + + useEffect(() => { + if (!isAutoClosable) return; + remainingTime.current = duration; + }, [isAutoClosable, duration]); + + useEffect(() => { + if (!isAutoClosable || duration === Infinity || duration < 0) return; + + let timeoutId: ReturnType; + + const pauseTimer = () => { + if (timerStartRef.current) { + const elapsedTime = new Date().getTime() - timerStartRef.current; + + remainingTime.current -= elapsedTime; + } + }; + + const startTimer = () => { + if (remainingTime.current === Infinity) return; + + timerStartRef.current = new Date().getTime(); + + timeoutId = setTimeout(dismissNotification, remainingTime.current); + }; + + if (isPauseToRemove) { + pauseTimer(); + } else { + startTimer(); + } + + return () => clearTimeout(timeoutId); + }, [isPauseToRemove, closeNotification, isAutoClosable]); + + useEffect(() => { + if (dismiss) { + closeNotification(); + } + }, [dismiss]); + + const classes = cn( + styles.notification_item, + animationClasses[position], + isFadingOut ? styles.hidden : '', + onClick ? styles.pointer : '', + className + ); + + const containerRole = theme === 'danger' ? 'alert' : 'status'; + + return ( +
  • + {children} +
  • + ); +}; diff --git a/components/notifications/src/services/notifications.service.ts b/components/notifications/src/services/notifications.service.ts new file mode 100644 index 00000000..62e3b5af --- /dev/null +++ b/components/notifications/src/services/notifications.service.ts @@ -0,0 +1,111 @@ +import { + INotificationsItem, + TCreateNotificationParams, + TCustomNotificationParams, + TNotificationItemId, + TNotificationRender, + TThemedNotificationParams +} from '../Notifications.types'; +import { publisher } from './notificationsPubSub.service'; + +let notificationCounter = 1; + +class NotificationsService { + private notifications: Array = []; + private readonly notify: () => void; + + constructor(publisher: (data: Array) => void) { + this.notify = () => publisher(this.notifications); + } + + create = (partial: TCreateNotificationParams): TNotificationItemId => { + const id = partial?.id || notificationCounter++; + const theme = partial?.theme || 'ordinary'; + const index = this.notifications.findIndex(item => item.id === id); + const updatedItem = { + ...this.notifications[index], + ...partial, + id, + theme + }; + + if (index !== -1) { + this.notifications[index] = updatedItem; + } else { + this.notifications.push(updatedItem); + } + + this.notify(); + + return id; + }; + + update = (notificationId: TNotificationItemId, patch: Partial): void => { + const index = this.notifications.findIndex(notification => notification.id === notificationId); + + if (index === -1) return; + + Object.assign(this.notifications[index], patch); + + this.notify(); + }; + + ordinary = (params: TThemedNotificationParams): TNotificationItemId => { + return this.create({ ...params, theme: 'ordinary' }); + }; + + success = (params: TThemedNotificationParams): TNotificationItemId => { + return this.create({ ...params, theme: 'success' }); + }; + + danger = (params: TThemedNotificationParams): TNotificationItemId => { + return this.create({ ...params, theme: 'danger' }); + }; + + warning = (params: TThemedNotificationParams): TNotificationItemId => { + return this.create({ ...params, theme: 'warning' }); + }; + + info = (params: TThemedNotificationParams): TNotificationItemId => { + return this.create({ ...params, theme: 'info' }); + }; + + custom = (render: TNotificationRender, params: TCustomNotificationParams = {}): TNotificationItemId => { + return this.create({ ...params, render, theme: 'custom' }); + }; + + remove = (id: TNotificationItemId): void => { + const index = this.notifications.findIndex(notification => notification.id === id); + + this.notifications = [...this.notifications.slice(0, index), ...this.notifications.slice(index + 1)]; + + this.notify(); + }; + + removeAll = (): void => { + this.notifications = []; + + this.notify(); + }; + + dismiss = (id: TNotificationItemId): void => { + this.notifications = this.notifications.map(notification => { + if (id === notification.id) { + return { ...notification, dismiss: true }; + } + return notification; + }); + + this.notify(); + }; + + dismissAll = (): void => { + this.notifications = this.notifications.map(notification => { + return { ...notification, dismiss: true }; + }); + + this.notify(); + }; +} + +export const notificationService = new NotificationsService(publisher); diff --git a/components/notifications/src/services/notificationsPubSub.service.ts b/components/notifications/src/services/notificationsPubSub.service.ts new file mode 100644 index 00000000..906a2948 --- /dev/null +++ b/components/notifications/src/services/notificationsPubSub.service.ts @@ -0,0 +1,21 @@ +import PubSub from '@byndyusoft-ui/pub-sub'; +import { Callback } from '@byndyusoft-ui/types'; +import { INotificationsItem } from '../Notifications.types'; + +type TNotificationsPubSubInstance = { + updateState: (data: Array) => void; +}; + +const INSTANCE_NAME = 'notifications'; + +const notificationsPubSubInstance = PubSub.getInstance(INSTANCE_NAME); + +export const publisher = (dataItems: Array): void => { + notificationsPubSubInstance.publish('updateState', dataItems); +}; + +export const subscriber = (listener: Callback>): Callback => { + notificationsPubSubInstance.subscribe('updateState', listener); + + return () => notificationsPubSubInstance.unsubscribe('updateState', listener); +}; diff --git a/components/notifications/src/utilities.ts b/components/notifications/src/utilities.ts new file mode 100644 index 00000000..73d40bde --- /dev/null +++ b/components/notifications/src/utilities.ts @@ -0,0 +1,21 @@ +import { TPlatformValue } from './Notifications.types'; + +export function cn(...classes: Array): string { + return classes.filter(Boolean).join(' '); +} + +export const normalizeCssValue = (value: number | string): number | string => { + return typeof value === 'number' ? `${value}px` : value; +}; + +export const normalizePlatformValue = ( + type: 'web' | 'mobile', + defaultValue: number, + platformValue?: TPlatformValue +): string | number => { + if (typeof platformValue === 'number' || typeof platformValue === 'string') { + return normalizeCssValue(platformValue ?? defaultValue); + } + + return normalizeCssValue(platformValue?.[type] ?? defaultValue); +}; diff --git a/components/notifications/tsconfig.build.json b/components/notifications/tsconfig.build.json new file mode 100644 index 00000000..8623abd9 --- /dev/null +++ b/components/notifications/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/*.tests.ts", "src/*.stories.*"] +} diff --git a/components/notifications/tsconfig.json b/components/notifications/tsconfig.json new file mode 100644 index 00000000..98f42aee --- /dev/null +++ b/components/notifications/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs" + }, + "include": ["../../types.d.ts", "src"] +} diff --git a/package-lock.json b/package-lock.json index 8833650e..96f6540f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -100,6 +100,24 @@ "react-dom": ">=17" } }, + "components/notifications": { + "version": "0.0.1", + "license": "ISC", + "devDependencies": { + "@byndyusoft-ui/types": "^0.1.0" + }, + "peerDependencies": { + "@byndyusoft-ui/pub-sub": "^0.0.1", + "react": ">=17", + "react-dom": ">=17" + } + }, + "components/notifications/node_modules/@byndyusoft-ui/types": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@byndyusoft-ui/types/-/types-0.1.1.tgz", + "integrity": "sha512-hRQbyzPbz1pdcLbYtgUnnk5pOlUY2yNpJ/o28NX4vQwmg2hNyPgifaK8STT77rhMEL5btcETYkD7YrMSz11EDA==", + "dev": true + }, "components/portal": { "name": "@byndyusoft-ui/portal", "version": "1.0.0", @@ -2294,10 +2312,18 @@ "resolved": "components/modals-provider", "link": true }, + "node_modules/@byndyusoft-ui/notifications": { + "resolved": "components/notifications", + "link": true + }, "node_modules/@byndyusoft-ui/portal": { "resolved": "components/portal", "link": true }, + "node_modules/@byndyusoft-ui/pub-sub": { + "resolved": "services/pub-sub", + "link": true + }, "node_modules/@byndyusoft-ui/reset-css": { "resolved": "styles/reset-css", "link": true @@ -32115,6 +32141,10 @@ "version": "0.2.0", "license": "Apache-2.0" }, + "services/pub-sub": { + "version": "0.0.1", + "license": "Apache-2.0" + }, "styles/keyframes-css": { "name": "@byndyusoft-ui/keyframes-css", "version": "0.0.1", diff --git a/services/pub-sub/.npmignore b/services/pub-sub/.npmignore new file mode 100644 index 00000000..85de9cf9 --- /dev/null +++ b/services/pub-sub/.npmignore @@ -0,0 +1 @@ +src diff --git a/services/pub-sub/README.md b/services/pub-sub/README.md new file mode 100644 index 00000000..0713649d --- /dev/null +++ b/services/pub-sub/README.md @@ -0,0 +1,137 @@ +# `@byndyusoft-ui/pub-sub` + +> A performant Pub/Sub interface with controlled instance management + +### Installation + +```bash +npm i @byndyusoft-ui/pub-sub +``` + +## Usage + +#### Import the class + +```ts +import PubSub from '@byndyusoft-ui/pub-sub'; +``` + +#### Define your channels +Create a type that defines the channels and their corresponding callback signatures. + +```ts +type ChannelsType = { + addTodo: (data: TodoType) => void; + removeTodo: (todoId: number) => void; + removeAll: () => void; +}; +``` + +#### Create an instance +Use the `getInstance` method to create or retrieve a singleton instance of `PubSub`. + +```ts +const pubSubInstance = PubSub.getInstance(); +``` + +#### Subscribe and unsubscribe to a channel +Remove a specific callback from a channel to stop receiving notifications. + +```ts +const addTodoCallback = (data: TodoType) => { + console.log('Added new todo:', data); +}; + +const removeTodoCallback = (todoId: number) => { + console.log(`Removed todo: ${todoId}`); +}; + +const removeAllCallback = () => { + console.log('All todos deleted'); +}; + +// subscribe +pubSubInstance.subscribe('addTodo', addTodoCallback); +pubSubInstance.subscribe('removeTodo', removeTodoCallback); +pubSubInstance.subscribe('removeAll', removeAllCallback); + +// unsubscribe +pubSubInstance.unsubscribe('addTodo', addTodoCallback); +pubSubInstance.unsubscribe('removeTodo', removeTodoCallback); +pubSubInstance.unsubscribe('removeAll', removeAllCallback); + +``` + +#### Publish to a channel + +```ts +pubSubInstance.publish('addTodo', { id: 1, text: 'Some todo'}); +pubSubInstance.publish('removeTodo', 1); +pubSubInstance.publish('removeAll'); +``` + +#### Publish asynchronously +Use publishAsync to publish data and handle asynchronous subscribers. + +```ts + +pubSubInstance.subscribe('asyncMessage', async (data) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log(`Async received: ${data}`); +}); + +await pubSubInstance.publishAsync('asyncMessage', 'This is asynchronous!'); +``` + +#### Reset all subscriptions +Clear all channels and their associated subscribers. + +```ts +pubSubInstance.reset(); +``` + +#### Singleton Instances +`PubSub` supports multiple named singleton instances using instanceKey. +This allows you to create isolated instances for different parts of your application. + +> Note: If instanceKey is not provided, the instance with the default name `"_default"` will be used. + +Usage example: +```ts +import PubSub from '@byndyusoft-ui/pub-sub'; + +// Get the instance with the default name "_default" +const defaultPubSub = PubSub.getInstance(); + +// Get an instance with a custom name +const customPubSub = PubSub.getInstance('custom'); + +// Instances are isolated from each other +defaultPubSub.subscribe('event', (data) => { + console.log(`Default instance: ${data}`); +}); + +customPubSub.subscribe('event', (data) => { + console.log(`Custom instance: ${data}`); +}); + +// Publish events in different instances +defaultPubSub.publish('event', 'Hello from default!'); +customPubSub.publish('event', 'Hello from custom!'); +``` + +#### Adapter for Interfaces +If you're using `interface` instead of `type`, you can use the helper type +`ChannelsRecordAdapter` to ensure compatibility with the index signature: + +```ts +import { type ChannelsRecordAdapter } from '@byndyusoft-ui/pub-sub' + +interface TodoChannels { + addTodo: (data: TodoType) => void; + removeTodo: (todoId: number) => void; + removeAll: () => void; +} + +const pubSubInstance = PubSub.getInstance>(); +``` diff --git a/services/pub-sub/package.json b/services/pub-sub/package.json new file mode 100644 index 00000000..b6ccbb9e --- /dev/null +++ b/services/pub-sub/package.json @@ -0,0 +1,34 @@ +{ + "name": "@byndyusoft-ui/pub-sub", + "version": "0.0.1", + "description": "Byndyusoft UI Service", + "keywords": [ + "byndyusoft", + "byndyusoft-ui", + "channels", + "publish", + "subscribe", + "Pub/Sub" + ], + "author": "Gleb Fomin ", + "homepage": "https://github.com/Byndyusoft/ui/tree/master/services/pub-sub#readme", + "license": "Apache-2.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Byndyusoft/ui.git" + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "clean": "rimraf dist", + "lint": "eslint src --config ../../eslint.config.js", + "test": "jest --config ../../jest.config.js --roots services/pub-sub/src" + }, + "bugs": { + "url": "https://github.com/Byndyusoft/ui/issues" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/services/pub-sub/src/index.ts b/services/pub-sub/src/index.ts new file mode 100644 index 00000000..ebf70700 --- /dev/null +++ b/services/pub-sub/src/index.ts @@ -0,0 +1,3 @@ +export { default } from './pubSub'; + +export type { ChannelsRecordAdapter } from './pubSub.types'; diff --git a/services/pub-sub/src/pubSub.tests.ts b/services/pub-sub/src/pubSub.tests.ts new file mode 100644 index 00000000..caccc734 --- /dev/null +++ b/services/pub-sub/src/pubSub.tests.ts @@ -0,0 +1,87 @@ +import PubSub from './pubSub'; + +type TChannels = { + testChannel: (data?: string) => void; + asyncChannel: (data?: string) => Promise; +}; + +describe('services/pub-sub', () => { + let pubSub: PubSub; + + beforeEach(() => { + pubSub = PubSub.getInstance(); + }); + + afterEach(() => { + pubSub.reset(); + }); + + test('should create a new instance and get the same instance for the same key', () => { + const instance1 = PubSub.getInstance('instance1'); + const instance2 = PubSub.getInstance('instance1'); + const instance3 = PubSub.getInstance('instance2'); + + expect(instance1).toBe(instance2); + expect(instance1).not.toBe(instance3); + }); + + test('should subscribe and publish to a channel', () => { + const callback = jest.fn(); + pubSub.subscribe('testChannel', callback); + + pubSub.publish('testChannel', 'Hello, World!'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('Hello, World!'); + }); + + test('should not call callback if no subscribers', () => { + const callback = jest.fn(); + pubSub.publish('testChannel'); + + expect(callback).not.toHaveBeenCalled(); + }); + + test('should unsubscribe from a channel', () => { + const callback = jest.fn(); + pubSub.subscribe('testChannel', callback); + pubSub.unsubscribe('testChannel', callback); + + pubSub.publish('testChannel'); + + expect(callback).not.toHaveBeenCalled(); + }); + + test('should warn if no subscribers are present for a channel', () => { + console.warn = jest.fn(); + + pubSub.publish('testChannel', 'No one is listening'); + + expect(console.warn).toHaveBeenCalledWith('No subscribers for channel: testChannel'); + }); + + test('should handle async subscribe callbacks', async () => { + const asyncCallback = jest.fn().mockResolvedValue(undefined); + pubSub.subscribe('asyncChannel', asyncCallback); + + await pubSub.publishAsync('asyncChannel', 'Async data'); + + expect(asyncCallback).toHaveBeenCalledTimes(1); + expect(asyncCallback).toHaveBeenCalledWith('Async data'); + }); + + test('should reset all subscriptions', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + pubSub.subscribe('testChannel', callback1); + pubSub.subscribe('testChannel', callback2); + + pubSub.reset(); + + pubSub.publish('testChannel'); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + }); +}); diff --git a/services/pub-sub/src/pubSub.ts b/services/pub-sub/src/pubSub.ts new file mode 100644 index 00000000..a2b83178 --- /dev/null +++ b/services/pub-sub/src/pubSub.ts @@ -0,0 +1,94 @@ +import { TChannelData, TChannelMap, TDefaultChannels, TPubSubInstances } from './pubSub.types'; + +const DEFAULT_NAME_INSTANCE = '_default'; + +class PubSub { + private static instances: TPubSubInstances = new Map(); + private channels: TChannelMap = new Map(); + + private constructor() {} + + /** + * Getting an instance of a class. + */ + static getInstance( + instanceKey: string = DEFAULT_NAME_INSTANCE + ): PubSub { + if (!this.instances.get(instanceKey)) { + this.instances.set(instanceKey, new PubSub()); + } + + return this.instances.get(instanceKey) as PubSub; + } + + /** + * Subscribe to the channel. + */ + subscribe( + channel: ChannelKey, + callback: ChannelsRecord[ChannelKey] + ): void { + if (!this.channels.has(channel)) { + this.channels.set(channel, new Set()); + } + (this.channels.get(channel) as Set).add(callback); + } + + /** + * Unsubscribe from the channel. + */ + unsubscribe( + channel: ChannelKey, + callback: ChannelsRecord[ChannelKey] + ): void { + const channelSet = this.channels.get(channel); + if (channelSet) { + channelSet.delete(callback); + if (channelSet.size === 0) { + this.channels.delete(channel); + } + } + } + + /** + * Publishing to the channel. + */ + publish( + channel: ChannelKey, + data?: TChannelData + ): void { + const channelSet = this.channels.get(channel); + if (channelSet) { + for (const callback of channelSet) { + callback(data); + } + } else { + console.warn(`No subscribers for channel: ${channel as string}`); + } + } + + async publishAsync( + channel: ChannelKey, + data?: TChannelData + ): Promise { + const channelSet = this.channels.get(channel); + if (channelSet) { + for (const callback of channelSet) { + if (callback) { + await callback(data); + } + } + } else { + console.warn(`No subscribers for channel: ${channel as string}`); + } + } + + /** + * Reset all subscriptions. + */ + reset(): void { + this.channels.clear(); + } +} + +export default PubSub; diff --git a/services/pub-sub/src/pubSub.types.ts b/services/pub-sub/src/pubSub.types.ts new file mode 100644 index 00000000..df78ebb8 --- /dev/null +++ b/services/pub-sub/src/pubSub.types.ts @@ -0,0 +1,14 @@ +export type TDefaultChannels = Record void>; + +export type TChannelMap = Map< + keyof ChannelsRecord, + Set +>; + +export type TPubSubInstances = Map; + +export type TChannelData = Parameters< + ChannelsRecord[ChannelKey] +>[0]; + +export type ChannelsRecordAdapter = { [K in keyof T]: T[K] }; diff --git a/services/pub-sub/tsconfig.build.json b/services/pub-sub/tsconfig.build.json new file mode 100644 index 00000000..b4b36060 --- /dev/null +++ b/services/pub-sub/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/*.tests.ts"] +} diff --git a/services/pub-sub/tsconfig.json b/services/pub-sub/tsconfig.json new file mode 100644 index 00000000..5b7870da --- /dev/null +++ b/services/pub-sub/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "commonjs", + "target": "es6" + }, + "include": ["src"] +}