RocketChat/Rocket.Chat

View on GitHub
packages/mock-providers/src/MockedAppRootBuilder.tsx

Summary

Maintainability
F
3 days
Test Coverage
import type { ISetting, Serialized, SettingValue } from '@rocket.chat/core-typings';
import type { ServerMethodName, ServerMethodParameters, ServerMethodReturn } from '@rocket.chat/ddp-client';
import { Emitter } from '@rocket.chat/emitter';
import languages from '@rocket.chat/i18n/dist/languages';
import type { Method, OperationParams, OperationResult, PathPattern, UrlParams } from '@rocket.chat/rest-typings';
import type { ModalContextValue, TranslationKey } from '@rocket.chat/ui-contexts';
import {
    AuthorizationContext,
    ConnectionStatusContext,
    RouterContext,
    ServerContext,
    SettingsContext,
    TranslationContext,
    UserContext,
    ActionManagerContext,
    ModalContext,
} from '@rocket.chat/ui-contexts';
import type { DecoratorFn } from '@storybook/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createInstance } from 'i18next';
import type { ObjectId } from 'mongodb';
import type { ContextType, JSXElementConstructor, ReactNode } from 'react';
import React, { useEffect, useReducer } from 'react';
import { I18nextProvider, initReactI18next } from 'react-i18next';
import { useSyncExternalStore } from 'use-sync-external-store/shim';

type Mutable<T> = {
    -readonly [P in keyof T]: T[P];
};

// eslint-disable-next-line @typescript-eslint/naming-convention
interface MockedAppRootEvents {
    'update-modal': void;
}

export class MockedAppRootBuilder {
    private wrappers: Array<(children: ReactNode) => ReactNode> = [];

    private connectionStatus: ContextType<typeof ConnectionStatusContext> = {
        connected: true,
        status: 'connected',
        retryTime: undefined,
        reconnect: () => undefined,
    };

    private server: ContextType<typeof ServerContext> = {
        absoluteUrl: (path: string) => `http://localhost:3000/${path}`,
        callEndpoint: <TMethod extends Method, TPathPattern extends PathPattern>({
            method,
            pathPattern,
        }: {
            method: TMethod;
            pathPattern: TPathPattern;
            keys: UrlParams<TPathPattern>;
            params: OperationParams<TMethod, TPathPattern>;
        }): Promise<Serialized<OperationResult<TMethod, TPathPattern>>> => {
            throw new Error(`not implemented (method: ${method}, pathPattern: ${pathPattern})`);
        },
        getStream: () => () => () => undefined,
        uploadToEndpoint: () => Promise.reject(new Error('not implemented')),
        callMethod: () => Promise.reject(new Error('not implemented')),
        info: undefined,
    };

    private router: ContextType<typeof RouterContext> = {
        buildRoutePath: () => '/',
        defineRoutes: () => () => undefined,
        getLocationPathname: () => '/',
        getLocationSearch: () => '',
        getRouteName: () => undefined,
        getRouteParameters: () => ({}),
        getRoutes: () => [],
        getSearchParameters: () => ({}),
        navigate: () => undefined,
        subscribeToRouteChange: () => () => undefined,
        subscribeToRoutesChange: () => () => undefined,
        getRoomRoute: () => ({ path: '/' }),
    };

    private settings: Mutable<ContextType<typeof SettingsContext>> = {
        hasPrivateAccess: true,
        isLoading: false,
        querySetting: (_id: string) => [() => () => undefined, () => undefined],
        querySettings: () => [() => () => undefined, () => []],
        dispatch: async () => undefined,
    };

    private user: ContextType<typeof UserContext> = {
        logout: () => Promise.reject(new Error('not implemented')),
        queryPreference: () => [() => () => undefined, () => undefined],
        queryRoom: () => [() => () => undefined, () => undefined],
        querySubscription: () => [() => () => undefined, () => undefined],
        querySubscriptions: () => [() => () => undefined, () => []],
        user: null,
        userId: null,
    };

    private modal: ModalContextValue = {
        currentModal: { component: null },
        modal: {
            setModal: (modal) => {
                this.modal = {
                    ...this.modal,
                    currentModal: { component: modal },
                };
                this.events.emit('update-modal');
            },
        },
    };

