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 : }
1050 4 : }
1051 :
1052 4 : _set_debounce(state: DialogFieldState, delay: number, func: () => void) {
1053 4 : this._cancel_debounce(state);
1054 :
1055 4 : if (delay === 0) {
1056 4 : debug("no debounce trigger", state_path(state));
1057 4 : func();
1058 4 : } else {
1059 4 : debug("debounce trigger", state_path(state), delay);
1060 4 : state.debounce_func = func;
1061 4 : state.debounce_timer = window.setTimeout(
1062 3 : () => {
1063 3 : state.debounce_timer = 0;
1064 3 : state.debounce_func = null;
1065 3 : func();
1066 3 : },
1067 4 : delay,
1068 4 : );
1069 4 : }
1070 4 : }
1071 :
1072 4 : _run_all_debouncing_now() {
1073 4 : this._for_each_field_state(state => {
1074 3 : if (state.debounce_timer) {
1075 3 : debug("running debounced trigger now", state_path(state));
1076 3 : cockpit.assert(state.debounce_func);
1077 3 : window.clearTimeout(state.debounce_timer);
1078 3 : state.debounce_timer = 0;
1079 3 : state.debounce_func();
1080 3 : }
1081 4 : });
1082 4 : }
1083 :
1084 : /* TASKS
1085 :
1086 : Tasks are a little abstraction that runs a asynchronous
1087 : function. Before running the action function, we need to wait
1088 : for them all to finish.
1089 : */
1090 :
1091 4 : async _run_all_tasks_now() {
1092 4 : let awaited: boolean = false;
1093 4 : do {
1094 : // Start all the things that might have been queued up for
1095 : // debouncing.
1096 :
1097 4 : this._run_all_debouncing_now();
1098 :
1099 : // Wait for the tasks that are currently running. While
1100 : // waiting, new things might have been qeued up for
1101 : // debouncing or new tasks might have been started, so we
1102 : // have to go back to the beginning.
1103 :
1104 4 : awaited = false;
1105 4 : await this._for_each_field_state_async(async state => {
1106 2 : if (state.validation_task) {
1107 2 : await state.validation_task.wait();
1108 2 : awaited = true;
1109 2 : }
1110 2 : if (state.update_task) {
1111 2 : await state.update_task.wait();
1112 2 : awaited = true;
1113 2 : }
1114 2 : for (const task of state.update_tasks.values()) {
1115 2 : await task.wait();
1116 2 : awaited = true;
1117 2 : }
1118 4 : });
1119 4 : } while (awaited);
1120 4 : }
1121 :
1122 4 : _abort_state_tasks(state: DialogFieldState, only_updates: boolean = false) {
1123 4 : debug("cancelling state tasks", state_path(state), only_updates);
1124 2 : if (state.validation_task && !only_updates)
1125 2 : state.validation_task.abort();
1126 4 : if (state.update_task)
1127 3 : state.update_task.abort();
1128 4 : for (const task of state.update_tasks.values())
1129 2 : task.abort();
1130 4 : for (const sub of state.sub.values())
1131 3 : this._abort_state_tasks(sub, only_updates);
1132 4 : }
1133 :
1134 : /* VALIDATION
1135 :
1136 : Validation is started by calling the #trigger_validation
1137 : method. This will reset all validation errors and mark all
1138 : fields as "irrelevant". Then it calls the provided "validate"
1139 : callback, which in turn will (eventually but synchronously)
1140 : call the "_validate_value" or "_validate_value_async" methods
1141 : of all relevant value paths. Those functions will mark their
1142 : fields as relevant and eventually call #set_validation to
1143 : install the validation results in the field states.
1144 :
1145 : After this, all irrelevant asynchronous validation tasks are
1146 : cancelled.
1147 : */
1148 :
1149 4 : #validation_needed: boolean = false;
1150 4 : #validation_running: boolean = false;
1151 :
1152 4 : #trigger_validation(): void {
1153 4 : debug("trigger validation");
1154 4 : if (!this.#validate_callback)
1155 4 : return;
1156 :
1157 3 : this.#validation_needed = true;
1158 2 : if (this.#validation_running) {
1159 2 : debug("validation postponed");
1160 2 : return;
1161 2 : }
1162 :
1163 3 : this.#validation_running = true;
1164 3 : while (this.#validation_needed) {
1165 3 : debug("running validation");
1166 3 : this.#validation_needed = false;
1167 3 : this.#validation_failed = false;
1168 3 : this._for_each_field_state(state => {
1169 3 : state.relevant = false;
1170 3 : state.validation_text = undefined;
1171 3 : });
1172 3 : this.#validate_callback(this);
1173 3 : this._for_each_field_state(state => {
1174 1 : if (!state.relevant && state.validation_task) {
1175 1 : debug("aborting irrelevant validation task", state_path(state));
1176 1 : state.validation_task.abort();
1177 1 : }
1178 3 : });
1179 3 : }
1180 3 : this.#validation_running = false;
1181 :
1182 3 : this.#update();
1183 4 : }
1184 :
1185 3 : #set_validation(state: DialogFieldState, result: unknown) {
1186 3 : if (result) {
1187 3 : const own = get_validation_result_own_string(result);
1188 3 : if (own) {
1189 3 : state.validation_text = own;
1190 3 : this.#validation_failed = true;
1191 3 : this.#online_validation = true;
1192 3 : }
1193 2 : if (typeof result == "object") {
1194 2 : for (const [k, v] of Object.entries(result)) {
1195 2 : const sub = k && state.sub.get(k);
1196 2 : if (sub)
1197 2 : this.#set_validation(sub, v);
1198 2 : }
1199 2 : }
1200 3 : }
1201 3 : }
1202 :
1203 : /* The field state has a cache of the most recently validated
1204 : value. If the current value is still the same, actual
1205 : validation is skipped and the cached result from last time is
1206 : used.
1207 :
1208 : Calling #set_validation_state_result is the final thing that
1209 : should happen when validating a field. It will install the
1210 : result in the cache and then call #set_validation.
1211 : */
1212 :
1213 3 : #set_validation_state_result(
1214 3 : state: DialogFieldState,
1215 3 : val: unknown,
1216 3 : result: unknown,
1217 3 : ) {
1218 3 : state.cached_value = val;
1219 3 : state.cached_result = result;
1220 3 : this.#set_validation(state, result);
1221 3 : }
1222 :
1223 : /* The first thing should be of course to probe that cache. If we
1224 : get a hit, it is used immediately to call #set_validation.
1225 : */
1226 :
1227 3 : #probe_validation_state_cache(state: DialogFieldState, val: unknown): boolean {
1228 3 : if (Object.is(state.cached_value, val)) {
1229 3 : debug("cache hit", state_path(state), JSON.stringify(val), state.cached_result);
1230 3 : this.#set_validation(state, state.cached_result);
1231 3 : return true;
1232 3 : } else
1233 3 : return false;
1234 3 : }
1235 :
1236 : /* And in fact, _validate_value does exactly those two things.
1237 : */
1238 :
1239 3 : _validate_value(state: DialogFieldState, val: unknown, func: () => unknown): void {
1240 3 : state.relevant = true;
1241 3 : if (!this.#probe_validation_state_cache(state, val)) {
1242 3 : const result = func();
1243 3 : debug("sync validate", state_path(state), JSON.stringify(result));
1244 3 : this.#set_validation_state_result(state, val, result);
1245 3 : }
1246 3 : }
1247 :
1248 : /* Now asynchronous validation.
1249 :
1250 : If there was no cache hit, asynchronous validation starts with
1251 : a timeout, followed by letting a asynchronous function run to
1252 : resolution. This is managed by a DialogTask.
1253 :
1254 : Starting a new task of course aborts any previous one. It also
1255 : installs the current value in the cache, so that subsequent
1256 : validation rounds do nothing until the value actually changes.
1257 :
1258 : When the validation result has been computed, we need to check
1259 : whether we have been aborted so that we don't install
1260 : out-dated results.
1261 : */
1262 :
1263 1 : _validate_value_async(
1264 1 : state: DialogFieldState,
1265 1 : val: unknown,
1266 1 : func: (signal: AbortSignal) => Promise<unknown>
1267 1 : ): void {
1268 1 : state.relevant = true;
1269 1 : if (!this.#probe_validation_state_cache(state, val)) {
1270 1 : state.cached_value = val;
1271 1 : state.cached_result = undefined;
1272 :
1273 1 : if (state.validation_task)
1274 1 : state.validation_task.abort();
1275 1 : state.validation_task = new DialogTask(
1276 1 : state_path(state) + ":validate",
1277 1 : async task => {
1278 1 : const signal = task.get_abort_signal();
1279 1 : let result;
1280 1 : try {
1281 1 : result = await func(signal);
1282 1 : } catch (ex) {
1283 1 : console.error(ex);
1284 1 : }
1285 1 : if (!signal.aborted) {
1286 1 : debug("async validate result", state_path(state), result);
1287 1 : this.#set_validation_state_result(state, val, result);
1288 1 : this.#update();
1289 1 : }
1290 1 : },
1291 1 : task => {
1292 1 : if (state.validation_task == task)
1293 1 : state.validation_task = null;
1294 1 : }
1295 1 : );
1296 1 : }
1297 1 : }
1298 :
1299 2 : _set_value_async(
1300 2 : state: DialogFieldState,
1301 2 : func: (signal: AbortSignal) => Promise<void>
1302 2 : ): void {
1303 2 : const task = new DialogTask(
1304 2 : state_path(state) + ":set",
1305 2 : async task => {
1306 2 : try {
1307 2 : await func(task.get_abort_signal());
1308 0 : } catch (ex) {
1309 0 : console.error(ex);
1310 0 : }
1311 2 : },
1312 2 : task => {
1313 2 : if (state.update_task == task)
1314 2 : state.update_task = null;
1315 2 : }
1316 2 : );
1317 :
1318 2 : if (state.update_task)
1319 1 : state.update_task.abort();
1320 2 : state.update_task = task;
1321 2 : }
1322 :
1323 1 : _get_value_async(
1324 1 : state: DialogFieldState,
1325 1 : func: (signal: AbortSignal) => Promise<void>
1326 1 : ): void {
1327 1 : const task = new DialogTask(
1328 1 : state_path(state) + ":get",
1329 1 : async task => {
1330 1 : try {
1331 1 : await func(task.get_abort_signal());
1332 0 : } catch (ex) {
1333 0 : console.error(ex);
1334 0 : }
1335 1 : },
1336 1 : task => {
1337 1 : state.update_tasks.delete(task);
1338 1 : }
1339 1 : );
1340 :
1341 1 : state.update_tasks.add(task);
1342 1 : }
1343 :
1344 : /* The first thing run_action does is to trigger a new validation
1345 : round and then wait for all the asynchronous results to have
1346 : come in.
1347 :
1348 : If there are any DialogFieldState objects that are waiting
1349 : for a timeout, we want to abort those and start over, so that
1350 : their validation starts immediately. (Also, it would be hairy
1351 : to wait for those timeouts to be over from here.)
1352 : */
1353 :
1354 4 : async validate(): Promise<boolean> {
1355 4 : this.#online_validation = true;
1356 4 : this.#trigger_validation();
1357 4 : await this._run_all_tasks_now();
1358 4 : return !this.#validation_failed;
1359 4 : }
1360 :
1361 3 : set_cancel(cancel: (() => void) | null) {
1362 3 : this.#cancel_function = cancel;
1363 3 : this.#update();
1364 3 : }
1365 :
1366 4 : async run_action(func: (vals: V) => Promise<void>): Promise<boolean> {
1367 4 : this.error = null;
1368 4 : this.#cancel_function = null;
1369 4 : this.#action_running = true;
1370 4 : this.#update();
1371 3 : if (!await this.validate()) {
1372 3 : this.#action_running = false;
1373 3 : this.#update();
1374 3 : return false;
1375 3 : }
1376 :
1377 4 : try {
1378 4 : this.#block_updates = true;
1379 4 : await func(this.values);
1380 4 : } catch (ex) {
1381 4 : console.error(String(ex));
1382 4 : this.error = ex;
1383 4 : }
1384 :
1385 4 : this.#cancel_function = null;
1386 4 : this.#action_running = false;
1387 4 : this.#block_updates = false;
1388 4 : this.#update();
1389 :
1390 4 : return !this.error;
1391 4 : }
1392 :
1393 3 : cancel(onClose: () => void): void {
1394 2 : if (this.#action_running) {
1395 2 : if (this.#cancel_function)
1396 2 : this.#cancel_function();
1397 2 : } else {
1398 3 : this._abort_state_tasks(this.#top_state);
1399 3 : onClose();
1400 3 : }
1401 3 : }
1402 :
1403 4 : top(changed_func?: ((values: V) => void) | undefined): DialogField<V> {
1404 4 : return new DialogField<V>(
1405 4 : this as DialogState<unknown>,
1406 4 : this.#top_state,
1407 4 : () => this.values,
1408 4 : (val) => {
1409 4 : debug("set", val);
1410 2 : if (this.#block_updates) {
1411 : // Deny state changes while actions run. This
1412 : // prevents the user from interacting with the
1413 : // dialog while it is busy. The alternative would
1414 : // be to officially disable all fields and prevent
1415 : // interactions that way, but that is visually
1416 : // very jarring and not something that we have
1417 : // been doing earlier.
1418 2 : debug("set denied");
1419 2 : return;
1420 2 : }
1421 4 : this.values = val;
1422 4 : this.#update();
1423 4 : },
1424 4 : () => {
1425 4 : if (this.#online_validation)
1426 4 : this.#trigger_validation();
1427 4 : if (changed_func)
1428 2 : changed_func(this.values);
1429 4 : },
1430 4 : );
1431 4 : }
1432 :
1433 4 : field<K extends keyof V>(tag: K, changed_func?: ((val: V[K]) => void) | undefined): DialogField<V[K]> {
1434 4 : return this.top().sub(tag, changed_func);
1435 4 : }
1436 39 : }
1437 :
1438 39 : export class DialogError {
1439 : title: string;
1440 : details: React.ReactNode;
1441 :
1442 2 : constructor(title: string, details?: React.ReactNode) {
1443 2 : this.title = title;
1444 2 : this.details = details;
1445 2 : }
1446 :
1447 2 : toString() {
1448 2 : return this.title + ": " + String(this.details);
1449 2 : }
1450 :
1451 1 : static fromError(title: string, err: unknown) {
1452 1 : if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
1453 1 : return new DialogError(title, err.message);
1454 1 : } else {
1455 1 : return new DialogError(title, String(err));
1456 1 : }
1457 1 : }
1458 39 : }
1459 :
1460 4 : export function useDialogState<V extends object>(
1461 4 : init: V | (() => V),
1462 4 : validate?: undefined | ((dlg: DialogState<V>) => void),
1463 4 : ) : DialogState<V> {
1464 4 : const dlg = useObject(
1465 4 : () => new DialogState(
1466 2 : typeof init == "function" ? init() : init,
1467 4 : validate
1468 4 : ),
1469 4 : null,
1470 4 : []
1471 4 : );
1472 4 : useOn(dlg, "changed");
1473 4 : return dlg;
1474 4 : }
1475 :
1476 2 : export function useDialogState_async<V extends object>(
1477 2 : init: () => Promise<V>,
1478 2 : validate?: undefined | ((dlg: DialogState<V>) => void),
1479 2 : ) : null | DialogError | DialogState<V> {
1480 2 : const [dlg, setDlg] = useState<null | DialogError | DialogState<V>>(null);
1481 1 : useOn((dlg instanceof DialogError ? null : dlg), "changed");
1482 2 : useInit(async () => {
1483 2 : try {
1484 2 : setDlg(new DialogState<V>(await init(), validate));
1485 1 : } catch (ex) {
1486 1 : if (ex instanceof DialogError)
1487 1 : setDlg(ex);
1488 : else
1489 1 : setDlg(DialogError.fromError(_("Error during initialization"), ex));
1490 1 : }
1491 2 : });
1492 2 : return dlg;
1493 2 : }
1494 :
1495 : // Common elements
1496 :
1497 4 : export function DialogErrorMessage<V>({
1498 4 : dialog,
1499 4 : } : {
1500 : dialog: DialogState<V> | DialogError | null,
1501 4 : }) {
1502 3 : const err = (!dialog || dialog instanceof DialogError) ? dialog : dialog.error;
1503 4 : if (!err)
1504 4 : return null;
1505 :
1506 4 : let title: string;
1507 4 : let details: React.ReactNode;
1508 :
1509 3 : if (err instanceof DialogError) {
1510 3 : title = err.title;
1511 3 : details = err.details;
1512 2 : } else if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
1513 3 : title = _("Failed");
1514 3 : details = err.message;
1515 2 : } else {
1516 2 : title = _("Failed");
1517 2 : details = String(err);
1518 2 : }
1519 :
1520 4 : return (
1521 4 : <Alert
1522 4 : ouiaId="dialog-error-message"
1523 4 : variant='danger'
1524 4 : isInline
1525 4 : title={title}
1526 : >
1527 4 : {details}
1528 4 : </Alert>
1529 : );
1530 4 : }
1531 :
1532 4 : export function DialogActionButton<V>({
1533 4 : dialog,
1534 4 : action,
1535 4 : excuse,
1536 4 : onClose = undefined,
1537 4 : isDisabled,
1538 4 : isAriaDisabled,
1539 4 : ...props
1540 4 : } : {
1541 : dialog: DialogState<V> | DialogError | null,
1542 : action: (values: V) => Promise<void>,
1543 : excuse?: string | undefined,
1544 : onClose?: undefined | (() => void)
1545 4 : } & Omit<ButtonProps, "action">) {
1546 4 : const [running, setRunning] = useState(false);
1547 :
1548 4 : const btn = (
1549 4 : <Button
1550 4 : ouiaId="dialog-apply"
1551 4 : isLoading={!!dialog && !(dialog instanceof DialogError) && dialog.busy && running}
1552 4 : isDisabled={!dialog || dialog instanceof DialogError || dialog.actions_disabled || !!isDisabled}
1553 4 : isAriaDisabled={!!excuse || !!isAriaDisabled}
1554 4 : onClick={async () => {
1555 4 : cockpit.assert(dialog && !(dialog instanceof DialogError));
1556 4 : setRunning(true);
1557 4 : if (await dialog.run_action(action) && onClose)
1558 4 : onClose();
1559 4 : setRunning(false);
1560 4 : }}
1561 4 : {...props}
1562 4 : />
1563 : );
1564 :
1565 3 : if (excuse) {
1566 3 : return (
1567 3 : <Tooltip
1568 3 : content={excuse}
1569 : >
1570 3 : {btn}
1571 3 : </Tooltip>
1572 : );
1573 3 : } else {
1574 4 : return btn;
1575 4 : }
1576 4 : }
1577 :
1578 4 : export function DialogCancelButton<V>({
1579 4 : dialog,
1580 4 : onClose,
1581 4 : isDisabled,
1582 4 : children,
1583 4 : ...props
1584 4 : } : {
1585 : dialog: DialogState<V> | DialogError | null,
1586 : onClose: () => void
1587 4 : } & ButtonProps) {
1588 4 : return (
1589 4 : <Button
1590 4 : ouiaId="dialog-cancel"
1591 4 : isDisabled={!dialog || (dialog instanceof DialogState && dialog.cancel_disabled) || !!isDisabled}
1592 4 : variant="link"
1593 3 : onClick={() => {
1594 3 : if (dialog instanceof DialogState)
1595 1 : dialog.cancel(onClose);
1596 : else
1597 1 : onClose();
1598 3 : }}
1599 4 : {...props}
1600 : >
1601 3 : {children || _("Cancel")}
1602 4 : </Button>
1603 : );
1604 4 : }
1605 :
1606 : /* Common dialog field implementations.
1607 : */
1608 :
1609 : type falsy = null | undefined | false;
1610 :
1611 4 : export function DialogHelperText<V>({
1612 4 : field,
1613 4 : excuse,
1614 4 : warning,
1615 4 : explanation,
1616 4 : } : {
1617 : field: DialogField<V>;
1618 : excuse?: string | falsy;
1619 : warning?: React.ReactNode;
1620 : explanation?: React.ReactNode;
1621 4 : }) {
1622 4 : let text: React.ReactNode = field.validation_text();
1623 4 : let variant: HelperTextItemProps["variant"] = "error";
1624 3 : if (!text && excuse) {
1625 3 : text = excuse;
1626 3 : variant = "default";
1627 3 : }
1628 2 : if (!text && warning) {
1629 2 : text = warning;
1630 2 : variant = "warning";
1631 2 : }
1632 4 : if (!text) {
1633 4 : text = explanation;
1634 4 : variant = "default";
1635 4 : }
1636 :
1637 4 : if (!text)
1638 4 : return null;
1639 :
1640 4 : return (
1641 4 : <FormHelperText>
1642 4 : <HelperText>
1643 4 : <HelperTextItem data-ouia-component-id={field.ouia_id("helper-text")} variant={variant}>
1644 4 : {text}
1645 4 : </HelperTextItem>
1646 4 : </HelperText>
1647 4 : </FormHelperText>
1648 : );
1649 4 : }
1650 :
1651 : /* Many of the porcelain wrappers can put themselves automatically
1652 : into a FormGroup. In that case, the <label> produced by the
1653 : FormGroup and the actual input element should be connected via
1654 : "for" and "id" attributes, for a11y reasons.
1655 :
1656 : But a porcelain wrapper can also be used without an automatic
1657 : FormGroup. In that case the user has to provide an explicit id for
1658 : making that connection or maybe an aria-label.
1659 :
1660 : The useFormId hook helps with that.
1661 : */
1662 :
1663 4 : function useFormId(id: string | undefined, label: React.ReactNode) {
1664 4 : const random_id = useId();
1665 2 : return id || (label ? random_id : undefined);
1666 4 : }
1667 :
1668 4 : export const OptionalFormGroup = ({
1669 4 : label,
1670 4 : children,
1671 4 : fieldId,
1672 4 : ...props
1673 4 : } : {
1674 : label: React.ReactNode,
1675 : children: React.ReactNode,
1676 : fieldId?: string | undefined,
1677 4 : } & Omit<FormGroupProps, "fieldId" | "label" | "children">) => {
1678 4 : if (label) {
1679 4 : return (
1680 4 : <FormGroup
1681 4 : label={label}
1682 3 : {...fieldId ? { fieldId } : {} }
1683 4 : {...props}
1684 : >
1685 4 : {children}
1686 4 : </FormGroup>
1687 : );
1688 2 : } else {
1689 2 : return children;
1690 2 : }
1691 4 : };
1692 :
1693 3 : export const DialogTextInput = ({
1694 3 : label = null,
1695 3 : field,
1696 3 : debounce,
1697 3 : excuse,
1698 3 : warning,
1699 3 : explanation,
1700 3 : isDisabled = false,
1701 3 : id,
1702 3 : ...props
1703 3 : } : {
1704 : label?: React.ReactNode,
1705 : field: DialogField<string>,
1706 : debounce?: number | undefined,
1707 : excuse?: string | falsy,
1708 : warning?: React.ReactNode,
1709 : explanation?: React.ReactNode,
1710 : isDisabled?: boolean,
1711 3 : } & Omit<TextInputProps, "label" | "value" | "onChange">) => {
1712 3 : const fid = useFormId(id, label);
1713 3 : return (
1714 3 : <OptionalFormGroup label={label} fieldId={fid}>
1715 3 : <TextInput
1716 3 : id={fid}
1717 3 : ouiaId={field.ouia_id()}
1718 3 : value={field.get()}
1719 2 : onChange={(_event, val) => field.set_debounced(val, debounce)}
1720 3 : isDisabled={!!excuse || isDisabled}
1721 3 : {...props}
1722 3 : />
1723 3 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1724 3 : </OptionalFormGroup>
1725 : );
1726 3 : };
1727 :
1728 2 : export const DialogPasswordInput = ({
1729 2 : label = null,
1730 2 : field,
1731 2 : debounce,
1732 2 : excuse,
1733 2 : warning,
1734 2 : explanation,
1735 2 : isDisabled = false,
1736 2 : id,
1737 2 : ...props
1738 2 : } : {
1739 : label?: React.ReactNode,
1740 : field: DialogField<string>,
1741 : debounce?: number | undefined,
1742 : excuse?: string | falsy,
1743 : warning?: React.ReactNode,
1744 : explanation?: React.ReactNode,
1745 : isDisabled?: boolean,
1746 2 : } & Omit<TextInputProps, "label" | "value" | "onChange">) => {
1747 2 : const [visible, setVisible] = useState(false);
1748 2 : const fid = useFormId(id, label);
1749 2 : return (
1750 2 : <OptionalFormGroup label={label} fieldId={fid}>
1751 2 : <InputGroup>
1752 2 : <InputGroupItem isFill>
1753 2 : <TextInput
1754 2 : id={fid}
1755 2 : ouiaId={field.ouia_id()}
1756 1 : type={visible ? "text" : "password"}
1757 2 : value={field.get()}
1758 2 : onChange={(_event, value) => field.set_debounced(value, debounce)}
1759 2 : isDisabled={!!excuse || isDisabled}
1760 2 : {...props}
1761 2 : />
1762 2 : </InputGroupItem>
1763 2 : <InputGroupItem>
1764 2 : <Button
1765 2 : variant="control"
1766 1 : aria-label={visible ? _("Hide password") : _("Show password")}
1767 0 : onClick={() => setVisible(!visible)}
1768 : >
1769 1 : {visible ? <EyeSlashIcon /> : <EyeIcon />}
1770 2 : </Button>
1771 2 : </InputGroupItem>
1772 2 : </InputGroup>
1773 2 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1774 2 : </OptionalFormGroup>
1775 : );
1776 2 : };
1777 :
1778 2 : export const DialogCheckbox = ({
1779 2 : field_label = null,
1780 2 : checkbox_label,
1781 2 : field,
1782 2 : excuse,
1783 2 : warning,
1784 2 : explanation,
1785 2 : } : {
1786 : field_label?: React.ReactNode,
1787 : checkbox_label: string,
1788 : field: DialogField<boolean>,
1789 : excuse?: string | falsy,
1790 : warning?: React.ReactNode,
1791 : explanation?: React.ReactNode,
1792 2 : }) => {
1793 2 : const id = useId();
1794 2 : return (
1795 2 : <OptionalFormGroup label={field_label} hasNoPaddingTop>
1796 2 : <Checkbox
1797 2 : id={id}
1798 2 : ouiaId={field.ouia_id()}
1799 2 : isChecked={field.get()}
1800 2 : label={checkbox_label}
1801 1 : onChange={(_event, checked) => field.set(checked)}
1802 2 : isDisabled={!!excuse}
1803 2 : />
1804 2 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1805 2 : </OptionalFormGroup>
1806 : );
1807 2 : };
1808 :
1809 : export interface DialogRadioSelectOption<T extends string> {
1810 : value: T,
1811 : label: React.ReactNode,
1812 : explanation?: React.ReactNode,
1813 : excuse?: string | falsy,
1814 : }
1815 :
1816 2 : export function DialogRadioSelect<T extends string>({
1817 2 : label = null,
1818 2 : field,
1819 2 : options,
1820 2 : warning,
1821 2 : explanation,
1822 2 : isInline = false,
1823 2 : } : {
1824 : label?: React.ReactNode,
1825 : field: DialogField<T>,
1826 : options: DialogRadioSelectOption<T>[],
1827 : warning?: React.ReactNode,
1828 : explanation?: React.ReactNode,
1829 : isInline?: boolean,
1830 2 : }) {
1831 2 : const random_id = useId();
1832 :
1833 2 : function makeLabel(o: DialogRadioSelectOption<T>, i: number) {
1834 2 : const exc = o.excuse ? <> ({o.excuse})</> : null;
1835 2 : const pad = (!isInline && i < options.length - 1) ? <><br />{"\u00A0"}</> : null;
1836 2 : const exp = o.explanation ? <><br /><small>{o.explanation}{pad}</small></> : null;
1837 2 : return <div data-ouia-component-id={field.ouia_id(o.value + "-label")}>{o.label}{exc}{exp}</div>;
1838 2 : }
1839 :
1840 2 : return (
1841 2 : <OptionalFormGroup
1842 2 : label={label}
1843 2 : hasNoPaddingTop
1844 2 : isInline={isInline}
1845 2 : data-ouia-component-id={field.ouia_id()}
1846 2 : data-value={field.get()}
1847 : >
1848 : {
1849 2 : options.map((o, i) =>
1850 2 : <Radio
1851 2 : key={o.value}
1852 2 : id={random_id + o.value}
1853 2 : ouiaId={field.ouia_id(o.value)}
1854 2 : name={o.value}
1855 2 : isChecked={field.get() == o.value}
1856 2 : label={makeLabel(o, i)}
1857 1 : onChange={() => field.set(o.value)}
1858 2 : isDisabled={!!o.excuse}
1859 2 : />
1860 2 : )
1861 : }
1862 2 : <DialogHelperText explanation={explanation} warning={warning} field={field} />
1863 2 : </OptionalFormGroup>
1864 : );
1865 2 : }
1866 :
1867 : export interface DialogDropdownSelectOption<T extends string> {
1868 : value: T;
1869 : label: string;
1870 : }
1871 :
1872 3 : export function DialogDropdownSelect<T extends string>({
1873 3 : label,
1874 3 : field,
1875 3 : excuse,
1876 3 : warning,
1877 3 : explanation,
1878 3 : options,
1879 3 : id,
1880 3 : ...props
1881 3 : } : {
1882 : label?: React.ReactNode,
1883 : field: DialogField<T>,
1884 : excuse?: string | falsy,
1885 : warning?: React.ReactNode,
1886 : explanation?: React.ReactNode,
1887 : options: DialogDropdownSelectOption<T>[],
1888 3 : } & Omit<FormSelectProps, "ref" | "children">) {
1889 3 : const fid = useFormId(id, label);
1890 3 : return (
1891 3 : <OptionalFormGroup label={label} fieldId={fid}>
1892 3 : <FormSelect
1893 3 : id={fid}
1894 3 : ouiaId={field.ouia_id()}
1895 2 : onChange={(_event, val) => field.set(val as T) }
1896 1 : validated={warning ? "warning" : undefined}
1897 3 : isDisabled={!!excuse}
1898 3 : value={field.get()}
1899 3 : {...props}
1900 : >
1901 : {
1902 3 : options.map(
1903 3 : o => {
1904 3 : return <FormSelectOption key={o.value} value={o.value} label={o.label} />;
1905 3 : }
1906 3 : )
1907 : }
1908 3 : </FormSelect>
1909 3 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1910 3 : </OptionalFormGroup>
1911 : );
1912 3 : }
1913 :
1914 2 : export function DialogDropdownSelectObject<T>({
1915 2 : label,
1916 2 : field,
1917 2 : excuse,
1918 2 : warning,
1919 2 : explanation,
1920 2 : options,
1921 2 : option_label = (o: T): string => { cockpit.assert(typeof o == "string"); return o },
1922 2 : id,
1923 2 : ...props
1924 2 : } : {
1925 : label?: React.ReactNode,
1926 : field: DialogField<T>,
1927 : excuse?: string | falsy,
1928 : warning?: React.ReactNode,
1929 : explanation?: React.ReactNode,
1930 : options: T[],
1931 : option_label?: (o: T) => string,
1932 2 : } & Omit<FormSelectProps, "ref" | "children">) {
1933 2 : const fid = useFormId(id, label);
1934 2 : return (
1935 2 : <OptionalFormGroup label={label} fieldId={fid}>
1936 2 : <FormSelect
1937 2 : id={fid}
1938 2 : ouiaId={field.ouia_id()}
1939 1 : onChange={(_event, val) => {
1940 1 : const opt = options.find(o => option_label(o) == val);
1941 1 : field.set(opt!);
1942 1 : }}
1943 1 : validated={warning ? "warning" : undefined}
1944 2 : isDisabled={!!excuse}
1945 2 : value={option_label(field.get())}
1946 2 : {...props}
1947 : >
1948 : {
1949 2 : options.map(
1950 2 : o => {
1951 2 : const l = option_label(o);
1952 2 : return <FormSelectOption key={l} value={l} label={l} />;
1953 2 : }
1954 2 : )
1955 : }
1956 2 : </FormSelect>
1957 2 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1958 2 : </OptionalFormGroup>
1959 : );
1960 2 : }
|