LCOV - code coverage report
Current view: top level - pkg/lib/cockpit - dialog.tsx Coverage Total Hit
Test: cockpit Lines: 99.3 % 952 945
Test Date: 2026-07-17 12:03:54

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

Generated by: LCOV version 2.0-1