    private authorization: ContextType<typeof AuthorizationContext> = {
        queryPermission: () => [() => () => undefined, () => false],
        queryAtLeastOnePermission: () => [() => () => undefined, () => false],
        queryAllPermissions: () => [() => () => undefined, () => false],
        queryRole: () => [() => () => undefined, () => false],
        roleStore: {
            roles: {},
            emit: () => undefined,
            on: () => () => undefined,
            off: () => undefined,
            events: (): Array<'change'> => ['change'],
            has: () => false,
            once: () => () => undefined,
        },
    };

    private events = new Emitter<MockedAppRootEvents>();

    wrap(wrapper: (children: ReactNode) => ReactNode): this {
        this.wrappers.push(wrapper);
        return this;
    }

    withEndpoint<TMethod extends Method, TPathPattern extends PathPattern>(
        method: TMethod,
        pathPattern: TPathPattern,
        response: (
            params: OperationParams<TMethod, TPathPattern>,
        ) => Serialized<OperationResult<TMethod, TPathPattern>> | Promise<Serialized<OperationResult<TMethod, TPathPattern>>>,
    ): this {
        const innerFn = this.server.callEndpoint;

        const outerFn = <TMethod extends Method, TPathPattern extends PathPattern>(args: {
            method: TMethod;
            pathPattern: TPathPattern;
            keys: UrlParams<TPathPattern>;
            params: OperationParams<TMethod, TPathPattern>;
        }): Promise<Serialized<OperationResult<TMethod, TPathPattern>>> => {
            if (args.method === String(method) && args.pathPattern === String(pathPattern)) {
                return Promise.resolve(response(args.params)) as Promise<Serialized<OperationResult<TMethod, TPathPattern>>>;
            }

            return innerFn(args);
        };

        this.server.callEndpoint = outerFn;

        return this;
    }

    withMethod<TMethodName extends ServerMethodName>(methodName: TMethodName, response: () => ServerMethodReturn<TMethodName>): this {
        const innerFn = this.server.callMethod;

        const outerFn = <TMethodName extends ServerMethodName>(
            innerMethodName: TMethodName,
            ...innerArgs: ServerMethodParameters<TMethodName>
        ): Promise<ServerMethodReturn<TMethodName>> => {
            if (innerMethodName === String(methodName)) {
                return Promise.resolve(response()) as Promise<ServerMethodReturn<TMethodName>>;
            }

            if (!innerFn) {
                throw new Error('not implemented');
            }

            return innerFn(innerMethodName, ...innerArgs);
        };

        this.server.callMethod = outerFn;

        return this;
    }

    withPermission(permission: string): this {
        const innerFn = this.authorization.queryPermission;

        const outerFn = (
            innerPermission: string | ObjectId,
            innerScope?: string | ObjectId | undefined,
            innerScopedRoles?: string[] | undefined,
        ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean] => {
            if (innerPermission === permission) {
                return [() => () => undefined, () => true];
            }

            return innerFn(innerPermission, innerScope, innerScopedRoles);
        };

        this.authorization.queryPermission = outerFn;

        const innerFn2 = this.authorization.queryAtLeastOnePermission;

        const outerFn2 = (
            innerPermissions: Array<string | ObjectId>,
            innerScope?: string | ObjectId | undefined,
            innerScopedRoles?: string[] | undefined,
        ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean] => {
            if (innerPermissions.includes(permission)) {
                return [() => () => undefined, () => true];
            }

            return innerFn2(innerPermissions, innerScope, innerScopedRoles);
        };

        this.authorization.queryAtLeastOnePermission = outerFn2;

        const innerFn3 = this.authorization.queryAllPermissions;

        const outerFn3 = (
            innerPermissions: Array<string | ObjectId>,
            innerScope?: string | ObjectId | undefined,
            innerScopedRoles?: string[] | undefined,
        ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean] => {
            if (innerPermissions.includes(permission)) {
                return [() => () => undefined, () => true];
            }

            return innerFn3(innerPermissions, innerScope, innerScopedRoles);
        };

        this.authorization.queryAllPermissions = outerFn3;

        return this;
    }

