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