LCOV - code coverage report
Current view: top level - pkg/lib - cockpit-components-dialog.jsx Coverage Total Hit
Test: cockpit Lines: 92.9 % 238 221
Test Date: 2026-06-25 09:20:42

            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 : }
        

Generated by: LCOV version 2.0-1