LCOV - code coverage report
Current view: top level - pkg/lib/cockpit - dialog.tsx Coverage Total Hit
Test: cockpit Lines: 94.2 % 726 684
Test Date: 2026-06-01 17:00:21

            Line data    Source code
       1           36 : /*
       2              :  * Copyright (C) 2025 Red Hat, Inc.
       3              :  *
       4              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       5              :  */
       6              : 
       7              : /** Dialog Implementation Convenience Kit **/
       8              : 
       9              : /* TODOs:
      10              : 
      11              :    - progress reporting
      12              :    - array splicing via value handles
      13              :    - different validation rules for different actions
      14              :  */
      15              : 
      16              : /* This is a framework for conveniently implementing dialogs. It is
      17              :    meant to save us from having to think through all the details every
      18              :    time of how a dialog should work exactly and to allow us to just
      19              :    get on with the business logic.
      20              : 
      21              :    The framework has two parts (like git): plumbing and porcelain.
      22              : 
      23              :    The plumbing takes care of the state management of dialog values,
      24              :    asynchronous and debounced input validation, progress feedback and
      25              :    errors from actions, etc. It's basically a couple of rather
      26              :    complicated JavaScript classes that work in the background.  This
      27              :    is the part we don't want to implement from scratch for every
      28              :    dialog.
      29              : 
      30              :    The porcelain is a set of React components that integrates with the
      31              :    plumbing. They are usually very straightforward and easy to
      32              :    write. If none of the existing ones works for you, just write a new
      33              :    one that does. But they are boilerplatey, so having common ones for
      34              :    common things like text input fields makes a lot of sense, too.
      35              : 
      36              :    Here is an example of a simple dialog that prompts for some text
      37              :    and writes it to the journal:
      38              : 
      39              :     interface LoggerValues {
      40              :         text: string;
      41              :     }
      42              : 
      43              :     const LoggerDialog = () => {
      44              :         const Dialogs = useDialogs();
      45              : 
      46              :         function validate() {
      47              :             dlg.value("text").validate(v => {
      48              :                 if (!v)
      49              :                     return "Text can not be empty";
      50              :             });
      51              :         }
      52              : 
      53              :         async function apply(values: LoggerValues) {
      54              :             await cockpit.spawn(["logger", values.text]);
      55              :         }
      56              : 
      57              :         const dlg = useDialogState({ text: "" }, validate);
      58              :         const text_field = dlg.field("text");
      59              : 
      60              :         return (
      61              :             <Modal position="top" variant="medium" isOpen onClose={Dialogs.close}>
      62              :                 <ModalHeader title="Logger" />
      63              :                 <ModalBody>
      64              :                     <DialogErrorMessage dialog={dlg} />
      65              :                     <Form>
      66              :                         <FormGroup label="Log message">
      67              :                             <TextInput
      68              :                                 value={text_field.get()}
      69              :                                 onChange={(_event, val) => text_field.set(val)}
      70              :                             />
      71              :                             <DialogHelperText field={text_field} />
      72              :                         </FormGroup>
      73              :                     </Form>
      74              :                 </ModalBody>
      75              :                 <ModalFooter>
      76              :                     <DialogActionButton dialog={dlg} action={apply} onClose={Dialogs.close}>
      77              :                         Log
      78              :                     </DialogActionButton>
      79              :                     <DialogCancelButton dialog={dlg} onClose={Dialogs.close}/>
      80              :                 </ModalFooter>
      81              :             </Modal>
      82              :         );
      83              :     };
      84              : 
      85              :     const LoggerButton = () => {
      86              :         const Dialogs = useDialogs();
      87              : 
      88              :         return (
      89              :             <Button onClick={() => Dialogs.show(<LoggerDialog />)}>Open logger dialog</Button>
      90              :         );
      91              :     };
      92              : 
      93              :    This uses some porcelain for the error message and footer buttons,
      94              :    but none for the text input field.  There is a "DialogTextInput"
      95              :    porcelain component that could have been used to make this example
      96              :    even more concise. But we didn't use it here just to show how input
      97              :    form elements hook into the plumbing.
      98              : 
      99              :    PLUMBING API
     100              : 
     101              :    The central piece of the plumbing API is the useDialogState hook
     102              :    (and it's async variant useDialogState_async).  You can think of it
     103              :    as "useState" on steroids.
     104              : 
     105              :    - Like useState, useDialogState gives you a place to store state in
     106              :      a function component, and gives you a way to change that state
     107              :      and trigger a render so that the new state is put on the screen.
     108              : 
     109              :    - Unlike useState, you are only supposed to have a single
     110              :      useDialogState and put all of the state in a single JavaScript
     111              :      object.  And instead of a single setter function, there are ways
     112              :      to get setters to individual parts of that object via "handles".
     113              : 
     114              :    - The handles work also for nested objects and arrays. You can get
     115              :      a "sub handles" for a single key from a handle for an object, for
     116              :      example.
     117              : 
     118              :    - The useDialogState hook also provides for global validation of
     119              :      the state object, at exactly the right times, and communicates
     120              :      the result of that to the "Apply" button, for example.
     121              : 
     122              :    - The handles to parts of the state give you access to everything
     123              :      needed to implement a part of the dialog form: The current value,
     124              :      a method to change the value, and any validation errors that
     125              :      should be shown.  This makes it possible to write encapsulated
     126              :      components that can be cleanly combined into full dialogs.
     127              : 
     128              :    - The useDialogState hook also provides for miscellaneous things
     129              :      like transporting the exception from running the "Apply" action
     130              :      to the error message in the dialog.
     131              : 
     132              :    Here is a speedrun of the plumbing API:
     133              : 
     134              :    - dlg = useDialogState(init, validate)
     135              : 
     136              :    This is a React hook that creates a new instance of the plumbing
     137              :    machinery.  It also causes the current component to re-render
     138              :    appropriately.
     139              : 
     140              :    The values of the input fields of a dialog are stored in a
     141              :    JavaScript object, and that object is initialized to "init", or the
     142              :    result of calling "init" if it is a function.  The actual values of
     143              :    a dialog can be anything, strings, number, other objects, arrays,
     144              :    etc, to an arbitrary depth.
     145              : 
     146              :    The "validate" parameter is a function that performs input
     147              :    validation. It has to follow a very specific code pattern, which is
     148              :    explained below.
     149              : 
     150              :    There is a variant of useDialogState for asynchronous
     151              :    initialization, called useDialogState_async. It takes a async init
     152              :    function and returns null until that function has resolved.  You
     153              :    should render a spinner in the dialog while "dlg" is null.
     154              : 
     155              :    If the asynchronous "init" function throws an exception, "dlg" is
     156              :    set to a DialogError object. In that case you should render the
     157              :    error in the dialog.
     158              : 
     159              :    The porcelain components that do not work on actual dialog values,
     160              :    such as DialogActionButton and DialogErrorMessage, can deal with
     161              :    their "dialog" properties being null or a DialogError, and will do
     162              :    the right thing.
     163              : 
     164              :    The return value of useDialogState, "dlg", has a number of fields
     165              :    and methods.
     166              : 
     167              :    - handle = dlg.field(name)
     168              :    - handle = dlg.field(name, update_func)
     169              : 
     170              :    This returns a handle for a specific field of the dialog
     171              :    values. Using handles like this becomes convenient when there are
     172              :    nested values, and when writing reusable porcelain components. They
     173              :    also work well with TypeScript. For simple dialogs they might feel
     174              :    a bit clunky.
     175              : 
     176              :    The second argument, "update_func", is optional. If given, it
     177              :    should be a function and that function will be called whenever the
     178              :    dialog value is changed via the returned handle (and the returned
     179              :    handle only).
     180              : 
     181              :    - handle = dlg.top()
     182              :    - handle = dlg.top(update_func)
     183              : 
     184              :    Get a handle for the whole value object.  The usual
     185              :    "dlg.field(name)" call is actually just a shortcut for
     186              :    "dlg.top().sub(name)".  But since that looks quite obscure in
     187              :    simple dialogs that only have one level of values, we have the
     188              :    "dlg.field(name)" shortcut as well.  This whole-value handle is
     189              :    useful for "dlg.top().at(...)", see below, or for update
     190              :    notifications that trigger for each and every change.
     191              : 
     192              :    - dlg.values
     193              : 
     194              :    The current whole dialog value object. This is the same as
     195              :    "dlg.top().get()", but accessing it is common enough in simple
     196              :    dialogs that exposing it directly makes sense.
     197              : 
     198              :    The object will never be mutated by the plumbing itself. When it
     199              :    needs to be changed, a whole new value object is constructed and
     200              :    assigned to "dlg.values".
     201              : 
     202              :    - handle.get()
     203              : 
     204              :    Get the current value of a value handle.
     205              : 
     206              :    - handle.validation_text()
     207              : 
     208              :    Get the current validation error message for this value. This is
     209              :    "undefined" when there is no message.
     210              : 
     211              :    - handle.set(val)
     212              : 
     213              :    Set the current value of a value handle. This will re-render the
     214              :    dialog, and do input validation as necessary and all the other
     215              :    things that you don't need to think about.
     216              : 
     217              :    - handle.sub(name_or_index)
     218              :    - handle.sub(name_or_index, update_func)
     219              : 
     220              :    Get a handle for a nested value. When the current value is an
     221              :    object, you should pass the name of a nested field. If it is an
     222              :    array, pass the index of the desired element.  See "dlg.field()"
     223              :    above for more information about handles.
     224              : 
     225              :    - handle.at(witness)
     226              : 
     227              :    Get a handle with a narrowed type for "handle".  The new handle
     228              :    works like "handle" and modifies the same place in the dialog value
     229              :    object, but it's type will be the type of "witness".  This is
     230              :    useful to carry over type inference into value handles.  The
     231              :    general pattern is:
     232              : 
     233              :      const val = handle.get();
     234              :      if (some_type_narrowing_condition(val)) {
     235              :        const narrowed_handle = handle.at(val);
     236              : 
     237              :        ...
     238              :      }
     239              : 
     240              :    - handle.add(val)
     241              : 
     242              :    If the current value is an array, append "val" at the end.
     243              : 
     244              :    - handle.remove(index)
     245              : 
     246              :    If the current value is an array, remove the element at "index".
     247              :    It is important to use this function instead of just "handle.set()"
     248              :    with an appropriately modified array. By using this function, the
     249              :    plumbing is able to keep its internal state in synch, which is
     250              :    especially important for asynchronous validation functions.
     251              : 
     252              :    However, it is okay to just replace an array with a different
     253              :    array, so you are not strictly required to use this function. But
     254              :    doing so might look to the validation machinery as if each and
     255              :    every element of the array has just changed, and it will do a lot
     256              :    of needless validations all over again.
     257              : 
     258              :    - handle.map(func)
     259              : 
     260              :    If the current value is an array, map "func" over handles for its
     261              :    elements. This is nice for creating React components for arrays.
     262              : 
     263              :    - handle.forEach(func)
     264              : 
     265              :    If the current value is an array, call "func" with handles for each
     266              :    of its elements, in order. This is nice for "validate" functions.
     267              : 
     268              :    Now back to the fields and methods of the dialog state.
     269              : 
     270              :    - dlg.busy
     271              :    - dlg.actions_disabled
     272              :    - dlg.cancel_disabled
     273              : 
     274              :    Boolean flags that indicate which parts of the dialog should be
     275              :    disabled. The porcelain should of course look at these and do the
     276              :    right thing.
     277              : 
     278              :    - dlg.error
     279              : 
     280              :    The most recent error thrown by an action function.  This can be
     281              :    any kind of JavaScript value, but the idea is that it is something
     282              :    with a "message" field, or a DialogError instance.  The
     283              :    DialogErrorMessage porcelain component will do the right thing with
     284              :    these kind of error values.
     285              : 
     286              :    - dlg.run_action(func)
     287              : 
     288              :    Performs input validation (if necessary) and if that was
     289              :    successful, calls "func" and puts the dialog into a "busy" state
     290              :    while it runs. When "func" throws an error, it is caught and stored
     291              :    in "dlg.error".
     292              : 
     293              :    "dlg.run_action" returns true when validation has passed and "func"
     294              :    has completed without throwing an error.
     295              : 
     296              :    All state changes via "field.set()" are denied while
     297              :    "dlg.run_action" is running. This is done to prevent the user from
     298              :    interacting with the dialog while an action runs. But there is
     299              :    nothing fundamentally wrong with programmatically changing dialog
     300              :    state as part of an action. If you want to do that, write code like
     301              : 
     302              :      if (dlg.run_action(...))
     303              :        dlg.field("xxx").set(...)
     304              : 
     305              :    - dlg.set_cancel(func)
     306              : 
     307              :    Arranges for "func" to be called when the cancel button is clicked.
     308              :    You should call this only from a action function passed to
     309              :    "run_action" and you need to take care to reset this via
     310              :    "dlg.set_cancel(null)" once the cancel function should no longer be
     311              :    called.  When a action funtion finishes or throws an error from
     312              :    within "dlg.run_action", the cancel function is automatically
     313              :    reset.
     314              : 
     315              :    Let's now finally talk about input validation.
     316              : 
     317              :    Input validation is done by a single, central function for the
     318              :    whole dialog.  This has been done so that there is a central place
     319              :    that establishes the "shape" of the dialog values. This is
     320              :    important for dialogs that have expander areas or other optional
     321              :    things.
     322              : 
     323              :    If such an optional part of the values has failed validation
     324              :    earlier, but has subsequently been removed from the dialog by the
     325              :    user, the plumbing needs to know that it should now ignore this
     326              :    failed validation. But the code that knows what is currently in the
     327              :    dialog is the render function that instantiates all the field input
     328              :    components (like TextInput). This hacker here has found no reliable
     329              :    and non-magical way to connect what the render function actually
     330              :    does with the plumbing machinery. So everyone has to write a big
     331              :    validate function now that duplicates this, sorry!
     332              : 
     333              :    The formal job of the validation function is to call the "validate"
     334              :    method (or "validate_async") of all relevant dialog value handles.
     335              :    If and only if the render function instantiates a component for a
     336              :    dialog value, should the validate function visit it.
     337              : 
     338              :    - handle.validate(v => ...)
     339              : 
     340              :    This might call the given function with the current value of the
     341              :    handle. If it passes validation, the function should return
     342              :    "undefined". If it fails, the function should return a string with
     343              :    the appropriate message. This message will be available from the
     344              :    "handle.validation_text" method and should be shown by the React
     345              :    component for this value, of course.
     346              : 
     347              :    The "v => ..." function is only called when necessary, when the
     348              :    value has actually changed.
     349              : 
     350              :    The "v => ..." function should not make any modifications to
     351              :    anything involved in the dialog. Specifically, it should not call
     352              :    "set()" on any value handle.
     353              : 
     354              :    A validation function can also return an object with validation
     355              :    errors for its sub-fields.  This is useful if multiple fields need
     356              :    to be validated together.  Consider this example:
     357              : 
     358              :        field.validate(v => {
     359              :          if (v.mode != "auto" && v.size == 0)
     360              :            return { "size": "Can't be zero in manual mode." }
     361              :        });
     362              : 
     363              :    If your validation function needs to communicate out-of-band with
     364              :    your action function (maybe to pass the results of some expensive
     365              :    operations that you don't want to repeat in your action function),
     366              :    then you need to find some other way. Maybe with a memoized
     367              :    function or an explicit cache.
     368              : 
     369              :    - handle.validate_async(debounce, async v >= ...)
     370              : 
     371              :    Calls the given async function "debounce" milliseconds after the
     372              :    value represented by the handle has last been changed. (Or
     373              :    immediately when the apply button is clicked.)  When the function
     374              :    throws an exception, the validation is considered to have been
     375              :    successful.
     376              : 
     377              :    See the documentation for "handle.validate" above for more rules
     378              :    that apply to validation functions.
     379              : 
     380              :    TESTING
     381              : 
     382              :    Our automated tests will want to drive the dialogs created by this
     383              :    framework, of course.  To support this, the various DOM elements
     384              :    instantiated for a dialog should be decorated with structured "id"
     385              :    attributes. The code that instantiates an element can get suitable
     386              :    IDs for dialog value handles with the following function:
     387              : 
     388              :    - handle.id(tag)
     389              : 
     390              :    This will return a unique and predictable string for the value
     391              :    handle that will also include "tag". This is suitable for the "id"
     392              :    attribute of DOM elements associated with "value".  The "tag"
     393              :    parameter can be used to generate multiple IDs if a component has
     394              :    multiple interesting DOM elements.  The "tag" parameter defaults to
     395              :    "field", see below.
     396              : 
     397              :    There is a support library for use by the tests that can generate
     398              :    the same IDs, and there are also some guidelines for how to use
     399              :    these IDs:
     400              : 
     401              :    - The main input element (text input, form select, ...) should use
     402              :      the "field" tag.
     403              : 
     404              :    - The helper text should use the "helper-text" tag.
     405              : 
     406              :    - A set of radio buttons should use a different tag for each
     407              :      button. Whatever makes sense in the specific case.
     408              : 
     409              :    - ...
     410              : 
     411              :    PORCELAIN GALLERY
     412              : 
     413              :    Here are some noteworthy React components that integrate with the
     414              :    plumbing API.
     415              : 
     416              :    - <DialogErrorMessage dialog={dlg} />
     417              : 
     418              :    This creates an appropriate Alert for "dlg.error", if it is set. It
     419              :    works well with instances of DialogError, and all usual errors
     420              :    thrown by the Cockpit API.
     421              : 
     422              :    In addition to a proper DialogState, the "dialog" property can be
     423              :    anything returned by "use_DialogState_async".
     424              : 
     425              :    If given one the of Cockpit API errors, the title of the Alert will
     426              :    be a generic "Failed" text. If you want more control, use a
     427              :    DialogError.
     428              : 
     429              :    A DialogError contains a title and details, and the details can
     430              :    come from another error.  For example:
     431              : 
     432              :        try {
     433              :            await cockpit.spawn(["/bin/frob", "--bars"])
     434              :        } catch (ex) {
     435              :            throw DialogError.fromError("Failed to frob the bars", ex);
     436              :        }
     437              : 
     438              :    You can also construct a DialogError directly from title and
     439              :    details:
     440              : 
     441              :        throw new DialogError("Failed to frob", <pre>...</pre>);
     442              : 
     443              :    In that case, the details can be any React node.
     444              : 
     445              :    - <DialogActionButton dialog={dlg} action={func} onClose={close_func}>
     446              : 
     447              :    This will produce a action button for a dialog that correctly disables
     448              :    itself according to the state of "dlg".
     449              : 
     450              :    In addition to a proper DialogState, the "dialog" property can be
     451              :    anything returned by "use_DialogState_async".
     452              : 
     453              :    When clicked, "func" will be run via "dlg.run_action". If "func"
     454              :    completes successfully, "close_func" is called to close the dialog.
     455              : 
     456              :    - <DialogCancelButton dialog={dlg} onClose={close_func} />
     457              : 
     458              :    This will produce a cancel button for a dialog that correctly
     459              :    disables itself according to the state of "dlg".
     460              : 
     461              :    In addition to a proper DialogState, the "dialog" property can be
     462              :    anything returned by "use_DialogState_async".
     463              : 
     464              :    Clicking it will either just close the dialog by calling
     465              :    "close_func", or run the cancel function provided by the currently
     466              :    running action function (if there is any).
     467              : 
     468              :    - <DialogTextInput label="Name" field={dlg.field("name")} ... />
     469              : 
     470              :    This will produce a TextInput in a (optional) FormGroup that will
     471              :    manage the given value handle.  The "label" property is optional
     472              :    and omitting it will also omit the FormGroup.
     473              : 
     474              :   - <DialogCheckbox label= field= .../>
     475              : 
     476              :   For a single checkbox that drives a boolean.
     477              : 
     478              :   - <DialogRadioSelect label= field= options= .../>
     479              : 
     480              :   For a group of radio buttons.  The options can be disabled and have
     481              :   explanations.
     482              : 
     483              :   - <DialogDropdownSelect label= field= options= .../> </>
     484              : 
     485              :   For a simple dropdown select. Options can not be disabled or have
     486              :   explanations.
     487              : 
     488              :   - <DialogDropdownSelectObject label= field= options= option_label= />
     489              : 
     490              :   A variant of the simple dropdown select from above where the options
     491              :   can be of any type whatsoever, such as something directly from your
     492              :   data model. A simple case is selecting from an array of strings. In
     493              :   that case you can omit the "option_label" function.
     494              : 
     495              :   WRITING COMPLEX PORCELAIN COMPONENTS
     496              : 
     497              :   Here is a pattern that you might want to follow when writing
     498              :   complicated components. Even if they are not meant to be reused
     499              :   much, it pays of to try to encapsulate their behavior.
     500              : 
     501              :   Let's write a component for two level selection.  Parameter is
     502              :   something like
     503              : 
     504              :      {
     505              :        "Fruit": [ "Apple", "Banana" ],
     506              :        "Bread": [ "Toast", "Rye" ],
     507              :        "Meat": [ "Chicken", "Pork" ],
     508              :      }
     509              : 
     510              :   and there will be two dropdowns in the dialog, one for selecting
     511              :   between "Fruit", "Bread", and "Meat"; and one for selecting "Apple"
     512              :   or "Banana" when the first is "Fruit", etc.
     513              : 
     514              :   First, declare the type of the value that the component works with.
     515              :   It should store everything needed by the component, to simplify
     516              :   initialization and validation.
     517              : 
     518              :     export interface TwoLevelSelectValue {
     519              :       first: string;
     520              :       second: string;
     521              : 
     522              :       _firsts: string[],
     523              :       _options: Record<string, string[]>,
     524              :     }
     525              : 
     526              :   Write a "init" function to create such a value:
     527              : 
     528              :     export function init_TwoLevelSelect(options: Record<string, string[]>): TwoLevelValue {
     529              :       const _firsts = Object.keys(options);
     530              :       const _seconds = options[_firsts[0]];
     531              : 
     532              :       return {
     533              :         first: _firsts[0],
     534              :         second: _seconds[0],
     535              : 
     536              :         _firsts,
     537              :         _seconds,
     538              :         _options: options,
     539              :       };
     540              :     }
     541              : 
     542              :   And the component itself:
     543              : 
     544              :     export const TwoLevelSelect = ({ field } : { field: DialogField<TwoLevelSelectValue> }) => {
     545              :       const { _firsts, _seconds, _options } = field.get();
     546              : 
     547              :       function update_first(f: string) {
     548              :         const _seconds = _options[f];
     549              :         value.sub("second").set(_seconds[0]);
     550              :         value.sub("_seconds").set(_seconds);
     551              :       }
     552              : 
     553              :       return (
     554              :         <>
     555              :           <DialogDropdownSelectObject
     556              :             label="First"
     557              :             field={field.sub("first", update_first)}
     558              :             options={_firsts}
     559              :           />
     560              :           <DialogDropdownSelectObject
     561              :             label="Second"
     562              :             field={field.sub("second")}
     563              :             options={_seconds}
     564              :           />
     565              :         </>
     566              :       );
     567              :     }
     568              : 
     569              :   It would be used in a dialog like this:
     570              : 
     571              :     interface DialogValues {
     572              :       food: TwoLevelSelectValue;
     573              :     }
     574              : 
     575              :     function init() {
     576              :       return {
     577              :         food: init_TwoLevelSelect({ "Fruit": [ "Apple", "Banana" ], "Bread": [ "Toast", "Rye" ], "Meat": [ "Chicken", "Pork" ] }),
     578              :       }
     579              :     }
     580              : 
     581              :     const dlg = useDialogState(init);
     582              : 
     583              :     return (
     584              :       ...
     585              :       <TwoLevelSelect field={dlg.field("food")} />
     586              :       ...
     587              :     );
     588              : 
     589              :   Here is a pattern for handling types that include alternatives, such
     590              :   as "TwoLevelSelectValue | string".  This could be used to encode
     591              :   either the state for a working TwoLevelSelect component, or an
     592              :   excuse message that explains why it can't work.
     593              : 
     594              :     function init_TwoLevelSelect(options: Record<string, string[]>): TwoLevelSelectValue | string {
     595              :       if (Object.keys(options).length == 0)
     596              :         return _("Nothing to select.");
     597              : 
     598              :       return { ... };
     599              :     }
     600              : 
     601              :     export const TwoLevel = ({ field } : { field: DialogField<TwoLevelValue | string> }) => {
     602              :       const val = field.get();
     603              :       if (typeof val == "string")
     604              :           return null;
     605              : 
     606              :       const tls_field = field.at(val);
     607              : 
     608              :       const { _firsts, _seconds, _options } = tls_field.get();
     609              :       ...
     610              :     }
     611              : 
     612              :   Note the use of the "field.at()" function to get a handle for a
     613              :   TwoLevelSelectValue that can be used to access the "first" sub
     614              :   value, etc.
     615              :  */
     616              : 
     617           36 : import React, { useState } from "react";
     618              : import { useObject, useInit, useOn } from 'hooks';
     619              : import { EventEmitter } from 'cockpit/event';
     620              : 
     621           36 : import cockpit from "cockpit";
     622              : 
     623              : import { Button, type ButtonProps } from "@patternfly/react-core/dist/esm/components/Button/index.js";
     624              : import { FormGroup, type FormGroupProps, FormHelperText } from "@patternfly/react-core/dist/esm/components/Form";
     625              : import { TextInput, type TextInputProps } from "@patternfly/react-core/dist/esm/components/TextInput";
     626              : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
     627              : import {
     628              :     HelperText, HelperTextItem, type HelperTextItemProps
     629              : } from "@patternfly/react-core/dist/esm/components/HelperText";
     630              : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
     631              : import {
     632              :     FormSelect, FormSelectOption, type FormSelectProps,
     633              : } from "@patternfly/react-core/dist/esm/components/FormSelect";
     634              : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
     635              : import { InputGroup, InputGroupItem } from "@patternfly/react-core/dist/esm/components/InputGroup/index.js";
     636              : import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons";
     637              : 
     638           36 : const _ = cockpit.gettext;
     639              : 
     640            1 : function debug(...args: unknown[]) {
     641            1 :     if (window.debugging == "all" || window.debugging?.includes("dialog"))
     642            1 :         console.debug("dialog:", ...args);
     643            1 : }
     644              : 
     645              : type ArrayElement<ArrayType> =
     646              :   ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
     647              : 
     648            1 : function toSpliced<T>(arr: T[], start: number, deleteCount: number, ...rest: T[]): T[] {
     649            1 :     const copy = [...arr];
     650            1 :     copy.splice(start, deleteCount, ...rest);
     651            1 :     return copy;
     652            1 : }
     653              : 
     654              : /* It would be nice to make it so that TypeScript complains when you
     655              :    try to return errors for sub-fields that don't exist.  We can
     656              :    construct a type that describes this (by recursively mapping all
     657              :    field to strings, essentially), but TypeScript will not ordinarily
     658              :    prevent a validation function from returning something that also
     659              :    has other fields, because it does only do "excessive property
     660              :    checks" for literals that are assigned to locations with a known
     661              :    type.  Example:
     662              : 
     663              :       interface ComboValue {
     664              :         size: number;
     665              :         unit: string;
     666              :       }
     667              : 
     668              :       dlg.field("combo").validate(v => {
     669              :         return {
     670              :           size: true,           // TypeScript error here as expected, because true is not a string
     671              :           unt: "No such unit",  // Want a TypeScript error here bc there is no "unt" in the type, but wont happen
     672              :         }
     673              :       });
     674              : 
     675              :    We would need to explicitly annotate the validation function with
     676              :    the right return type like so:
     677              : 
     678              :       dlg.field("combo").validate((v): DialogValidationResult<ComboValue> => {
     679              :         return {
     680              :           unt: "No such unit",  // Now we get an error.
     681              :         }
     682              :       });
     683              : 
     684              :    But I doubt that people will be happy to write out their type like
     685              :    this every time...
     686              :  */
     687              : 
     688              : export type DialogValidationResult<T> = (
     689              :    T extends object
     690              :     ? ({ [Property in keyof T]+?: DialogValidationResult<T[Property]> } & { ""?: undefined | string }) | undefined | string
     691              :     : undefined | string | { ""?: undefined | string }
     692              : );
     693              : 
     694           36 : export class DialogField<T> {
     695              :     /* eslint-disable no-use-before-define */
     696            1 :     #dialog: DialogState<unknown>;
     697              :     /* eslint-enable */
     698            1 :     #getter: () => T;
     699            1 :     #setter: (val: T) => void;
     700            1 :     #path: string;
     701              : 
     702            1 :     constructor(
     703            1 :         dialog: DialogState<unknown>,
     704            1 :         getter: () => T,
     705            1 :         setter: (val: T) => void,
     706            1 :         path: string
     707            1 :     ) {
     708            1 :         this.#dialog = dialog;
     709            1 :         this.#getter = getter;
     710            1 :         this.#setter = setter;
     711            1 :         this.#path = path;
     712            1 :     }
     713              : 
     714            1 :     validation_text(): string | undefined {
     715            1 :         return this.#dialog._get_validation(this.#path);
     716            1 :     }
     717              : 
     718            1 :     get(): T {
     719            1 :         return this.#getter();
     720            1 :     }
     721              : 
     722            1 :     set(val: T): void {
     723            1 :         this.#setter(val);
     724            1 :     }
     725              : 
     726            1 :     id(tag: string = "field"): string {
     727            1 :         return "dialog-" + tag + "-" + this.#path;
     728            1 :     }
     729              : 
     730            1 :     map<X>(func: (val: DialogField<ArrayElement<T>>, index: number) => X): X[] {
     731            1 :         const val = this.get();
     732            1 :         if (Array.isArray(val)) {
     733            1 :             return val.map((_, i) => func(this.sub(i as keyof T) as DialogField<ArrayElement<T>>, i));
     734            1 :         } else
     735            1 :             return [];
     736            1 :     }
     737              : 
     738            1 :     forEach(func: (val: DialogField<ArrayElement<T>>, index: number) => void): void {
     739            1 :         const val = this.get();
     740            1 :         if (Array.isArray(val)) {
     741            1 :             val.forEach((_, i) => func(this.sub(i as keyof T) as DialogField<ArrayElement<T>>, i));
     742            1 :         }
     743            1 :     }
     744              : 
     745            1 :     remove(index: number) {
     746            1 :         const val = this.get();
     747            1 :         if (Array.isArray(val)) {
     748            1 :             for (let j = index; j < val.length - 1; j++)
     749            1 :                 this.#dialog._rename_validation_state(this.#path, j + 1, j);
     750            1 :             this.set(toSpliced(val, index, 1) as T);
     751            1 :         }
     752            1 :     }
     753              : 
     754            1 :     add(item: ArrayElement<T>) {
     755            1 :         const val = this.get();
     756            1 :         if (Array.isArray(val)) {
     757            1 :             this.set(val.concat(item) as T);
     758            1 :         }
     759            1 :     }
     760              : 
     761            1 :     sub<K extends keyof T>(tag: K, update_func?: ((val: T[K]) => void) | undefined): DialogField<T[K]> {
     762            1 :         return new DialogField<T[K]>(
     763            1 :             this.#dialog,
     764            1 :             () => this.get()[tag],
     765            1 :             (val) => {
     766            1 :                 const container = this.get();
     767            1 :                 if (Array.isArray(container) && typeof tag == "number")
     768            1 :                     this.#setter(toSpliced(container, tag, 1, val) as T);
     769              :                 else
     770            1 :                     this.#setter({ ...container, [tag]: val });
     771            1 :                 if (update_func)
     772            1 :                     update_func(val);
     773            1 :             },
     774            1 :             this.#path ? this.#path + "." + String(tag) : String(tag)
     775            1 :         );
     776            1 :     }
     777              : 
     778            1 :     at<TT extends T>(witness: TT): DialogField<TT> {
     779            1 :         cockpit.assert(Object.is(witness, this.get()));
     780            1 :         return new DialogField<TT>(
     781            1 :             this.#dialog,
     782            1 :             () => this.get() as TT,
     783            1 :             (val) => {
     784            1 :                 this.#setter(val);
     785            1 :             },
     786            1 :             this.#path,
     787            1 :         );
     788            1 :     }
     789              : 
     790            1 :     validate(func: (val: T) => DialogValidationResult<T>): void {
     791            1 :         const val = this.get();
     792            1 :         this.#dialog._validate_value(this.#path, val, () => func(val));
     793            1 :     }
     794              : 
     795            1 :     validate_async(debounce: number, func: (val: T) => Promise<DialogValidationResult<T>>): void {
     796            1 :         const val = this.get();
     797            1 :         this.#dialog._validate_value_async(this.#path, val, debounce, () => func(val));
     798            1 :     }
     799           36 : }
     800              : 
     801            1 : function get_validation_result_own_string(result: unknown): string | undefined {
     802            1 :     if (typeof result == "string")
     803            1 :         return result;
     804            1 :     else if (result && typeof result == "object" && "" in result && typeof result[""] == "string")
     805            1 :         return result[""];
     806              :     else
     807            1 :         return undefined;
     808            1 : }
     809              : 
     810              : interface DialogValidationState {
     811              :     path: string;
     812              :     cached_value: unknown;
     813              :     cached_result: unknown;
     814              :     timeout_id: number;
     815              :     promise: Promise<void> | undefined;
     816              :     round_id: unknown;
     817              : }
     818              : 
     819              : interface DialogStateEvents {
     820              :     changed(): void;
     821              : }
     822              : 
     823            1 : export class DialogState<V> extends EventEmitter<DialogStateEvents> {
     824              :     values: V;
     825              : 
     826            1 :     busy: boolean = false;
     827            1 :     actions_disabled: boolean = false;
     828            1 :     cancel_disabled: boolean = false;
     829            1 :     cancel_function: (() => void) | null = null;
     830              : 
     831            1 :     error: unknown = null;
     832              : 
     833            1 :     #validation_failed: boolean = false;
     834            1 :     #online_validation: boolean = false;
     835            1 :     #action_running: boolean = false;
     836            1 :     #validation: Record<string, string | undefined> = { };
     837            1 :     #validation_state: Record<string, DialogValidationState> = { };
     838              : 
     839              :     /* eslint-disable no-use-before-define */
     840            1 :     #validate_callback: undefined | ((dlg: DialogState<V>) => void);
     841              :     /* eslint-enable */
     842              : 
     843            1 :     constructor(init: V, validate: undefined | ((dlg: DialogState<V>) => void)) {
     844            1 :         super();
     845            1 :         this.#validate_callback = validate;
     846            1 :         this.values = init;
     847            1 :     }
     848              : 
     849            1 :     #update() {
     850            1 :         this.busy = this.#action_running;
     851            1 :         this.actions_disabled = this.#action_running || this.#validation_failed;
     852            1 :         this.cancel_disabled = this.#action_running && !this.cancel_function;
     853            1 :         this.emit("changed");
     854            1 :     }
     855              : 
     856              :     /* VALIDATION
     857              :      */
     858              : 
     859              :     /* Validation is started by calling the #trigger_validation
     860              :        method. This will reset all validation errors and then call the
     861              :        provided "validate" callback, which in turn will (eventually
     862              :        but synchronously) call the "_validate_value" or
     863              :        "_validate_value_async" methods of all relevant value paths.
     864              :        Those functions will eventually call #set_validation to install
     865              :        the validation results in the fresh #validation object created
     866              :        by #trigger_validation.
     867              :      */
     868              : 
     869            1 :     #trigger_validation(): void {
     870            1 :         debug("trigger validation");
     871            1 :         if (!this.#validate_callback)
     872            1 :             return;
     873            1 :         this.#validation = { };
     874            1 :         this.#validation_failed = false;
     875            1 :         this.#validate_callback(this);
     876            1 :         this.#update();
     877            1 :     }
     878              : 
     879            1 :     #set_validation(path: string, result: unknown) {
     880            1 :         if (result) {
     881            1 :             const own = get_validation_result_own_string(result);
     882            1 :             if (own) {
     883            1 :                 this.#validation[path] = own;
     884            1 :                 this.#validation_failed = true;
     885            1 :                 this.#online_validation = true;
     886            1 :                 this.#update();
     887            1 :             }
     888            1 :             if (typeof result == "object") {
     889            1 :                 for (const [k, v] of Object.entries(result)) {
     890            1 :                     if (k)
     891            0 :                         this.#set_validation(path ? path + "." + k : k, v);
     892            1 :                 }
     893            1 :             }
     894            1 :         }
     895            1 :     }
     896              : 
     897            1 :     _get_validation(path: string): string | undefined {
     898            1 :         if (path in this.#validation)
     899            1 :             return this.#validation[path];
     900              :         else
     901            1 :             return undefined;
     902            1 :     }
     903              : 
     904              :     /* In between #trigger_validation and #set_validation, a lot is
     905              :        going on, especially with asynchronous validation.
     906              : 
     907              :        We use a DialogValidationState object to keep the necessary
     908              :        state for that, such as cached results, and timeouts and
     909              :        promises.
     910              : 
     911              :        Note that a DialogValidationState object can change which path
     912              :        it is for, see _rename_validation_state below. So we have to be
     913              :        careful to always get the path out of the DialogValidationState
     914              :        object.
     915              :      */
     916              : 
     917            1 :     #get_validation_state(path: string): DialogValidationState {
     918            1 :         if (!(path in this.#validation_state))
     919            1 :             this.#validation_state[path] = {
     920            1 :                 path,
     921            1 :                 cached_value: undefined,
     922            1 :                 cached_result: undefined,
     923            1 :                 timeout_id: 0,
     924            1 :                 promise: undefined,
     925            1 :                 round_id: undefined,
     926            1 :             };
     927            1 :         return this.#validation_state[path];
     928            1 :     }
     929              : 
     930              :     /* Calling #set_validation_state_result is the final thing that
     931              :        should happen when validating a given path. It will install the
     932              :        result in the cache and then call #set_validation.
     933              :      */
     934              : 
     935            1 :     #set_validation_state_result(
     936            1 :         state: DialogValidationState,
     937            1 :         val: unknown,
     938            1 :         result: unknown,
     939            1 :     ) {
     940            1 :         state.cached_value = val;
     941            1 :         state.cached_result = result;
     942            1 :         state.timeout_id = 0;
     943            1 :         state.promise = undefined;
     944            1 :         state.round_id = undefined;
     945            1 :         this.#set_validation(state.path, result);
     946            1 :     }
     947              : 
     948              :     /* The first thing should be of course to probe that cache.  If we
     949              :        get a hit, it is used immediately to call #set_validation.
     950              : 
     951              :        In that case, the DialogValidationState is also made part of
     952              :        the current round since any asynchronous validation that is
     953              :        currently running is still relevant. See below for more about
     954              :        that.
     955              :      */
     956              : 
     957            1 :     #probe_validation_state_cache(state: DialogValidationState, val: unknown): boolean {
     958            1 :         if (Object.is(state.cached_value, val)) {
     959            1 :             state.round_id = this.#get_current_validation_round_id();
     960            1 :             debug("cache hit", state.path, state.cached_result);
     961            1 :             this.#set_validation(state.path, state.cached_result);
     962            1 :             return true;
     963            1 :         } else
     964            1 :             return false;
     965            1 :     }
     966              : 
     967              :     /* And in fact, _validate_value does exactly those two things.
     968              :      */
     969              : 
     970            1 :     _validate_value(path: string, val: unknown, func: () => unknown): void {
     971            1 :         const state = this.#get_validation_state(path);
     972            1 :         if (!this.#probe_validation_state_cache(state, val)) {
     973            1 :             const result = func();
     974            1 :             debug("sync validate", state.path, result);
     975            1 :             this.#set_validation_state_result(state, val, result);
     976            1 :         }
     977            1 :     }
     978              : 
     979              :     /* Now asynchronous validation.
     980              : 
     981              :        Each call to #trigger_validation starts a new "validation
     982              :        round" and a DialogValidationState keeps track to which round
     983              :        it applies to.  This matters of course for asynchronous
     984              :        validation: If async validation for a given path was started in
     985              :        one round, and then the next round happens but the path is no
     986              :        longer enumerated by the validation callback (i.e., its value
     987              :        is no longer relevant for the dialog), then this asynchronous
     988              :        validation should have no effect.
     989              : 
     990              :        We use the #validation object as the round identifier, since it
     991              :        is created fresh by each call to #trigger_validation.
     992              :      */
     993              : 
     994            1 :     #get_current_validation_round_id(): unknown {
     995            1 :         return this.#validation;
     996            1 :     }
     997              : 
     998            1 :     #is_current_validation_round_id(id: unknown): boolean {
     999            1 :         return Object.is(id, this.#validation);
    1000            1 :     }
    1001              : 
    1002              :     /* If there was no cache hit, asynchronous validation starts with
    1003              :        a timeout, followed by letting a asynchronous function run to
    1004              :        resolution.
    1005              : 
    1006              :        Setting a new timeout of course cancels any previously set
    1007              :        one. It also installs the current value in the cache, so that
    1008              :        subsequent validation rounds do nothing until the value
    1009              :        actually changes.
    1010              : 
    1011              :        One interesting thing to note is that when doing the final
    1012              :        validation before running an action function, no debouncing
    1013              :        delay should be applied of course. We want to get on with
    1014              :        validation immediately.
    1015              :      */
    1016              : 
    1017            1 :     #set_validation_state_timeout(
    1018            1 :         state: DialogValidationState,
    1019            1 :         val: unknown,
    1020            1 :         delay: number,
    1021            1 :         func: () => void,
    1022            1 :     ) {
    1023            1 :         if (state.timeout_id) {
    1024            1 :             debug("timeout cancel", state.path);
    1025            1 :             window.clearTimeout(state.timeout_id);
    1026            1 :             state.timeout_id = 0;
    1027            1 :         }
    1028            1 :         if (this.#action_running || delay == 0) {
    1029            1 :             func();
    1030            1 :         } else {
    1031            1 :             state.cached_value = val;
    1032            1 :             state.cached_result = undefined;
    1033            1 :             state.timeout_id = window.setTimeout(
    1034            1 :                 () => {
    1035            1 :                     debug("timeout", state.path);
    1036            1 :                     if (!this.#validation_state_is_current(state)) {
    1037            1 :                         debug("timeout outdated", state.path);
    1038            1 :                         return;
    1039            1 :                     }
    1040            1 :                     func();
    1041            1 :                 },
    1042            1 :                 delay);
    1043            1 :             state.promise = undefined;
    1044            1 :             state.round_id = this.#get_current_validation_round_id();
    1045            1 :         }
    1046            1 :     }
    1047              : 
    1048              :     /* Once the timeout is over (and the path is still relevant to the
    1049              :        current round), the actual asynchronous validation is launched.
    1050              :        This promise that represents it is simply installed in the
    1051              :        DialogValidationState.
    1052              :      */
    1053              : 
    1054            1 :     #set_validation_state_promise(
    1055            1 :         state: DialogValidationState,
    1056            1 :         val: unknown,
    1057            1 :         prom: Promise<void>,
    1058            1 :     ) {
    1059            1 :         state.cached_value = val;
    1060            1 :         state.cached_result = undefined;
    1061            1 :         state.timeout_id = 0;
    1062            1 :         state.promise = prom;
    1063            1 :         state.round_id = this.#get_current_validation_round_id();
    1064            1 :     }
    1065              : 
    1066              :     /* Unlike with the timeout, we can not cancel the old promise when
    1067              :        installing a new one. Instead we check at the end whether it is
    1068              :        still really us that is supposed to deliver the result, by
    1069              :        comparing promises.
    1070              : 
    1071              :        To summarize:
    1072              : 
    1073              :        - The round id check will fail if the value is no longer
    1074              :          relevant to the dialog.  For example, say there is a text
    1075              :          input that can be toggled in and out of the dialog via a
    1076              :          checkbox. Now a validation round is started while the text
    1077              :          input is part of the dialog. During the debounce timeout or
    1078              :          while the asynchronous validation function runs, the user
    1079              :          toggles the checkbox (which triggers a new validation round)
    1080              :          and the text input is no longer part of the dialog. Now when
    1081              :          the timeout or validation for the text input concludes, the
    1082              :          round id check fails and the result is ignored, as it should.
    1083              : 
    1084              :        - The promise check will fail when a asynchronous validation
    1085              :          takes longer than the debounce timeout.  Let's say there is a
    1086              :          text input with a debounce timeout of 1 second and a
    1087              :          validation function that takes 2 seconds. The user makes a
    1088              :          change that triggers validation and then remains idle for
    1089              :          more than a second. After one second, the timeout expires and
    1090              :          the promise is created and starts running. It will finish at
    1091              :          second 3, but we are not there yet. At second 1.5 the user
    1092              :          makes another change, a new timeout expires at 2.5 and a new
    1093              :          promise is created. At second 3 the original promise finally
    1094              :          comes to a conclusion, and the path is still relevant to the
    1095              :          dialog, but this promise is no longer the current
    1096              :          promise. Its result will be ignored, as it should.
    1097              :      */
    1098              : 
    1099            1 :     #validation_state_is_current(state: DialogValidationState, prom?: Promise<void>): boolean {
    1100            1 :         return (
    1101            1 :             (!prom || Object.is(state.promise, prom)) &&
    1102            1 :                 this.#is_current_validation_round_id(state.round_id)
    1103              :         );
    1104            1 :     }
    1105              : 
    1106              :     /* _validate_value_async puts this all together.
    1107              :      */
    1108              : 
    1109            1 :     _validate_value_async(path: string, val: unknown, debounce: number, func: () => Promise<unknown>): void {
    1110            1 :         const state = this.#get_validation_state(path);
    1111            1 :         if (!this.#probe_validation_state_cache(state, val)) {
    1112            1 :             debug("async validate start debounce", state.path, val);
    1113            1 :             this.#set_validation_state_timeout(
    1114            1 :                 state,
    1115            1 :                 val,
    1116            1 :                 debounce,
    1117            1 :                 () => {
    1118            1 :                     debug("async validate start promise", state.path, val);
    1119            1 :                     const prom =
    1120            1 :                         func()
    1121            1 :                                 .catch(
    1122            1 :                                     ex => {
    1123            1 :                                         console.error(ex);
    1124            1 :                                         return undefined;
    1125            1 :                                     }
    1126            1 :                                 )
    1127            1 :                                 .then(
    1128            1 :                                     result => {
    1129            1 :                                         if (this.#validation_state_is_current(state, prom)) {
    1130            1 :                                             debug("async validate done", state.path, result);
    1131            1 :                                             this.#set_validation_state_result(state, val, result);
    1132            1 :                                         } else {
    1133            1 :                                             debug("promise outdated", state.path);
    1134            1 :                                         }
    1135            1 :                                     }
    1136            1 :                                 );
    1137            1 :                     this.#set_validation_state_promise(state, val, prom);
    1138            1 :                 }
    1139              :             );
    1140            1 :         }
    1141            1 :     }
    1142              : 
    1143              :     /* Since the DialogValidationState for a path is so important, it
    1144              :        is also important to keep them firmly associated with each
    1145              :        other when the path of a value changes.
    1146              : 
    1147              :        A path might change when there are arrays involved, and
    1148              :        elements get new indices without actually changing identity.
    1149              :      */
    1150              : 
    1151            1 :     _rename_validation_state(path: string, from: number, to: number) {
    1152            1 :         const from_path = path + "." + String(from);
    1153            1 :         const to_path = path + "." + String(to);
    1154            1 :         if (from_path in this.#validation_state) {
    1155            1 :             debug("rename", from_path, to_path);
    1156            1 :             this.#validation_state[to_path] = this.#validation_state[from_path];
    1157            1 :             this.#validation_state[to_path].path = to_path;
    1158            1 :             delete this.#validation_state[from_path];
    1159            1 :         }
    1160            1 :         for (const k in this.#validation_state) {
    1161            1 :             if (k.indexOf(from_path + ".") == 0) {
    1162            1 :                 const to = to_path + k.substring(from_path.length);
    1163            1 :                 debug("rename", k, to);
    1164            1 :                 this.#validation_state[to] = this.#validation_state[k];
    1165            1 :                 this.#validation_state[to].path = to;
    1166            1 :                 delete this.#validation_state[k];
    1167            1 :             }
    1168            1 :         }
    1169            1 :     }
    1170              : 
    1171              :     /* The first thing run_action does is to trigger a new validation
    1172              :        round and then wait for all the asynchronous results to have
    1173              :        come in.
    1174              : 
    1175              :        If there are any DialogValidationState objects that are waiting
    1176              :        for a timeout, we want to cancel those and start over, so that
    1177              :        their validation starts immediately. (Also, it would be hairy
    1178              :        to wait for those timeouts to be over from here.)
    1179              :      */
    1180              : 
    1181            1 :     async validate(): Promise<boolean> {
    1182            1 :         this.#cancel_all_validation_timeouts();
    1183            1 :         this.#trigger_validation();
    1184            1 :         await this.#wait_for_validation_promises();
    1185            1 :         return !this.#validation_failed;
    1186            1 :     }
    1187              : 
    1188            1 :     #cancel_all_validation_timeouts() {
    1189            1 :         for (const p in this.#validation_state) {
    1190            1 :             const state = this.#validation_state[p];
    1191            1 :             if (state.timeout_id) {
    1192            1 :                 debug("timeout bulk cancel", p);
    1193            1 :                 window.clearTimeout(state.timeout_id);
    1194            1 :                 delete this.#validation_state[p];
    1195            1 :             }
    1196            1 :         }
    1197            1 :     }
    1198              : 
    1199            1 :     async #wait_for_validation_promises(): Promise<void> {
    1200            1 :         for (const path in this.#validation_state) {
    1201            1 :             const state = this.#validation_state[path];
    1202            1 :             if (state.promise) {
    1203            1 :                 debug("waiting for promise", path);
    1204            1 :                 await state.promise;
    1205            1 :                 debug("waiting for promise done", path);
    1206            1 :             }
    1207            1 :         }
    1208            1 :     }
    1209              : 
    1210            0 :     set_cancel(cancel: (() => void) | null) {
    1211            0 :         this.cancel_function = cancel;
    1212            0 :         this.#update();
    1213            0 :     }
    1214              : 
    1215            1 :     async run_action(func: (vals: V) => Promise<void>): Promise<boolean> {
    1216            1 :         this.error = null;
    1217            1 :         this.cancel_function = null;
    1218            1 :         this.#action_running = true;
    1219            1 :         this.#update();
    1220            1 :         if (!await this.validate()) {
    1221            1 :             this.#action_running = false;
    1222            1 :             this.#update();
    1223            1 :             return false;
    1224            1 :         }
    1225              : 
    1226            1 :         try {
    1227            1 :             await func(this.values);
    1228            1 :         } catch (ex) {
    1229            1 :             console.error(String(ex));
    1230            1 :             this.error = ex;
    1231            1 :         }
    1232              : 
    1233            1 :         this.cancel_function = null;
    1234            1 :         this.#action_running = false;
    1235            1 :         this.#update();
    1236              : 
    1237            1 :         return !this.error;
    1238            1 :     }
    1239              : 
    1240            1 :     top(update_func?: ((val: V) => void) | undefined): DialogField<V> {
    1241            1 :         return new DialogField<V>(
    1242            1 :             this as DialogState<unknown>,
    1243            1 :             () => this.values,
    1244            1 :             (val) => {
    1245            1 :                 debug("set", val);
    1246            1 :                 if (this.#action_running) {
    1247              :                     // Deny state changes while actions run.  This
    1248              :                     // prevents the user from interacting with the
    1249              :                     // dialog while it is busy. The alternative would
    1250              :                     // be to officially disable all fields and prevent
    1251              :                     // interactions that way, but that is visually
    1252              :                     // very jarring and not something that we have
    1253              :                     // been doing earlier.
    1254            1 :                     debug("set denied");
    1255            1 :                     return;
    1256            1 :                 }
    1257            1 :                 this.values = val;
    1258            1 :                 this.#update();
    1259            1 :                 if (this.#online_validation)
    1260            1 :                     this.#trigger_validation();
    1261            1 :                 if (update_func)
    1262            1 :                     update_func(val);
    1263            1 :             },
    1264            1 :             "");
    1265            1 :     }
    1266              : 
    1267            1 :     field<K extends keyof V>(tag: K, update_func?: ((val: V[K]) => void) | undefined): DialogField<V[K]> {
    1268            1 :         return this.top().sub(tag, update_func);
    1269            1 :     }
    1270           36 : }
    1271              : 
    1272           36 : export class DialogError {
    1273              :     title: string;
    1274              :     details: React.ReactNode;
    1275              : 
    1276            1 :     constructor(title: string, details?: React.ReactNode) {
    1277            1 :         this.title = title;
    1278            1 :         this.details = details;
    1279            1 :     }
    1280              : 
    1281            1 :     toString() {
    1282            1 :         return this.title + ": " + String(this.details);
    1283            1 :     }
    1284              : 
    1285            1 :     static fromError(title: string, err: unknown) {
    1286            1 :         if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
    1287            1 :             return new DialogError(title, err.message);
    1288            1 :         } else {
    1289            1 :             return new DialogError(title, String(err));
    1290            1 :         }
    1291            1 :     }
    1292           36 : }
    1293              : 
    1294            1 : export function useDialogState<V extends object>(
    1295            1 :     init: V | (() => V),
    1296            1 :     validate?: undefined | ((dlg: DialogState<V>) => void),
    1297            1 : ) : DialogState<V> {
    1298            1 :     const dlg = useObject(
    1299            1 :         () => new DialogState(
    1300            1 :             typeof init == "function" ? init() : init,
    1301            1 :             validate
    1302            1 :         ),
    1303            1 :         null,
    1304            1 :         []
    1305            1 :     );
    1306            1 :     useOn(dlg, "changed");
    1307            1 :     return dlg;
    1308            1 : }
    1309              : 
    1310            1 : export function useDialogState_async<V extends object>(
    1311            1 :     init: () => Promise<V>,
    1312            1 :     validate?: undefined | ((dlg: DialogState<V>) => void),
    1313            1 : ) : null | DialogError | DialogState<V> {
    1314            1 :     const [dlg, setDlg] = useState<null | DialogError | DialogState<V>>(null);
    1315            1 :     useOn((dlg instanceof DialogError ? null : dlg), "changed");
    1316            1 :     useInit(async () => {
    1317            1 :         try {
    1318            1 :             setDlg(new DialogState<V>(await init(), validate));
    1319            1 :         } catch (ex) {
    1320            1 :             if (ex instanceof DialogError)
    1321            1 :                 setDlg(ex);
    1322              :             else
    1323            1 :                 setDlg(DialogError.fromError(_("Error during initialization"), ex));
    1324            1 :         }
    1325            1 :     });
    1326            1 :     return dlg;
    1327            1 : }
    1328              : 
    1329              : // Common elements
    1330              : 
    1331            1 : export function DialogErrorMessage<V>({
    1332            1 :     dialog,
    1333            1 : } : {
    1334              :     dialog: DialogState<V> | DialogError | null,
    1335            1 : }) {
    1336            1 :     const err = (!dialog || dialog instanceof DialogError) ? dialog : dialog.error;
    1337            1 :     if (!err)
    1338            1 :         return null;
    1339              : 
    1340            1 :     let title: string;
    1341            1 :     let details: React.ReactNode;
    1342              : 
    1343            1 :     if (err instanceof DialogError) {
    1344            1 :         title = err.title;
    1345            1 :         details = err.details;
    1346            1 :     } else if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
    1347            1 :         title = _("Failed");
    1348            1 :         details = err.message;
    1349            1 :     } else {
    1350            1 :         title = _("Failed");
    1351            1 :         details = String(err);
    1352            1 :     }
    1353              : 
    1354            1 :     return (
    1355            1 :         <Alert
    1356            1 :             id="dialog-error-message"
    1357            1 :             variant='danger'
    1358            1 :             isInline
    1359            1 :             title={title}
    1360              :         >
    1361            1 :             {details}
    1362            1 :         </Alert>
    1363              :     );
    1364            1 : }
    1365              : 
    1366            1 : export function DialogActionButton<V>({
    1367            1 :     dialog,
    1368            1 :     children,
    1369            1 :     action,
    1370            1 :     onClose = undefined,
    1371            1 :     ...props
    1372            1 : } : {
    1373              :     dialog: DialogState<V> | DialogError | null,
    1374              :     children: React.ReactNode,
    1375              :     action: (values: V) => Promise<void>,
    1376              :     onClose?: undefined | (() => void)
    1377            1 : } & Omit<ButtonProps, "id" | "action" | "isLoading" | "isDisabled" | "variant" | "onClick">) {
    1378            1 :     return (
    1379            1 :         <Button
    1380            1 :             id="dialog-apply"
    1381            1 :             isLoading={!!dialog && !(dialog instanceof DialogError) && dialog.busy}
    1382            1 :             isDisabled={!dialog || dialog instanceof DialogError || dialog.actions_disabled}
    1383            1 :             variant="primary"
    1384            1 :             onClick={async () => {
    1385            1 :                 cockpit.assert(dialog && !(dialog instanceof DialogError));
    1386            1 :                 if (await dialog.run_action(action) && onClose)
    1387            1 :                     onClose();
    1388            1 :             }}
    1389            1 :             {...props}
    1390              :         >
    1391            1 :             {children}
    1392            1 :         </Button>
    1393              :     );
    1394            1 : }
    1395              : 
    1396            1 : export function DialogCancelButton<V>({
    1397            1 :     dialog,
    1398            1 :     onClose,
    1399            1 :     ...props
    1400            1 : } : {
    1401              :     dialog: DialogState<V> | DialogError | null,
    1402              :     onClose: () => void
    1403            1 : } & Omit<ButtonProps, "id" | "isDisabled" | "variant" | "onClick">) {
    1404            1 :     return (
    1405            1 :         <Button
    1406            1 :             id="dialog-cancel"
    1407            1 :             isDisabled={!dialog || (dialog instanceof DialogState && dialog.cancel_disabled)}
    1408            1 :             variant="link"
    1409            1 :             onClick={() => {
    1410            1 :                 if (dialog instanceof DialogState && dialog.cancel_function)
    1411            0 :                     dialog.cancel_function();
    1412              :                 else
    1413            1 :                     onClose();
    1414            1 :             }}
    1415            1 :             {...props}
    1416              :         >
    1417            1 :             {_("Cancel")}
    1418            1 :         </Button>
    1419              :     );
    1420            1 : }
    1421              : 
    1422              : /* Common dialog field implementations.
    1423              :  */
    1424              : 
    1425              : type falsy = null | undefined | false;
    1426              : 
    1427            1 : export function DialogHelperText<V>({
    1428            1 :     field,
    1429            1 :     excuse,
    1430            1 :     warning,
    1431            1 :     explanation,
    1432            1 : } : {
    1433              :     field: DialogField<V>;
    1434              :     excuse?: string | falsy;
    1435              :     warning?: React.ReactNode;
    1436              :     explanation?: React.ReactNode;
    1437            1 : }) {
    1438            1 :     let text: React.ReactNode = field.validation_text();
    1439            1 :     let variant: HelperTextItemProps["variant"] = "error";
    1440            1 :     if (!text && excuse) {
    1441            1 :         text = excuse;
    1442            1 :         variant = "default";
    1443            1 :     }
    1444            1 :     if (!text && warning) {
    1445            1 :         text = warning;
    1446            1 :         variant = "warning";
    1447            1 :     }
    1448            1 :     if (!text) {
    1449            1 :         text = explanation;
    1450            1 :         variant = "default";
    1451            1 :     }
    1452              : 
    1453            1 :     if (!text)
    1454            1 :         return null;
    1455              : 
    1456            1 :     return (
    1457            1 :         <FormHelperText>
    1458            1 :             <HelperText>
    1459            1 :                 <HelperTextItem id={field.id("helper-text")} variant={variant}>
    1460            1 :                     {text}
    1461            1 :                 </HelperTextItem>
    1462            1 :             </HelperText>
    1463            1 :         </FormHelperText>
    1464              :     );
    1465            1 : }
    1466              : 
    1467            1 : export const OptionalFormGroup = ({
    1468            1 :     label,
    1469            1 :     children,
    1470            1 :     ...props
    1471            1 : } : {
    1472              :     label: React.ReactNode,
    1473              :     children: React.ReactNode,
    1474            1 : } & Omit<FormGroupProps, "label" | "children">) => {
    1475            1 :     if (label) {
    1476            1 :         return (
    1477            1 :             <FormGroup
    1478            1 :                 label={label}
    1479            1 :                 {...props}
    1480              :             >
    1481            1 :                 {children}
    1482            1 :             </FormGroup>
    1483              :         );
    1484            1 :     } else {
    1485            1 :         return children;
    1486            1 :     }
    1487            1 : };
    1488              : 
    1489            1 : export const DialogTextInput = ({
    1490            1 :     label = null,
    1491            1 :     field,
    1492            1 :     excuse,
    1493            1 :     warning,
    1494            1 :     explanation,
    1495            1 :     isDisabled = false,
    1496            1 :     ...props
    1497            1 : } : {
    1498              :     label?: React.ReactNode,
    1499              :     field: DialogField<string>,
    1500              :     excuse?: string | falsy,
    1501              :     warning?: React.ReactNode,
    1502              :     explanation?: React.ReactNode,
    1503              :     isDisabled?: boolean,
    1504            1 : } & Omit<TextInputProps, "id" | "label" | "value" | "onChange">) => {
    1505            1 :     return (
    1506            1 :         <OptionalFormGroup label={label} fieldId={field.id()}>
    1507            1 :             <TextInput
    1508            1 :                 id={field.id()}
    1509            1 :                 value={field.get()}
    1510            1 :                 onChange={(_event, val) => field.set(val)}
    1511            1 :                 isDisabled={!!excuse || isDisabled}
    1512            1 :                 {...props}
    1513            1 :             />
    1514            1 :             <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
    1515            1 :         </OptionalFormGroup>
    1516              :     );
    1517            1 : };
    1518              : 
    1519            0 : export const DialogPasswordInput = ({
    1520            0 :     label = null,
    1521            0 :     field,
    1522            0 :     excuse,
    1523            0 :     warning,
    1524            0 :     explanation,
    1525            0 :     isDisabled = false,
    1526            0 :     ...props
    1527            0 : } : {
    1528              :     label?: React.ReactNode,
    1529              :     field: DialogField<string>,
    1530              :     excuse?: string | falsy,
    1531              :     warning?: React.ReactNode,
    1532              :     explanation?: React.ReactNode,
    1533              :     isDisabled?: boolean,
    1534            0 : } & Omit<TextInputProps, "id" | "label" | "value" | "onChange">) => {
    1535            0 :     const [visible, setVisible] = useState(false);
    1536              : 
    1537            0 :     return (
    1538            0 :         <OptionalFormGroup label={label} fieldId={field.id()}>
    1539            0 :             <InputGroup>
    1540            0 :                 <InputGroupItem isFill>
    1541            0 :                     <TextInput
    1542            0 :                         id={field.id()}
    1543            0 :                         type={visible ? "text" : "password"}
    1544            0 :                         value={field.get()}
    1545            0 :                         onChange={(_event, value) => field.set(value)}
    1546            0 :                         isDisabled={!!excuse || isDisabled}
    1547            0 :                         {...props}
    1548            0 :                     />
    1549            0 :                 </InputGroupItem>
    1550            0 :                 <InputGroupItem>
    1551            0 :                     <Button
    1552            0 :                         variant="control"
    1553            0 :                         aria-label={visible ? _("Hide password") : _("Show password")}
    1554            0 :                         onClick={() => setVisible(!visible)}
    1555              :                     >
    1556            0 :                         {visible ? <EyeSlashIcon /> : <EyeIcon />}
    1557            0 :                     </Button>
    1558            0 :                 </InputGroupItem>
    1559            0 :             </InputGroup>
    1560            0 :             <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
    1561            0 :         </OptionalFormGroup>
    1562              :     );
    1563            0 : };
    1564              : 
    1565            1 : export const DialogCheckbox = ({
    1566            1 :     field_label = null,
    1567            1 :     checkbox_label,
    1568            1 :     field,
    1569            1 :     excuse,
    1570            1 :     warning,
    1571            1 :     explanation,
    1572            1 : } : {
    1573              :     field_label?: React.ReactNode,
    1574              :     checkbox_label: string,
    1575              :     field: DialogField<boolean>,
    1576              :     excuse?: string | falsy,
    1577              :     warning?: React.ReactNode,
    1578              :     explanation?: React.ReactNode,
    1579            1 : }) => {
    1580            1 :     return (
    1581            1 :         <OptionalFormGroup label={field_label} hasNoPaddingTop>
    1582            1 :             <Checkbox
    1583            1 :                 id={field.id()}
    1584            1 :                 isChecked={field.get()}
    1585            1 :                 label={checkbox_label}
    1586            1 :                 onChange={(_event, checked) => field.set(checked)}
    1587            1 :                 isDisabled={!!excuse}
    1588            1 :             />
    1589            1 :             <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
    1590            1 :         </OptionalFormGroup>
    1591              :     );
    1592            1 : };
    1593              : 
    1594              : export interface DialogRadioSelectOption<T extends string> {
    1595              :     value: T,
    1596              :     label: React.ReactNode,
    1597              :     explanation?: React.ReactNode,
    1598              :     excuse?: string | falsy,
    1599              : }
    1600              : 
    1601            1 : export function DialogRadioSelect<T extends string>({
    1602            1 :     label = null,
    1603            1 :     field,
    1604            1 :     options,
    1605            1 :     warning,
    1606            1 :     explanation,
    1607            1 :     isInline = false,
    1608            1 : } : {
    1609              :     label?: React.ReactNode,
    1610              :     field: DialogField<T>,
    1611              :     options: DialogRadioSelectOption<T>[],
    1612              :     warning?: React.ReactNode,
    1613              :     explanation?: React.ReactNode,
    1614              :     isInline?: boolean,
    1615            1 : }) {
    1616            1 :     function makeLabel(o: DialogRadioSelectOption<T>, i: number) {
    1617            1 :         const exc = o.excuse ? <> ({o.excuse})</> : null;
    1618            1 :         const pad = (!isInline && i < options.length - 1) ? <><br />{"\u00A0"}</> : null;
    1619            1 :         const exp = o.explanation ? <><br /><small>{o.explanation}{pad}</small></> : null;
    1620            1 :         return <div id={field.id(o.value + "-label")}>{o.label}{exc}{exp}</div>;
    1621            1 :     }
    1622              : 
    1623            1 :     return (
    1624            1 :         <OptionalFormGroup
    1625            1 :             label={label}
    1626            1 :             hasNoPaddingTop
    1627            1 :             isInline={isInline}
    1628            1 :             id={field.id()}
    1629            1 :             data-value={field.get()}
    1630              :         >
    1631              :             {
    1632            1 :                 options.map((o, i) =>
    1633            1 :                     <Radio
    1634            1 :                         key={o.value}
    1635            1 :                         id={field.id(o.value)}
    1636            1 :                         name={o.value}
    1637            1 :                         isChecked={field.get() == o.value}
    1638            1 :                         label={makeLabel(o, i)}
    1639            1 :                         onChange={() => field.set(o.value)}
    1640            1 :                         isDisabled={!!o.excuse}
    1641            1 :                     />
    1642            1 :                 )
    1643              :             }
    1644            1 :             <DialogHelperText explanation={explanation} warning={warning} field={field} />
    1645            1 :         </OptionalFormGroup>
    1646              :     );
    1647            1 : }
    1648              : 
    1649              : export interface DialogDropdownSelectOption<T extends string> {
    1650              :     value: T;
    1651              :     label: string;
    1652              : }
    1653              : 
    1654            1 : export function DialogDropdownSelect<T extends string>({
    1655            1 :     label,
    1656            1 :     field,
    1657            1 :     excuse,
    1658            1 :     warning,
    1659            1 :     explanation,
    1660            1 :     options,
    1661            1 :     ...props
    1662            1 : } : {
    1663              :     label?: React.ReactNode,
    1664              :     field: DialogField<T>,
    1665              :     excuse?: string | falsy,
    1666              :     warning?: React.ReactNode,
    1667              :     explanation?: React.ReactNode,
    1668              :     options: DialogDropdownSelectOption<T>[],
    1669            1 : } & Omit<FormSelectProps, "ref" | "children">) {
    1670            1 :     return (
    1671            1 :         <OptionalFormGroup label={label}>
    1672            1 :             <FormSelect
    1673            1 :                 id={field.id()}
    1674            1 :                 onChange={(_event, val) => field.set(val as T) }
    1675            1 :                 validated={warning ? "warning" : undefined}
    1676            1 :                 isDisabled={!!excuse}
    1677            1 :                 value={field.get()}
    1678            1 :                 {...props}
    1679              :             >
    1680              :                 {
    1681            1 :                     options.map(
    1682            1 :                         o => {
    1683            1 :                             return <FormSelectOption key={o.value} value={o.value} label={o.label} />;
    1684            1 :                         }
    1685            1 :                     )
    1686              :                 }
    1687            1 :             </FormSelect>
    1688            1 :             <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
    1689            1 :         </OptionalFormGroup>
    1690              :     );
    1691            1 : }
    1692              : 
    1693            1 : export function DialogDropdownSelectObject<T>({
    1694            1 :     label,
    1695            1 :     field,
    1696            1 :     excuse,
    1697            1 :     warning,
    1698            1 :     explanation,
    1699            1 :     options,
    1700            1 :     option_label = (o: T): string => { cockpit.assert(typeof o == "string"); return o },
    1701            1 :     ...props
    1702            1 : } : {
    1703              :     label?: React.ReactNode,
    1704              :     field: DialogField<T>,
    1705              :     excuse?: string | falsy,
    1706              :     warning?: React.ReactNode,
    1707              :     explanation?: React.ReactNode,
    1708              :     options: T[],
    1709              :     option_label?: (o: T) => string,
    1710            1 : } & Omit<FormSelectProps, "ref" | "children">) {
    1711            1 :     return (
    1712            1 :         <OptionalFormGroup label={label}>
    1713            1 :             <FormSelect
    1714            1 :                 id={field.id()}
    1715            1 :                 onChange={(_event, val) => {
    1716            1 :                     const opt = options.find(o => option_label(o) == val);
    1717            1 :                     field.set(opt!);
    1718            1 :                 }}
    1719            1 :                 validated={warning ? "warning" : undefined}
    1720            1 :                 isDisabled={!!excuse}
    1721            1 :                 value={option_label(field.get())}
    1722            1 :                 {...props}
    1723              :             >
    1724              :                 {
    1725            1 :                     options.map(
    1726            1 :                         o => {
    1727            1 :                             const l = option_label(o);
    1728            1 :                             return <FormSelectOption key={l} value={l} label={l} />;
    1729            1 :                         }
    1730            1 :                     )
    1731              :                 }
    1732            1 :             </FormSelect>
    1733            1 :             <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
    1734            1 :         </OptionalFormGroup>
    1735              :     );
    1736            1 : }
        

Generated by: LCOV version 2.0-1