Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(notifications): prevent duplicates #394

Merged
merged 7 commits into from
May 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions src/components/notifier/Notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,3 @@ export const Notification = ({
</Alert>
);
};

export interface INotification {
/** Title of the alert. */
title: string | ReactNode;
/** Content under the title. */
content: string | ReactNode;
/** Duration in milliseconds. E.g. 5000 by default */
duration?: number;
isError?: boolean;
}
144 changes: 70 additions & 74 deletions src/components/notifier/notifier.tsx
Original file line number Diff line number Diff line change
@@ -1,105 +1,101 @@
import { AlertStatus, ToastId, useToast } from '@chakra-ui/react';
import React, { ReactNode, useCallback } from 'react';
import {
AlertStatus,
CreateToastFnReturn,
ToastId,
useToast,
} from '@chakra-ui/react';
import React, { ReactNode, useCallback, useMemo } from 'react';
import { standaloneToast } from '../../theme-chakra/ChakraThemeProvider';
import { Notification } from './Notification';

export interface INotification {
/** Title of the alert. */
title: string | ReactNode;
/** Content under the title. */
content: string | ReactNode;
/** Duration in milliseconds. E.g. 5000 by default */
duration?: number;
/** Status e.g. warning, error, success */
status?: AlertStatus;
/** A unique ID that blocks other notifications with the same ID */
uniqueId?: ToastId;
}

export const useNotifier = () => {
const toast = useToast();

const notify = useCallback(
(status: AlertStatus) => (notification: INotification) => {
const render = ({ onClose, id }: { onClose(): void; id: ToastId }) => {
return (
<Notification
title={notification.title}
content={notification.content}
onClose={onClose}
status={status}
onMouseEnter={() => hoverHandler(id)}
onMouseLeave={() => unhoverHandler(id)}
/>
);
};

const hoverHandler = (id: ToastId) => {
toast.update(id, { duration: 1e6, render });
};
const buildNotifier =
(toast: CreateToastFnReturn, status: AlertStatus) =>
(notification: INotification) => {
const duration = notification.duration ?? 5000;
const render = ({ onClose, id }: { onClose(): void; id: ToastId }) => {
const duration = notification.duration ?? 5000;

const unhoverHandler = (id: ToastId) => {
const dontLetToastDisappear = () =>
toast.update(id, {
duration: notification.duration ?? 5000,
duration: 1e6,
render,
});
};

toast({
status,
duration: notification.duration ?? 5000,
isClosable: true,
render,
});
},
[toast],
);

return {
success: notify('success'),
error: notify('error'),
info: notify('info'),
warning: notify('warning'),
closeAll: toast.closeAll,
close: toast.close,
};
};
const letToastDisappear = () =>
toast.update(id, {
duration,
render,
});

export const createNotifier = () => {
const notify = (status: AlertStatus) => (notification: INotification) => {
const render = ({ onClose, id }: { onClose(): void; id: ToastId }) => {
return (
<Notification
title={notification.title}
content={notification.content}
onClose={onClose}
status={status}
onMouseEnter={() => hoverHandler(id)}
onMouseLeave={() => unhoverHandler(id)}
onMouseEnter={dontLetToastDisappear}
onMouseLeave={letToastDisappear}
/>
);
};

const hoverHandler = (id: ToastId) => {
standaloneToast.update(id, { duration: 1e6, render });
};

const unhoverHandler = (id: ToastId) => {
standaloneToast.update(id, {
duration: notification.duration ?? 5000,
if (!(notification.uniqueId && toast.isActive(notification.uniqueId))) {
return toast({
position: 'top-right',
status,
duration,
isClosable: true,
render,
id: notification.uniqueId,
});
};

return standaloneToast({
status,
duration: notification.duration ?? 5000,
isClosable: true,
position: 'top-right',
render,
});
}
return null;
};

return {
success: notify('success'),
error: notify('error'),
info: notify('info'),
warning: notify('warning'),
closeAll: standaloneToast.closeAll,
close: standaloneToast.close,
};
type Notifier = ReturnType<typeof buildNotifier>;

const createMethods = (
toast: CreateToastFnReturn,
notifyWithStatus: (status: AlertStatus) => Notifier,
) => ({
success: notifyWithStatus('success'),
error: notifyWithStatus('error'),
info: notifyWithStatus('info'),
warning: notifyWithStatus('warning'),
closeAll: toast.closeAll,
close: toast.close,
});

export const useNotifier = () => {
const toast = useToast();

const notifyWithStatus = useCallback(
(status: AlertStatus) => buildNotifier(toast, status),
[toast],
);

return useMemo(
() => createMethods(toast, notifyWithStatus),
[notifyWithStatus, toast],
);
};

export const createNotifier = () => {
const notifyWithStatus = (status: AlertStatus) =>
buildNotifier(standaloneToast, status);

return createMethods(standaloneToast, notifyWithStatus);
};
57 changes: 55 additions & 2 deletions src/components/notifier/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import { Box } from '../box';
import { Button } from '../button';
import { Flex } from '../flex';
import Value from '../typography/value';
import { INotification } from './Notification';
import { createNotifier, useNotifier } from './notifier';
import { INotification, createNotifier, useNotifier } from './notifier';

const meta: Meta<INotification> = {
title: 'Notifier',
Expand Down Expand Up @@ -160,6 +159,60 @@ export const Success: Story = {
},
};

export const PreventDuplicateNotifications: Story = {
render: (args) => {
const notify = useNotifier();

const showRegular = () => {
notify.info({
title: `Regular: ${args.title}`,
content: args.content,
duration: args.duration,
});
};

const showPrevented = () => {
notify.success({
title: `Prevents others: ${args.title}`,
content: args.content,
duration: args.duration,
uniqueId: 'just one id',
});
};

return (
<Flex flexDirection="column" gap="1rem">
<Value fontSize="18px" width="300px">
If you pass an ID to a notifier, it is going to prevent other
notifications with the same ID from being shown.
</Value>
<Flex gap="20px">
<Button onClick={showRegular}>Regular</Button>
<Button onClick={showPrevented}>With ID given</Button>
</Flex>
</Flex>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const portal = within(document.querySelector('.chakra-portal')!);

userEvent.click(canvas.getByText('Regular'));
userEvent.click(canvas.getByText('Regular'));
userEvent.click(canvas.getByText('Regular'));
expect(
await portal.findAllByText('Regular: Something happened'),
).toHaveLength(3);

userEvent.click(canvas.getByText('With ID given'));
userEvent.click(canvas.getByText('With ID given'));
userEvent.click(canvas.getByText('With ID given'));
expect(
await portal.findAllByText('Prevents others: Something happened'),
).toHaveLength(1);
},
};

export const Standalone: Story = {
parameters: {
docs: {
Expand Down