Line data Source code
1 : /*
2 : * Copyright (C) 2016 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 308 : import cockpit from "cockpit";
7 308 : import React from "react";
8 308 : import { createRoot } from "react-dom/client";
9 308 : 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 308 : 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 308 : class DialogFooter extends React.Component {
43 114 : constructor(props) {
44 114 : super(props);
45 114 : this.state = {
46 114 : action_in_progress: false,
47 114 : action_progress_message: '',
48 114 : action_progress_cancel: null,
49 114 : action_canceled: false,
50 114 : action_caption_in_progress: '',
51 114 : };
52 114 : this.update_progress = this.update_progress.bind(this);
53 114 : this.cancel_click = this.cancel_click.bind(this);
54 114 : }
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 30 : cancel_click(e) {
96 30 : this.setState({ action_canceled: true });
97 :
98 30 : 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 29 : if (this.props.dialog_done)
108 29 : this.props.dialog_done(false);
109 29 : if (e)
110 29 : e.stopPropagation();
111 30 : }
112 :
113 114 : 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 114 : let wait_element;
122 114 : 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 114 : return (
159 114 : <>
160 114 : { this.props.extra_element }
161 114 : { action_buttons }
162 114 : <Button variant={cancel_variant} className="cancel" onClick={this.cancel_click} isDisabled={cancel_disabled}>{ cancel_text }</Button>
163 114 : { wait_element }
164 114 : </>
165 : );
166 114 : }
167 308 : }
168 :
169 308 : DialogFooter.propTypes = {
170 308 : cancel_clicked: PropTypes.func,
171 308 : cancel_button: PropTypes.object,
172 308 : actions: PropTypes.array.isRequired,
173 308 : dialog_done: PropTypes.func,
174 308 : set_error: PropTypes.func.isRequired,
175 308 : };
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 308 : class Dialog extends React.Component {
195 114 : 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 114 : 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 114 : if (document.activeElement)
203 114 : document.activeElement.blur();
204 114 : }
205 :
206 106 : componentWillUnmount() {
207 106 : window.sessionStorage.setItem("cockpit_has_modal", false);
208 106 : }
209 :
210 114 : render() {
211 114 : let help = null;
212 114 : let footer = null;
213 114 : if (this.props.helpLink)
214 3 : footer = <a href={this.props.helpLink} target="_blank" rel="noopener noreferrer">{_("Learn more")} <ExternalLinkAltIcon /></a>;
215 :
216 114 : 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 114 : const error = this.props.error || this.props.static_error;
225 7 : const error_alert = error && <Alert variant='danger' isInline title={error} />;
226 :
227 114 : return (
228 112 : <Modal position="top" variant={this.props.variant || "medium"}
229 0 : onEscapePress={() => undefined}
230 114 : id={this.props.id}
231 114 : isOpen>
232 114 : <ModalHeader title={this.props.title}
233 114 : titleIconVariant={this.props.titleIconVariant}
234 114 : help={help}
235 114 : />
236 114 : <ModalBody>
237 114 : <Stack hasGutter>
238 114 : { error_alert }
239 114 : <StackItem>
240 114 : { this.props.body }
241 114 : </StackItem>
242 114 : </Stack>
243 114 : </ModalBody>
244 114 : <ModalFooter>
245 114 : {this.props.footer}
246 114 : </ModalFooter>
247 114 : </Modal>
248 : );
249 114 : }
250 308 : }
251 308 : Dialog.propTypes = {
252 : // TODO: fix following by refactoring the logic showing modal dialog (recently show_modal_dialog())
253 308 : title: PropTypes.string, // is effectively required, but show_modal_dialog() provides initially no props and resets them later.
254 308 : body: PropTypes.element, // is effectively required, see above
255 308 : static_error: PropTypes.string,
256 308 : error: PropTypes.string,
257 308 : footer: PropTypes.element, // is effectively required, see above
258 308 : id: PropTypes.string,
259 308 : };
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 114 : export function show_modal_dialog(props, footerProps) {
270 114 : const dialogName = 'cockpit_modal_dialog';
271 : // don't allow nested dialogs, just close whatever is open
272 114 : const curElement = document.getElementById(dialogName);
273 114 : 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 114 : const rootElement = document.createElement("div");
281 114 : root = createRoot(rootElement);
282 114 : rootElement.id = dialogName;
283 114 : document.body.appendChild(rootElement);
284 :
285 : // register our own on-close callback
286 114 : let origCallback;
287 106 : const closeCallback = function() {
288 106 : if (origCallback)
289 11 : origCallback.apply(this, arguments);
290 106 : root.unmount();
291 106 : rootElement.remove();
292 106 : };
293 :
294 114 : const dialogObj = { };
295 114 : let error = null;
296 114 : dialogObj.props = props;
297 114 : dialogObj.footerProps = null;
298 114 : dialogObj.render = function() {
299 114 : 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 114 : if (rootElement.offsetParent)
305 114 : root.render(<Dialog {...dialogObj.props} error={error} />);
306 114 : };
307 114 : function updateFooterAndRender() {
308 114 : if (dialogObj.props === null || dialogObj.props === undefined)
309 3 : dialogObj.props = { };
310 114 : dialogObj.props.footer = <DialogFooter {...dialogObj.footerProps} />;
311 114 : dialogObj.render();
312 114 : }
313 114 : dialogObj.setFooterProps = function(footerProps) {
314 114 : dialogObj.footerProps = footerProps;
315 114 : if (dialogObj.footerProps.dialog_done != closeCallback) {
316 114 : origCallback = dialogObj.footerProps.dialog_done;
317 114 : dialogObj.footerProps.dialog_done = closeCallback;
318 114 : }
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 114 : updateFooterAndRender();
324 114 : };
325 114 : dialogObj.setProps = function(props) {
326 114 : dialogObj.props = props;
327 114 : updateFooterAndRender();
328 114 : };
329 114 : dialogObj.setFooterProps(footerProps);
330 114 : dialogObj.setProps(props);
331 :
332 : // now actually render
333 114 : dialogObj.render();
334 :
335 114 : return dialogObj;
336 114 : }
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 : }
|