LCOV - code coverage report
Current view: top level - pkg/lib - cockpit-components-dialog.jsx Coverage Total Hit
Test: cockpit Lines: 81.1 % 238 193
Test Date: 2026-06-16 14:09:37

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2016 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6           72 : import cockpit from "cockpit";
       7           72 : import React from "react";
       8           72 : import { createRoot } from "react-dom/client";
       9           72 : 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           72 : 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           72 : 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           72 : }
     168              : 
     169           72 : DialogFooter.propTypes = {
     170           72 :     cancel_clicked: PropTypes.func,
     171           72 :     cancel_button: PropTypes.object,
     172           72 :     actions: PropTypes.array.isRequired,
     173           72 :     dialog_done: PropTypes.func,
     174           72 :     set_error: PropTypes.func.isRequired,
     175           72 : };
     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           72 : 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           72 : }
     251           72 : Dialog.propTypes = {
     252              :     // TODO: fix following by refactoring the logic showing modal dialog (recently show_modal_dialog())
     253           72 :     title: PropTypes.string, // is effectively required, but show_modal_dialog() provides initially no props and resets them later.
     254           72 :     body: PropTypes.element, // is effectively required, see above
     255           72 :     static_error: PropTypes.string,
     256           72 :     error: PropTypes.string,
     257           72 :     footer: PropTypes.element, // is effectively required, see above
     258           72 :     id: PropTypes.string,
     259           72 : };
     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 : }
        

Generated by: LCOV version 2.0-1