LCOV - code coverage report
Current view: top level - pkg/lib/cockpit - dialog.tsx Coverage Total Hit
Test: cockpit Lines: 95.1 % 883 840
Test Date: 2026-06-25 09:20:42

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

Generated by: LCOV version 2.0-1