🏡 index : quine-global-labs/mirror-cockpit-project-cockpit.git

/*
 * Copyright (C) 2022 Red Hat, Inc.
 * SPDX-License-Identifier: LGPL-2.1-or-later
 */

/* DIALOG PRESENTATION PROTOCOL
 *
 * Example:
 *
 * import { WithDialogs, useDialogs } from "dialogs.jsx";
 *
 * const App = () =>
 *   <WithDialogs>
 *     <Page>
 *       <ExampleButton />
 *     </Page>
 *   </WithDialogs>;
 *
 * const ExampleButton = () => {
 *   const Dialogs = useDialogs();
 *   return <Button onClick={() => Dialogs.show(<MyDialog />)}>Open dialog</Button>;
 * };
 *
 * const MyDialog = () => {
 *   const Dialogs = useDialogs();
 *   return (
 *     <Modal title="My dialog"
 *            isOpen
 *            onClose={Dialogs.close}>
 *       <p>Hello!</p>
 *     </Modal>);
 * };
 *
 * This does two things: It maintains the state of whether the dialog
 * is open, and it does it high up in the DOM, in a stable place.
 * Even if ExampleButton is no longer part of the DOM, the dialog will
 * stay open and remain usable.
 *
 * The "WithDialogs" component enables all its children to show
 * dialogs.  Such a dialog will stay open as long as the WithDialogs
 * component itself is mounted.  Thus, you should put the WithDialogs
 * component somewhere high up in your component tree, maybe even as
 * the very top-most component.
 *
 * If your Cockpit application has multiple pages and navigation
 * between these pages is controlled by the browser URL, then each of
 * these pages should have its own WithDialogs wrapper. This way, a
 * dialog opened on one page closes when the user navigates away from
 * that page. To make sure that React maintains separate states for
 * WithDialogs components, give them unique "key" properties.
 *
 * A component that wants to show a dialogs needs to get hold of the
 * current "Dialogs" context and then call it's "show" method.  For a
 * function component the Dialogs context is returned by
 * "useDialogs()", as shown above in the example.
 *
 * A class component can declare a static context type and then use
 * "this.context" to find the Dialogs object:
 *
 * import { DialogsContext } from "dialogs.jsx";
 *
 * class ExampleButton extends React.Component {
 *   static contextType = DialogsContext;
 *
 *   function render() {
 *     const Dialogs = this.context;
 *     return <Button onClick={() => Dialogs.show(<MyDialog />)}>Open dialog</Button>;
 *   }
 * }
 *
 *
 * - Dialogs.show(component)
 *
 * Calling "Dialogs.show" will render the given component as a direct
 * child of the inner-most enclosing "WithDialogs" component.  The
 * component is of course intended to be a dialog, such as
 * Patternfly's "Modal".  There is only ever one of these; a second
 * call to "show" is considered a bug and "Dialogs.close" must be called first.
 *
 * - Dialogs.close()
 *
 * Calling "Dialogs.close()" will close the currently open Dialog.  It can only
 * be used with dialogs shown by `Dialogs.show()`.
 *
 * - Dialogs.run(component, {... props})
 *
 * Shows a dialog and asynchronously waits for it to close.  This creates and
 * shows a MyDialog with the given properties, plus a special "dialogResult"
 * property which has .resolve() and .reject() methods on it.  Calling either
 * of those will resolve the promise returned by Dialogs.run() accordingly,
 * closing the dialog in the process.  The created dialog cannot be closed with
 * Dialogs.close(). See the example:
 *
 * const MyDialog = ({ title, dialogResult }) => {
 *     return (
 *         <Modal title={title}>
 *             <Button onClick={() => dialogResult.resolve("yes")}>Yes</Button>
 *             <Button onClick={() => dialogResult.resolve("no")}>No</Button>
 *         </Modal>
 *     );
 * };
 *
 * const AsyncDialogExample = () => {
 *     const Dialogs = useDialogs();
 *
 *     const clicked = async () => {
 *         try {
 *             const result = await Dialogs.run(MyDialog, { title: "Example" });
 *             console.log(result);
 *         } catch (err) {
 *         }
 *     };
 *
 *     return <Button onClick={clicked}>Open dialog</Button>;
 * };
 *
 * - Dialogs.isActive()
 *
 * Returns `true` if a dialog is currently being shown.
 *
 */

import React, { useContext, useState, useRef } from "react";

export interface DialogResult<T> {
    resolve(value: T): void;
    reject(exc: unknown): void;
}

export interface Dialogs {
    show(dialog: React.ReactNode): void;
    close(): void;
    run<T, P>(component: React.ComponentType<P & { dialogResult: DialogResult<T> }>, properties: P): Promise<T>;
    isActive(): boolean;
}

export const DialogsContext = React.createContext<Dialogs | null>(null);
export const useDialogs = () => {
    const dialogs = useContext(DialogsContext);
    if (dialogs === null) {
        throw new Error("useDialogs can only be called inside of <WithDialogs/>");
    }
    return dialogs;
};

export const WithDialogs = ({ children } : { children: React.ReactNode }) => {
    const [dialog, setDialog] = useState<React.ReactNode>(null);
    type State = "close" | "show" | "run";
    const shown = useRef<State>("close");

    function transition(expected: State, to: State, arg: React.ReactNode = null) {
        if (shown.current !== expected)
            throw new Error(`Dialogs.${to}(${JSON.stringify(arg)}) called, but that's only valid ` +
                            `after .${expected}(), current dialog is ${JSON.stringify(dialog)}.`);
        shown.current = to;
        setDialog(arg);
    }

    const Dialogs: Dialogs = {
        show: (component: React.ReactNode) => transition("close", "show", component),
        close: () => transition("show", "close"),
        run: async (component, props) => {
            try {
                return await new Promise((resolve, reject) => {
                    transition("close", "run",
                               React.createElement(component, { ...props, dialogResult: { resolve, reject } }));
                });
            } finally {
                transition("run", "close");
            }
        },
        isActive: () => dialog !== null
    };

    return (
        <DialogsContext.Provider value={Dialogs}>
            {children}
            {dialog}
        </DialogsContext.Provider>);
};