mirror of
https://github.com/cockpit-project/cockpit.git
synced 2026-11-03 21:49:50 -09:00
1971 lines
65 KiB
TypeScript
1971 lines
65 KiB
TypeScript
/*
|
|
* Copyright (C) 2025 Red Hat, Inc.
|
|
*
|
|
* SPDX-License-Identifier: LGPL-2.1-or-later
|
|
*/
|
|
|
|
/** Dialog Implementation Convenience Kit **/
|
|
|
|
/* TODOs:
|
|
|
|
- progress reporting
|
|
- array splicing via value handles
|
|
- different validation rules for different actions
|
|
*/
|
|
|
|
/* This is a framework for conveniently implementing dialogs. It is
|
|
meant to save us from having to think through all the details every
|
|
time of how a dialog should work exactly and to allow us to just
|
|
get on with the business logic.
|
|
|
|
The framework has two parts (like git): plumbing and porcelain.
|
|
|
|
The plumbing takes care of the state management of dialog values,
|
|
asynchronous and debounced input validation, progress feedback and
|
|
errors from actions, etc. It's basically a couple of rather
|
|
complicated JavaScript classes that work in the background. This
|
|
is the part we don't want to implement from scratch for every
|
|
dialog.
|
|
|
|
The porcelain is a set of React components that integrates with the
|
|
plumbing. They are usually very straightforward and easy to
|
|
write. If none of the existing ones works for you, just write a new
|
|
one that does. But they are boilerplatey, so having common ones for
|
|
common things like text input fields makes a lot of sense, too.
|
|
|
|
Here is an example of a simple dialog that prompts for some text
|
|
and writes it to the journal:
|
|
|
|
interface LoggerValues {
|
|
text: string;
|
|
}
|
|
|
|
const LoggerDialog = () => {
|
|
const Dialogs = useDialogs();
|
|
|
|
function validate() {
|
|
dlg.field("text").validate(v => {
|
|
if (!v)
|
|
return "Text can not be empty";
|
|
});
|
|
}
|
|
|
|
async function apply(values: LoggerValues) {
|
|
await cockpit.spawn(["logger", values.text]);
|
|
}
|
|
|
|
const dlg = useDialogState({ text: "" }, validate);
|
|
const text_field = dlg.field("text");
|
|
|
|
return (
|
|
<Modal position="top" variant="medium" isOpen onClose={Dialogs.close}>
|
|
<ModalHeader title="Logger" />
|
|
<ModalBody>
|
|
<DialogErrorMessage dialog={dlg} />
|
|
<Form>
|
|
<FormGroup label="Log message">
|
|
<TextInput
|
|
value={text_field.get()}
|
|
onChange={(_event, val) => text_field.set(val)}
|
|
/>
|
|
<DialogHelperText field={text_field} />
|
|
</FormGroup>
|
|
</Form>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<DialogActionButton dialog={dlg} action={apply} onClose={Dialogs.close}>
|
|
Log
|
|
</DialogActionButton>
|
|
<DialogCancelButton dialog={dlg} onClose={Dialogs.close}/>
|
|
</ModalFooter>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
const LoggerButton = () => {
|
|
const Dialogs = useDialogs();
|
|
|
|
return (
|
|
<Button onClick={() => Dialogs.show(<LoggerDialog />)}>Open logger dialog</Button>
|
|
);
|
|
};
|
|
|
|
This uses some porcelain for the error message and footer buttons,
|
|
but none for the text input field. There is a "DialogTextInput"
|
|
porcelain component that could have been used to make this example
|
|
even more concise. But we didn't use it here just to show how input
|
|
form elements hook into the plumbing.
|
|
|
|
PLUMBING API
|
|
|
|
The central piece of the plumbing API is the useDialogState hook
|
|
(and it's async variant useDialogState_async). You can think of it
|
|
as "useState" on steroids.
|
|
|
|
- Like useState, useDialogState gives you a place to store state in
|
|
a function component, and gives you a way to change that state
|
|
and trigger a render so that the new state is put on the screen.
|
|
|
|
- Unlike useState, you are only supposed to have a single
|
|
useDialogState and put all of the state in a single JavaScript
|
|
object. And instead of a single setter function, there are ways
|
|
to get setters to individual parts of that object via "handles".
|
|
|
|
- The handles work also for nested objects and arrays. You can get
|
|
a "sub handles" for a single key from a handle for an object, for
|
|
example.
|
|
|
|
- The useDialogState hook also provides for global validation of
|
|
the state object, at exactly the right times, and communicates
|
|
the result of that to the "Apply" button, for example.
|
|
|
|
- The handles to parts of the state give you access to everything
|
|
needed to implement a part of the dialog form: The current value,
|
|
a method to change the value, and any validation errors that
|
|
should be shown. This makes it possible to write encapsulated
|
|
components that can be cleanly combined into full dialogs.
|
|
|
|
- The useDialogState hook also provides for miscellaneous things
|
|
like transporting the exception from running the "Apply" action
|
|
to the error message in the dialog.
|
|
|
|
Here is a speedrun of the plumbing API:
|
|
|
|
- dlg = useDialogState(init, validate)
|
|
|
|
This is a React hook that creates a new instance of the plumbing
|
|
machinery. It also causes the current component to re-render
|
|
appropriately.
|
|
|
|
The values of the input fields of a dialog are stored in a
|
|
JavaScript object, and that object is initialized to "init", or the
|
|
result of calling "init" if it is a function. The actual values of
|
|
a dialog can be anything, strings, number, other objects, arrays,
|
|
etc, to an arbitrary depth.
|
|
|
|
The "validate" parameter is a function that performs input
|
|
validation. It has to follow a very specific code pattern, which is
|
|
explained below.
|
|
|
|
There is a variant of useDialogState for asynchronous
|
|
initialization, called useDialogState_async. It takes a async init
|
|
function and returns null until that function has resolved. You
|
|
should render a spinner in the dialog while "dlg" is null.
|
|
|
|
If the asynchronous "init" function throws an exception, "dlg" is
|
|
set to a DialogError object. In that case you should render the
|
|
error in the dialog.
|
|
|
|
The porcelain components that do not work on actual dialog values,
|
|
such as DialogActionButton and DialogErrorMessage, can deal with
|
|
their "dialog" properties being null or a DialogError, and will do
|
|
the right thing.
|
|
|
|
The return value of useDialogState, "dlg", has a number of fields
|
|
and methods.
|
|
|
|
- handle = dlg.field(name)
|
|
- handle = dlg.field(name, changed_func)
|
|
|
|
This returns a handle for a specific field of the dialog
|
|
values. Using handles like this becomes convenient when there are
|
|
nested values, and when writing reusable porcelain components. They
|
|
also work well with TypeScript. For simple dialogs they might feel
|
|
a bit clunky.
|
|
|
|
The second argument, "changed_func", is optional. If given, it
|
|
should be a function and that function will be called whenever the
|
|
dialog value is changed via the returned handle (and the returned
|
|
handle only). It might also be called some time later, when
|
|
"set_debounced" is used.
|
|
|
|
- handle = dlg.top()
|
|
- handle = dlg.top(changed_func)
|
|
|
|
Get a handle for the whole value object. The usual
|
|
"dlg.field(name)" call is actually just a shortcut for
|
|
"dlg.top().sub(name)". But since that looks quite obscure in
|
|
simple dialogs that only have one level of values, we have the
|
|
"dlg.field(name)" shortcut as well. This whole-value handle is
|
|
useful for "dlg.top().at(...)", see below, or for notifications
|
|
that trigger for each and every change.
|
|
|
|
- dlg.values
|
|
|
|
The current whole dialog value object. This is the same as
|
|
"dlg.top().get()", but accessing it is common enough in simple
|
|
dialogs that exposing it directly makes sense.
|
|
|
|
The object will never be mutated by the plumbing itself. When it
|
|
needs to be changed, a whole new value object is constructed and
|
|
assigned to "dlg.values".
|
|
|
|
- handle.get()
|
|
|
|
Get the current value of a value handle.
|
|
|
|
- handle.validation_text()
|
|
|
|
Get the current validation error message for this value. This is
|
|
"undefined" when there is no message.
|
|
|
|
- handle.set(val)
|
|
|
|
Set the current value of a value handle. This will re-render the
|
|
dialog, and do input validation as necessary and all the other
|
|
things that you don't need to think about.
|
|
|
|
This is actually the same as "handle.set_debounced" with a delay of
|
|
0, see below.
|
|
|
|
- handle.set_debounced(val)
|
|
- handle.set_debounced(val, delay)
|
|
|
|
Set the current value of a value handle immediately as described
|
|
above for "handle.set", and render the dialog. But validation and
|
|
calling of the "changed" functions happens only after "delay"
|
|
milliseconds.
|
|
|
|
When "delay" is omitted, it defaults to a sensible value for
|
|
debouncing keyboard input.
|
|
|
|
- handle.notify(changed_func)
|
|
|
|
Return a new handle for the same dialog field that "handle"
|
|
represents, but call "changed_func" whenever the field is changed
|
|
via the new handle.
|
|
|
|
- handle.sub(name_or_index)
|
|
- handle.sub(name_or_index, changed_func)
|
|
|
|
Get a handle for a nested value. When the current value is an
|
|
object, you should pass the name of a nested field. If it is an
|
|
array, pass the index of the desired element. See "dlg.field()"
|
|
above for more information about handles.
|
|
|
|
- handle.get_async((val, signal) => ...)
|
|
- handle.set_async((val, signal) => new_val)
|
|
|
|
These are for running asynchronous code. The dialog waits for all
|
|
asynchronous tasks started by these functions to be finished before
|
|
running the action function. When the dialog is cancelled, they
|
|
all get cancelled.
|
|
|
|
The return value of "handle.set_async" is made the new value of the
|
|
field, but only if the value of the field hasn't changed in the
|
|
meantime. There can only be one currently active "set_async" call.
|
|
If you call it again before the previous one has finished, that
|
|
previous call will be cancelled at that point, just as if the field
|
|
value had changed.
|
|
|
|
The "handle.get_async" function is a slight variation on this. It
|
|
is meant to perform asynchronous computations that do not modify
|
|
the field value itself, but have some other side effects. There
|
|
can be more than one call active at a given time. They only get
|
|
cancelled when the value of the field changes.
|
|
|
|
The asynchronous tasks must be careful to only perform their side
|
|
effects when they have not been cancelled yet. This can be done
|
|
with the help of their "signal" parameter, which is a standard
|
|
AbortSignal.
|
|
|
|
Because of the way JavaScript works, the asynchronous functions
|
|
keep running and they need to voluntarily inspect "signal.aborted"
|
|
to figure out when they should stop. They can also use all other
|
|
features of a AbortSignal, of course, such as setting
|
|
"signal.onabort", adding event listeners, etc.
|
|
|
|
- handle.at(witness)
|
|
|
|
Get a handle with a narrowed type for "handle". The new handle
|
|
works like "handle" and modifies the same place in the dialog value
|
|
object, but it's type will be the type of "witness". This is
|
|
useful to carry over type inference into value handles. The
|
|
general pattern is:
|
|
|
|
const val = handle.get();
|
|
if (some_type_narrowing_condition(val)) {
|
|
const narrowed_handle = handle.at(val);
|
|
|
|
...
|
|
}
|
|
|
|
- handle.add(val)
|
|
|
|
If the current value is an array, append "val" at the end.
|
|
|
|
- handle.remove(index)
|
|
|
|
If the current value is an array, remove the element at "index".
|
|
It is important to use this function instead of just "handle.set()"
|
|
with an appropriately modified array. By using this function, the
|
|
plumbing is able to keep its internal state in synch, which is
|
|
especially important for asynchronous validation and "changed"
|
|
functions.
|
|
|
|
However, it is okay to just replace an array with a different
|
|
array, so you are not strictly required to use this function. But
|
|
doing so might look to the validation machinery as if each and
|
|
every element of the array has just changed, and it will do a lot
|
|
of needless validations all over again.
|
|
|
|
- handle.map(func)
|
|
|
|
If the current value is an array, map "func" over handles for its
|
|
elements. This is nice for creating React components for arrays.
|
|
|
|
- handle.forEach(func)
|
|
|
|
If the current value is an array, call "func" with handles for each
|
|
of its elements, in order. This is nice for "validate" functions.
|
|
|
|
Now back to the fields and methods of the dialog state.
|
|
|
|
- dlg.busy
|
|
- dlg.actions_disabled
|
|
- dlg.cancel_disabled
|
|
|
|
Boolean flags that indicate which parts of the dialog should be
|
|
disabled. The porcelain should of course look at these and do the
|
|
right thing.
|
|
|
|
- dlg.error
|
|
|
|
The most recent error thrown by an action function. This can be
|
|
any kind of JavaScript value, but the idea is that it is something
|
|
with a "message" field, or a DialogError instance. The
|
|
DialogErrorMessage porcelain component will do the right thing with
|
|
these kind of error values.
|
|
|
|
- dlg.run_action(func)
|
|
|
|
Waits for all asynchronous updates and input validation to be done
|
|
and if that was successful, calls "func" and puts the dialog into a
|
|
"busy" state while it runs. When "func" throws an error, it is
|
|
caught and stored in "dlg.error".
|
|
|
|
"dlg.run_action" returns true when validation has passed and "func"
|
|
has completed without throwing an error.
|
|
|
|
All state changes via "field.set()" are denied while "func" is
|
|
running. This is done to prevent the user from interacting with the
|
|
dialog while an action runs. But there is nothing fundamentally
|
|
wrong with programmatically changing dialog state as part of an
|
|
action. If you want to do that, write code like
|
|
|
|
if (dlg.run_action(...))
|
|
dlg.field("xxx").set(...)
|
|
|
|
- dlg.cancel(onClose)
|
|
|
|
Does whatever should happen when the "Cancel" button is
|
|
clicked. When an action is running, it will call the "cancel
|
|
function" (see below). Otherwise all validation and update tasks
|
|
are cancelled and the dialog is closed by calling "onClose".
|
|
|
|
- dlg.set_cancel(func)
|
|
|
|
Arranges for "func" to be called when the cancel button is clicked.
|
|
You should call this only from a action function passed to
|
|
"run_action" and you need to take care to reset this via
|
|
"dlg.set_cancel(null)" once the cancel function should no longer be
|
|
called. When a action function finishes or throws an error from
|
|
within "dlg.run_action", the cancel function is automatically
|
|
reset.
|
|
|
|
VALIDATION
|
|
|
|
Input validation is done by a single, central function for the
|
|
whole dialog. This has been done so that there is a central place
|
|
that establishes the "shape" of the dialog values. This is
|
|
important for dialogs that have optional parts.
|
|
|
|
If such an optional part of the values has failed validation
|
|
earlier, but has subsequently been removed from the dialog by the
|
|
user, the plumbing needs to know that it should now ignore this
|
|
failed validation. But the code that knows what is currently in the
|
|
dialog is the render function that instantiates all the field input
|
|
components (like TextInput). This hacker here has found no reliable
|
|
and non-magical way to connect what the render function actually
|
|
does with the plumbing machinery. So everyone has to write a big
|
|
validate function now that duplicates this, sorry!
|
|
|
|
The formal job of the validation function is to call the "validate"
|
|
method (or "validate_async") of all relevant dialog value handles.
|
|
If and only if a validation failure of a field should prevent
|
|
running the action function, should the validate function visit it.
|
|
|
|
- handle.validate(v => ...)
|
|
|
|
This might call the given function with the current value of the
|
|
handle. If it passes validation, the function should return
|
|
"undefined". If it fails, the function should return a string with
|
|
the appropriate message. This message will be available from the
|
|
"handle.validation_text" method and should be shown by the React
|
|
component for this value, of course. Returning an error here will
|
|
also disable the action buttons.
|
|
|
|
The "v => ..." function is only called when necessary, when the
|
|
value has actually changed.
|
|
|
|
A validation function can also return an object with validation
|
|
errors for its sub-fields. This is useful if multiple fields need
|
|
to be validated together. Consider this example:
|
|
|
|
field.validate(v => {
|
|
if (v.mode != "auto" && v.size == 0)
|
|
return { "size": "Can't be zero in manual mode." }
|
|
});
|
|
|
|
If your validation function needs to communicate out-of-band with
|
|
your action function (maybe to pass the results of some expensive
|
|
operations that you don't want to repeat in your action function),
|
|
then you can modify field values via calls to "handle.set". (Be
|
|
careful not to create endless validation loops!)
|
|
|
|
- handle.validate_async(async (v, task) >= ...)
|
|
|
|
Same as "handle.validate", but the function is asynchronous and
|
|
"dialog.run_action" will wait for these functions to complete
|
|
before actually carrying out the dialog action.
|
|
|
|
UPDATES
|
|
|
|
Sometimes dialog values need to be changed in reaction to other
|
|
changes. For example, when the user selects a ISO for creating a
|
|
new virtual machine, you might want to run some code that detects
|
|
the OS on that ISO and then adapts the rest of the dialog to the
|
|
minimum storage requirements of the OS. Sometimes you can do
|
|
everything at render time, but sometimes you might want to run some
|
|
code as part of the event handler for the user action, and
|
|
sometimes you need to run asynchronous code.
|
|
|
|
(Don't use useEffect, please, just stick the code into the event
|
|
handler.)
|
|
|
|
It's okay and simplest to just put that code right next to the call
|
|
to "handler.set()". If that call is in a porcelain component (as
|
|
it probably often will be), you can pass a "changed_func" when
|
|
creating the handle for that porcelain component with
|
|
"handler.sub()" or "dialog.field()". For example:
|
|
|
|
function plate_changed(val: string) {
|
|
console.log("NEW LICENSE PLATE", val);
|
|
}
|
|
|
|
return (
|
|
<DialogTextInput
|
|
label="License plate number"
|
|
field={dlg.field("plate", plate_changed)}
|
|
/>
|
|
);
|
|
|
|
The function "plate_changed" will be called whenever the user
|
|
changes the "plate" field via the DialogTextInput (subject to
|
|
debouncing). The "plate_changed" function will not be called
|
|
when the "plate" is changed in other places. If that should
|
|
happen, you have to arrange for it explicitly.
|
|
|
|
Functions like "plate_changed" can and should modify the dialog
|
|
fields via calls to "handle.set()".
|
|
|
|
If you want to run asynchronous code, you can do so with
|
|
"handle.set_async()" or "handle.get_async()". For example, if you
|
|
want to asynchronously fetch the car model for a given license
|
|
plate from a database, you can do it like this:
|
|
|
|
function plate_changed(val: string) {
|
|
dlg.field("model").set_async(async () => await fetch_model(val));
|
|
}
|
|
|
|
When arrays are involved, dialog fields can move around while your
|
|
asynchronous update function runs. To help with this, handles will
|
|
keep referring to the same field even if it moves around in its
|
|
array.
|
|
|
|
TESTING
|
|
|
|
Our automated tests will want to drive the dialogs created by this
|
|
framework, of course. To support this, the various DOM elements
|
|
instantiated for a dialog should be decorated with structured "id"
|
|
attributes. The code that instantiates an element can get suitable
|
|
IDs for dialog value handles with the following function:
|
|
|
|
- handle.ouia_id(tag)
|
|
|
|
This will return a predictable string for the value handle. This
|
|
is suitable for the "data-ouia-component-id" attribute of DOM
|
|
elements associated with "handle". The "tag" parameter can be used
|
|
to generate multiple IDs if a component has multiple interesting
|
|
DOM elements. The "tag" parameter defaults to "field", see below.
|
|
|
|
There is a support library for use by the tests that can generate
|
|
the same IDs, and there are also some guidelines for how to use
|
|
these IDs:
|
|
|
|
- The main input element (text input, form select, ...) should use
|
|
the "field" tag.
|
|
|
|
- The helper text should use the "helper-text" tag.
|
|
|
|
- A set of radio buttons should use a different tag for each
|
|
button. Whatever makes sense in the specific case.
|
|
|
|
- ...
|
|
|
|
PORCELAIN GALLERY
|
|
|
|
Here are some noteworthy React components that integrate with the
|
|
plumbing API.
|
|
|
|
- <DialogErrorMessage dialog={dlg} />
|
|
|
|
This creates an appropriate Alert for "dlg.error", if it is set. It
|
|
works well with instances of DialogError, and all usual errors
|
|
thrown by the Cockpit API.
|
|
|
|
In addition to a proper DialogState, the "dialog" property can be
|
|
anything returned by "use_DialogState_async".
|
|
|
|
If given one the of Cockpit API errors, the title of the Alert will
|
|
be a generic "Failed" text. If you want more control, use a
|
|
DialogError.
|
|
|
|
A DialogError contains a title and details, and the details can
|
|
come from another error. For example:
|
|
|
|
try {
|
|
await cockpit.spawn(["/bin/frob", "--bars"])
|
|
} catch (ex) {
|
|
throw DialogError.fromError("Failed to frob the bars", ex);
|
|
}
|
|
|
|
You can also construct a DialogError directly from title and
|
|
details:
|
|
|
|
throw new DialogError("Failed to frob", <pre>...</pre>);
|
|
|
|
In that case, the details can be any React node.
|
|
|
|
- <DialogActionButton dialog={dlg} action={func} onClose={close_func}>
|
|
|
|
This will produce a action button for a dialog that correctly disables
|
|
itself according to the state of "dlg".
|
|
|
|
In addition to a proper DialogState, the "dialog" property can be
|
|
anything returned by "use_DialogState_async".
|
|
|
|
When clicked, "func" will be run via "dlg.run_action". If "func"
|
|
completes successfully, "close_func" is called to close the dialog.
|
|
|
|
- <DialogCancelButton dialog={dlg} onClose={close_func} />
|
|
|
|
This will produce a cancel button for a dialog that correctly
|
|
disables itself according to the state of "dlg".
|
|
|
|
In addition to a proper DialogState, the "dialog" property can be
|
|
anything returned by "use_DialogState_async".
|
|
|
|
Clicking it will either just close the dialog by calling
|
|
"close_func", or run the cancel function provided by the currently
|
|
running action function (if there is any).
|
|
|
|
- <DialogTextInput label="Name" field={dlg.field("name")} ... />
|
|
|
|
This will produce a TextInput in a (optional) FormGroup that will
|
|
manage the given value handle. The "label" property is optional
|
|
and omitting it will also omit the FormGroup.
|
|
|
|
- <DialogCheckbox label= field= .../>
|
|
|
|
For a single checkbox that drives a boolean.
|
|
|
|
- <DialogRadioSelect label= field= options= .../>
|
|
|
|
For a group of radio buttons. The options can be disabled and have
|
|
explanations.
|
|
|
|
- <DialogDropdownSelect label= field= options= .../> </>
|
|
|
|
For a simple dropdown select. Options can not be disabled or have
|
|
explanations.
|
|
|
|
- <DialogDropdownSelectObject label= field= options= option_label= />
|
|
|
|
A variant of the simple dropdown select from above where the options
|
|
can be of any type whatsoever, such as something directly from your
|
|
data model. A simple case is selecting from an array of strings. In
|
|
that case you can omit the "option_label" function.
|
|
|
|
*/
|
|
|
|
import React, { useState, useId } from "react";
|
|
import { useObject, useInit, useOn } from 'hooks';
|
|
import { EventEmitter } from 'cockpit/event';
|
|
|
|
import cockpit from "cockpit";
|
|
|
|
import { Button, type ButtonProps } from "@patternfly/react-core/dist/esm/components/Button/index.js";
|
|
import { FormGroup, type FormGroupProps, FormHelperText } from "@patternfly/react-core/dist/esm/components/Form";
|
|
import { TextInput, type TextInputProps } from "@patternfly/react-core/dist/esm/components/TextInput";
|
|
import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
|
|
import {
|
|
HelperText, HelperTextItem, type HelperTextItemProps
|
|
} from "@patternfly/react-core/dist/esm/components/HelperText";
|
|
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
|
|
import {
|
|
FormSelect, FormSelectOption, type FormSelectProps,
|
|
} from "@patternfly/react-core/dist/esm/components/FormSelect";
|
|
import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
|
|
import { InputGroup, InputGroupItem } from "@patternfly/react-core/dist/esm/components/InputGroup/index.js";
|
|
import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons";
|
|
import { Tooltip } from "@patternfly/react-core/dist/esm/components/Tooltip";
|
|
|
|
const _ = cockpit.gettext;
|
|
|
|
function debug(...args: unknown[]) {
|
|
if (window.debugging == "all" || window.debugging?.includes("dialog"))
|
|
console.debug("dialog:", ...args);
|
|
}
|
|
|
|
type ArrayElement<ArrayType> =
|
|
ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
|
|
|
|
function toSpliced<T>(arr: T[], start: number, deleteCount: number, ...rest: T[]): T[] {
|
|
const copy = [...arr];
|
|
copy.splice(start, deleteCount, ...rest);
|
|
return copy;
|
|
}
|
|
|
|
/* It would be nice to make it so that TypeScript complains when you
|
|
try to return errors for sub-fields that don't exist. We can
|
|
construct a type that describes this (by recursively mapping all
|
|
field to strings, essentially), but TypeScript will not ordinarily
|
|
prevent a validation function from returning something that also
|
|
has other fields, because it does only do "excessive property
|
|
checks" for literals that are assigned to locations with a known
|
|
type. Example:
|
|
|
|
interface ComboValue {
|
|
size: number;
|
|
unit: string;
|
|
}
|
|
|
|
dlg.field("combo").validate(v => {
|
|
return {
|
|
size: true, // TypeScript error here as expected, because true is not a string
|
|
unt: "No such unit", // Want a TypeScript error here bc there is no "unt" in the type, but won't happen
|
|
}
|
|
});
|
|
|
|
We would need to explicitly annotate the validation function with
|
|
the right return type like so:
|
|
|
|
dlg.field("combo").validate((v): DialogValidationResult<ComboValue> => {
|
|
return {
|
|
unt: "No such unit", // Now we get an error.
|
|
}
|
|
});
|
|
|
|
But I doubt that people will be happy to write out their type like
|
|
this every time...
|
|
*/
|
|
|
|
export type DialogValidationResult<T> = (
|
|
T extends object
|
|
? ({ [Property in keyof T]+?: DialogValidationResult<T[Property]> } & { ""?: undefined | string }) | undefined | string
|
|
: undefined | string | { ""?: undefined | string }
|
|
);
|
|
|
|
function state_path(state: DialogFieldState): string {
|
|
const p = state.parent ? state_path(state.parent) : "";
|
|
const t = String(state.tag);
|
|
return p ? `${p}.${t}` : t;
|
|
}
|
|
|
|
export class DialogField<T> {
|
|
/* eslint-disable no-use-before-define */
|
|
#dialog: DialogState<unknown>;
|
|
#state: DialogFieldState;
|
|
/* eslint-enable */
|
|
#getter: () => T;
|
|
#setter: (val: T) => void;
|
|
#trigger: () => void;
|
|
|
|
constructor(
|
|
dialog: DialogState<unknown>,
|
|
state: DialogFieldState,
|
|
getter: () => T,
|
|
setter: (val: T) => void,
|
|
trigger: () => void,
|
|
) {
|
|
this.#dialog = dialog;
|
|
this.#state = state;
|
|
this.#getter = getter;
|
|
this.#setter = setter;
|
|
this.#trigger = trigger;
|
|
}
|
|
|
|
validation_text(): string | undefined {
|
|
return this.#state.validation_text;
|
|
}
|
|
|
|
get(): T {
|
|
return this.#getter();
|
|
}
|
|
|
|
set(val: T): void {
|
|
this.set_debounced(val, 0);
|
|
}
|
|
|
|
set_debounced(val: T, delay?: number): void {
|
|
this.#dialog._abort_state_tasks(this.#state, true);
|
|
this.#setter(val);
|
|
this.#dialog._set_debounce(this.#state, delay === undefined ? 500 : delay, () => this.#trigger());
|
|
}
|
|
|
|
ouia_id(tag: string = "field"): string {
|
|
return "dialog-" + tag + "-" + state_path(this.#state);
|
|
}
|
|
|
|
map<X>(func: (val: DialogField<ArrayElement<T>>, index: number) => X): X[] {
|
|
const val = this.get();
|
|
if (Array.isArray(val)) {
|
|
return val.map((_, i) => func(this.sub(i as keyof T) as DialogField<ArrayElement<T>>, i));
|
|
} else
|
|
return [];
|
|
}
|
|
|
|
forEach(func: (val: DialogField<ArrayElement<T>>, index: number) => void): void {
|
|
const val = this.get();
|
|
if (Array.isArray(val)) {
|
|
val.forEach((_, i) => func(this.sub(i as keyof T) as DialogField<ArrayElement<T>>, i));
|
|
}
|
|
}
|
|
|
|
remove(index: number) {
|
|
const val = this.get();
|
|
if (Array.isArray(val)) {
|
|
const sub = this.#state.sub.get(index);
|
|
if (sub) {
|
|
this.#dialog._cancel_debounce(sub);
|
|
this.#dialog._abort_state_tasks(sub);
|
|
sub.tag = -1;
|
|
}
|
|
for (let j = index; j < val.length - 1; j++) {
|
|
const sub = this.#state.sub.get(j + 1);
|
|
if (sub) {
|
|
sub.tag = j;
|
|
this.#state.sub.set(j, sub);
|
|
}
|
|
}
|
|
this.#state.sub.delete(val.length - 1);
|
|
this.#setter(toSpliced(val, index, 1) as T);
|
|
this.#trigger();
|
|
}
|
|
}
|
|
|
|
add(item: ArrayElement<T>) {
|
|
const val = this.get();
|
|
if (Array.isArray(val)) {
|
|
this.#setter(val.concat(item) as T);
|
|
this.#trigger();
|
|
}
|
|
}
|
|
|
|
notify(changed_func: (val: T) => void): DialogField<T> {
|
|
return new DialogField<T>(
|
|
this.#dialog,
|
|
this.#state,
|
|
this.#getter,
|
|
this.#setter,
|
|
() => {
|
|
changed_func(this.#getter());
|
|
this.#trigger();
|
|
},
|
|
);
|
|
}
|
|
|
|
sub<K extends keyof T>(tag: K, changed_func?: ((val: T[K]) => void) | undefined): DialogField<T[K]> {
|
|
const sub = this.#dialog._get_sub_state(this.#state, tag);
|
|
|
|
const getter = (): T[K] => {
|
|
const container = this.get();
|
|
if (Array.isArray(container) && typeof sub.tag == "number") {
|
|
return container[sub.tag];
|
|
} else {
|
|
return container[tag];
|
|
}
|
|
};
|
|
|
|
const setter = (val: T[K]) => {
|
|
const container = this.get();
|
|
if (Array.isArray(container) && typeof sub.tag == "number") {
|
|
this.#setter(toSpliced(container, sub.tag, 1, val) as T);
|
|
} else {
|
|
this.#setter({ ...container, [tag]: val });
|
|
}
|
|
};
|
|
|
|
const trigger = () => {
|
|
if (changed_func)
|
|
changed_func(getter());
|
|
this.#trigger();
|
|
};
|
|
|
|
return new DialogField<T[K]>(
|
|
this.#dialog,
|
|
sub,
|
|
getter,
|
|
setter,
|
|
trigger,
|
|
);
|
|
}
|
|
|
|
at<TT extends T>(witness: TT): DialogField<TT> {
|
|
cockpit.assert(Object.is(witness, this.get()));
|
|
return this as unknown as DialogField<TT>;
|
|
}
|
|
|
|
validate(func: (val: T) => DialogValidationResult<T>): void {
|
|
const val = this.get();
|
|
this.#dialog._validate_value(this.#state, val, () => func(val));
|
|
}
|
|
|
|
validate_async(func: (val: T, signal: AbortSignal) => Promise<DialogValidationResult<T>>): void {
|
|
const val = this.get();
|
|
this.#dialog._validate_value_async(this.#state, val, signal => func(val, signal));
|
|
}
|
|
|
|
set_async(func: (val: T, signal: AbortSignal) => Promise<T>): void {
|
|
const val = this.get();
|
|
this.#dialog._set_value_async(this.#state, async signal => {
|
|
const new_val = await func(val, signal);
|
|
if (!signal.aborted)
|
|
this.set(new_val);
|
|
});
|
|
}
|
|
|
|
get_async(func: (val: T, signal: AbortSignal) => Promise<void>): void {
|
|
const val = this.get();
|
|
this.#dialog._get_value_async(this.#state, signal => func(val, signal));
|
|
}
|
|
}
|
|
|
|
function get_validation_result_own_string(result: unknown): string | undefined {
|
|
if (typeof result == "string")
|
|
return result;
|
|
else if (result && typeof result == "object" && "" in result && typeof result[""] == "string")
|
|
return result[""];
|
|
else
|
|
return undefined;
|
|
}
|
|
|
|
export class DialogTask {
|
|
#name: string;
|
|
#promise: Promise<void>;
|
|
#controller: AbortController;
|
|
|
|
constructor(
|
|
name: string,
|
|
func: (task: DialogTask) => Promise<void>,
|
|
done: (task: DialogTask) => void,
|
|
) {
|
|
debug("starting task", name);
|
|
this.#name = name;
|
|
this.#controller = new AbortController();
|
|
this.#promise = func(this);
|
|
this.#promise.finally(() => {
|
|
debug("task done", this.#name);
|
|
done(this);
|
|
});
|
|
}
|
|
|
|
async wait() {
|
|
debug("waiting for task", this.#name);
|
|
await this.#promise;
|
|
}
|
|
|
|
get_abort_signal() {
|
|
return this.#controller.signal;
|
|
}
|
|
|
|
abort() {
|
|
debug("aborting task", this.#name);
|
|
this.#controller.abort();
|
|
}
|
|
}
|
|
|
|
/* A DialogFieldState object holds all state for a field. Unlike
|
|
handles, there is at most one of these objects for each field, and
|
|
each handle for a given field refers to the exact same
|
|
DialogFieldState object.
|
|
|
|
DialogFieldStates are created on-demand and will over time form a
|
|
tree (expressed via "parent" and the children in the "sub" map)
|
|
that corresponds to the nesting of the dialog values.
|
|
|
|
The "tag" is used to access the dialog value. A handle constructed
|
|
via dlg.field("name") will point to a state object with tag "name",
|
|
for example, and calling handle.get() will return
|
|
dlg.values["name"].
|
|
|
|
Other members of a DialogFieldState relate to validation and
|
|
asynchronous updates.
|
|
*/
|
|
|
|
interface DialogFieldState {
|
|
parent: DialogFieldState | null,
|
|
tag: string | number | symbol;
|
|
sub: Map<string | number | symbol, DialogFieldState>;
|
|
// debouncing
|
|
debounce_timer: number;
|
|
debounce_func: null | (() => void);
|
|
// validation
|
|
relevant: boolean;
|
|
validation_text: string | undefined;
|
|
cached_value: unknown;
|
|
cached_result: unknown;
|
|
validation_task: DialogTask | null;
|
|
// updates
|
|
update_task: DialogTask | null;
|
|
update_tasks: Set<DialogTask>;
|
|
}
|
|
|
|
interface DialogStateEvents {
|
|
changed(): void;
|
|
}
|
|
|
|
export class DialogState<V> extends EventEmitter<DialogStateEvents> {
|
|
values: V;
|
|
|
|
busy: boolean = false;
|
|
actions_disabled: boolean = false;
|
|
cancel_disabled: boolean = false;
|
|
|
|
error: unknown = null;
|
|
|
|
#validation_failed: boolean = false;
|
|
#online_validation: boolean = false;
|
|
#action_running: boolean = false;
|
|
#block_updates: boolean = false;
|
|
#cancel_function: (() => void) | null = null;
|
|
|
|
#top_state: DialogFieldState;
|
|
|
|
/* eslint-disable no-use-before-define */
|
|
#validate_callback: undefined | ((dlg: DialogState<V>) => void);
|
|
/* eslint-enable */
|
|
|
|
constructor(init: V, validate: undefined | ((dlg: DialogState<V>) => void)) {
|
|
debug("open");
|
|
super();
|
|
this.#validate_callback = validate;
|
|
this.values = init;
|
|
this.#top_state = {
|
|
parent: null,
|
|
tag: "",
|
|
sub: new Map(),
|
|
debounce_timer: 0,
|
|
debounce_func: null,
|
|
relevant: false,
|
|
validation_text: undefined,
|
|
cached_value: undefined,
|
|
cached_result: undefined,
|
|
validation_task: null,
|
|
update_task: null,
|
|
update_tasks: new Set(),
|
|
};
|
|
}
|
|
|
|
#update() {
|
|
this.busy = this.#action_running;
|
|
this.actions_disabled = this.#action_running || this.#validation_failed;
|
|
this.cancel_disabled = this.#action_running && !this.#cancel_function;
|
|
this.emit("changed");
|
|
}
|
|
|
|
/* FIELD STATES
|
|
|
|
During validation and asynchronous updates, a lot is going on.
|
|
|
|
We use a DialogFieldState object to keep the necessary
|
|
state for that, such as cached results, and timeouts and
|
|
promises.
|
|
|
|
These state objects keep their identity when arrays elements
|
|
move around. Their "index" field will be changed when that
|
|
happens.
|
|
*/
|
|
|
|
_get_sub_state(state: DialogFieldState, tag: string | number | symbol): DialogFieldState {
|
|
let sub = state.sub.get(tag);
|
|
if (!sub) {
|
|
sub = {
|
|
parent: state,
|
|
tag,
|
|
sub: new Map(),
|
|
debounce_timer: 0,
|
|
debounce_func: null,
|
|
relevant: false,
|
|
validation_text: undefined,
|
|
cached_value: undefined,
|
|
cached_result: undefined,
|
|
validation_task: null,
|
|
update_task: null,
|
|
update_tasks: new Set(),
|
|
};
|
|
state.sub.set(tag, sub);
|
|
}
|
|
return sub;
|
|
}
|
|
|
|
_for_each_field_state(func: (state: DialogFieldState) => void) {
|
|
function visit(state: DialogFieldState) {
|
|
func(state);
|
|
for (const sub of state.sub.values())
|
|
visit(sub);
|
|
}
|
|
visit(this.#top_state);
|
|
}
|
|
|
|
async _for_each_field_state_async(func: (state: DialogFieldState) => Promise<void>) {
|
|
async function visit(state: DialogFieldState) {
|
|
await func(state);
|
|
for (const sub of state.sub.values())
|
|
await visit(sub);
|
|
}
|
|
await visit(this.#top_state);
|
|
}
|
|
|
|
/* DEBOUNCING
|
|
*/
|
|
|
|
_cancel_debounce(state: DialogFieldState) {
|
|
if (state.debounce_timer) {
|
|
debug("cancelling debounce", state_path(state));
|
|
window.clearTimeout(state.debounce_timer);
|
|
state.debounce_timer = 0;
|
|
state.debounce_func = null;
|
|
}
|
|
for (const sub of state.sub.values())
|
|
this._cancel_debounce(sub);
|
|
}
|
|
|
|
_set_debounce(state: DialogFieldState, delay: number, func: () => void) {
|
|
if (this.#block_updates)
|
|
return;
|
|
|
|
this._cancel_debounce(state);
|
|
|
|
if (delay === 0) {
|
|
debug("no debounce trigger", state_path(state));
|
|
func();
|
|
} else {
|
|
debug("debounce trigger", state_path(state), delay);
|
|
state.debounce_func = func;
|
|
state.debounce_timer = window.setTimeout(
|
|
() => {
|
|
state.debounce_timer = 0;
|
|
state.debounce_func = null;
|
|
func();
|
|
},
|
|
delay,
|
|
);
|
|
}
|
|
}
|
|
|
|
_run_all_debouncing_now() {
|
|
let debounced: boolean;
|
|
do {
|
|
debounced = false;
|
|
this._for_each_field_state(state => {
|
|
if (state.debounce_timer) {
|
|
debug("running debounced trigger now", state_path(state));
|
|
cockpit.assert(state.debounce_func);
|
|
window.clearTimeout(state.debounce_timer);
|
|
state.debounce_timer = 0;
|
|
state.debounce_func();
|
|
debounced = true;
|
|
}
|
|
});
|
|
} while (debounced);
|
|
}
|
|
|
|
/* TASKS
|
|
|
|
Tasks are a little abstraction that runs a asynchronous
|
|
function. Before running the action function, we need to wait
|
|
for them all to finish.
|
|
*/
|
|
|
|
async _run_all_tasks_now() {
|
|
let awaited: boolean;
|
|
do {
|
|
// Start all the things that might have been queued up for
|
|
// debouncing.
|
|
|
|
this._run_all_debouncing_now();
|
|
|
|
// Wait for the tasks that are currently running. While
|
|
// waiting, new things might have been queued up for
|
|
// debouncing or new tasks might have been started, so we
|
|
// have to go back to the beginning.
|
|
|
|
awaited = false;
|
|
await this._for_each_field_state_async(async state => {
|
|
if (state.validation_task) {
|
|
await state.validation_task.wait();
|
|
awaited = true;
|
|
}
|
|
if (state.update_task) {
|
|
await state.update_task.wait();
|
|
awaited = true;
|
|
}
|
|
for (const task of state.update_tasks.values()) {
|
|
await task.wait();
|
|
awaited = true;
|
|
}
|
|
});
|
|
} while (awaited);
|
|
}
|
|
|
|
_abort_state_tasks(state: DialogFieldState, only_updates: boolean = false) {
|
|
debug("cancelling state tasks", state_path(state), only_updates);
|
|
if (state.validation_task && !only_updates)
|
|
state.validation_task.abort();
|
|
if (state.update_task)
|
|
state.update_task.abort();
|
|
for (const task of state.update_tasks.values())
|
|
task.abort();
|
|
for (const sub of state.sub.values())
|
|
this._abort_state_tasks(sub, only_updates);
|
|
}
|
|
|
|
/* VALIDATION
|
|
|
|
Validation is started by calling the #trigger_validation
|
|
method. This will reset all validation errors and mark all
|
|
fields as "irrelevant". Then it calls the provided "validate"
|
|
callback, which in turn will (eventually but synchronously)
|
|
call the "_validate_value" or "_validate_value_async" methods
|
|
of all relevant value paths. Those functions will mark their
|
|
fields as relevant and eventually call #set_validation to
|
|
install the validation results in the field states.
|
|
|
|
After this, all irrelevant asynchronous validation tasks are
|
|
cancelled.
|
|
*/
|
|
|
|
#validation_needed: boolean = false;
|
|
#validation_running: boolean = false;
|
|
|
|
#trigger_validation(): void {
|
|
debug("trigger validation");
|
|
if (!this.#validate_callback)
|
|
return;
|
|
|
|
this.#validation_needed = true;
|
|
if (this.#validation_running) {
|
|
debug("validation postponed");
|
|
return;
|
|
}
|
|
|
|
this.#validation_running = true;
|
|
while (this.#validation_needed) {
|
|
debug("running validation");
|
|
this.#validation_needed = false;
|
|
this.#validation_failed = false;
|
|
this._for_each_field_state(state => {
|
|
state.relevant = false;
|
|
state.validation_text = undefined;
|
|
});
|
|
this.#validate_callback(this);
|
|
this._for_each_field_state(state => {
|
|
if (!state.relevant && state.validation_task) {
|
|
debug("aborting irrelevant validation task", state_path(state));
|
|
state.validation_task.abort();
|
|
}
|
|
});
|
|
}
|
|
this.#validation_running = false;
|
|
|
|
this.#update();
|
|
}
|
|
|
|
#set_validation(state: DialogFieldState, result: unknown) {
|
|
if (result) {
|
|
const own = get_validation_result_own_string(result);
|
|
if (own) {
|
|
state.validation_text = own;
|
|
this.#validation_failed = true;
|
|
this.#online_validation = true;
|
|
}
|
|
if (typeof result == "object") {
|
|
for (const [k, v] of Object.entries(result)) {
|
|
const sub = k && state.sub.get(k);
|
|
if (sub)
|
|
this.#set_validation(sub, v);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* The field state has a cache of the most recently validated
|
|
value. If the current value is still the same, actual
|
|
validation is skipped and the cached result from last time is
|
|
used.
|
|
|
|
Calling #set_validation_state_result is the final thing that
|
|
should happen when validating a field. It will install the
|
|
result in the cache and then call #set_validation.
|
|
*/
|
|
|
|
#set_validation_state_result(
|
|
state: DialogFieldState,
|
|
val: unknown,
|
|
result: unknown,
|
|
) {
|
|
state.cached_value = val;
|
|
state.cached_result = result;
|
|
this.#set_validation(state, result);
|
|
}
|
|
|
|
/* The first thing should be of course to probe that cache. If we
|
|
get a hit, it is used immediately to call #set_validation.
|
|
*/
|
|
|
|
#probe_validation_state_cache(state: DialogFieldState, val: unknown): boolean {
|
|
if (Object.is(state.cached_value, val)) {
|
|
debug("cache hit", state_path(state), JSON.stringify(val), state.cached_result);
|
|
this.#set_validation(state, state.cached_result);
|
|
return true;
|
|
} else
|
|
return false;
|
|
}
|
|
|
|
/* And in fact, _validate_value does exactly those two things.
|
|
*/
|
|
|
|
_validate_value(state: DialogFieldState, val: unknown, func: () => unknown): void {
|
|
state.relevant = true;
|
|
if (!this.#probe_validation_state_cache(state, val)) {
|
|
const result = func();
|
|
debug("sync validate", state_path(state), JSON.stringify(result));
|
|
this.#set_validation_state_result(state, val, result);
|
|
}
|
|
}
|
|
|
|
/* Now asynchronous validation.
|
|
|
|
If there was no cache hit, asynchronous validation starts with
|
|
a timeout, followed by letting a asynchronous function run to
|
|
resolution. This is managed by a DialogTask.
|
|
|
|
Starting a new task of course aborts any previous one. It also
|
|
installs the current value in the cache, so that subsequent
|
|
validation rounds do nothing until the value actually changes.
|
|
|
|
When the validation result has been computed, we need to check
|
|
whether we have been aborted so that we don't install
|
|
out-dated results.
|
|
*/
|
|
|
|
_validate_value_async(
|
|
state: DialogFieldState,
|
|
val: unknown,
|
|
func: (signal: AbortSignal) => Promise<unknown>
|
|
): void {
|
|
state.relevant = true;
|
|
if (!this.#probe_validation_state_cache(state, val)) {
|
|
state.cached_value = val;
|
|
state.cached_result = undefined;
|
|
|
|
if (state.validation_task)
|
|
state.validation_task.abort();
|
|
state.validation_task = new DialogTask(
|
|
state_path(state) + ":validate",
|
|
async task => {
|
|
const signal = task.get_abort_signal();
|
|
let result;
|
|
try {
|
|
result = await func(signal);
|
|
} catch (ex) {
|
|
console.error(ex);
|
|
}
|
|
if (!signal.aborted) {
|
|
debug("async validate result", state_path(state), result);
|
|
this.#set_validation_state_result(state, val, result);
|
|
this.#update();
|
|
}
|
|
},
|
|
task => {
|
|
if (state.validation_task == task)
|
|
state.validation_task = null;
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
_set_value_async(
|
|
state: DialogFieldState,
|
|
func: (signal: AbortSignal) => Promise<void>
|
|
): void {
|
|
if (state.update_task)
|
|
state.update_task.abort();
|
|
|
|
state.update_task = new DialogTask(
|
|
state_path(state) + ":set",
|
|
async task => {
|
|
try {
|
|
await func(task.get_abort_signal());
|
|
} catch (ex) {
|
|
console.error(ex);
|
|
}
|
|
},
|
|
task => {
|
|
if (state.update_task == task)
|
|
state.update_task = null;
|
|
}
|
|
);
|
|
}
|
|
|
|
_get_value_async(
|
|
state: DialogFieldState,
|
|
func: (signal: AbortSignal) => Promise<void>
|
|
): void {
|
|
const task = new DialogTask(
|
|
state_path(state) + ":get",
|
|
async task => {
|
|
try {
|
|
await func(task.get_abort_signal());
|
|
} catch (ex) {
|
|
console.error(ex);
|
|
}
|
|
},
|
|
task => {
|
|
state.update_tasks.delete(task);
|
|
}
|
|
);
|
|
|
|
state.update_tasks.add(task);
|
|
}
|
|
|
|
/* The first thing run_action does is to trigger a new validation
|
|
round and then wait for all the asynchronous results to have
|
|
come in.
|
|
|
|
If there are any DialogFieldState objects that are waiting
|
|
for a timeout, we want to abort those and start over, so that
|
|
their validation starts immediately. (Also, it would be hairy
|
|
to wait for those timeouts to be over from here.)
|
|
*/
|
|
|
|
async validate(): Promise<boolean> {
|
|
this.#online_validation = true;
|
|
this.#trigger_validation();
|
|
await this._run_all_tasks_now();
|
|
return !this.#validation_failed;
|
|
}
|
|
|
|
set_cancel(cancel: (() => void) | null) {
|
|
this.#cancel_function = cancel;
|
|
this.#update();
|
|
}
|
|
|
|
async run_action(func: (vals: V) => Promise<void>): Promise<boolean> {
|
|
this.error = null;
|
|
this.#cancel_function = null;
|
|
this.#action_running = true;
|
|
this.#update();
|
|
if (!await this.validate()) {
|
|
this.#action_running = false;
|
|
this.#update();
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
this.#block_updates = true;
|
|
await func(this.values);
|
|
} catch (ex) {
|
|
console.error(String(ex));
|
|
this.error = ex;
|
|
}
|
|
|
|
this.#cancel_function = null;
|
|
this.#action_running = false;
|
|
this.#block_updates = false;
|
|
this.#update();
|
|
|
|
return !this.error;
|
|
}
|
|
|
|
cancel(onClose: () => void): void {
|
|
if (this.#action_running) {
|
|
if (this.#cancel_function)
|
|
this.#cancel_function();
|
|
} else {
|
|
this._cancel_debounce(this.#top_state);
|
|
this._abort_state_tasks(this.#top_state);
|
|
onClose();
|
|
}
|
|
}
|
|
|
|
top(changed_func?: ((values: V) => void) | undefined): DialogField<V> {
|
|
return new DialogField<V>(
|
|
this as DialogState<unknown>,
|
|
this.#top_state,
|
|
() => this.values,
|
|
(val) => {
|
|
debug("set", val);
|
|
if (this.#block_updates) {
|
|
// Deny state changes while actions run. This
|
|
// prevents the user from interacting with the
|
|
// dialog while it is busy. The alternative would
|
|
// be to officially disable all fields and prevent
|
|
// interactions that way, but that is visually
|
|
// very jarring and not something that we have
|
|
// been doing earlier.
|
|
debug("set denied");
|
|
return;
|
|
}
|
|
this.values = val;
|
|
this.#update();
|
|
},
|
|
() => {
|
|
if (this.#online_validation)
|
|
this.#trigger_validation();
|
|
if (changed_func)
|
|
changed_func(this.values);
|
|
},
|
|
);
|
|
}
|
|
|
|
field<K extends keyof V>(tag: K, changed_func?: ((val: V[K]) => void) | undefined): DialogField<V[K]> {
|
|
return this.top().sub(tag, changed_func);
|
|
}
|
|
}
|
|
|
|
export class DialogError {
|
|
title: string;
|
|
details: React.ReactNode;
|
|
|
|
constructor(title: string, details?: React.ReactNode) {
|
|
this.title = title;
|
|
this.details = details;
|
|
}
|
|
|
|
toString() {
|
|
return this.title + ": " + String(this.details);
|
|
}
|
|
|
|
static fromError(title: string, err: unknown) {
|
|
if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
|
|
return new DialogError(title, err.message);
|
|
} else {
|
|
return new DialogError(title, String(err));
|
|
}
|
|
}
|
|
}
|
|
|
|
export function useDialogState<V extends object>(
|
|
init: V | (() => V),
|
|
validate?: undefined | ((dlg: DialogState<V>) => void),
|
|
) : DialogState<V> {
|
|
const dlg = useObject(
|
|
() => new DialogState(
|
|
typeof init == "function" ? init() : init,
|
|
validate
|
|
),
|
|
null,
|
|
[]
|
|
);
|
|
useOn(dlg, "changed");
|
|
return dlg;
|
|
}
|
|
|
|
export function useDialogState_async<V extends object>(
|
|
init: () => Promise<V>,
|
|
validate?: undefined | ((dlg: DialogState<V>) => void),
|
|
) : null | DialogError | DialogState<V> {
|
|
const [dlg, setDlg] = useState<null | DialogError | DialogState<V>>(null);
|
|
useOn((dlg instanceof DialogError ? null : dlg), "changed");
|
|
useInit(async () => {
|
|
try {
|
|
setDlg(new DialogState<V>(await init(), validate));
|
|
} catch (ex) {
|
|
if (ex instanceof DialogError)
|
|
setDlg(ex);
|
|
else
|
|
setDlg(DialogError.fromError(_("Error during initialization"), ex));
|
|
}
|
|
});
|
|
return dlg;
|
|
}
|
|
|
|
// Common elements
|
|
|
|
export function DialogErrorMessage<V>({
|
|
dialog,
|
|
} : {
|
|
dialog: DialogState<V> | DialogError | null,
|
|
}) {
|
|
const err = (!dialog || dialog instanceof DialogError) ? dialog : dialog.error;
|
|
if (!err)
|
|
return null;
|
|
|
|
let title: string;
|
|
let details: React.ReactNode;
|
|
|
|
if (err instanceof DialogError) {
|
|
title = err.title;
|
|
details = err.details;
|
|
} else if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
|
|
title = _("Failed");
|
|
details = err.message;
|
|
} else {
|
|
title = _("Failed");
|
|
details = String(err);
|
|
}
|
|
|
|
return (
|
|
<Alert
|
|
ouiaId="dialog-error-message"
|
|
variant='danger'
|
|
isInline
|
|
title={title}
|
|
>
|
|
{details}
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
export function DialogActionButton<V>({
|
|
dialog,
|
|
action,
|
|
excuse,
|
|
onClose = undefined,
|
|
isDisabled,
|
|
isAriaDisabled,
|
|
...props
|
|
} : {
|
|
dialog: DialogState<V> | DialogError | null,
|
|
action: (values: V) => Promise<void>,
|
|
excuse?: string | undefined,
|
|
onClose?: undefined | (() => void)
|
|
} & Omit<ButtonProps, "action">) {
|
|
const [running, setRunning] = useState(false);
|
|
|
|
const btn = (
|
|
<Button
|
|
ouiaId="dialog-apply"
|
|
isLoading={!!dialog && !(dialog instanceof DialogError) && dialog.busy && running}
|
|
isDisabled={!dialog || dialog instanceof DialogError || dialog.actions_disabled || !!isDisabled}
|
|
isAriaDisabled={!!excuse || !!isAriaDisabled}
|
|
onClick={async () => {
|
|
cockpit.assert(dialog && !(dialog instanceof DialogError));
|
|
setRunning(true);
|
|
if (await dialog.run_action(action) && onClose)
|
|
onClose();
|
|
setRunning(false);
|
|
}}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
if (excuse) {
|
|
return (
|
|
<Tooltip
|
|
content={excuse}
|
|
>
|
|
{btn}
|
|
</Tooltip>
|
|
);
|
|
} else {
|
|
return btn;
|
|
}
|
|
}
|
|
|
|
export function DialogCancelButton<V>({
|
|
dialog,
|
|
onClose,
|
|
isDisabled,
|
|
children,
|
|
...props
|
|
} : {
|
|
dialog: DialogState<V> | DialogError | null,
|
|
onClose: () => void
|
|
} & ButtonProps) {
|
|
return (
|
|
<Button
|
|
ouiaId="dialog-cancel"
|
|
isDisabled={!dialog || (dialog instanceof DialogState && dialog.cancel_disabled) || !!isDisabled}
|
|
variant="link"
|
|
onClick={() => {
|
|
if (dialog instanceof DialogState)
|
|
dialog.cancel(onClose);
|
|
else
|
|
onClose();
|
|
}}
|
|
{...props}
|
|
>
|
|
{children || _("Cancel")}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
/* Common dialog field implementations.
|
|
*/
|
|
|
|
type falsy = null | undefined | false;
|
|
|
|
export function DialogHelperText<V>({
|
|
field,
|
|
excuse,
|
|
warning,
|
|
explanation,
|
|
} : {
|
|
field: DialogField<V>;
|
|
excuse?: string | falsy;
|
|
warning?: React.ReactNode;
|
|
explanation?: React.ReactNode;
|
|
}) {
|
|
let text: React.ReactNode = field.validation_text();
|
|
let variant: HelperTextItemProps["variant"] = "error";
|
|
if (!text && excuse) {
|
|
text = excuse;
|
|
variant = "default";
|
|
}
|
|
if (!text && warning) {
|
|
text = warning;
|
|
variant = "warning";
|
|
}
|
|
if (!text) {
|
|
text = explanation;
|
|
variant = "default";
|
|
}
|
|
|
|
if (!text)
|
|
return null;
|
|
|
|
return (
|
|
<FormHelperText>
|
|
<HelperText>
|
|
<HelperTextItem data-ouia-component-id={field.ouia_id("helper-text")} variant={variant}>
|
|
{text}
|
|
</HelperTextItem>
|
|
</HelperText>
|
|
</FormHelperText>
|
|
);
|
|
}
|
|
|
|
/* Many of the porcelain wrappers can put themselves automatically
|
|
into a FormGroup. In that case, the <label> produced by the
|
|
FormGroup and the actual input element should be connected via
|
|
"for" and "id" attributes, for a11y reasons.
|
|
|
|
But a porcelain wrapper can also be used without an automatic
|
|
FormGroup. In that case the user has to provide an explicit id for
|
|
making that connection or maybe an aria-label.
|
|
|
|
The useFormId hook helps with that.
|
|
*/
|
|
|
|
function useFormId(id: string | undefined, label: React.ReactNode) {
|
|
const random_id = useId();
|
|
return id || (label ? random_id : undefined);
|
|
}
|
|
|
|
export const OptionalFormGroup = ({
|
|
label,
|
|
children,
|
|
fieldId,
|
|
...props
|
|
} : {
|
|
label: React.ReactNode,
|
|
children: React.ReactNode,
|
|
fieldId?: string | undefined,
|
|
} & Omit<FormGroupProps, "fieldId" | "label" | "children">) => {
|
|
if (label) {
|
|
return (
|
|
<FormGroup
|
|
label={label}
|
|
{...fieldId ? { fieldId } : {} }
|
|
{...props}
|
|
>
|
|
{children}
|
|
</FormGroup>
|
|
);
|
|
} else {
|
|
return children;
|
|
}
|
|
};
|
|
|
|
export const DialogTextInput = ({
|
|
label = null,
|
|
field,
|
|
debounce,
|
|
excuse,
|
|
warning,
|
|
explanation,
|
|
isDisabled = false,
|
|
id,
|
|
...props
|
|
} : {
|
|
label?: React.ReactNode,
|
|
field: DialogField<string>,
|
|
debounce?: number | undefined,
|
|
excuse?: string | falsy,
|
|
warning?: React.ReactNode,
|
|
explanation?: React.ReactNode,
|
|
isDisabled?: boolean,
|
|
} & Omit<TextInputProps, "label" | "value" | "onChange">) => {
|
|
const fid = useFormId(id, label);
|
|
return (
|
|
<OptionalFormGroup label={label} fieldId={fid}>
|
|
<TextInput
|
|
id={fid}
|
|
ouiaId={field.ouia_id()}
|
|
value={field.get()}
|
|
onChange={(_event, val) => field.set_debounced(val, debounce)}
|
|
isDisabled={!!excuse || isDisabled}
|
|
{...props}
|
|
/>
|
|
<DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
|
|
</OptionalFormGroup>
|
|
);
|
|
};
|
|
|
|
export const DialogPasswordInput = ({
|
|
label = null,
|
|
field,
|
|
debounce,
|
|
excuse,
|
|
warning,
|
|
explanation,
|
|
isDisabled = false,
|
|
id,
|
|
...props
|
|
} : {
|
|
label?: React.ReactNode,
|
|
field: DialogField<string>,
|
|
debounce?: number | undefined,
|
|
excuse?: string | falsy,
|
|
warning?: React.ReactNode,
|
|
explanation?: React.ReactNode,
|
|
isDisabled?: boolean,
|
|
} & Omit<TextInputProps, "label" | "value" | "onChange">) => {
|
|
const [visible, setVisible] = useState(false);
|
|
const fid = useFormId(id, label);
|
|
return (
|
|
<OptionalFormGroup label={label} fieldId={fid}>
|
|
<InputGroup>
|
|
<InputGroupItem isFill>
|
|
<TextInput
|
|
id={fid}
|
|
ouiaId={field.ouia_id()}
|
|
type={visible ? "text" : "password"}
|
|
value={field.get()}
|
|
onChange={(_event, value) => field.set_debounced(value, debounce)}
|
|
isDisabled={!!excuse || isDisabled}
|
|
{...props}
|
|
/>
|
|
</InputGroupItem>
|
|
<InputGroupItem>
|
|
<Button
|
|
variant="control"
|
|
aria-label={visible ? _("Hide password") : _("Show password")}
|
|
onClick={() => setVisible(!visible)}
|
|
>
|
|
{visible ? <EyeSlashIcon /> : <EyeIcon />}
|
|
</Button>
|
|
</InputGroupItem>
|
|
</InputGroup>
|
|
<DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
|
|
</OptionalFormGroup>
|
|
);
|
|
};
|
|
|
|
export const DialogCheckbox = ({
|
|
field_label = null,
|
|
checkbox_label,
|
|
field,
|
|
excuse,
|
|
warning,
|
|
explanation,
|
|
} : {
|
|
field_label?: React.ReactNode,
|
|
checkbox_label: string,
|
|
field: DialogField<boolean>,
|
|
excuse?: string | falsy,
|
|
warning?: React.ReactNode,
|
|
explanation?: React.ReactNode,
|
|
}) => {
|
|
const id = useId();
|
|
return (
|
|
<OptionalFormGroup label={field_label} hasNoPaddingTop>
|
|
<Checkbox
|
|
id={id}
|
|
ouiaId={field.ouia_id()}
|
|
isChecked={field.get()}
|
|
label={checkbox_label}
|
|
onChange={(_event, checked) => field.set(checked)}
|
|
isDisabled={!!excuse}
|
|
/>
|
|
<DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
|
|
</OptionalFormGroup>
|
|
);
|
|
};
|
|
|
|
export interface DialogRadioSelectOption<T extends string> {
|
|
value: T,
|
|
label: React.ReactNode,
|
|
explanation?: React.ReactNode,
|
|
excuse?: string | falsy,
|
|
}
|
|
|
|
export function DialogRadioSelect<T extends string>({
|
|
label = null,
|
|
field,
|
|
options,
|
|
warning,
|
|
explanation,
|
|
isInline = false,
|
|
} : {
|
|
label?: React.ReactNode,
|
|
field: DialogField<T>,
|
|
options: DialogRadioSelectOption<T>[],
|
|
warning?: React.ReactNode,
|
|
explanation?: React.ReactNode,
|
|
isInline?: boolean,
|
|
}) {
|
|
const random_id = useId();
|
|
|
|
function makeLabel(o: DialogRadioSelectOption<T>, i: number) {
|
|
const exc = o.excuse ? <> ({o.excuse})</> : null;
|
|
const pad = (!isInline && i < options.length - 1) ? <><br />{"\u00A0"}</> : null;
|
|
const exp = o.explanation ? <><br /><small>{o.explanation}{pad}</small></> : null;
|
|
return <div data-ouia-component-id={field.ouia_id(o.value + "-label")}>{o.label}{exc}{exp}</div>;
|
|
}
|
|
|
|
return (
|
|
<OptionalFormGroup
|
|
label={label}
|
|
hasNoPaddingTop
|
|
isInline={isInline}
|
|
data-ouia-component-id={field.ouia_id()}
|
|
data-value={field.get()}
|
|
>
|
|
{
|
|
options.map((o, i) =>
|
|
<Radio
|
|
key={o.value}
|
|
id={random_id + o.value}
|
|
ouiaId={field.ouia_id(o.value)}
|
|
name={o.value}
|
|
isChecked={field.get() == o.value}
|
|
label={makeLabel(o, i)}
|
|
onChange={() => field.set(o.value)}
|
|
isDisabled={!!o.excuse}
|
|
/>
|
|
)
|
|
}
|
|
<DialogHelperText explanation={explanation} warning={warning} field={field} />
|
|
</OptionalFormGroup>
|
|
);
|
|
}
|
|
|
|
export interface DialogDropdownSelectOption<T extends string> {
|
|
value: T;
|
|
label: string;
|
|
}
|
|
|
|
export function DialogDropdownSelect<T extends string>({
|
|
label,
|
|
field,
|
|
excuse,
|
|
warning,
|
|
explanation,
|
|
options,
|
|
id,
|
|
...props
|
|
} : {
|
|
label?: React.ReactNode,
|
|
field: DialogField<T>,
|
|
excuse?: string | falsy,
|
|
warning?: React.ReactNode,
|
|
explanation?: React.ReactNode,
|
|
options: DialogDropdownSelectOption<T>[],
|
|
} & Omit<FormSelectProps, "ref" | "children">) {
|
|
const fid = useFormId(id, label);
|
|
return (
|
|
<OptionalFormGroup label={label} fieldId={fid}>
|
|
<FormSelect
|
|
id={fid}
|
|
ouiaId={field.ouia_id()}
|
|
onChange={(_event, val) => field.set(val as T) }
|
|
validated={warning ? "warning" : undefined}
|
|
isDisabled={!!excuse}
|
|
value={field.get()}
|
|
{...props}
|
|
>
|
|
{
|
|
options.map(
|
|
o => {
|
|
return <FormSelectOption key={o.value} value={o.value} label={o.label} />;
|
|
}
|
|
)
|
|
}
|
|
</FormSelect>
|
|
<DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
|
|
</OptionalFormGroup>
|
|
);
|
|
}
|
|
|
|
export function DialogDropdownSelectObject<T>({
|
|
label,
|
|
field,
|
|
excuse,
|
|
warning,
|
|
explanation,
|
|
options,
|
|
option_label = (o: T): string => { cockpit.assert(typeof o == "string"); return o },
|
|
id,
|
|
...props
|
|
} : {
|
|
label?: React.ReactNode,
|
|
field: DialogField<T>,
|
|
excuse?: string | falsy,
|
|
warning?: React.ReactNode,
|
|
explanation?: React.ReactNode,
|
|
options: T[],
|
|
option_label?: (o: T) => string,
|
|
} & Omit<FormSelectProps, "ref" | "children">) {
|
|
const fid = useFormId(id, label);
|
|
return (
|
|
<OptionalFormGroup label={label} fieldId={fid}>
|
|
<FormSelect
|
|
id={fid}
|
|
ouiaId={field.ouia_id()}
|
|
onChange={(_event, val) => {
|
|
const opt = options.find(o => option_label(o) == val);
|
|
field.set(opt!);
|
|
}}
|
|
validated={warning ? "warning" : undefined}
|
|
isDisabled={!!excuse}
|
|
value={option_label(field.get())}
|
|
{...props}
|
|
>
|
|
{
|
|
options.map(
|
|
o => {
|
|
const l = option_label(o);
|
|
return <FormSelectOption key={l} value={l} label={l} />;
|
|
}
|
|
)
|
|
}
|
|
</FormSelect>
|
|
<DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
|
|
</OptionalFormGroup>
|
|
);
|
|
}
|