Line data Source code
1 : /*
2 : * Copyright (C) 2016 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 311 : import cockpit from "cockpit";
7 311 : import React from "react";
8 311 : import { createRoot } from "react-dom/client";
9 311 : import PropTypes from "prop-types";
10 : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
11 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
12 : import {
13 : Modal, ModalBody, ModalFooter, ModalHeader
14 : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
15 : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
16 : import { Stack, StackItem } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
17 : import { HelpIcon, ExternalLinkAltIcon } from '@patternfly/react-icons';
18 :
19 : import "cockpit-components-dialog.scss";
20 :
21 311 : const _ = cockpit.gettext;
22 :
23 : /*
24 : * React template for a Cockpit dialog footer
25 : * It can wait for an action to complete,
26 : * has a 'Cancel' button and an action button (defaults to 'OK')
27 : * Expected props:
28 : * - cancel_clicked optional
29 : * Callback called when the dialog is canceled
30 : * - cancel_button optional, defaults to 'Cancel' text styled as a link
31 : * - list of actions, each an object with:
32 : * - clicked
33 : * Callback function that is expected to return a promise.
34 : * parameter: callback to set the progress text
35 : * - caption optional, defaults to 'Ok'
36 : * - disabled optional, defaults to false
37 : * - style defaults to 'secondary', other options: 'primary', 'danger'
38 : * - idle_message optional, always show this message on the last row when idle
39 : * - dialog_done optional, callback when dialog is finished (param true if success, false on cancel)
40 : * - set_error: required, callback to set/clear error message from actions
41 : */
42 311 : class DialogFooter extends React.Component {
43 115 : constructor(props) {
44 115 : super(props);
45 115 : this.state = {
46 115 : action_in_progress: false,
47 115 : action_progress_message: '',
48 115 : action_progress_cancel: null,
49 115 : action_canceled: false,
50 115 : action_caption_in_progress: '',
51 115 : };
52 115 : this.update_progress = this.update_progress.bind(this);
53 115 : this.cancel_click = this.cancel_click.bind(this);
54 115 : }
55 :
56 56 : update_progress(msg, cancel) {
57 56 : this.setState({ action_progress_message: msg, action_progress_cancel: cancel });
58 56 : }
59 :
60 104 : action_click(handler, caption, e) {
61 104 : this.setState({
62 104 : action_progress_message: '',
63 104 : action_in_progress: true,
64 104 : action_caption_in_progress: caption,
65 104 : action_canceled: false,
66 104 : });
67 :
68 104 : const p = handler(this.update_progress)
69 100 : .then(() => {
70 100 : this.props.set_error(null);
71 100 : this.setState({ action_in_progress: false });
72 100 : if (this.props.dialog_done)
73 100 : this.props.dialog_done(true);
74 100 : })
75 30 : .catch(error => {
76 2 : if (this.state.action_canceled) {
77 2 : if (this.props.dialog_done)
78 2 : this.props.dialog_done(false);
79 1 : } else {
80 29 : this.props.set_error(error);
81 29 : this.setState({ action_in_progress: false });
82 29 : }
83 : /* Always log global dialog errors for easier debugging */
84 30 : if (error)
85 1 : console.warn(error.message || error.toString());
86 30 : });
87 :
88 104 : if (p.progress)
89 11 : p.progress(this.update_progress);
90 :
91 104 : if (e)
92 104 : e.stopPropagation();
93 104 : }
94 :
95 31 : cancel_click(e) {
96 31 : this.setState({ action_canceled: true });
97 :
98 31 : if (this.props.cancel_clicked)
99 0 : this.props.cancel_clicked();
100 :
101 : // an action might be in progress, let that handler decide what to do if they added a cancel function
102 1 : if (this.state.action_in_progress && this.state.action_progress_cancel) {
103 1 : this.state.action_progress_cancel();
104 1 : return;
105 1 : }
106 :
107 30 : if (this.props.dialog_done)
108 30 : this.props.dialog_done(false);
109 30 : if (e)
110 30 : e.stopPropagation();
111 31 : }
112 :
113 115 : render() {
114 91 : const cancel_text = this.props?.cancel_button?.text ?? _("Cancel");
115 91 : const cancel_variant = this.props?.cancel_button?.variant ?? "link";
116 :
117 : // If an action is in progress, show the spinner with its message and disable all actions.
118 : // Cancel is only enabled when the action promise has a cancel method, or we get one
119 : // via the progress reporting.
120 :
121 115 : let wait_element;
122 115 : let actions_disabled = false;
123 104 : const cancel_disabled = this.state.action_in_progress && !this.state.action_progress_cancel;
124 104 : if (this.state.action_in_progress) {
125 104 : actions_disabled = true;
126 104 : wait_element = <div className="dialog-wait-ct">
127 104 : <span>{ this.state.action_progress_message }</span>
128 104 : </div>;
129 77 : } else if (this.props.idle_message) {
130 77 : wait_element = <div className="dialog-wait-ct">
131 77 : { this.props.idle_message }
132 77 : </div>;
133 77 : }
134 :
135 114 : const action_buttons = this.props.actions.map(action => {
136 114 : let caption;
137 114 : if ('caption' in action)
138 3 : caption = action.caption;
139 : else
140 3 : caption = _("Ok");
141 :
142 3 : let variant = action.style || "secondary";
143 111 : if (variant == "primary" && action.danger)
144 65 : variant = "danger";
145 :
146 114 : return (<Button
147 114 : key={ caption }
148 114 : className="apply"
149 114 : variant={ variant }
150 104 : isLoading={ this.state.action_in_progress && this.state.action_caption_in_progress == caption }
151 114 : isDanger={ action.danger }
152 114 : onClick={ this.action_click.bind(this, action.clicked, caption) }
153 114 : isDisabled={ actions_disabled || action.disabled }
154 114 : >{ caption }</Button>
155 : );
156 114 : });
157 :
158 115 : return (
159 115 : <>
160 115 : { this.props.extra_element }
161 115 : { action_buttons }
162 115 : <Button variant={cancel_variant} className="cancel" onClick={this.cancel_click} isDisabled={cancel_disabled}>{ cancel_text }</Button>
163 115 : { wait_element }
164 115 : </>
165 : );
166 115 : }
167 311 : }
168 :
169 311 : DialogFooter.propTypes = {
170 311 : cancel_clicked: PropTypes.func,
171 311 : cancel_button: PropTypes.object,
172 311 : actions: PropTypes.array.isRequired,
173 311 : dialog_done: PropTypes.func,
174 311 : set_error: PropTypes.func.isRequired,
175 311 : };
176 :
177 : /*
178 : * React template for a Cockpit dialog
179 : * The primary action button is disabled while its action is in progress (waiting for promise)
180 : * Removes focus on other elements on showing
181 : * Expected props:
182 : * - title (string)
183 : * - body (react element, top element should be of class modal-body)
184 : * It is recommended for information gathering dialogs to pass references
185 : * to the input components to the controller. That way, the controller can
186 : * extract all necessary information (e.g. for input validation) when an
187 : * action is triggered.
188 : * - static_error optional, always show this error after the body element
189 : * - footer (react element, top element should be of class modal-footer)
190 : * - id optional, id that is assigned to the top level dialog node, but not the backdrop
191 : * - variant: See PF6 Modal component's 'variant' property
192 : * - titleIconVariant: See PF6 ModalHeader component's 'titleIconVariant' property
193 : */
194 311 : class Dialog extends React.Component {
195 115 : componentDidMount() {
196 : // For the scenario that cockpit-storage is used inside anaconda Web UI
197 : // We need to know if there is an open dialog in order to create the backdrop effect
198 : // on the parent window
199 115 : window.sessionStorage.setItem("cockpit_has_modal", true);
200 :
201 : // if we used a button to open this, make sure it's not focused anymore
202 115 : if (document.activeElement)
203 115 : document.activeElement.blur();
204 115 : }
205 :
206 107 : componentWillUnmount() {
207 107 : window.sessionStorage.setItem("cockpit_has_modal", false);
208 107 : }
209 :
210 115 : render() {
211 115 : let help = null;
212 115 : let footer = null;
213 115 : if (this.props.helpLink)
214 3 : footer = <a href={this.props.helpLink} target="_blank" rel="noopener noreferrer">{_("Learn more")} <ExternalLinkAltIcon /></a>;
215 :
216 115 : if (this.props.helpMessage)
217 3 : help = <Popover
218 3 : bodyContent={this.props.helpMessage}
219 3 : footerContent={footer}
220 : >
221 3 : <Button icon={<HelpIcon />} variant="plain" aria-label={_("Learn more")} />
222 3 : </Popover>;
223 :
224 115 : const error = this.props.error || this.props.static_error;
225 7 : const error_alert = error && <Alert variant='danger' isInline title={error} />;
226 :
227 115 : return (
228 113 : <Modal position="top" variant={this.props.variant || "medium"}
229 0 : onEscapePress={() => undefined}
230 115 : id={this.props.id}
231 115 : isOpen>
232 115 : <ModalHeader title={this.props.title}
233 115 : titleIconVariant={this.props.titleIconVariant}
234 115 : help={help}
235 115 : />
236 115 : <ModalBody>
237 115 : <Stack hasGutter>
238 115 : { error_alert }
239 115 : <StackItem>
240 115 : { this.props.body }
241 115 : </StackItem>
242 115 : </Stack>
243 115 : </ModalBody>
244 115 : <ModalFooter>
245 115 : {this.props.footer}
246 115 : </ModalFooter>
247 115 : </Modal>
248 : );
249 115 : }
250 311 : }
251 311 : Dialog.propTypes = {
252 : // TODO: fix following by refactoring the logic showing modal dialog (recently show_modal_dialog())
253 311 : title: PropTypes.string, // is effectively required, but show_modal_dialog() provides initially no props and resets them later.
254 311 : body: PropTypes.element, // is effectively required, see above
255 311 : static_error: PropTypes.string,
256 311 : error: PropTypes.string,
257 311 : footer: PropTypes.element, // is effectively required, see above
258 311 : id: PropTypes.string,
259 311 : };
260 :
261 : /* Create and show a dialog
262 : * For this, create a containing DOM node at the body level
263 : * The returned object has the following methods:
264 : * - setFooterProps replace the current footerProps and render
265 : * - setProps replace the current props and render
266 : * - render render again using the stored props
267 : * The DOM node and React metadata are freed once the dialog has closed
268 : */
269 115 : export function show_modal_dialog(props, footerProps) {
270 115 : const dialogName = 'cockpit_modal_dialog';
271 : // don't allow nested dialogs, just close whatever is open
272 115 : const curElement = document.getElementById(dialogName);
273 115 : let root;
274 5 : if (curElement) {
275 5 : root = createRoot(curElement);
276 5 : root.unmount();
277 5 : curElement.remove();
278 5 : }
279 : // create an element to render into
280 115 : const rootElement = document.createElement("div");
281 115 : root = createRoot(rootElement);
282 115 : rootElement.id = dialogName;
283 115 : document.body.appendChild(rootElement);
284 :
285 : // register our own on-close callback
286 115 : let origCallback;
287 107 : const closeCallback = function() {
288 107 : if (origCallback)
289 11 : origCallback.apply(this, arguments);
290 107 : root.unmount();
291 107 : rootElement.remove();
292 107 : };
293 :
294 115 : const dialogObj = { };
295 115 : let error = null;
296 115 : dialogObj.props = props;
297 115 : dialogObj.footerProps = null;
298 115 : dialogObj.render = function() {
299 115 : dialogObj.props.footer = <DialogFooter {...dialogObj.footerProps} />;
300 : // Don't render if we are no longer part of the document.
301 : // This would be mostly harmless except that it will remove
302 : // the input focus from whatever element has it, which is
303 : // unpleasant and also disrupts the tests.
304 115 : if (rootElement.offsetParent)
305 115 : root.render(<Dialog {...dialogObj.props} error={error} />);
306 115 : };
307 115 : function updateFooterAndRender() {
308 115 : if (dialogObj.props === null || dialogObj.props === undefined)
309 3 : dialogObj.props = { };
310 115 : dialogObj.props.footer = <DialogFooter {...dialogObj.footerProps} />;
311 115 : dialogObj.render();
312 115 : }
313 115 : dialogObj.setFooterProps = function(footerProps) {
314 115 : dialogObj.footerProps = footerProps;
315 115 : if (dialogObj.footerProps.dialog_done != closeCallback) {
316 115 : origCallback = dialogObj.footerProps.dialog_done;
317 115 : dialogObj.footerProps.dialog_done = closeCallback;
318 115 : }
319 103 : dialogObj.footerProps.set_error = e => {
320 3 : error = typeof e === 'object' && e !== null ? (e.message || e.toString()) : e;
321 103 : dialogObj.render();
322 103 : };
323 115 : updateFooterAndRender();
324 115 : };
325 115 : dialogObj.setProps = function(props) {
326 115 : dialogObj.props = props;
327 115 : updateFooterAndRender();
328 115 : };
329 115 : dialogObj.setFooterProps(footerProps);
330 115 : dialogObj.setProps(props);
331 :
332 : // now actually render
333 115 : dialogObj.render();
334 :
335 115 : return dialogObj;
336 115 : }
337 :
338 0 : export function apply_modal_dialog(event) {
339 0 : const dialog = event.target?.closest("[role=dialog]");
340 0 : const button = dialog?.querySelector("button.apply");
341 :
342 0 : if (button) {
343 0 : const event = new MouseEvent('click', {
344 0 : view: window,
345 0 : bubbles: true,
346 0 : cancelable: true,
347 0 : button: 0
348 0 : });
349 0 : button.dispatchEvent(event);
350 0 : }
351 :
352 0 : event.preventDefault();
353 0 : return false;
354 0 : }
|