LCOV - code coverage report
Current view: top level - pkg/lib/cockpit - dialog.tsx Coverage Total Hit
Test: cockpit Lines: 99.2 % 906 899
Test Date: 2026-07-03 07:31:16

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

Generated by: LCOV version 2.0-1