Line data Source code
1 : /*
2 : * Copyright (C) 2022 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 : /* DIALOG PRESENTATION PROTOCOL
7 : *
8 : * Example:
9 : *
10 : * import { WithDialogs, useDialogs } from "dialogs.jsx";
11 : *
12 : * const App = () =>
13 : * <WithDialogs>
14 : * <Page>
15 : * <ExampleButton />
16 : * </Page>
17 : * </WithDialogs>;
18 : *
19 : * const ExampleButton = () => {
20 : * const Dialogs = useDialogs();
21 : * return <Button onClick={() => Dialogs.show(<MyDialog />)}>Open dialog</Button>;
22 : * };
23 : *
24 : * const MyDialog = () => {
25 : * const Dialogs = useDialogs();
26 : * return (
27 : * <Modal title="My dialog"
28 : * isOpen
29 : * onClose={Dialogs.close}>
30 : * <p>Hello!</p>
31 : * </Modal>);
32 : * };
33 : *
34 : * This does two things: It maintains the state of whether the dialog
35 : * is open, and it does it high up in the DOM, in a stable place.
36 : * Even if ExampleButton is no longer part of the DOM, the dialog will
37 : * stay open and remain usable.
38 : *
39 : * The "WithDialogs" component enables all its children to show
40 : * dialogs. Such a dialog will stay open as long as the WithDialogs
41 : * component itself is mounted. Thus, you should put the WithDialogs
42 : * component somewhere high up in your component tree, maybe even as
43 : * the very top-most component.
44 : *
45 : * If your Cockpit application has multiple pages and navigation
46 : * between these pages is controlled by the browser URL, then each of
47 : * these pages should have its own WithDialogs wrapper. This way, a
48 : * dialog opened on one page closes when the user navigates away from
49 : * that page. To make sure that React maintains separate states for
50 : * WithDialogs components, give them unique "key" properties.
51 : *
52 : * A component that wants to show a dialogs needs to get hold of the
53 : * current "Dialogs" context and then call it's "show" method. For a
54 : * function component the Dialogs context is returned by
55 : * "useDialogs()", as shown above in the example.
56 : *
57 : * A class component can declare a static context type and then use
58 : * "this.context" to find the Dialogs object:
59 : *
60 : * import { DialogsContext } from "dialogs.jsx";
61 : *
62 : * class ExampleButton extends React.Component {
63 : * static contextType = DialogsContext;
64 : *
65 : * function render() {
66 : * const Dialogs = this.context;
67 : * return <Button onClick={() => Dialogs.show(<MyDialog />)}>Open dialog</Button>;
68 : * }
69 : * }
70 : *
71 : *
72 : * - Dialogs.show(component)
73 : *
74 : * Calling "Dialogs.show" will render the given component as a direct
75 : * child of the inner-most enclosing "WithDialogs" component. The
76 : * component is of course intended to be a dialog, such as
77 : * Patternfly's "Modal". There is only ever one of these; a second
78 : * call to "show" is considered a bug and "Dialogs.close" must be called first.
79 : *
80 : * - Dialogs.close()
81 : *
82 : * Calling "Dialogs.close()" will close the currently open Dialog. It can only
83 : * be used with dialogs shown by `Dialogs.show()`.
84 : *
85 : * - Dialogs.run(component, {... props})
86 : *
87 : * Shows a dialog and asynchronously waits for it to close. This creates and
88 : * shows a MyDialog with the given properties, plus a special "dialogResult"
89 : * property which has .resolve() and .reject() methods on it. Calling either
90 : * of those will resolve the promise returned by Dialogs.run() accordingly,
91 : * closing the dialog in the process. The created dialog cannot be closed with
92 : * Dialogs.close(). See the example:
93 : *
94 : * const MyDialog = ({ title, dialogResult }) => {
95 : * return (
96 : * <Modal title={title}>
97 : * <Button onClick={() => dialogResult.resolve("yes")}>Yes</Button>
98 : * <Button onClick={() => dialogResult.resolve("no")}>No</Button>
99 : * </Modal>
100 : * );
101 : * };
102 : *
103 : * const AsyncDialogExample = () => {
104 : * const Dialogs = useDialogs();
105 : *
106 : * const clicked = async () => {
107 : * try {
108 : * const result = await Dialogs.run(MyDialog, { title: "Example" });
109 : * console.log(result);
110 : * } catch (err) {
111 : * }
112 : * };
113 : *
114 : * return <Button onClick={clicked}>Open dialog</Button>;
115 : * };
116 : *
117 : * - Dialogs.isActive()
118 : *
119 : * Returns `true` if a dialog is currently being shown.
120 : *
121 : */
122 :
123 557 : import React, { useContext, useState, useRef } from "react";
124 :
125 : export interface DialogResult<T> {
126 : resolve(value: T): void;
127 : reject(exc: unknown): void;
128 : }
129 :
130 : export interface Dialogs {
131 : show(dialog: React.ReactNode): void;
132 : close(): void;
133 : run<T, P>(component: React.ComponentType<P & { dialogResult: DialogResult<T> }>, properties: P): Promise<T>;
134 : isActive(): boolean;
135 : }
136 :
137 557 : export const DialogsContext = React.createContext<Dialogs | null>(null);
138 536 : export const useDialogs = () => {
139 536 : const dialogs = useContext(DialogsContext);
140 90 : if (dialogs === null) {
141 90 : throw new Error("useDialogs can only be called inside of <WithDialogs/>");
142 90 : }
143 536 : return dialogs;
144 536 : };
145 :
146 557 : export const WithDialogs = ({ children } : { children: React.ReactNode }) => {
147 557 : const [dialog, setDialog] = useState<React.ReactNode>(null);
148 : type State = "close" | "show" | "run";
149 557 : const shown = useRef<State>("close");
150 :
151 85 : function transition(expected: State, to: State, arg: React.ReactNode = null) {
152 85 : if (shown.current !== expected)
153 5 : throw new Error(`Dialogs.${to}(${JSON.stringify(arg)}) called, but that's only valid ` +
154 5 : `after .${expected}(), current dialog is ${JSON.stringify(dialog)}.`);
155 85 : shown.current = to;
156 85 : setDialog(arg);
157 85 : }
158 :
159 557 : const Dialogs: Dialogs = {
160 78 : show: (component: React.ReactNode) => transition("close", "show", component),
161 74 : close: () => transition("show", "close"),
162 7 : run: async (component, props) => {
163 7 : try {
164 7 : return await new Promise((resolve, reject) => {
165 7 : transition("close", "run",
166 7 : React.createElement(component, { ...props, dialogResult: { resolve, reject } }));
167 7 : });
168 7 : } finally {
169 7 : transition("run", "close");
170 7 : }
171 7 : },
172 0 : isActive: () => dialog !== null
173 557 : };
174 :
175 557 : return (
176 557 : <DialogsContext.Provider value={Dialogs}>
177 557 : {children}
178 557 : {dialog}
179 557 : </DialogsContext.Provider>);
180 557 : };
|