Line data Source code
1 : /*
2 : * Copyright (C) 2016 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 73 : import cockpit from "cockpit";
7 73 : import React from "react";
8 73 : import { createRoot } from "react-dom/client";
9 73 : 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 73 : 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 73 : class DialogFooter extends React.Component {
43 17 : constructor(props) {
44 17 : super(props);
45 17 : this.state = {
46 17 : action_in_progress: false,
47 17 : action_progress_message: '',
48 17 : action_progress_cancel: null,
49 17 : action_canceled: false,
50 17 : action_caption_in_progress: '',
51 17 : };
52 17 : this.update_progress = this.update_progress.bind(this);
53 17 : this.cancel_click = this.cancel_click.bind(this);
54 17 : }
55 :
56 3 : update_progress(msg, cancel) {
57 3 : this.setState({ action_progress_message: msg, action_progress_cancel: cancel });
58 3 : }
59 :
60 16 : action_click(handler, caption, e) {
61 16 : this.setState({
62 16 : action_progress_message: '',
63 16 : action_in_progress: true,
64 16 : action_caption_in_progress: caption,
65 16 : action_canceled: false,
66 16 : });
67 :
68 16 : const p = handler(this.update_progress)
69 15 : .then(() => {
70 15 : this.props.set_error(null);
71 15 : this.setState({ action_in_progress: false });
72 15 : if (this.props.dialog_done)
73 15 : this.props.dialog_done(true);
74 15 : })
75 6 : .catch(error => {
76 0 : if (this.state.action_canceled) {
77 0 : if (this.props.dialog_done)
78 0 : this.props.dialog_done(false);
79 0 : } else {
80 6 : this.props.set_error(error);
81 6 : this.setState({ action_in_progress: false });
82 6 : }
83 : /* Always log global dialog errors for easier debugging */
84 6 : if (error)
85 0 : console.warn(error.message || error.toString());
86 6 : });
87 :
88 16 : if (p.progress)
89 8 : p.progress(this.update_progress);
90 :
91 16 : if (e)
92 16 : e.stopPropagation();
93 16 : }
94 :
95 3 : cancel_click(e) {
96 3 : this.setState({ action_canceled: true });
97 :
98 3 : 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 0 : if (this.state.action_in_progress && this.state.action_progress_cancel) {
103 0 : this.state.action_progress_cancel();
104 0 : return;
105 0 : }
106 :
107 3 : if (this.props.dialog_done)
108 3 : this.props.dialog_done(false);
109 3 : if (e)
110 3 : e.stopPropagation();
111 3 : }
112 :
113 17 : render() {
114 0 : const cancel_text = this.props?.cancel_button?.text ?? _("Cancel");
115 0 : 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 17 : let wait_element;
122 17 : let actions_disabled = false;
123 16 : const cancel_disabled = this.state.action_in_progress && !this.state.action_progress_cancel;
124 16 : if (this.state.action_in_progress) {
125 16 : actions_disabled = true;
126 16 : wait_element = <div className="dialog-wait-ct">
127 16 : <span>{ this.state.action_progress_message }</span>
128 16 : </div>;
129 3 : } else if (this.props.idle_message) {
130 3 : wait_element = <div className="dialog-wait-ct">
131 3 : { this.props.idle_message }
132 3 : </div>;
133 3 : }
134 :
135 17 : const action_buttons = this.props.actions.map(action => {
136 17 : let caption;
137 17 : if ('caption' in action)
138 0 : caption = action.caption;
139 : else
140 0 : caption = _("Ok");
141 :
142 0 : let variant = action.style || "secondary";
143 17 : if (variant == "primary" && action.danger)
144 0 : variant = "danger";
145 :
146 17 : return (<Button
147 17 : key={ caption }
148 17 : className="apply"
149 17 : variant={ variant }
150 16 : isLoading={ this.state.action_in_progress && this.state.action_caption_in_progress == caption }
151 17 : isDanger={ action.danger }
152 17 : onClick={ this.action_click.bind(this, action.clicked, caption) }
153 17 : isDisabled={ actions_disabled || action.disabled }
154 17 : >{ caption }</Button>
155 : );
156 17 : });
157 :
158 17 : return (
159 17 : <>
160 17 : { this.props.extra_element }
161 17 : { action_buttons }
162 17 : <Button variant={cancel_variant} className="cancel" onClick={this.cancel_click} isDisabled={cancel_disabled}>{ cancel_text }</Button>
163 17 : { wait_element }
164 17 : </>
165 : );
166 17 : }
167 73 : }
168 :
169 73 : DialogFooter.propTypes = {
170 73 : cancel_clicked: PropTypes.func,
171 73 : cancel_button: PropTypes.object,
172 73 : actions: PropTypes.array.isRequired,
173 73 : dialog_done: PropTypes.func,
174 73 : set_error: PropTypes.func.isRequired,
175 73 : };
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 73 : class Dialog extends React.Component {
195 17 : 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 17 : 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 17 : if (document.activeElement)
203 17 : document.activeElement.blur();
204 17 : }
205 :
206 15 : componentWillUnmount() {
207 15 : window.sessionStorage.setItem("cockpit_has_modal", false);
208 15 : }
209 :
210 17 : render() {
211 17 : let help = null;
212 17 : let footer = null;
213 17 : if (this.props.helpLink)
214 0 : footer = <a href={this.props.helpLink} target="_blank" rel="noopener noreferrer">{_("Learn more")} <ExternalLinkAltIcon /></a>;
215 :
216 17 : if (this.props.helpMessage)
217 0 : help = <Popover
218 0 : bodyContent={this.props.helpMessage}
219 0 : footerContent={footer}
220 : >
221 0 : <Button icon={<HelpIcon />} variant="plain" aria-label={_("Learn more")} />
222 0 : </Popover>;
223 :
224 17 : const error = this.props.error || this.props.static_error;
225 3 : const error_alert = error && <Alert variant='danger' isInline title={error} />;
226 :
227 17 : return (
228 15 : <Modal position="top" variant={this.props.variant || "medium"}
229 0 : onEscapePress={() => undefined}
230 17 : id={this.props.id}
231 17 : isOpen>
232 17 : <ModalHeader title={this.props.title}
233 17 : titleIconVariant={this.props.titleIconVariant}
234 17 : help={help}
235 17 : />
236 17 : <ModalBody>
237 17 : <Stack hasGutter>
238 17 : { error_alert }
239 17 : <StackItem>
240 17 : { this.props.body }
241 17 : </StackItem>
242 17 : </Stack>
243 17 : </ModalBody>
244 17 : <ModalFooter>
245 17 : {this.props.footer}
246 17 : </ModalFooter>
247 17 : </Modal>
248 : );
249 17 : }
250 73 : }
251 73 : Dialog.propTypes = {
252 : // TODO: fix following by refactoring the logic showing modal dialog (recently show_modal_dialog())
253 73 : title: PropTypes.string, // is effectively required, but show_modal_dialog() provides initially no props and resets them later.
254 73 : body: PropTypes.element, // is effectively required, see above
255 73 : static_error: PropTypes.string,
256 73 : error: PropTypes.string,
257 73 : footer: PropTypes.element, // is effectively required, see above
258 73 : id: PropTypes.string,
259 73 : };
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 17 : export function show_modal_dialog(props, footerProps) {
270 17 : const dialogName = 'cockpit_modal_dialog';
271 : // don't allow nested dialogs, just close whatever is open
272 17 : const curElement = document.getElementById(dialogName);
273 17 : let root;
274 0 : if (curElement) {
275 0 : root = createRoot(curElement);
276 0 : root.unmount();
277 0 : curElement.remove();
278 0 : }
279 : // create an element to render into
280 17 : const rootElement = document.createElement("div");
281 17 : root = createRoot(rootElement);
282 17 : rootElement.id = dialogName;
283 17 : document.body.appendChild(rootElement);
284 :
285 : // register our own on-close callback
286 17 : let origCallback;
287 15 : const closeCallback = function() {
288 15 : if (origCallback)
289 4 : origCallback.apply(this, arguments);
290 15 : root.unmount();
291 15 : rootElement.remove();
292 15 : };
293 :
294 17 : const dialogObj = { };
295 17 : let error = null;
296 17 : dialogObj.props = props;
297 17 : dialogObj.footerProps = null;
298 17 : dialogObj.render = function() {
299 17 : 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 17 : if (rootElement.offsetParent)
305 17 : root.render(<Dialog {...dialogObj.props} error={error} />);
306 17 : };
307 17 : function updateFooterAndRender() {
308 17 : if (dialogObj.props === null || dialogObj.props === undefined)
309 0 : dialogObj.props = { };
310 17 : dialogObj.props.footer = <DialogFooter {...dialogObj.footerProps} />;
311 17 : dialogObj.render();
312 17 : }
313 17 : dialogObj.setFooterProps = function(footerProps) {
314 17 : dialogObj.footerProps = footerProps;
315 17 : if (dialogObj.footerProps.dialog_done != closeCallback) {
316 17 : origCallback = dialogObj.footerProps.dialog_done;
317 17 : dialogObj.footerProps.dialog_done = closeCallback;
318 17 : }
319 16 : dialogObj.footerProps.set_error = e => {
320 0 : error = typeof e === 'object' && e !== null ? (e.message || e.toString()) : e;
321 16 : dialogObj.render();
322 16 : };
323 17 : updateFooterAndRender();
324 17 : };
325 17 : dialogObj.setProps = function(props) {
326 17 : dialogObj.props = props;
327 17 : updateFooterAndRender();
328 17 : };
329 17 : dialogObj.setFooterProps(footerProps);
330 17 : dialogObj.setProps(props);
331 :
332 : // now actually render
333 17 : dialogObj.render();
334 :
335 17 : return dialogObj;
336 17 : }
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 : }
|