LCOV - code coverage report
Current view: top level - pkg/lib/cockpit - dialog.tsx Coverage Total Hit
Test: cockpit Lines: 95.5 % 869 830
Test Date: 2026-06-16 14:09:37

            Line data    Source code
       1            3 : /*
       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.field("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.get_async(debounce, (val, task) => ...)
     226              :    - handle.set_async(debounce, (val, task) => new_val)
     227              : 
     228              :    These are for running debounced, asynchronous code.  Both functions
     229              :    will run the given function after "debounce" milliseconds, but only
     230              :    if the value of the field hasn't changed in the meantime.  The
     231              :    dialog waits for all asynchronous tasks started by these functions
     232              :    to be finished before running the action function.  When the dialog
     233              :    is cancelled, they all get cancelled.
     234              : 
     235              :    The return value of "handle.set_async" is made the new value of the
     236              :    field, but only if the value of the field hasn't changed in the
     237              :    meantime.  There can only be one currently active "set_async" call.
     238              :    If you call it again before the previous one has finished, that
     239              :    previous call will be cancelled at that point, just as if the field
     240              :    value had changed.
     241              : 
     242              :    The "handle.get_async" function is a slight variation on this. It
     243              :    is meant to perform asynchronous computations that do not modify
     244              :    the field value itself, but have some other side effects.  Maybe
     245              :    they modify multiple other field values or some React state. There
     246              :    can be more than one call active at a given time. They only get
     247              :    cancelled when the value of the field changes.
     248              : 
     249              :    The asynchronous tasks must be careful to only perform their side
     250              :    effects when they have not been cancelled yet.  Because of the way
     251              :    JavaScript works, the asynchronous functions keep running and they
     252              :    need to voluntarily call "task.is_cancelled()" to figure out when
     253              :    they should stop.
     254              : 
     255              :    As an example, here is how you might implement set_async on top of
     256              :    get_async:
     257              : 
     258              :       function set_async(handle, debounce, func) {
     259              :           handle.get_async(debounce, (val, task) => {
     260              :               const new_val = await func(val, task);
     261              :               if (!task.is_cancelled())
     262              :                   handle.set(new_val);
     263              :           })
     264              :       }
     265              : 
     266              :    - handle.at(witness)
     267              : 
     268              :    Get a handle with a narrowed type for "handle".  The new handle
     269              :    works like "handle" and modifies the same place in the dialog value
     270              :    object, but it's type will be the type of "witness".  This is
     271              :    useful to carry over type inference into value handles.  The
     272              :    general pattern is:
     273              : 
     274              :      const val = handle.get();
     275              :      if (some_type_narrowing_condition(val)) {
     276              :        const narrowed_handle = handle.at(val);
     277              : 
     278              :        ...
     279              :      }
     280              : 
     281              :    - handle.add(val)
     282              : 
     283              :    If the current value is an array, append "val" at the end.
     284              : 
     285              :    - handle.remove(index)
     286              : 
     287              :    If the current value is an array, remove the element at "index".
     288              :    It is important to use this function instead of just "handle.set()"
     289              :    with an appropriately modified array. By using this function, the
     290              :    plumbing is able to keep its internal state in synch, which is
     291              :    especially important for asynchronous validation and update
     292              :    functions.
     293              : 
     294              :    However, it is okay to just replace an array with a different
     295              :    array, so you are not strictly required to use this function. But
     296              :    doing so might look to the validation machinery as if each and
     297              :    every element of the array has just changed, and it will do a lot
     298              :    of needless validations all over again.
     299              : 
     300              :    - handle.map(func)
     301              : 
     302              :    If the current value is an array, map "func" over handles for its
     303              :    elements. This is nice for creating React components for arrays.
     304              : 
     305              :    - handle.forEach(func)
     306              : 
     307              :    If the current value is an array, call "func" with handles for each
     308              :    of its elements, in order. This is nice for "validate" functions.
     309              : 
     310              :    Now back to the fields and methods of the dialog state.
     311              : 
     312              :    - dlg.busy
     313              :    - dlg.actions_disabled
     314              :    - dlg.cancel_disabled
     315              : 
     316              :    Boolean flags that indicate which parts of the dialog should be
     317              :    disabled. The porcelain should of course look at these and do the
     318              :    right thing.
     319              : 
     320              :    - dlg.error
     321              : 
     322              :    The most recent error thrown by an action function.  This can be
     323              :    any kind of JavaScript value, but the idea is that it is something
     324              :    with a "message" field, or a DialogError instance.  The
     325              :    DialogErrorMessage porcelain component will do the right thing with
     326              :    these kind of error values.
     327              : 
     328              :    - dlg.run_action(func)
     329              : 
     330              :    Waits for all asynchronous updates and input validation to be done
     331              :    and if that was successful, calls "func" and puts the dialog into a
     332              :    "busy" state while it runs. When "func" throws an error, it is
     333              :    caught and stored in "dlg.error".
     334              : 
     335              :    "dlg.run_action" returns true when validation has passed and "func"
     336              :    has completed without throwing an error.
     337              : 
     338              :    All state changes via "field.set()" are denied while "func" is
     339              :    running. This is done to prevent the user from interacting with the
     340              :    dialog while an action runs. But there is nothing fundamentally
     341              :    wrong with programmatically changing dialog state as part of an
     342              :    action. If you want to do that, write code like
     343              : 
     344              :      if (dlg.run_action(...))
     345              :        dlg.field("xxx").set(...)
     346              : 
     347              :    - dlg.cancel(onClose)
     348              : 
     349              :    Does whatever should happen when the "Cancel" button is
     350              :    clicked. When an action is running, it will call the "cancel
     351              :    function" (see below).  Otherwise all validation and update tasks
     352              :    are cancelled and the dialog is closed by calling "onClose".
     353              : 
     354              :    - dlg.set_cancel(func)
     355              : 
     356              :    Arranges for "func" to be called when the cancel button is clicked.
     357              :    You should call this only from a action function passed to
     358              :    "run_action" and you need to take care to reset this via
     359              :    "dlg.set_cancel(null)" once the cancel function should no longer be
     360              :    called.  When a action funtion finishes or throws an error from
     361              :    within "dlg.run_action", the cancel function is automatically
     362              :    reset.
     363              : 
     364              :    - dlg.set_id_prefix(id_prefix)
     365              : 
     366              :    This sets the prefix used by the handle.id() function. This is only
     367              :    necessary when testing stacked dialogs, which should be rare.
     368              : 
     369              :    VALIDATION
     370              : 
     371              :    Input validation is done by a single, central function for the
     372              :    whole dialog.  This has been done so that there is a central place
     373              :    that establishes the "shape" of the dialog values. This is
     374              :    important for dialogs that have optional parts.
     375              : 
     376              :    If such an optional part of the values has failed validation
     377              :    earlier, but has subsequently been removed from the dialog by the
     378              :    user, the plumbing needs to know that it should now ignore this
     379              :    failed validation. But the code that knows what is currently in the
     380              :    dialog is the render function that instantiates all the field input
     381              :    components (like TextInput). This hacker here has found no reliable
     382              :    and non-magical way to connect what the render function actually
     383              :    does with the plumbing machinery. So everyone has to write a big
     384              :    validate function now that duplicates this, sorry!
     385              : 
     386              :    The formal job of the validation function is to call the "validate"
     387              :    method (or "validate_async") of all relevant dialog value handles.
     388              :    If and only if a validation failure of a field should prevent
     389              :    running the action function, should the validate function visit it.
     390              : 
     391              :    - handle.validate(v => ...)
     392              : 
     393              :    This might call the given function with the current value of the
     394              :    handle. If it passes validation, the function should return
     395              :    "undefined". If it fails, the function should return a string with
     396              :    the appropriate message. This message will be available from the
     397              :    "handle.validation_text" method and should be shown by the React
     398              :    component for this value, of course. Returning an error here will
     399              :    also disable the action buttons.
     400              : 
     401              :    The "v => ..." function is only called when necessary, when the
     402              :    value has actually changed.
     403              : 
     404              :    A validation function can also return an object with validation
     405              :    errors for its sub-fields.  This is useful if multiple fields need
     406              :    to be validated together.  Consider this example:
     407              : 
     408              :        field.validate(v => {
     409              :          if (v.mode != "auto" && v.size == 0)
     410              :            return { "size": "Can't be zero in manual mode." }
     411              :        });
     412              : 
     413              :    If your validation function needs to communicate out-of-band with
     414              :    your action function (maybe to pass the results of some expensive
     415              :    operations that you don't want to repeat in your action function),
     416              :    then you can modify field values via calls to "handle.set". (Be
     417              :    careful not to create endless validation loops!)
     418              : 
     419              :    - handle.validate_async(debounce, async (v, task) >= ...)
     420              : 
     421              :    Calls the given async function "debounce" milliseconds after the
     422              :    value represented by the handle has last been changed. (Or
     423              :    immediately when the apply button is clicked.)  When the function
     424              :    throws an exception, the validation is considered to have been
     425              :    successful.
     426              : 
     427              :    See the documentation for "handle.validate" above for more rules
     428              :    that apply to validation functions.
     429              : 
     430              :    UPDATES
     431              : 
     432              :    Sometimes dialog values need to be changed in reaction to other
     433              :    changes.  For example, when the user selects a ISO for creating a
     434              :    new virtual machine, you might want to run some code that detects
     435              :    the OS on that ISO and then adapts the rest of the dialog to the
     436              :    minimum storage requirements of the OS.  Sometimes you can do
     437              :    everything at render time, but sometimes you might want to run some
     438              :    code as part of the event handler for the user action, and
     439              :    sometimes you need to run asynchornous code.
     440              : 
     441              :    (Don't use useEffect, please, just stick the code into the event
     442              :    handler.)
     443              : 
     444              :    It's okay and simplest to just put that code right next to the call
     445              :    to "handler.set()".  If that call is in a porcelain component (as
     446              :    it probably often will be), you can pass a "update_func" when
     447              :    creating the handle for that porcelain component with
     448              :    "handler.sub()" or "dialog.field()".  For example:
     449              : 
     450              :        function on_plate_change(val: string) {
     451              :            console.log("NEW LICENSE PLATE", val);
     452              :        }
     453              : 
     454              :        return (
     455              :            <DialogTextInput
     456              :                label="License plate number"
     457              :                field={dlg.field("plate", on_plate_change)}
     458              :            />
     459              :        );
     460              : 
     461              :    The function "on_plate_change" will be called whenever the user
     462              :    changes the "plate" field via the DialogTextInput.  The
     463              :    "on_plate_change" function will not be called when the "plate" is
     464              :    changed in other places.  If that should happen, you have to
     465              :    arrange for it explicitly.
     466              : 
     467              :    Functions like "on_plate_change" can and should modify the dialog
     468              :    fields via calls to "handle.set()".
     469              : 
     470              :    If you want to run asynchronous code, you can do so with
     471              :    "handle.set_async()" or "handle.get_async()".  For example, if you
     472              :    want to asynchronously fetch the car model for a given license
     473              :    plate from a database, you can do it like this:
     474              : 
     475              :        function on_plate_change(val: string) {
     476              :            dlg.field("model").set_async(1000, async () => await fetch_model(val));
     477              :        }
     478              : 
     479              :    When arrays are involved, dialog fields can move around while your
     480              :    asynchronous update function runs.  To help with this, handles will
     481              :    keep referring to the same field even if it moves around in its
     482              :    array.
     483              : 
     484              :    TESTING
     485              : 
     486              :    Our automated tests will want to drive the dialogs created by this
     487              :    framework, of course.  To support this, the various DOM elements
     488              :    instantiated for a dialog should be decorated with structured "id"
     489              :    attributes. The code that instantiates an element can get suitable
     490              :    IDs for dialog value handles with the following function:
     491              : 
     492              :    - handle.id(tag)
     493              : 
     494              :    This will return a unique and predictable string for the value
     495              :    handle that will also include "tag". This is suitable for the "id"
     496              :    attribute of DOM elements associated with "value".  The "tag"
     497              :    parameter can be used to generate multiple IDs if a component has
     498              :    multiple interesting DOM elements.  The "tag" parameter defaults to
     499              :    "field", see below.
     500              : 
     501              :    There is a support library for use by the tests that can generate
     502              :    the same IDs, and there are also some guidelines for how to use
     503              :    these IDs:
     504              : 
     505              :    - The main input element (text input, form select, ...) should use
     506              :      the "field" tag.
     507              : 
     508              :    - The helper text should use the "helper-text" tag.
     509              : 
     510              :    - A set of radio buttons should use a different tag for each
     511              :      button. Whatever makes sense in the specific case.
     512              : 
     513              :    - ...
     514              : 
     515              :    PORCELAIN GALLERY
     516              : 
     517              :    Here are some noteworthy React components that integrate with the
     518              :    plumbing API.
     519              : 
     520              :    - <DialogErrorMessage dialog={dlg} />
     521              : 
     522              :    This creates an appropriate Alert for "dlg.error", if it is set. It
     523              :    works well with instances of DialogError, and all usual errors
     524              :    thrown by the Cockpit API.
     525              : 
     526              :    In addition to a proper DialogState, the "dialog" property can be
     527              :    anything returned by "use_DialogState_async".
     528              : 
     529              :    If given one the of Cockpit API errors, the title of the Alert will
     530              :    be a generic "Failed" text. If you want more control, use a
     531              :    DialogError.
     532              : 
     533              :    A DialogError contains a title and details, and the details can
     534              :    come from another error.  For example:
     535              : 
     536              :        try {
     537              :            await cockpit.spawn(["/bin/frob", "--bars"])
     538              :        } catch (ex) {
     539              :            throw DialogError.fromError("Failed to frob the bars", ex);
     540              :        }
     541              : 
     542              :    You can also construct a DialogError directly from title and
     543              :    details:
     544              : 
     545              :        throw new DialogError("Failed to frob", <pre>...</pre>);
     546              : 
     547              :    In that case, the details can be any React node.
     548              : 
     549              :    - <DialogActionButton dialog={dlg} action={func} onClose={close_func}>
     550              : 
     551              :    This will produce a action button for a dialog that correctly disables
     552              :    itself according to the state of "dlg".
     553              : 
     554              :    In addition to a proper DialogState, the "dialog" property can be
     555              :    anything returned by "use_DialogState_async".
     556              : 
     557              :    When clicked, "func" will be run via "dlg.run_action". If "func"
     558              :    completes successfully, "close_func" is called to close the dialog.
     559              : 
     560              :    - <DialogCancelButton dialog={dlg} onClose={close_func} />
     561              : 
     562              :    This will produce a cancel button for a dialog that correctly
     563              :    disables itself according to the state of "dlg".
     564              : 
     565              :    In addition to a proper DialogState, the "dialog" property can be
     566              :    anything returned by "use_DialogState_async".
     567              : 
     568              :    Clicking it will either just close the dialog by calling
     569              :    "close_func", or run the cancel function provided by the currently
     570              :    running action function (if there is any).
     571              : 
     572              :    - <DialogTextInput label="Name" field={dlg.field("name")} ... />
     573              : 
     574              :    This will produce a TextInput in a (optional) FormGroup that will
     575              :    manage the given value handle.  The "label" property is optional
     576              :    and omitting it will also omit the FormGroup.
     577              : 
     578              :   - <DialogCheckbox label= field= .../>
     579              : 
     580              :   For a single checkbox that drives a boolean.
     581              : 
     582              :   - <DialogRadioSelect label= field= options= .../>
     583              : 
     584              :   For a group of radio buttons.  The options can be disabled and have
     585              :   explanations.
     586              : 
     587              :   - <DialogDropdownSelect label= field= options= .../> </>
     588              : 
     589              :   For a simple dropdown select. Options can not be disabled or have
     590              :   explanations.
     591              : 
     592              :   - <DialogDropdownSelectObject label= field= options= option_label= />
     593              : 
     594              :   A variant of the simple dropdown select from above where the options
     595              :   can be of any type whatsoever, such as something directly from your
     596              :   data model. A simple case is selecting from an array of strings. In
     597              :   that case you can omit the "option_label" function.
     598              : 
     599              :  */
     600              : 
     601            3 : import React, { useState } from "react";
     602              : import { useObject, useInit, useOn } from 'hooks';
     603              : import { EventEmitter } from 'cockpit/event';
     604              : 
     605            3 : import cockpit from "cockpit";
     606              : 
     607              : import { Button, type ButtonProps } from "@patternfly/react-core/dist/esm/components/Button/index.js";
     608              : import { FormGroup, type FormGroupProps, FormHelperText } from "@patternfly/react-core/dist/esm/components/Form";
     609              : import { TextInput, type TextInputProps } from "@patternfly/react-core/dist/esm/components/TextInput";
     610              : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
     611              : import {
     612              :     HelperText, HelperTextItem, type HelperTextItemProps
     613              : } from "@patternfly/react-core/dist/esm/components/HelperText";
     614              : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
     615              : import {
     616              :     FormSelect, FormSelectOption, type FormSelectProps,
     617              : } from "@patternfly/react-core/dist/esm/components/FormSelect";
     618              : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
     619              : import { InputGroup, InputGroupItem } from "@patternfly/react-core/dist/esm/components/InputGroup/index.js";
     620              : import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons";
     621              : 
     622            3 : const _ = cockpit.gettext;
     623              : 
     624            2 : function debug(...args: unknown[]) {
     625            2 :     if (window.debugging == "all" || window.debugging?.includes("dialog"))
     626            2 :         console.debug("dialog:", ...args);
     627            2 : }
     628              : 
     629              : type ArrayElement<ArrayType> =
     630              :   ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
     631              : 
     632            1 : function toSpliced<T>(arr: T[], start: number, deleteCount: number, ...rest: T[]): T[] {
     633            1 :     const copy = [...arr];
     634            1 :     copy.splice(start, deleteCount, ...rest);
     635            1 :     return copy;
     636            1 : }
     637              : 
     638              : /* It would be nice to make it so that TypeScript complains when you
     639              :    try to return errors for sub-fields that don't exist.  We can
     640              :    construct a type that describes this (by recursively mapping all
     641              :    field to strings, essentially), but TypeScript will not ordinarily
     642              :    prevent a validation function from returning something that also
     643              :    has other fields, because it does only do "excessive property
     644              :    checks" for literals that are assigned to locations with a known
     645              :    type.  Example:
     646              : 
     647              :       interface ComboValue {
     648              :         size: number;
     649              :         unit: string;
     650              :       }
     651              : 
     652              :       dlg.field("combo").validate(v => {
     653              :         return {
     654              :           size: true,           // TypeScript error here as expected, because true is not a string
     655              :           unt: "No such unit",  // Want a TypeScript error here bc there is no "unt" in the type, but wont happen
     656              :         }
     657              :       });
     658              : 
     659              :    We would need to explicitly annotate the validation function with
     660              :    the right return type like so:
     661              : 
     662              :       dlg.field("combo").validate((v): DialogValidationResult<ComboValue> => {
     663              :         return {
     664              :           unt: "No such unit",  // Now we get an error.
     665              :         }
     666              :       });
     667              : 
     668              :    But I doubt that people will be happy to write out their type like
     669              :    this every time...
     670              :  */
     671              : 
     672              : export type DialogValidationResult<T> = (
     673              :    T extends object
     674              :     ? ({ [Property in keyof T]+?: DialogValidationResult<T[Property]> } & { ""?: undefined | string }) | undefined | string
     675              :     : undefined | string | { ""?: undefined | string }
     676              : );
     677              : 
     678            2 : function state_path(state: DialogFieldState): string {
     679            2 :     const p = state.parent ? state_path(state.parent) : "";
     680            2 :     const t = String(state.tag);
     681            1 :     return p ? `${p}.${t}` : t;
     682            2 : }
     683              : 
     684            3 : export class DialogField<T> {
     685              :     /* eslint-disable no-use-before-define */
     686            2 :     #dialog: DialogState<unknown>;
     687            2 :     #state: DialogFieldState;
     688              :     /* eslint-enable */
     689            2 :     #getter: () => T;
     690            2 :     #setter: (val: T) => void;
     691              : 
     692            2 :     constructor(
     693            2 :         dialog: DialogState<unknown>,
     694            2 :         state: DialogFieldState,
     695            2 :         getter: () => T,
     696            2 :         setter: (val: T) => void,
     697            2 :     ) {
     698            2 :         this.#dialog = dialog;
     699            2 :         this.#state = state;
     700            2 :         this.#getter = getter;
     701            2 :         this.#setter = setter;
     702            2 :     }
     703              : 
     704            2 :     validation_text(): string | undefined {
     705            2 :         return this.#state.validation_text;
     706            2 :     }
     707              : 
     708            2 :     get(): T {
     709            2 :         return this.#getter();
     710            2 :     }
     711              : 
     712            2 :     set(val: T): void {
     713            2 :         this.#dialog._cancel_state_tasks(this.#state, true);
     714            2 :         this.#setter(val);
     715            2 :     }
     716              : 
     717            2 :     id(tag: string = "field"): string {
     718            2 :         return this.#dialog.id_prefix + "-" + tag + "-" + state_path(this.#state);
     719            2 :     }
     720              : 
     721            2 :     map<X>(func: (val: DialogField<ArrayElement<T>>, index: number) => X): X[] {
     722            2 :         const val = this.get();
     723            2 :         if (Array.isArray(val)) {
     724            1 :             return val.map((_, i) => func(this.sub(i as keyof T) as DialogField<ArrayElement<T>>, i));
     725            2 :         } else
     726            2 :             return [];
     727            2 :     }
     728              : 
     729            1 :     forEach(func: (val: DialogField<ArrayElement<T>>, index: number) => void): void {
     730            1 :         const val = this.get();
     731            1 :         if (Array.isArray(val)) {
     732            1 :             val.forEach((_, i) => func(this.sub(i as keyof T) as DialogField<ArrayElement<T>>, i));
     733            1 :         }
     734            1 :     }
     735              : 
     736            1 :     remove(index: number) {
     737            1 :         const val = this.get();
     738            1 :         if (Array.isArray(val)) {
     739            1 :             const sub = this.#state.sub.get(index);
     740            1 :             if (sub) {
     741            1 :                 this.#dialog._cancel_state_tasks(sub);
     742            1 :                 sub.tag = -1;
     743            1 :             }
     744            1 :             for (let j = index; j < val.length - 1; j++) {
     745            1 :                 const sub = this.#state.sub.get(j + 1);
     746            1 :                 if (sub) {
     747            1 :                     sub.tag = j;
     748            1 :                     this.#state.sub.set(j, sub);
     749            1 :                 }
     750            1 :                 this.#state.sub.delete(val.length - 1);
     751            1 :             }
     752            1 :             this.#setter(toSpliced(val, index, 1) as T);
     753            1 :         }
     754            1 :     }
     755              : 
     756            1 :     add(item: ArrayElement<T>) {
     757            1 :         const val = this.get();
     758            1 :         if (Array.isArray(val)) {
     759            1 :             this.#setter(val.concat(item) as T);
     760            1 :         }
     761            1 :     }
     762              : 
     763            2 :     sub<K extends keyof T>(tag: K, update_func?: ((val: T[K]) => void) | undefined): DialogField<T[K]> {
     764            2 :         const sub = this.#dialog._get_sub_state(this.#state, tag);
     765            2 :         return new DialogField<T[K]>(
     766            2 :             this.#dialog,
     767            2 :             sub,
     768            2 :             () => {
     769            2 :                 const container = this.get();
     770            1 :                 if (Array.isArray(container) && typeof sub.tag == "number") {
     771            1 :                     return container[sub.tag];
     772            1 :                 } else {
     773            2 :                     return container[tag];
     774            2 :                 }
     775            2 :             },
     776            2 :             (val) => {
     777            2 :                 const container = this.get();
     778            1 :                 if (Array.isArray(container) && typeof sub.tag == "number") {
     779            1 :                     this.#setter(toSpliced(container, sub.tag, 1, val) as T);
     780            1 :                 } else {
     781            2 :                     this.#setter({ ...container, [tag]: val });
     782            2 :                 }
     783            2 :                 if (update_func)
     784            2 :                     update_func(val);
     785            2 :             },
     786            2 :         );
     787            2 :     }
     788              : 
     789            1 :     at<TT extends T>(witness: TT): DialogField<TT> {
     790            1 :         cockpit.assert(Object.is(witness, this.get()));
     791            1 :         return this as unknown as DialogField<TT>;
     792            1 :     }
     793              : 
     794            1 :     validate(func: (val: T) => DialogValidationResult<T>): void {
     795            1 :         const val = this.get();
     796            1 :         this.#dialog._validate_value(this.#state, val, () => func(val));
     797            1 :     }
     798              : 
     799            1 :     validate_async(debounce: number, func: (val: T, task: DialogTask) => Promise<DialogValidationResult<T>>): void {
     800            1 :         const val = this.get();
     801            1 :         this.#dialog._validate_value_async(this.#state, val, debounce, task => func(val, task));
     802            1 :     }
     803              : 
     804            2 :     set_async(debounce: number, func: (val: T, task: DialogTask) => Promise<T>): void {
     805            2 :         const val = this.get();
     806            2 :         this.#dialog._update_value_async(this.#state, true, debounce, async task => {
     807            2 :             const new_val = await func(val, task);
     808            2 :             if (!task.is_cancelled())
     809            2 :                 this.set(new_val);
     810            2 :         });
     811            2 :     }
     812              : 
     813            1 :     get_async(debounce: number, func: (val: T, task: DialogTask) => Promise<void>): void {
     814            1 :         const val = this.get();
     815            1 :         this.#dialog._update_value_async(this.#state, false, debounce, task => func(val, task));
     816            1 :     }
     817            3 : }
     818              : 
     819            1 : function get_validation_result_own_string(result: unknown): string | undefined {
     820            1 :     if (typeof result == "string")
     821            1 :         return result;
     822            1 :     else if (result && typeof result == "object" && "" in result && typeof result[""] == "string")
     823            1 :         return result[""];
     824              :     else
     825            1 :         return undefined;
     826            1 : }
     827              : 
     828            3 : export class DialogTask {
     829            2 :     #name: string;
     830            2 :     #cancelled: boolean = false;
     831            2 :     #on_cancel: (() => void) | null = null;
     832            2 :     #timeout_id: number = 0;
     833            2 :     #promise: Promise<void> | null = null;
     834            2 :     #start: () => void;
     835            2 :     #done: (task: DialogTask) => void;
     836              : 
     837            2 :     constructor(
     838            2 :         name: string,
     839            2 :         debounce: number,
     840            2 :         func: (task: DialogTask) => Promise<void>,
     841            2 :         done: (task: DialogTask) => void,
     842            2 :     ) {
     843            2 :         this.#name = name;
     844            2 :         this.#done = done;
     845            2 :         this.#start = () => {
     846            2 :             debug("starting task", this.#name);
     847            2 :             cockpit.assert(!this.#cancelled);
     848            2 :             this.#promise = func(this);
     849            2 :             this.#promise.finally(() => {
     850            2 :                 debug("task done", this.#name);
     851            2 :                 done(this);
     852            2 :             });
     853            2 :         };
     854            2 :         this.#timeout_id = window.setTimeout(this.#start, debounce);
     855            2 :         debug("creating task", this.#name, debounce);
     856            2 :     }
     857              : 
     858            1 :     start_now() {
     859            1 :         if (!this.#promise && !this.#cancelled) {
     860            1 :             debug("skipping debounce of task", this.#name);
     861            1 :             window.clearTimeout(this.#timeout_id);
     862            1 :             this.#start();
     863            1 :         }
     864            1 :     }
     865              : 
     866            1 :     async wait() {
     867              :         // Waiting is only allowed for tasks that have actually been started.
     868            1 :         cockpit.assert(this.#promise);
     869            1 :         debug("waiting for task", this.#name);
     870            1 :         await this.#promise;
     871            1 :     }
     872              : 
     873            1 :     set_cancel(cancel: (() => void) | null) {
     874            1 :         this.#on_cancel = cancel;
     875            1 :     }
     876              : 
     877            2 :     is_cancelled() {
     878            2 :         return this.#cancelled;
     879            2 :     }
     880              : 
     881            2 :     cancel() {
     882            2 :         debug("cancelling task", this.#name);
     883            2 :         window.clearTimeout(this.#timeout_id);
     884            2 :         if (this.#on_cancel)
     885            1 :             this.#on_cancel();
     886            2 :         this.#cancelled = true;
     887            2 :         if (!this.#promise) {
     888            2 :             debug("cancelled task done", this.#name);
     889            2 :             this.#done(this);
     890            2 :         }
     891            2 :     }
     892            3 : }
     893              : 
     894              : /* A DialogFieldState object holds all state for a field.  Unlike
     895              :    handles, there is at most one of these objects for each field, and
     896              :    each handle for a given field refers to the exact same
     897              :    DialogFieldState object.
     898              : 
     899              :    DialogFieldStates are created on-demand and will over time form a
     900              :    tree via "parent" and "sub" that corresponds to the dialog value.
     901              : 
     902              :    The "tag" is used to access the dialog value.  A handle constructed
     903              :    via dlg.field("name") will point to a state object with tag "name",
     904              :    for example, and calling handle.get() will return
     905              :    dlg.values["name"].
     906              : 
     907              :    Other members of a DialogFieldState relate to validation and
     908              :    asynchronous updates.
     909              :  */
     910              : 
     911              : interface DialogFieldState {
     912              :     parent: DialogFieldState | null,
     913              :     tag: string | number | symbol;
     914              :     sub: Map<string | number | symbol, DialogFieldState>;
     915              :     // validation
     916              :     relevant: boolean;
     917              :     validation_text: string | undefined;
     918              :     cached_value: unknown;
     919              :     cached_result: unknown;
     920              :     validation_task: DialogTask | null;
     921              :     // updates
     922              :     update_task: DialogTask | null;
     923              :     update_tasks: Set<DialogTask>;
     924              : }
     925              : 
     926              : interface DialogStateEvents {
     927              :     changed(): void;
     928              : }
     929              : 
     930            2 : export class DialogState<V> extends EventEmitter<DialogStateEvents> {
     931              :     values: V;
     932              : 
     933            2 :     id_prefix: string = "dialog";
     934            2 :     busy: boolean = false;
     935            2 :     actions_disabled: boolean = false;
     936            2 :     cancel_disabled: boolean = false;
     937              : 
     938            2 :     error: unknown = null;
     939              : 
     940            2 :     #validation_failed: boolean = false;
     941            2 :     #online_validation: boolean = false;
     942            2 :     #action_running: boolean = false;
     943            2 :     #block_updates: boolean = false;
     944            2 :     #cancel_function: (() => void) | null = null;
     945              : 
     946            2 :     #top_state: DialogFieldState;
     947              : 
     948              :     /* eslint-disable no-use-before-define */
     949            2 :     #validate_callback: undefined | ((dlg: DialogState<V>) => void);
     950              :     /* eslint-enable */
     951              : 
     952            2 :     constructor(init: V, validate: undefined | ((dlg: DialogState<V>) => void)) {
     953            2 :         debug("open");
     954            2 :         super();
     955            2 :         this.#validate_callback = validate;
     956            2 :         this.values = init;
     957            2 :         this.#top_state = {
     958            2 :             parent: null,
     959            2 :             tag: "",
     960            2 :             sub: new Map(),
     961            2 :             relevant: false,
     962            2 :             validation_text: undefined,
     963            2 :             cached_value: undefined,
     964            2 :             cached_result: undefined,
     965            2 :             validation_task: null,
     966            2 :             update_task: null,
     967            2 :             update_tasks: new Set(),
     968            2 :         };
     969            2 :     }
     970              : 
     971            2 :     set_id_prefix(id_prefix: string): DialogState<V> {
     972            2 :         this.id_prefix = id_prefix;
     973            2 :         return this;
     974            2 :     }
     975              : 
     976            2 :     #update() {
     977            2 :         this.busy = this.#action_running;
     978            2 :         this.actions_disabled = this.#action_running || this.#validation_failed;
     979            2 :         this.cancel_disabled = this.#action_running && !this.#cancel_function;
     980            2 :         this.emit("changed");
     981            2 :     }
     982              : 
     983              :     /* FIELD STATES
     984              : 
     985              :        During validation and asynchronous updates, a lot is going on.
     986              : 
     987              :        We use a DialogFieldState object to keep the necessary
     988              :        state for that, such as cached results, and timeouts and
     989              :        promises.
     990              : 
     991              :        These state objects keep their identity when arrays elements
     992              :        move around.  Their "index" field will be changed when that
     993              :        happens.
     994              :      */
     995              : 
     996            2 :     _get_sub_state(state: DialogFieldState, tag: string | number | symbol): DialogFieldState {
     997            2 :         let sub = state.sub.get(tag);
     998            2 :         if (!sub) {
     999            2 :             sub = {
    1000            2 :                 parent: state,
    1001            2 :                 tag,
    1002            2 :                 sub: new Map(),
    1003            2 :                 relevant: false,
    1004            2 :                 validation_text: undefined,
    1005            2 :                 cached_value: undefined,
    1006            2 :                 cached_result: undefined,
    1007            2 :                 validation_task: null,
    1008            2 :                 update_task: null,
    1009            2 :                 update_tasks: new Set(),
    1010            2 :             };
    1011            2 :             state.sub.set(tag, sub);
    1012            2 :         }
    1013            2 :         return sub;
    1014            2 :     }
    1015              : 
    1016            2 :     _for_each_field_state(func: (state: DialogFieldState) => void) {
    1017            2 :         function visit(state: DialogFieldState) {
    1018            2 :             func(state);
    1019            2 :             for (const sub of state.sub.values())
    1020            2 :                 visit(sub);
    1021            2 :         }
    1022            2 :         visit(this.#top_state);
    1023            2 :     }
    1024              : 
    1025            2 :     async _for_each_field_state_async(func: (state: DialogFieldState) => Promise<void>) {
    1026            2 :         async function visit(state: DialogFieldState) {
    1027            2 :             await func(state);
    1028            2 :             for (const sub of state.sub.values())
    1029            2 :                 await visit(sub);
    1030            2 :         }
    1031            2 :         await visit(this.#top_state);
    1032            2 :     }
    1033              : 
    1034              :     /* TASKS
    1035              : 
    1036              :        Tasks are a little abstraction that runs a asynchronous
    1037              :        function after a debounce timeout.  Before running the action
    1038              :        function, we need to wait for them all to finish.
    1039              :      */
    1040              : 
    1041            2 :     async _run_all_tasks_now() {
    1042            2 :         let awaited: boolean = false;
    1043            2 :         do {
    1044            2 :             this._for_each_field_state(state => {
    1045            2 :                 if (state.validation_task)
    1046            1 :                     state.validation_task.start_now();
    1047            2 :                 if (state.update_task)
    1048            1 :                     state.update_task.start_now();
    1049            2 :                 for (const task of state.update_tasks.values())
    1050            1 :                     task.start_now();
    1051            2 :             });
    1052              : 
    1053            2 :             awaited = false;
    1054            2 :             await this._for_each_field_state_async(async state => {
    1055            1 :                 if (state.validation_task) {
    1056            1 :                     await state.validation_task.wait();
    1057            1 :                     awaited = true;
    1058            1 :                 }
    1059            1 :                 if (state.update_task) {
    1060            1 :                     await state.update_task.wait();
    1061            1 :                     awaited = true;
    1062            1 :                 }
    1063            1 :                 for (const task of state.update_tasks.values()) {
    1064            1 :                     await task.wait();
    1065            1 :                     awaited = true;
    1066            1 :                 }
    1067            2 :             });
    1068            2 :         } while (awaited);
    1069            2 :     }
    1070              : 
    1071            2 :     _cancel_state_tasks(state: DialogFieldState, only_updates: boolean = false) {
    1072            2 :         debug("cancelling state tasks", state_path(state), only_updates);
    1073            1 :         if (state.validation_task && !only_updates)
    1074            1 :             state.validation_task.cancel();
    1075            2 :         if (state.update_task)
    1076            2 :             state.update_task.cancel();
    1077            2 :         for (const task of state.update_tasks.values())
    1078            1 :             task.cancel();
    1079            2 :         for (const sub of state.sub.values())
    1080            1 :             this._cancel_state_tasks(sub, only_updates);
    1081            2 :     }
    1082              : 
    1083              :     /* VALIDATION
    1084              : 
    1085              :        Validation is started by calling the #trigger_validation
    1086              :        method. This will reset all validation errors and mark all
    1087              :        fields as "irrelevant". Then it calls the provided "validate"
    1088              :        callback, which in turn will (eventually but synchronously)
    1089              :        call the "_validate_value" or "_validate_value_async" methods
    1090              :        of all relevant value paths.  Those functions will mark their
    1091              :        fields as relevant and eventually call #set_validation to
    1092              :        install the validation results in the field states.
    1093              : 
    1094              :        After this, all irrelevant asynchronous validation tasks are
    1095              :        cancelled.
    1096              :      */
    1097              : 
    1098            2 :     #validation_needed: boolean = false;
    1099            2 :     #validation_running: boolean = false;
    1100              : 
    1101            2 :     #trigger_validation(): void {
    1102            2 :         debug("trigger validation");
    1103            2 :         if (!this.#validate_callback)
    1104            2 :             return;
    1105              : 
    1106            1 :         this.#validation_needed = true;
    1107            1 :         if (this.#validation_running) {
    1108            1 :             debug("validation postponed");
    1109            1 :             return;
    1110            1 :         }
    1111              : 
    1112            1 :         this.#validation_running = true;
    1113            1 :         while (this.#validation_needed) {
    1114            1 :             debug("running validation");
    1115            1 :             this.#validation_needed = false;
    1116            1 :             this.#validation_failed = false;
    1117            1 :             this._for_each_field_state(state => {
    1118            1 :                 state.relevant = false;
    1119            1 :                 state.validation_text = undefined;
    1120            1 :             });
    1121            1 :             this.#validate_callback(this);
    1122            1 :             this._for_each_field_state(state => {
    1123            1 :                 if (!state.relevant && state.validation_task) {
    1124            1 :                     debug("cancelling irrelevant validation task", state_path(state));
    1125            1 :                     state.validation_task.cancel();
    1126            1 :                 }
    1127            1 :             });
    1128            1 :         }
    1129            1 :         this.#validation_running = false;
    1130              : 
    1131            1 :         this.#update();
    1132            2 :     }
    1133              : 
    1134            1 :     #set_validation(state: DialogFieldState, result: unknown) {
    1135            1 :         if (result) {
    1136            1 :             const own = get_validation_result_own_string(result);
    1137            1 :             if (own) {
    1138            1 :                 state.validation_text = own;
    1139            1 :                 this.#validation_failed = true;
    1140            1 :                 this.#online_validation = true;
    1141            1 :             }
    1142            1 :             if (typeof result == "object") {
    1143            1 :                 for (const [k, v] of Object.entries(result)) {
    1144            1 :                     const sub = k && state.sub.get(k);
    1145            1 :                     if (sub)
    1146            1 :                         this.#set_validation(sub, v);
    1147            1 :                 }
    1148            1 :             }
    1149            1 :         }
    1150            1 :     }
    1151              : 
    1152              :     /* The field state has a cache of the most recently validated
    1153              :        value.  If the current value is still the same, actual
    1154              :        validation is skipped and the cached result from last time is
    1155              :        used.
    1156              : 
    1157              :        Calling #set_validation_state_result is the final thing that
    1158              :        should happen when validating a field. It will install the
    1159              :        result in the cache and then call #set_validation.
    1160              :      */
    1161              : 
    1162            1 :     #set_validation_state_result(
    1163            1 :         state: DialogFieldState,
    1164            1 :         val: unknown,
    1165            1 :         result: unknown,
    1166            1 :     ) {
    1167            1 :         state.cached_value = val;
    1168            1 :         state.cached_result = result;
    1169            1 :         this.#set_validation(state, result);
    1170            1 :     }
    1171              : 
    1172              :     /* The first thing should be of course to probe that cache.  If we
    1173              :        get a hit, it is used immediately to call #set_validation.
    1174              :      */
    1175              : 
    1176            1 :     #probe_validation_state_cache(state: DialogFieldState, val: unknown): boolean {
    1177            1 :         if (Object.is(state.cached_value, val)) {
    1178            1 :             debug("cache hit", state_path(state), JSON.stringify(val), state.cached_result);
    1179            1 :             this.#set_validation(state, state.cached_result);
    1180            1 :             return true;
    1181            1 :         } else
    1182            1 :             return false;
    1183            1 :     }
    1184              : 
    1185              :     /* And in fact, _validate_value does exactly those two things.
    1186              :      */
    1187              : 
    1188            1 :     _validate_value(state: DialogFieldState, val: unknown, func: () => unknown): void {
    1189            1 :         state.relevant = true;
    1190            1 :         if (!this.#probe_validation_state_cache(state, val)) {
    1191            1 :             const result = func();
    1192            1 :             debug("sync validate", state_path(state), JSON.stringify(result));
    1193            1 :             this.#set_validation_state_result(state, val, result);
    1194            1 :         }
    1195            1 :     }
    1196              : 
    1197              :     /* Now asynchronous validation.
    1198              : 
    1199              :        If there was no cache hit, asynchronous validation starts with
    1200              :        a timeout, followed by letting a asynchronous function run to
    1201              :        resolution.  This is managed by a DialogTask.
    1202              : 
    1203              :        Starting a new task of course cancels any previous one. It also
    1204              :        installs the current value in the cache, so that subsequent
    1205              :        validation rounds do nothing until the value actually changes.
    1206              : 
    1207              :        When the validation result has been computed, we need to check
    1208              :        whether we have been cancelled so that we don't install
    1209              :        out-dated results.
    1210              :      */
    1211              : 
    1212            1 :     _validate_value_async(
    1213            1 :         state: DialogFieldState,
    1214            1 :         val: unknown,
    1215            1 :         debounce: number,
    1216            1 :         func: (task: DialogTask) => Promise<unknown>
    1217            1 :     ): void {
    1218            1 :         state.relevant = true;
    1219            1 :         if (!this.#probe_validation_state_cache(state, val)) {
    1220            1 :             state.cached_value = val;
    1221            1 :             state.cached_result = undefined;
    1222              : 
    1223            1 :             if (state.validation_task)
    1224            1 :                 state.validation_task.cancel();
    1225            1 :             state.validation_task = new DialogTask(
    1226            1 :                 state_path(state) + ":validate",
    1227            1 :                 debounce,
    1228            1 :                 async task => {
    1229            1 :                     let result;
    1230            1 :                     try {
    1231            1 :                         result = await func(task);
    1232            1 :                     } catch (ex) {
    1233            1 :                         console.error(ex);
    1234            1 :                     }
    1235            1 :                     if (!task.is_cancelled()) {
    1236            1 :                         debug("async validate result", state_path(state), result);
    1237            1 :                         this.#set_validation_state_result(state, val, result);
    1238            1 :                         this.#update();
    1239            1 :                     }
    1240            1 :                 },
    1241            1 :                 task => {
    1242            1 :                     if (state.validation_task == task)
    1243            1 :                         state.validation_task = null;
    1244            1 :                 }
    1245            1 :             );
    1246            1 :         }
    1247            1 :     }
    1248              : 
    1249            2 :     _update_value_async(
    1250            2 :         state: DialogFieldState,
    1251            2 :         for_set: boolean,
    1252            2 :         debounce: number,
    1253            2 :         func: (task: DialogTask) => Promise<void>
    1254            2 :     ): void {
    1255            2 :         const task = new DialogTask(
    1256            2 :             state_path(state) + ":update",
    1257            2 :             debounce,
    1258            2 :             async ctxt => {
    1259            2 :                 try {
    1260            2 :                     await func(ctxt);
    1261            0 :                 } catch (ex) {
    1262            0 :                     console.error(ex);
    1263            0 :                 }
    1264            2 :             },
    1265            2 :             task => {
    1266            2 :                 if (for_set) {
    1267            2 :                     if (state.update_task == task)
    1268            2 :                         state.update_task = null;
    1269            1 :                 } else {
    1270            1 :                     state.update_tasks.delete(task);
    1271            1 :                 }
    1272            2 :             }
    1273            2 :         );
    1274              : 
    1275            2 :         if (for_set) {
    1276            2 :             if (state.update_task)
    1277            2 :                 state.update_task.cancel();
    1278            2 :             state.update_task = task;
    1279            1 :         } else {
    1280            1 :             state.update_tasks.add(task);
    1281            1 :         }
    1282            2 :     }
    1283              : 
    1284              :     /* The first thing run_action does is to trigger a new validation
    1285              :        round and then wait for all the asynchronous results to have
    1286              :        come in.
    1287              : 
    1288              :        If there are any DialogFieldState objects that are waiting
    1289              :        for a timeout, we want to cancel those and start over, so that
    1290              :        their validation starts immediately. (Also, it would be hairy
    1291              :        to wait for those timeouts to be over from here.)
    1292              :      */
    1293              : 
    1294            2 :     async validate(): Promise<boolean> {
    1295            2 :         this.#online_validation = true;
    1296            2 :         this.#trigger_validation();
    1297            2 :         await this._run_all_tasks_now();
    1298            2 :         return !this.#validation_failed;
    1299            2 :     }
    1300              : 
    1301            1 :     set_cancel(cancel: (() => void) | null) {
    1302            1 :         this.#cancel_function = cancel;
    1303            1 :         this.#update();
    1304            1 :     }
    1305              : 
    1306            2 :     async run_action(func: (vals: V) => Promise<void>): Promise<boolean> {
    1307            2 :         this.error = null;
    1308            2 :         this.#cancel_function = null;
    1309            2 :         this.#action_running = true;
    1310            2 :         this.#update();
    1311            1 :         if (!await this.validate()) {
    1312            1 :             this.#action_running = false;
    1313            1 :             this.#update();
    1314            1 :             return false;
    1315            1 :         }
    1316              : 
    1317            2 :         try {
    1318            2 :             this.#block_updates = true;
    1319            2 :             await func(this.values);
    1320            1 :         } catch (ex) {
    1321            1 :             console.error(String(ex));
    1322            1 :             this.error = ex;
    1323            1 :         }
    1324              : 
    1325            2 :         this.#cancel_function = null;
    1326            2 :         this.#action_running = false;
    1327            2 :         this.#block_updates = false;
    1328            2 :         this.#update();
    1329              : 
    1330            2 :         return !this.error;
    1331            2 :     }
    1332              : 
    1333            1 :     cancel(onClose: () => void): void {
    1334            1 :         if (this.#action_running) {
    1335            1 :             if (this.#cancel_function)
    1336            1 :                 this.#cancel_function();
    1337            1 :         } else {
    1338            1 :             this._cancel_state_tasks(this.#top_state);
    1339            1 :             onClose();
    1340            1 :         }
    1341            1 :     }
    1342              : 
    1343            2 :     top(update_func?: ((val: V) => void) | undefined): DialogField<V> {
    1344            2 :         return new DialogField<V>(
    1345            2 :             this as DialogState<unknown>,
    1346            2 :             this.#top_state,
    1347            2 :             () => this.values,
    1348            2 :             (val) => {
    1349            2 :                 debug("set", val);
    1350            1 :                 if (this.#block_updates) {
    1351              :                     // Deny state changes while actions run.  This
    1352              :                     // prevents the user from interacting with the
    1353              :                     // dialog while it is busy. The alternative would
    1354              :                     // be to officially disable all fields and prevent
    1355              :                     // interactions that way, but that is visually
    1356              :                     // very jarring and not something that we have
    1357              :                     // been doing earlier.
    1358            1 :                     debug("set denied");
    1359            1 :                     return;
    1360            1 :                 }
    1361            2 :                 this.values = val;
    1362            2 :                 this.#update();
    1363            2 :                 if (this.#online_validation)
    1364            1 :                     this.#trigger_validation();
    1365            2 :                 if (update_func)
    1366            1 :                     update_func(val);
    1367            2 :             },
    1368            2 :         );
    1369            2 :     }
    1370              : 
    1371            2 :     field<K extends keyof V>(tag: K, update_func?: ((val: V[K]) => void) | undefined): DialogField<V[K]> {
    1372            2 :         return this.top().sub(tag, update_func);
    1373            2 :     }
    1374            3 : }
    1375              : 
    1376            3 : export class DialogError {
    1377              :     title: string;
    1378              :     details: React.ReactNode;
    1379              : 
    1380            1 :     constructor(title: string, details?: React.ReactNode) {
    1381            1 :         this.title = title;
    1382            1 :         this.details = details;
    1383            1 :     }
    1384              : 
    1385            1 :     toString() {
    1386            1 :         return this.title + ": " + String(this.details);
    1387            1 :     }
    1388              : 
    1389            1 :     static fromError(title: string, err: unknown) {
    1390            1 :         if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
    1391            1 :             return new DialogError(title, err.message);
    1392            1 :         } else {
    1393            1 :             return new DialogError(title, String(err));
    1394            1 :         }
    1395            1 :     }
    1396            3 : }
    1397              : 
    1398            2 : export function useDialogState<V extends object>(
    1399            2 :     init: V | (() => V),
    1400            2 :     validate?: undefined | ((dlg: DialogState<V>) => void),
    1401            2 : ) : DialogState<V> {
    1402            2 :     const dlg = useObject(
    1403            2 :         () => new DialogState(
    1404            2 :             typeof init == "function" ? init() : init,
    1405            2 :             validate
    1406            2 :         ),
    1407            2 :         null,
    1408            2 :         []
    1409            2 :     );
    1410            2 :     useOn(dlg, "changed");
    1411            2 :     return dlg;
    1412            2 : }
    1413              : 
    1414            1 : export function useDialogState_async<V extends object>(
    1415            1 :     init: () => Promise<V>,
    1416            1 :     validate?: undefined | ((dlg: DialogState<V>) => void),
    1417            1 : ) : null | DialogError | DialogState<V> {
    1418            1 :     const [dlg, setDlg] = useState<null | DialogError | DialogState<V>>(null);
    1419            1 :     useOn((dlg instanceof DialogError ? null : dlg), "changed");
    1420            1 :     useInit(async () => {
    1421            1 :         try {
    1422            1 :             setDlg(new DialogState<V>(await init(), validate));
    1423            1 :         } catch (ex) {
    1424            1 :             if (ex instanceof DialogError)
    1425            1 :                 setDlg(ex);
    1426              :             else
    1427            1 :                 setDlg(DialogError.fromError(_("Error during initialization"), ex));
    1428            1 :         }
    1429            1 :     });
    1430            1 :     return dlg;
    1431            1 : }
    1432              : 
    1433              : // Common elements
    1434              : 
    1435            2 : export function DialogErrorMessage<V>({
    1436            2 :     dialog,
    1437            2 : } : {
    1438              :     dialog: DialogState<V> | DialogError | null,
    1439            2 : }) {
    1440            1 :     const err = (!dialog || dialog instanceof DialogError) ? dialog : dialog.error;
    1441            2 :     if (!err)
    1442            2 :         return null;
    1443              : 
    1444            1 :     let title: string;
    1445            1 :     let details: React.ReactNode;
    1446              : 
    1447            1 :     if (err instanceof DialogError) {
    1448            1 :         title = err.title;
    1449            1 :         details = err.details;
    1450            1 :     } else if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
    1451            1 :         title = _("Failed");
    1452            1 :         details = err.message;
    1453            1 :     } else {
    1454            1 :         title = _("Failed");
    1455            1 :         details = String(err);
    1456            1 :     }
    1457              : 
    1458            1 :     const pfx = dialog instanceof DialogState ? dialog.id_prefix : "dialog";
    1459              : 
    1460            2 :     return (
    1461            2 :         <Alert
    1462            2 :             id={`${pfx}-error-message`}
    1463            2 :             variant='danger'
    1464            2 :             isInline
    1465            2 :             title={title}
    1466              :         >
    1467            2 :             {details}
    1468            2 :         </Alert>
    1469              :     );
    1470            2 : }
    1471              : 
    1472            2 : export function DialogActionButton<V>({
    1473            2 :     dialog,
    1474            2 :     children,
    1475            2 :     action,
    1476            2 :     onClose = undefined,
    1477            2 :     ...props
    1478            2 : } : {
    1479              :     dialog: DialogState<V> | DialogError | null,
    1480              :     children: React.ReactNode,
    1481              :     action: (values: V) => Promise<void>,
    1482              :     onClose?: undefined | (() => void)
    1483            2 : } & Omit<ButtonProps, "id" | "action" | "isLoading" | "isDisabled" | "variant" | "onClick">) {
    1484            1 :     const pfx = dialog instanceof DialogState ? dialog.id_prefix : "dialog";
    1485              : 
    1486            2 :     return (
    1487            2 :         <Button
    1488            2 :             id={`${pfx}-apply`}
    1489            2 :             isLoading={!!dialog && !(dialog instanceof DialogError) && dialog.busy}
    1490            2 :             isDisabled={!dialog || dialog instanceof DialogError || dialog.actions_disabled}
    1491            2 :             variant="primary"
    1492            2 :             onClick={async () => {
    1493            2 :                 cockpit.assert(dialog && !(dialog instanceof DialogError));
    1494            2 :                 if (await dialog.run_action(action) && onClose)
    1495            2 :                     onClose();
    1496            2 :             }}
    1497            2 :             {...props}
    1498              :         >
    1499            2 :             {children}
    1500            2 :         </Button>
    1501              :     );
    1502            2 : }
    1503              : 
    1504            2 : export function DialogCancelButton<V>({
    1505            2 :     dialog,
    1506            2 :     onClose,
    1507            2 :     ...props
    1508            2 : } : {
    1509              :     dialog: DialogState<V> | DialogError | null,
    1510              :     onClose: () => void
    1511            2 : } & Omit<ButtonProps, "id" | "isDisabled" | "variant" | "onClick">) {
    1512            1 :     const pfx = dialog instanceof DialogState ? dialog.id_prefix : "dialog";
    1513              : 
    1514            2 :     return (
    1515            2 :         <Button
    1516            2 :             id={`${pfx}-cancel`}
    1517            2 :             isDisabled={!dialog || (dialog instanceof DialogState && dialog.cancel_disabled)}
    1518            2 :             variant="link"
    1519            1 :             onClick={() => {
    1520            1 :                 if (dialog instanceof DialogState)
    1521            1 :                     dialog.cancel(onClose);
    1522              :                 else
    1523            1 :                     onClose();
    1524            1 :             }}
    1525            2 :             {...props}
    1526              :         >
    1527            2 :             {_("Cancel")}
    1528            2 :         </Button>
    1529              :     );
    1530            2 : }
    1531              : 
    1532              : /* Common dialog field implementations.
    1533              :  */
    1534              : 
    1535              : type falsy = null | undefined | false;
    1536              : 
    1537            2 : export function DialogHelperText<V>({
    1538            2 :     field,
    1539            2 :     excuse,
    1540            2 :     warning,
    1541            2 :     explanation,
    1542            2 : } : {
    1543              :     field: DialogField<V>;
    1544              :     excuse?: string | falsy;
    1545              :     warning?: React.ReactNode;
    1546              :     explanation?: React.ReactNode;
    1547            2 : }) {
    1548            2 :     let text: React.ReactNode = field.validation_text();
    1549            2 :     let variant: HelperTextItemProps["variant"] = "error";
    1550            2 :     if (!text && excuse) {
    1551            2 :         text = excuse;
    1552            2 :         variant = "default";
    1553            2 :     }
    1554            1 :     if (!text && warning) {
    1555            1 :         text = warning;
    1556            1 :         variant = "warning";
    1557            1 :     }
    1558            2 :     if (!text) {
    1559            2 :         text = explanation;
    1560            2 :         variant = "default";
    1561            2 :     }
    1562              : 
    1563            2 :     if (!text)
    1564            2 :         return null;
    1565              : 
    1566            2 :     return (
    1567            2 :         <FormHelperText>
    1568            2 :             <HelperText>
    1569            2 :                 <HelperTextItem id={field.id("helper-text")} variant={variant}>
    1570            2 :                     {text}
    1571            2 :                 </HelperTextItem>
    1572            2 :             </HelperText>
    1573            2 :         </FormHelperText>
    1574              :     );
    1575            2 : }
    1576              : 
    1577            2 : export const OptionalFormGroup = ({
    1578            2 :     label,
    1579            2 :     children,
    1580            2 :     ...props
    1581            2 : } : {
    1582              :     label: React.ReactNode,
    1583              :     children: React.ReactNode,
    1584            2 : } & Omit<FormGroupProps, "label" | "children">) => {
    1585            2 :     if (label) {
    1586            2 :         return (
    1587            2 :             <FormGroup
    1588            2 :                 label={label}
    1589            2 :                 {...props}
    1590              :             >
    1591            2 :                 {children}
    1592            2 :             </FormGroup>
    1593              :         );
    1594            1 :     } else {
    1595            1 :         return children;
    1596            1 :     }
    1597            2 : };
    1598              : 
    1599            2 : export const DialogTextInput = ({
    1600            2 :     label = null,
    1601            2 :     field,
    1602            2 :     excuse,
    1603            2 :     warning,
    1604            2 :     explanation,
    1605            2 :     isDisabled = false,
    1606            2 :     ...props
    1607            2 : } : {
    1608              :     label?: React.ReactNode,
    1609              :     field: DialogField<string>,
    1610              :     excuse?: string | falsy,
    1611              :     warning?: React.ReactNode,
    1612              :     explanation?: React.ReactNode,
    1613              :     isDisabled?: boolean,
    1614            2 : } & Omit<TextInputProps, "id" | "label" | "value" | "onChange">) => {
    1615            2 :     return (
    1616            2 :         <OptionalFormGroup label={label} fieldId={field.id()}>
    1617            2 :             <TextInput
    1618            2 :                 id={field.id()}
    1619            2 :                 value={field.get()}
    1620            1 :                 onChange={(_event, val) => field.set(val)}
    1621            2 :                 isDisabled={!!excuse || isDisabled}
    1622            2 :                 {...props}
    1623            2 :             />
    1624            2 :             <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
    1625            2 :         </OptionalFormGroup>
    1626              :     );
    1627            2 : };
    1628              : 
    1629            0 : export const DialogPasswordInput = ({
    1630            0 :     label = null,
    1631            0 :     field,
    1632            0 :     excuse,
    1633            0 :     warning,
    1634            0 :     explanation,
    1635            0 :     isDisabled = false,
    1636            0 :     ...props
    1637            0 : } : {
    1638              :     label?: React.ReactNode,
    1639              :     field: DialogField<string>,
    1640              :     excuse?: string | falsy,
    1641              :     warning?: React.ReactNode,
    1642              :     explanation?: React.ReactNode,
    1643              :     isDisabled?: boolean,
    1644            0 : } & Omit<TextInputProps, "id" | "label" | "value" | "onChange">) => {
    1645            0 :     const [visible, setVisible] = useState(false);
    1646              : 
    1647            0 :     return (
    1648            0 :         <OptionalFormGroup label={label} fieldId={field.id()}>
    1649            0 :             <InputGroup>
    1650            0 :                 <InputGroupItem isFill>
    1651            0 :                     <TextInput
    1652            0 :                         id={field.id()}
    1653            0 :                         type={visible ? "text" : "password"}
    1654            0 :                         value={field.get()}
    1655            0 :                         onChange={(_event, value) => field.set(value)}
    1656            0 :                         isDisabled={!!excuse || isDisabled}
    1657            0 :                         {...props}
    1658            0 :                     />
    1659            0 :                 </InputGroupItem>
    1660            0 :                 <InputGroupItem>
    1661            0 :                     <Button
    1662            0 :                         variant="control"
    1663            0 :                         aria-label={visible ? _("Hide password") : _("Show password")}
    1664            0 :                         onClick={() => setVisible(!visible)}
    1665              :                     >
    1666            0 :                         {visible ? <EyeSlashIcon /> : <EyeIcon />}
    1667            0 :                     </Button>
    1668            0 :                 </InputGroupItem>
    1669            0 :             </InputGroup>
    1670            0 :             <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
    1671            0 :         </OptionalFormGroup>
    1672              :     );
    1673            0 : };
    1674              : 
    1675            2 : export const DialogCheckbox = ({
    1676            2 :     field_label = null,
    1677            2 :     checkbox_label,
    1678            2 :     field,
    1679            2 :     excuse,
    1680            2 :     warning,
    1681            2 :     explanation,
    1682            2 : } : {
    1683              :     field_label?: React.ReactNode,
    1684              :     checkbox_label: string,
    1685              :     field: DialogField<boolean>,
    1686              :     excuse?: string | falsy,
    1687              :     warning?: React.ReactNode,
    1688              :     explanation?: React.ReactNode,
    1689            2 : }) => {
    1690            2 :     return (
    1691            2 :         <OptionalFormGroup label={field_label} hasNoPaddingTop>
    1692            2 :             <Checkbox
    1693            2 :                 id={field.id()}
    1694            2 :                 isChecked={field.get()}
    1695            2 :                 label={checkbox_label}
    1696            1 :                 onChange={(_event, checked) => field.set(checked)}
    1697            2 :                 isDisabled={!!excuse}
    1698            2 :             />
    1699            2 :             <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
    1700            2 :         </OptionalFormGroup>
    1701              :     );
    1702            2 : };
    1703              : 
    1704              : export interface DialogRadioSelectOption<T extends string> {
    1705              :     value: T,
    1706              :     label: React.ReactNode,
    1707              :     explanation?: React.ReactNode,
    1708              :     excuse?: string | falsy,
    1709              : }
    1710              : 
    1711            2 : export function DialogRadioSelect<T extends string>({
    1712            2 :     label = null,
    1713            2 :     field,
    1714            2 :     options,
    1715            2 :     warning,
    1716            2 :     explanation,
    1717            2 :     isInline = false,
    1718            2 : } : {
    1719              :     label?: React.ReactNode,
    1720              :     field: DialogField<T>,
    1721              :     options: DialogRadioSelectOption<T>[],
    1722              :     warning?: React.ReactNode,
    1723              :     explanation?: React.ReactNode,
    1724              :     isInline?: boolean,
    1725            2 : }) {
    1726            2 :     function makeLabel(o: DialogRadioSelectOption<T>, i: number) {
    1727            2 :         const exc = o.excuse ? <> ({o.excuse})</> : null;
    1728            2 :         const pad = (!isInline && i < options.length - 1) ? <><br />{"\u00A0"}</> : null;
    1729            2 :         const exp = o.explanation ? <><br /><small>{o.explanation}{pad}</small></> : null;
    1730            2 :         return <div id={field.id(o.value + "-label")}>{o.label}{exc}{exp}</div>;
    1731            2 :     }
    1732              : 
    1733            2 :     return (
    1734            2 :         <OptionalFormGroup
    1735            2 :             label={label}
    1736            2 :             hasNoPaddingTop
    1737            2 :             isInline={isInline}
    1738            2 :             id={field.id()}
    1739            2 :             data-value={field.get()}
    1740              :         >
    1741              :             {
    1742            2 :                 options.map((o, i) =>
    1743            2 :                     <Radio
    1744            2 :                         key={o.value}
    1745            2 :                         id={field.id(o.value)}
    1746            2 :                         name={o.value}
    1747            2 :                         isChecked={field.get() == o.value}
    1748            2 :                         label={makeLabel(o, i)}
    1749            1 :                         onChange={() => field.set(o.value)}
    1750            2 :                         isDisabled={!!o.excuse}
    1751            2 :                     />
    1752            2 :                 )
    1753              :             }
    1754            2 :             <DialogHelperText explanation={explanation} warning={warning} field={field} />
    1755            2 :         </OptionalFormGroup>
    1756              :     );
    1757            2 : }
    1758              : 
    1759              : export interface DialogDropdownSelectOption<T extends string> {
    1760              :     value: T;
    1761              :     label: string;
    1762              : }
    1763              : 
    1764            2 : export function DialogDropdownSelect<T extends string>({
    1765            2 :     label,
    1766            2 :     field,
    1767            2 :     excuse,
    1768            2 :     warning,
    1769            2 :     explanation,
    1770            2 :     options,
    1771            2 :     ...props
    1772            2 : } : {
    1773              :     label?: React.ReactNode,
    1774              :     field: DialogField<T>,
    1775              :     excuse?: string | falsy,
    1776              :     warning?: React.ReactNode,
    1777              :     explanation?: React.ReactNode,
    1778              :     options: DialogDropdownSelectOption<T>[],
    1779            2 : } & Omit<FormSelectProps, "ref" | "children">) {
    1780            2 :     return (
    1781            2 :         <OptionalFormGroup label={label}>
    1782            2 :             <FormSelect
    1783            2 :                 id={field.id()}
    1784            1 :                 onChange={(_event, val) => field.set(val as T) }
    1785            1 :                 validated={warning ? "warning" : undefined}
    1786            2 :                 isDisabled={!!excuse}
    1787            2 :                 value={field.get()}
    1788            2 :                 {...props}
    1789              :             >
    1790              :                 {
    1791            2 :                     options.map(
    1792            2 :                         o => {
    1793            2 :                             return <FormSelectOption key={o.value} value={o.value} label={o.label} />;
    1794            2 :                         }
    1795            2 :                     )
    1796              :                 }
    1797            2 :             </FormSelect>
    1798            2 :             <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
    1799            2 :         </OptionalFormGroup>
    1800              :     );
    1801            2 : }
    1802              : 
    1803            2 : export function DialogDropdownSelectObject<T>({
    1804            2 :     label,
    1805            2 :     field,
    1806            2 :     excuse,
    1807            2 :     warning,
    1808            2 :     explanation,
    1809            2 :     options,
    1810            2 :     option_label = (o: T): string => { cockpit.assert(typeof o == "string"); return o },
    1811            2 :     ...props
    1812            2 : } : {
    1813              :     label?: React.ReactNode,
    1814              :     field: DialogField<T>,
    1815              :     excuse?: string | falsy,
    1816              :     warning?: React.ReactNode,
    1817              :     explanation?: React.ReactNode,
    1818              :     options: T[],
    1819              :     option_label?: (o: T) => string,
    1820            2 : } & Omit<FormSelectProps, "ref" | "children">) {
    1821            2 :     return (
    1822            2 :         <OptionalFormGroup label={label}>
    1823            2 :             <FormSelect
    1824            2 :                 id={field.id()}
    1825            1 :                 onChange={(_event, val) => {
    1826            1 :                     const opt = options.find(o => option_label(o) == val);
    1827            1 :                     field.set(opt!);
    1828            1 :                 }}
    1829            1 :                 validated={warning ? "warning" : undefined}
    1830            2 :                 isDisabled={!!excuse}
    1831            2 :                 value={option_label(field.get())}
    1832            2 :                 {...props}
    1833              :             >
    1834              :                 {
    1835            2 :                     options.map(
    1836            2 :                         o => {
    1837            2 :                             const l = option_label(o);
    1838            2 :                             return <FormSelectOption key={l} value={l} label={l} />;
    1839            2 :                         }
    1840            2 :                     )
    1841              :                 }
    1842            2 :             </FormSelect>
    1843            2 :             <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
    1844            2 :         </OptionalFormGroup>
    1845              :     );
    1846            2 : }
        

Generated by: LCOV version 2.0-1