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