    withJohnDoe(): this {
        this.user.userId = 'john.doe';

        this.user.user = {
            _id: 'john.doe',
            username: 'john.doe',
            name: 'John Doe',
            createdAt: new Date(),
            active: true,
            _updatedAt: new Date(),
            roles: ['admin'],
            type: 'user',
        };

        return this;
    }

    withAnonymous(): this {
        this.user.userId = null;
        this.user.user = null;

        return this;
    }

    withRole(role: string): this {
        if (!this.user.user) {
            throw new Error('user is not defined');
        }

        this.user.user.roles.push(role);

        const innerFn = this.authorization.queryRole;

        const outerFn = (
            innerRole: string | ObjectId,
            innerScope?: string | undefined,
            innerIgnoreSubscriptions?: boolean | undefined,
        ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean] => {
            if (innerRole === role) {
                return [() => () => undefined, () => true];
            }

            return innerFn(innerRole, innerScope, innerIgnoreSubscriptions);
        };

        this.authorization.queryRole = outerFn;

        return this;
    }

    withSetting(id: string, value: SettingValue): this {
        const setting = {
            _id: id,
            value,
        } as ISetting;

        const innerFn = this.settings.querySetting;

        const outerFn = (
            innerSetting: string,
        ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ISetting | undefined] => {
            if (innerSetting === id) {
                return [() => () => undefined, () => setting];
            }

            return innerFn(innerSetting);
        };

        this.settings.querySetting = outerFn;

        return this;
    }

    withUserPreference(id: string | ObjectId, value: unknown): this {
        const innerFn = this.user.queryPreference;

        const outerFn = <T,>(
            key: string | ObjectId,
            defaultValue?: T | undefined,
        ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T | undefined] => {
            if (key === id) {
                return [() => () => undefined, () => value as T];
            }

            return innerFn(key, defaultValue);
        };

        this.user.queryPreference = outerFn;

        return this;
    }

    withOpenModal(modal: ReactNode) {
        this.modal.currentModal = { component: modal };

        return this;
    }

    private i18n = createInstance({
        // debug: true,
        lng: 'en',
        fallbackLng: 'en',
        ns: ['core'],
        nsSeparator: '.',
        partialBundledLanguages: true,
        defaultNS: 'core',
        interpolation: {
            escapeValue: false,
        },
        initImmediate: false,
    }).use(initReactI18next);

    withTranslations(lng: string, ns: string, resources: Record<string, string>): this {
        const addResources = () => {
            this.i18n.addResources(lng, ns, resources);
            for (const [key, value] of Object.entries(resources)) {
                this.i18n.addResource(lng, ns, key, value);
            }
        };

        if (this.i18n.isInitialized) {
            addResources();
            return this;
        }

        this.i18n.on('initialized', addResources);
        return this;
    }

