Skip to content

Latest commit

 

History

History
344 lines (286 loc) · 12.4 KB

05-toaster.md

File metadata and controls

344 lines (286 loc) · 12.4 KB

« previous | next »

5. Toaster

Within this chapter we are going to provide a way to fire and forget messages which are displayed to the current user on the website. This is useful for example to inform a user about a successful save process in the backend.

5.1 Creating the toaster package

It would be nice to have a service to easily dispatch some toast messages with different severities like success or error. Best case I think would be to be able to trigger a message containing any content: Must obviously be a ReactNode or a string. I think it's also useful to let the messages disappear after 3 seconds or so.

First, we should install the uuid package to generate some random uuids for new toast messages. It's always useful to have an identifier on an object:

npm install uuid --save

and its types:

npm install @types/uuid --save-dev

Now let's try to define an interface and a context for the requirements above:

// src/packages/core/toaster/toaster.ts

import { v4 } from 'uuid';
import { createContext, ReactNode, useContext } from 'react';

type Severity = 'info' | 'success' | 'warning' | 'error';

type ToastMessageContent = ReactNode | string;

export type ToastMessage = {
    id: string;
    severity: Severity;
    content: ReactNode | string;
    autoHideDurationInMs?: null | number;
};

export type ToastMessageCreationSettings = Partial<ToastMessage> & { content: ToastMessageContent };

export function createToastMessage(settings: ToastMessageCreationSettings): ToastMessage {
    return {
        id: v4(),
        severity: 'info',
        autoHideDurationInMs: 3000,
        ...settings,
    };
}

export interface Toaster {
    showMessage(settings: ToastMessageCreationSettings): void;
}

const toasterContext = createContext<null | Toaster>(null);
export const ToasterProvider = toasterContext.Provider;

export function useToaster(): Toaster {
    const ctx = useContext(toasterContext);
    if (!ctx) {
        throw new Error('no Toaster was provided');
    }
    return ctx;
}

I think from a toaster-user-perspective this is an acceptable way to dispatch toast messages. From a toaster-manager-perspective we also need to consider that from wherever a toast message is dispatched these messages should run through a central bus and be forwarded to every handler which wants to do something with it. The observer pattern would be a perfect fit for that!

💡 The observer pattern: In terms of the observer pattern, a "subject" normally has a list of "observers", which are triggered by the subject whenever a specific event happens. This event can be a state change or something other. One may call this also the "listener" or "subscriber" pattern.

So let's reach this with a subscribable toaster implementation:

// src/packages/core/toaster/subscribableToaster.ts

import { createContext, useContext } from 'react';
import { Toaster, ToastMessageCreationSettings, createToastMessage, ToastMessage } from './toaster';

export type ToasterSubscriber = {
    id: string;
    onShowMessage: (message: ToastMessage) => void;
};

export class SubscribableToaster implements Toaster {
    private subscribers: ToasterSubscriber[];

    constructor() {
        this.subscribers = [];
        this.showMessage = this.showMessage.bind(this);
        this.subscribe = this.subscribe.bind(this);
        this.unSubscribe = this.unSubscribe.bind(this);
    }

    showMessage(settings: ToastMessageCreationSettings) {
        const toastMessage = createToastMessage(settings);
        this.subscribers.forEach((subscriber) => {
            subscriber.onShowMessage(toastMessage);
        });
    }

    subscribe(subscriber: ToasterSubscriber) {
        this.subscribers = [...this.subscribers, subscriber];
    }

    unSubscribe(subscriberId: string) {
        this.subscribers = this.subscribers.filter((subscriber) => subscriber.id !== subscriberId);
    }
}

const subscribableToasterContext = createContext<null | SubscribableToaster>(null);
export const SubscribableToasterProvider = subscribableToasterContext.Provider;

export function useSubscribableToaster(): SubscribableToaster {
    const toaster = useContext(subscribableToasterContext);
    if (!toaster) {
        throw new Error('no SubscribableToaster was provided');
    }
    return toaster;
}

And finally, export the files like so:

// src/packages/core/toaster/index.ts

export * from './toaster';
export * from './subscribableToaster';

5.2 Providing the services

Because the toaster implementations are not really dependent on the environment. We can use the same logic for the <TestServiceProvider> as we are going to write in the <ServiceProvider> component. Just add the following code to the two files:

// src/ServiceProvider.tsx and src/TestServiceProvider.tsx

// add the following import statement:
import { ToasterProvider, SubscribableToaster, SubscribableToasterProvider } from '@packages/core/toaster';

// create a toaster reference in the top of the service provider component
const toasterRef = useRef(new SubscribableToaster());

// provide the two toaster context values in the return statement:
return (
    <ToasterProvider value={toasterRef.current}>
        <SubscribableToasterProvider value={toasterRef.current}>
            {/* the already existing context providers... */}
        </SubscribableToasterProvider>
    </ToasterProvider>
);

In the meantime absolutely known stuff for us. Let's create a toast message renderer in the next step.

5.3 Designing a toaster subscriber with MUI

Next we need to create a subscriber or namely "observer" for our provided SubscribableToaster. So let's try to create a component which subscribes to the SubscribableToaster when it is mounted, but also unsubscribes from the list when it unmounts:

// src/packages/core/toaster/MuiToasterSubscriber.tsx

import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { Fade, Alert, Snackbar } from '@mui/material';
import { ToastMessage } from './toaster';
import { v4 } from 'uuid';
import { SubscribableToaster } from './subscribableToaster';

type MuiToastMessageProps = {
    data: ToastMessage;
    onClose: () => void;
};

const snackbarCloseAnimationDurationInMs = 300;

const MuiToastMessage: FC<MuiToastMessageProps> = (props) => {
    const [open, setOpen] = useState(true);
    function triggerCloseOnParentAfterCloseAnimationHasFinished() {
        setOpen(false);
        setTimeout(() => props.onClose(), snackbarCloseAnimationDurationInMs);
    }
    return (
        <Snackbar
            anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
            autoHideDuration={props.data.autoHideDurationInMs}
            TransitionComponent={Fade}
            open={open}
            onClose={() => triggerCloseOnParentAfterCloseAnimationHasFinished()}>
            <Alert
                variant="filled"
                severity={props.data.severity}
                onClose={() => triggerCloseOnParentAfterCloseAnimationHasFinished()}>
                {props.data.content}
            </Alert>
        </Snackbar>
    );
};

export type MuiToasterSubscriberProps = {
    toaster: SubscribableToaster;
};

export const MuiToasterSubscriber: FC<MuiToasterSubscriberProps> = (props) => {
    const subscriberIdRef = useRef(v4());
    const subscriberId = subscriberIdRef.current;
    const pipelinedMessagesRef = useRef<ToastMessage[]>([]);
    const activeMessageRef = useRef<null | ToastMessage>(null);
    const [activeMessage, setActiveMessage] = useState<null | ToastMessage>(null);
    const showNextMessage = useCallback(() => {
        const nextMessage = pipelinedMessagesRef.current.shift();
        if (activeMessageRef.current && !nextMessage) {
            activeMessageRef.current = null;
            setActiveMessage(null);
            return;
        }
        if (!nextMessage) {
            return;
        }
        activeMessageRef.current = nextMessage;
        setActiveMessage(nextMessage);
    }, [pipelinedMessagesRef, activeMessageRef, setActiveMessage]);
    useEffect(() => {
        props.toaster.subscribe({
            id: subscriberIdRef.current,
            onShowMessage: (message: ToastMessage) => {
                pipelinedMessagesRef.current.push(message);
                if (!activeMessageRef.current) {
                    showNextMessage();
                }
            },
        });
        return () => props.toaster.unSubscribe(subscriberId);
    }, [props.toaster, subscriberId, pipelinedMessagesRef, activeMessageRef, showNextMessage]);
    if (!activeMessage) {
        return null;
    }
    return <MuiToastMessage key={activeMessage.id} data={activeMessage} onClose={() => showNextMessage()} />;
};

Add this component also to the exports in the index.ts.

💡 The return statement of a hook must either be undefined or a function. The return function is a cleanup function. It's the equivalent of the class component's componentDidUnmount with the possibility of better encapsulation.

5.4 Implementing the toaster subscriber

I think the BlankPage component is the right place to implement the toaster subscriber we've created. With the following additions, we can support toast messages for all pages:

// src/components/page-layout/BlankPage.tsx

// add the following import:
import { MuiToasterSubscriber, useSubscribableToaster } from '@packages/core/toaster';

// use the subscribable toaster in the top of the BlankPage component:
const toaster = useSubscribableToaster();

// make the return statement of the BlankPage component look like so:
return (
    <>
        <CssBaseline />
        {props.children}
        <MuiToasterSubscriber toaster={toaster} />
    </>
);

5.5 Dispatch some toast messages

To be able to test if our toaster works, we should add some toast-message-dispatcher-links. This time we use the useToaster() hook because we only want to dispatch messages no matter which implementation of the toaster is provided.

// src/pages/IndexPage.tsx

import { FC } from 'react';
import { NavBarPage } from '@components/page-layout';
import { T, useTranslator } from '@packages/core/i18n';
import { useCurrentUser } from '@packages/core/auth';
import { FunctionalLink } from '@packages/core/routing';
import { useToaster } from '@packages/core/toaster';
import { Alert } from '@mui/material';

export const IndexPage: FC = () => {
    const { t } = useTranslator();
    const currentUser = useCurrentUser();
    const { showMessage } = useToaster();
    const username =
        currentUser.type === 'authenticated' ? currentUser.data.username : t('core.currentUser.guestDisplayName');
    const greeting = <T id="pages.indexPage.greeting" placeholders={{ username: <strong>{username}</strong> }} />;
    return (
        <NavBarPage title="Home">
            {greeting}
            <div style={{ marginTop: '15px' }}>
                <Alert severity="info">
                    <strong>MuiToasterSubscriber:</strong>
                    <br />
                    Note that if a toast message is displayed and you click outside of it, this toast message will
                    automatically be closed.
                    <br />
                    <br />
                    <FunctionalLink onClick={() => showMessage({ content: greeting })}>
                        trigger info toast
                    </FunctionalLink>
                    <br />
                    <FunctionalLink
                        onClick={() => {
                            showMessage({
                                severity: 'success',
                                autoHideDurationInMs: 1000,
                                content: <>First: {greeting}</>,
                            });
                            showMessage({
                                severity: 'success',
                                autoHideDurationInMs: 1000,
                                content: <>Second: {greeting}</>,
                            });
                        }}>
                        trigger multiple success toasts
                    </FunctionalLink>
                </Alert>
            </div>
        </NavBarPage>
    );
};

💾 branch 05-toaster

« previous | next »