    build(): JSXElementConstructor<{ children: ReactNode }> {
        const queryClient = new QueryClient({
            defaultOptions: {
                queries: { retry: false },
                mutations: { retry: false },
            },
            logger: {
                log: console.log,
                warn: console.warn,
                error: () => undefined,
            },
        });

        const { connectionStatus, server, router, settings, user, i18n, authorization, wrappers } = this;

        const reduceTranslation = (translation?: ContextType<typeof TranslationContext>): ContextType<typeof TranslationContext> => {
            return {
                ...translation,
                language: i18n.isInitialized ? i18n.language : 'en',
                languages: [
                    {
                        en: 'Default',
                        name: i18n.isInitialized ? i18n.t('Default') : 'Default',
                        ogName: i18n.isInitialized ? i18n.t('Default') : 'Default',
                        key: '',
                    },
                    ...(i18n.isInitialized
                        ? [...new Set([...i18n.languages, ...languages])].map((key) => ({
                                en: key,
                                name: new Intl.DisplayNames([key], { type: 'language' }).of(key) ?? key,
                                ogName: new Intl.DisplayNames([key], { type: 'language' }).of(key) ?? key,
                                key,
                          }))
                        : []),
                ],
                loadLanguage: async (language) => {
                    if (!i18n.isInitialized) {
                        return;
                    }

                    await i18n.changeLanguage(language);
                },
                translate: Object.assign(
                    (key: TranslationKey, options?: unknown) => (i18n.isInitialized ? i18n.t(key, options as { lng?: string }) : ''),
                    {
                        has: (key: string, options?: { lng?: string }): key is TranslationKey =>
                            !!key && i18n.isInitialized && i18n.exists(key, options),
                    },
                ),
            };
        };

        const subscribeToModal = (onStoreChange: () => void) => this.events.on('update-modal', onStoreChange);

        const getModalSnapshot = () => this.modal;

        i18n.init();

        return function MockedAppRoot({ children }) {
            const [translation, updateTranslation] = useReducer(reduceTranslation, undefined, () => reduceTranslation());

            useEffect(() => {
                i18n.on('initialized', updateTranslation);
                i18n.on('languageChanged', updateTranslation);

                return () => {
                    i18n.off('initialized', updateTranslation);
                    i18n.off('languageChanged', updateTranslation);
                };
            }, []);

            const modal = useSyncExternalStore(subscribeToModal, getModalSnapshot);

            return (
                <QueryClientProvider client={queryClient}>
                    <ConnectionStatusContext.Provider value={connectionStatus}>
                        <ServerContext.Provider value={server}>
                            <RouterContext.Provider value={router}>
                                <SettingsContext.Provider value={settings}>
                                    <I18nextProvider i18n={i18n}>
                                        <TranslationContext.Provider value={translation}>
                                            {/* <SessionProvider>
                                                <TooltipProvider>
                                                        <ToastMessagesProvider>
                                                                <LayoutProvider>
                                                                        <AvatarUrlProvider>
                                                                                <CustomSoundProvider> */}
                                            <UserContext.Provider value={user}>
                                                {/* <DeviceProvider>*/}
                                                <ModalContext.Provider value={modal}>
                                                    <AuthorizationContext.Provider value={authorization}>
                                                        {/* <EmojiPickerProvider>
                                                                <OmnichannelRoomIconProvider>
                                                                        <UserPresenceProvider>*/}
                                                        <ActionManagerContext.Provider
                                                            value={{
                                                                generateTriggerId: () => '',
                                                                emitInteraction: () => Promise.reject(new Error('not implemented')),
                                                                getInteractionPayloadByViewId: () => undefined,
                                                                handleServerInteraction: () => undefined,
                                                                off: () => undefined,
                                                                on: () => undefined,
                                                                openView: () => undefined,
                                                                disposeView: () => undefined,
                                                                notifyBusy: () => undefined,
                                                                notifyIdle: () => undefined,
                                                            }}
                                                        >
                                                            {/* <VideoConfProvider>
                                                                    <CallProvider>
                                                                        <OmnichannelProvider> */}
                                                            {wrappers.reduce<ReactNode>(
                                                                (children, wrapper) => wrapper(children),
                                                                <>
                                                                    {children}
                                                                    {modal.currentModal.component}
                                                                </>,
                                                            )}
                                                            {/*         </OmnichannelProvider>
                                                                    </CallProvider>
                                                                </VideoConfProvider>*/}
                                                        </ActionManagerContext.Provider>
                                                        {/*         </UserPresenceProvider>
                                                                </OmnichannelRoomIconProvider>
                                                            </EmojiPickerProvider>*/}
                                                    </AuthorizationContext.Provider>
                                                </ModalContext.Provider>
                                                {/* </DeviceProvider>*/}
                                            </UserContext.Provider>
                                            {/*                     </CustomSoundProvider>
                                                                </AvatarUrlProvider>
                                                            </LayoutProvider>
                                                        </ToastMessagesProvider>
                                                    </TooltipProvider>
                                                </SessionProvider> */}
                                        </TranslationContext.Provider>
                                    </I18nextProvider>
                                </SettingsContext.Provider>
                            </RouterContext.Provider>
                        </ServerContext.Provider>
                    </ConnectionStatusContext.Provider>
                </QueryClientProvider>
            );
        };
    }

    buildStoryDecorator(): DecoratorFn {
        const WrapperComponent = this.build();

        // eslint-disable-next-line react/display-name, react/no-multi-comp
        return (fn) => <WrapperComponent>{fn()}</WrapperComponent>;
    }
}