Line data Source code
1 37 : /*
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 : - handle.random_id()
515 :
516 : Returns a string that is suitable for the "id" attribute of a DOM
517 : element. All handles for the same dialog field will return the
518 : same id. These ids are unique across all dialogs so they also work
519 : when multiple dialogs are displayed simultaneously.
520 :
521 : PORCELAIN GALLERY
522 :
523 : Here are some noteworthy React components that integrate with the
524 : plumbing API.
525 :
526 : - <DialogErrorMessage dialog={dlg} />
527 :
528 : This creates an appropriate Alert for "dlg.error", if it is set. It
529 : works well with instances of DialogError, and all usual errors
530 : thrown by the Cockpit API.
531 :
532 : In addition to a proper DialogState, the "dialog" property can be
533 : anything returned by "use_DialogState_async".
534 :
535 : If given one the of Cockpit API errors, the title of the Alert will
536 : be a generic "Failed" text. If you want more control, use a
537 : DialogError.
538 :
539 : A DialogError contains a title and details, and the details can
540 : come from another error. For example:
541 :
542 : try {
543 : await cockpit.spawn(["/bin/frob", "--bars"])
544 : } catch (ex) {
545 : throw DialogError.fromError("Failed to frob the bars", ex);
546 : }
547 :
548 : You can also construct a DialogError directly from title and
549 : details:
550 :
551 : throw new DialogError("Failed to frob", <pre>...</pre>);
552 :
553 : In that case, the details can be any React node.
554 :
555 : - <DialogActionButton dialog={dlg} action={func} onClose={close_func}>
556 :
557 : This will produce a action button for a dialog that correctly disables
558 : itself according to the state of "dlg".
559 :
560 : In addition to a proper DialogState, the "dialog" property can be
561 : anything returned by "use_DialogState_async".
562 :
563 : When clicked, "func" will be run via "dlg.run_action". If "func"
564 : completes successfully, "close_func" is called to close the dialog.
565 :
566 : - <DialogCancelButton dialog={dlg} onClose={close_func} />
567 :
568 : This will produce a cancel button for a dialog that correctly
569 : disables itself according to the state of "dlg".
570 :
571 : In addition to a proper DialogState, the "dialog" property can be
572 : anything returned by "use_DialogState_async".
573 :
574 : Clicking it will either just close the dialog by calling
575 : "close_func", or run the cancel function provided by the currently
576 : running action function (if there is any).
577 :
578 : - <DialogTextInput label="Name" field={dlg.field("name")} ... />
579 :
580 : This will produce a TextInput in a (optional) FormGroup that will
581 : manage the given value handle. The "label" property is optional
582 : and omitting it will also omit the FormGroup.
583 :
584 : - <DialogCheckbox label= field= .../>
585 :
586 : For a single checkbox that drives a boolean.
587 :
588 : - <DialogRadioSelect label= field= options= .../>
589 :
590 : For a group of radio buttons. The options can be disabled and have
591 : explanations.
592 :
593 : - <DialogDropdownSelect label= field= options= .../> </>
594 :
595 : For a simple dropdown select. Options can not be disabled or have
596 : explanations.
597 :
598 : - <DialogDropdownSelectObject label= field= options= option_label= />
599 :
600 : A variant of the simple dropdown select from above where the options
601 : can be of any type whatsoever, such as something directly from your
602 : data model. A simple case is selecting from an array of strings. In
603 : that case you can omit the "option_label" function.
604 :
605 : */
606 :
607 37 : import React, { useState } from "react";
608 : import { useObject, useInit, useOn } from 'hooks';
609 : import { EventEmitter } from 'cockpit/event';
610 :
611 37 : import cockpit from "cockpit";
612 :
613 : import { Button, type ButtonProps } from "@patternfly/react-core/dist/esm/components/Button/index.js";
614 : import { FormGroup, type FormGroupProps, FormHelperText } from "@patternfly/react-core/dist/esm/components/Form";
615 : import { TextInput, type TextInputProps } from "@patternfly/react-core/dist/esm/components/TextInput";
616 : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
617 : import {
618 : HelperText, HelperTextItem, type HelperTextItemProps
619 : } from "@patternfly/react-core/dist/esm/components/HelperText";
620 : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
621 : import {
622 : FormSelect, FormSelectOption, type FormSelectProps,
623 : } from "@patternfly/react-core/dist/esm/components/FormSelect";
624 : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
625 : import { InputGroup, InputGroupItem } from "@patternfly/react-core/dist/esm/components/InputGroup/index.js";
626 : import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons";
627 :
628 37 : const _ = cockpit.gettext;
629 :
630 2 : function debug(...args: unknown[]) {
631 2 : if (window.debugging == "all" || window.debugging?.includes("dialog"))
632 2 : console.debug("dialog:", ...args);
633 2 : }
634 :
635 : type ArrayElement<ArrayType> =
636 : ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
637 :
638 1 : function toSpliced<T>(arr: T[], start: number, deleteCount: number, ...rest: T[]): T[] {
639 1 : const copy = [...arr];
640 1 : copy.splice(start, deleteCount, ...rest);
641 1 : return copy;
642 1 : }
643 :
644 : /* It would be nice to make it so that TypeScript complains when you
645 : try to return errors for sub-fields that don't exist. We can
646 : construct a type that describes this (by recursively mapping all
647 : field to strings, essentially), but TypeScript will not ordinarily
648 : prevent a validation function from returning something that also
649 : has other fields, because it does only do "excessive property
650 : checks" for literals that are assigned to locations with a known
651 : type. Example:
652 :
653 : interface ComboValue {
654 : size: number;
655 : unit: string;
656 : }
657 :
658 : dlg.field("combo").validate(v => {
659 : return {
660 : size: true, // TypeScript error here as expected, because true is not a string
661 : unt: "No such unit", // Want a TypeScript error here bc there is no "unt" in the type, but wont happen
662 : }
663 : });
664 :
665 : We would need to explicitly annotate the validation function with
666 : the right return type like so:
667 :
668 : dlg.field("combo").validate((v): DialogValidationResult<ComboValue> => {
669 : return {
670 : unt: "No such unit", // Now we get an error.
671 : }
672 : });
673 :
674 : But I doubt that people will be happy to write out their type like
675 : this every time...
676 : */
677 :
678 : export type DialogValidationResult<T> = (
679 : T extends object
680 : ? ({ [Property in keyof T]+?: DialogValidationResult<T[Property]> } & { ""?: undefined | string }) | undefined | string
681 : : undefined | string | { ""?: undefined | string }
682 : );
683 :
684 2 : function state_path(state: DialogFieldState): string {
685 2 : const p = state.parent ? state_path(state.parent) : "";
686 2 : const t = String(state.tag);
687 1 : return p ? `${p}.${t}` : t;
688 2 : }
689 :
690 37 : let next_global_dialog_id: number = 0;
691 :
692 37 : export class DialogField<T> {
693 : /* eslint-disable no-use-before-define */
694 2 : #dialog: DialogState<unknown>;
695 2 : #state: DialogFieldState;
696 : /* eslint-enable */
697 2 : #getter: () => T;
698 2 : #setter: (val: T) => void;
699 :
700 2 : constructor(
701 2 : dialog: DialogState<unknown>,
702 2 : state: DialogFieldState,
703 2 : getter: () => T,
704 2 : setter: (val: T) => void,
705 2 : ) {
706 2 : this.#dialog = dialog;
707 2 : this.#state = state;
708 2 : this.#getter = getter;
709 2 : this.#setter = setter;
710 2 : }
711 :
712 2 : validation_text(): string | undefined {
713 2 : return this.#state.validation_text;
714 2 : }
715 :
716 2 : get(): T {
717 2 : return this.#getter();
718 2 : }
719 :
720 2 : set(val: T): void {
721 2 : this.#dialog._abort_state_tasks(this.#state, true);
722 2 : this.#setter(val);
723 2 : }
724 :
725 2 : ouia_id(tag: string = "field"): string {
726 2 : return "dialog-" + tag + "-" + state_path(this.#state);
727 2 : }
728 :
729 2 : random_id(): string {
730 2 : if (this.#state.id == null)
731 2 : this.#state.id = "dialog-" + String(++next_global_dialog_id);
732 2 : return this.#state.id;
733 2 : }
734 :
735 2 : map<X>(func: (val: DialogField<ArrayElement<T>>, index: number) => X): X[] {
736 2 : const val = this.get();
737 2 : if (Array.isArray(val)) {
738 1 : return val.map((_, i) => func(this.sub(i as keyof T) as DialogField<ArrayElement<T>>, i));
739 2 : } else
740 2 : return [];
741 2 : }
742 :
743 1 : forEach(func: (val: DialogField<ArrayElement<T>>, index: number) => void): void {
744 1 : const val = this.get();
745 1 : if (Array.isArray(val)) {
746 1 : val.forEach((_, i) => func(this.sub(i as keyof T) as DialogField<ArrayElement<T>>, i));
747 1 : }
748 1 : }
749 :
750 1 : remove(index: number) {
751 1 : const val = this.get();
752 1 : if (Array.isArray(val)) {
753 1 : const sub = this.#state.sub.get(index);
754 1 : if (sub) {
755 1 : this.#dialog._abort_state_tasks(sub);
756 1 : sub.tag = -1;
757 1 : }
758 1 : for (let j = index; j < val.length - 1; j++) {
759 1 : const sub = this.#state.sub.get(j + 1);
760 1 : if (sub) {
761 1 : sub.tag = j;
762 1 : this.#state.sub.set(j, sub);
763 1 : }
764 1 : }
765 1 : this.#state.sub.delete(val.length - 1);
766 1 : this.#setter(toSpliced(val, index, 1) as T);
767 1 : }
768 1 : }
769 :
770 1 : add(item: ArrayElement<T>) {
771 1 : const val = this.get();
772 1 : if (Array.isArray(val)) {
773 1 : this.#setter(val.concat(item) as T);
774 1 : }
775 1 : }
776 :
777 2 : sub<K extends keyof T>(tag: K, update_func?: ((val: T[K]) => void) | undefined): DialogField<T[K]> {
778 2 : const sub = this.#dialog._get_sub_state(this.#state, tag);
779 2 : return new DialogField<T[K]>(
780 2 : this.#dialog,
781 2 : sub,
782 2 : () => {
783 2 : const container = this.get();
784 1 : if (Array.isArray(container) && typeof sub.tag == "number") {
785 1 : return container[sub.tag];
786 1 : } else {
787 2 : return container[tag];
788 2 : }
789 2 : },
790 2 : (val) => {
791 2 : const container = this.get();
792 1 : if (Array.isArray(container) && typeof sub.tag == "number") {
793 1 : this.#setter(toSpliced(container, sub.tag, 1, val) as T);
794 1 : } else {
795 2 : this.#setter({ ...container, [tag]: val });
796 2 : }
797 2 : if (update_func)
798 2 : update_func(val);
799 2 : },
800 2 : );
801 2 : }
802 :
803 1 : at<TT extends T>(witness: TT): DialogField<TT> {
804 1 : cockpit.assert(Object.is(witness, this.get()));
805 1 : return this as unknown as DialogField<TT>;
806 1 : }
807 :
808 1 : validate(func: (val: T) => DialogValidationResult<T>): void {
809 1 : const val = this.get();
810 1 : this.#dialog._validate_value(this.#state, val, () => func(val));
811 1 : }
812 :
813 1 : validate_async(debounce: number, func: (val: T, signal: AbortSignal) => Promise<DialogValidationResult<T>>): void {
814 1 : const val = this.get();
815 1 : this.#dialog._validate_value_async(this.#state, val, debounce, signal => func(val, signal));
816 1 : }
817 :
818 2 : set_async(debounce: number, func: (val: T, signal: AbortSignal) => Promise<T>): void {
819 2 : const val = this.get();
820 2 : this.#dialog._set_value_async(this.#state, debounce, async signal => {
821 2 : const new_val = await func(val, signal);
822 2 : if (!signal.aborted)
823 2 : this.set(new_val);
824 2 : });
825 2 : }
826 :
827 1 : get_async(debounce: number, func: (val: T, signal: AbortSignal) => Promise<void>): void {
828 1 : const val = this.get();
829 1 : this.#dialog._get_value_async(this.#state, debounce, signal => func(val, signal));
830 1 : }
831 37 : }
832 :
833 1 : function get_validation_result_own_string(result: unknown): string | undefined {
834 1 : if (typeof result == "string")
835 1 : return result;
836 1 : else if (result && typeof result == "object" && "" in result && typeof result[""] == "string")
837 1 : return result[""];
838 : else
839 1 : return undefined;
840 1 : }
841 :
842 37 : export class DialogTask {
843 2 : #name: string;
844 2 : #timeout_id: number = 0;
845 2 : #promise: Promise<void> | null = null;
846 2 : #start: () => void;
847 2 : #done: (task: DialogTask) => void;
848 2 : #controller: AbortController;
849 :
850 2 : constructor(
851 2 : name: string,
852 2 : debounce: number,
853 2 : func: (task: DialogTask) => Promise<void>,
854 2 : done: (task: DialogTask) => void,
855 2 : ) {
856 2 : this.#name = name;
857 2 : this.#done = done;
858 2 : this.#start = () => {
859 2 : debug("starting task", this.#name);
860 2 : cockpit.assert(!this.#controller.signal.aborted);
861 2 : this.#promise = func(this);
862 2 : this.#promise.finally(() => {
863 2 : debug("task done", this.#name);
864 2 : done(this);
865 2 : });
866 2 : };
867 2 : this.#timeout_id = window.setTimeout(this.#start, debounce);
868 2 : this.#controller = new AbortController();
869 2 : debug("creating task", this.#name, debounce);
870 2 : }
871 :
872 1 : start_now() {
873 1 : if (!this.#promise && !this.#controller.signal.aborted) {
874 1 : debug("skipping debounce of task", this.#name);
875 1 : window.clearTimeout(this.#timeout_id);
876 1 : this.#start();
877 1 : }
878 1 : }
879 :
880 1 : async wait() {
881 : // Waiting is only allowed for tasks that have actually been started.
882 1 : cockpit.assert(this.#promise);
883 1 : debug("waiting for task", this.#name);
884 1 : await this.#promise;
885 1 : }
886 :
887 2 : get_abort_signal() {
888 2 : return this.#controller.signal;
889 2 : }
890 :
891 2 : abort() {
892 2 : debug("aborting task", this.#name);
893 2 : window.clearTimeout(this.#timeout_id);
894 2 : this.#controller.abort();
895 2 : if (!this.#promise) {
896 2 : debug("aborted task done", this.#name);
897 2 : this.#done(this);
898 2 : }
899 2 : }
900 37 : }
901 :
902 : /* A DialogFieldState object holds all state for a field. Unlike
903 : handles, there is at most one of these objects for each field, and
904 : each handle for a given field refers to the exact same
905 : DialogFieldState object.
906 :
907 : DialogFieldStates are created on-demand and will over time form a
908 : tree (expressed via "parent" and the children in the "sub" map)
909 : that corresponds to the nesting of the dialog values.
910 :
911 : The "tag" is used to access the dialog value. A handle constructed
912 : via dlg.field("name") will point to a state object with tag "name",
913 : for example, and calling handle.get() will return
914 : dlg.values["name"].
915 :
916 : Other members of a DialogFieldState relate to validation and
917 : asynchronous updates.
918 : */
919 :
920 : interface DialogFieldState {
921 : parent: DialogFieldState | null,
922 : tag: string | number | symbol;
923 : sub: Map<string | number | symbol, DialogFieldState>;
924 : id: null | string;
925 : // validation
926 : relevant: boolean;
927 : validation_text: string | undefined;
928 : cached_value: unknown;
929 : cached_result: unknown;
930 : validation_task: DialogTask | null;
931 : // updates
932 : update_task: DialogTask | null;
933 : update_tasks: Set<DialogTask>;
934 : }
935 :
936 : interface DialogStateEvents {
937 : changed(): void;
938 : }
939 :
940 2 : export class DialogState<V> extends EventEmitter<DialogStateEvents> {
941 : values: V;
942 :
943 2 : busy: boolean = false;
944 2 : actions_disabled: boolean = false;
945 2 : cancel_disabled: boolean = false;
946 :
947 2 : error: unknown = null;
948 :
949 2 : #validation_failed: boolean = false;
950 2 : #online_validation: boolean = false;
951 2 : #action_running: boolean = false;
952 2 : #block_updates: boolean = false;
953 2 : #cancel_function: (() => void) | null = null;
954 :
955 2 : #top_state: DialogFieldState;
956 :
957 : /* eslint-disable no-use-before-define */
958 2 : #validate_callback: undefined | ((dlg: DialogState<V>) => void);
959 : /* eslint-enable */
960 :
961 2 : constructor(init: V, validate: undefined | ((dlg: DialogState<V>) => void)) {
962 2 : debug("open");
963 2 : super();
964 2 : this.#validate_callback = validate;
965 2 : this.values = init;
966 2 : this.#top_state = {
967 2 : parent: null,
968 2 : tag: "",
969 2 : sub: new Map(),
970 2 : id: null,
971 2 : relevant: false,
972 2 : validation_text: undefined,
973 2 : cached_value: undefined,
974 2 : cached_result: undefined,
975 2 : validation_task: null,
976 2 : update_task: null,
977 2 : update_tasks: new Set(),
978 2 : };
979 2 : }
980 :
981 2 : #update() {
982 2 : this.busy = this.#action_running;
983 2 : this.actions_disabled = this.#action_running || this.#validation_failed;
984 2 : this.cancel_disabled = this.#action_running && !this.#cancel_function;
985 2 : this.emit("changed");
986 2 : }
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 2 : _get_sub_state(state: DialogFieldState, tag: string | number | symbol): DialogFieldState {
1002 2 : let sub = state.sub.get(tag);
1003 2 : if (!sub) {
1004 2 : sub = {
1005 2 : parent: state,
1006 2 : tag,
1007 2 : sub: new Map(),
1008 2 : id: null,
1009 2 : relevant: false,
1010 2 : validation_text: undefined,
1011 2 : cached_value: undefined,
1012 2 : cached_result: undefined,
1013 2 : validation_task: null,
1014 2 : update_task: null,
1015 2 : update_tasks: new Set(),
1016 2 : };
1017 2 : state.sub.set(tag, sub);
1018 2 : }
1019 2 : return sub;
1020 2 : }
1021 :
1022 2 : _for_each_field_state(func: (state: DialogFieldState) => void) {
1023 2 : function visit(state: DialogFieldState) {
1024 2 : func(state);
1025 2 : for (const sub of state.sub.values())
1026 2 : visit(sub);
1027 2 : }
1028 2 : visit(this.#top_state);
1029 2 : }
1030 :
1031 2 : async _for_each_field_state_async(func: (state: DialogFieldState) => Promise<void>) {
1032 2 : async function visit(state: DialogFieldState) {
1033 2 : await func(state);
1034 2 : for (const sub of state.sub.values())
1035 2 : await visit(sub);
1036 2 : }
1037 2 : await visit(this.#top_state);
1038 2 : }
1039 :
1040 : /* TASKS
1041 :
1042 : Tasks are a little abstraction that runs a asynchronous
1043 : function after a debounce timeout. Before running the action
1044 : function, we need to wait for them all to finish.
1045 : */
1046 :
1047 2 : async _run_all_tasks_now() {
1048 2 : let awaited: boolean = false;
1049 2 : do {
1050 2 : this._for_each_field_state(state => {
1051 2 : if (state.validation_task)
1052 1 : state.validation_task.start_now();
1053 2 : if (state.update_task)
1054 1 : state.update_task.start_now();
1055 2 : for (const task of state.update_tasks.values())
1056 1 : task.start_now();
1057 2 : });
1058 :
1059 2 : awaited = false;
1060 2 : await this._for_each_field_state_async(async state => {
1061 1 : if (state.validation_task) {
1062 1 : await state.validation_task.wait();
1063 1 : awaited = true;
1064 1 : }
1065 1 : if (state.update_task) {
1066 1 : await state.update_task.wait();
1067 1 : awaited = true;
1068 1 : }
1069 1 : for (const task of state.update_tasks.values()) {
1070 1 : await task.wait();
1071 1 : awaited = true;
1072 1 : }
1073 2 : });
1074 2 : } while (awaited);
1075 2 : }
1076 :
1077 2 : _abort_state_tasks(state: DialogFieldState, only_updates: boolean = false) {
1078 2 : debug("cancelling state tasks", state_path(state), only_updates);
1079 1 : if (state.validation_task && !only_updates)
1080 1 : state.validation_task.abort();
1081 2 : if (state.update_task)
1082 2 : state.update_task.abort();
1083 2 : for (const task of state.update_tasks.values())
1084 1 : task.abort();
1085 2 : for (const sub of state.sub.values())
1086 2 : this._abort_state_tasks(sub, only_updates);
1087 2 : }
1088 :
1089 : /* VALIDATION
1090 :
1091 : Validation is started by calling the #trigger_validation
1092 : method. This will reset all validation errors and mark all
1093 : fields as "irrelevant". Then it calls the provided "validate"
1094 : callback, which in turn will (eventually but synchronously)
1095 : call the "_validate_value" or "_validate_value_async" methods
1096 : of all relevant value paths. Those functions will mark their
1097 : fields as relevant and eventually call #set_validation to
1098 : install the validation results in the field states.
1099 :
1100 : After this, all irrelevant asynchronous validation tasks are
1101 : cancelled.
1102 : */
1103 :
1104 2 : #validation_needed: boolean = false;
1105 2 : #validation_running: boolean = false;
1106 :
1107 2 : #trigger_validation(): void {
1108 2 : debug("trigger validation");
1109 2 : if (!this.#validate_callback)
1110 2 : return;
1111 :
1112 1 : this.#validation_needed = true;
1113 1 : if (this.#validation_running) {
1114 1 : debug("validation postponed");
1115 1 : return;
1116 1 : }
1117 :
1118 1 : this.#validation_running = true;
1119 1 : while (this.#validation_needed) {
1120 1 : debug("running validation");
1121 1 : this.#validation_needed = false;
1122 1 : this.#validation_failed = false;
1123 1 : this._for_each_field_state(state => {
1124 1 : state.relevant = false;
1125 1 : state.validation_text = undefined;
1126 1 : });
1127 1 : this.#validate_callback(this);
1128 1 : this._for_each_field_state(state => {
1129 1 : if (!state.relevant && state.validation_task) {
1130 1 : debug("aborting irrelevant validation task", state_path(state));
1131 1 : state.validation_task.abort();
1132 1 : }
1133 1 : });
1134 1 : }
1135 1 : this.#validation_running = false;
1136 :
1137 1 : this.#update();
1138 2 : }
1139 :
1140 1 : #set_validation(state: DialogFieldState, result: unknown) {
1141 1 : if (result) {
1142 1 : const own = get_validation_result_own_string(result);
1143 1 : if (own) {
1144 1 : state.validation_text = own;
1145 1 : this.#validation_failed = true;
1146 1 : this.#online_validation = true;
1147 1 : }
1148 1 : if (typeof result == "object") {
1149 1 : for (const [k, v] of Object.entries(result)) {
1150 1 : const sub = k && state.sub.get(k);
1151 1 : if (sub)
1152 1 : this.#set_validation(sub, v);
1153 1 : }
1154 1 : }
1155 1 : }
1156 1 : }
1157 :
1158 : /* The field state has a cache of the most recently validated
1159 : value. If the current value is still the same, actual
1160 : validation is skipped and the cached result from last time is
1161 : used.
1162 :
1163 : Calling #set_validation_state_result is the final thing that
1164 : should happen when validating a field. It will install the
1165 : result in the cache and then call #set_validation.
1166 : */
1167 :
1168 1 : #set_validation_state_result(
1169 1 : state: DialogFieldState,
1170 1 : val: unknown,
1171 1 : result: unknown,
1172 1 : ) {
1173 1 : state.cached_value = val;
1174 1 : state.cached_result = result;
1175 1 : this.#set_validation(state, result);
1176 1 : }
1177 :
1178 : /* The first thing should be of course to probe that cache. If we
1179 : get a hit, it is used immediately to call #set_validation.
1180 : */
1181 :
1182 1 : #probe_validation_state_cache(state: DialogFieldState, val: unknown): boolean {
1183 1 : if (Object.is(state.cached_value, val)) {
1184 1 : debug("cache hit", state_path(state), JSON.stringify(val), state.cached_result);
1185 1 : this.#set_validation(state, state.cached_result);
1186 1 : return true;
1187 1 : } else
1188 1 : return false;
1189 1 : }
1190 :
1191 : /* And in fact, _validate_value does exactly those two things.
1192 : */
1193 :
1194 1 : _validate_value(state: DialogFieldState, val: unknown, func: () => unknown): void {
1195 1 : state.relevant = true;
1196 1 : if (!this.#probe_validation_state_cache(state, val)) {
1197 1 : const result = func();
1198 1 : debug("sync validate", state_path(state), JSON.stringify(result));
1199 1 : this.#set_validation_state_result(state, val, result);
1200 1 : }
1201 1 : }
1202 :
1203 : /* Now asynchronous validation.
1204 :
1205 : If there was no cache hit, asynchronous validation starts with
1206 : a timeout, followed by letting a asynchronous function run to
1207 : resolution. This is managed by a DialogTask.
1208 :
1209 : Starting a new task of course aborts any previous one. It also
1210 : installs the current value in the cache, so that subsequent
1211 : validation rounds do nothing until the value actually changes.
1212 :
1213 : When the validation result has been computed, we need to check
1214 : whether we have been aborted so that we don't install
1215 : out-dated results.
1216 : */
1217 :
1218 1 : _validate_value_async(
1219 1 : state: DialogFieldState,
1220 1 : val: unknown,
1221 1 : debounce: number,
1222 1 : func: (signal: AbortSignal) => Promise<unknown>
1223 1 : ): void {
1224 1 : state.relevant = true;
1225 1 : if (!this.#probe_validation_state_cache(state, val)) {
1226 1 : state.cached_value = val;
1227 1 : state.cached_result = undefined;
1228 :
1229 1 : if (state.validation_task)
1230 1 : state.validation_task.abort();
1231 1 : state.validation_task = new DialogTask(
1232 1 : state_path(state) + ":validate",
1233 1 : debounce,
1234 1 : async task => {
1235 1 : const signal = task.get_abort_signal();
1236 1 : let result;
1237 1 : try {
1238 1 : result = await func(signal);
1239 1 : } catch (ex) {
1240 1 : console.error(ex);
1241 1 : }
1242 1 : if (!signal.aborted) {
1243 1 : debug("async validate result", state_path(state), result);
1244 1 : this.#set_validation_state_result(state, val, result);
1245 1 : this.#update();
1246 1 : }
1247 1 : },
1248 1 : task => {
1249 1 : if (state.validation_task == task)
1250 1 : state.validation_task = null;
1251 1 : }
1252 1 : );
1253 1 : }
1254 1 : }
1255 :
1256 2 : _set_value_async(
1257 2 : state: DialogFieldState,
1258 2 : debounce: number,
1259 2 : func: (signal: AbortSignal) => Promise<void>
1260 2 : ): void {
1261 2 : const task = new DialogTask(
1262 2 : state_path(state) + ":set",
1263 2 : debounce,
1264 2 : async task => {
1265 2 : try {
1266 2 : await func(task.get_abort_signal());
1267 0 : } catch (ex) {
1268 0 : console.error(ex);
1269 0 : }
1270 2 : },
1271 2 : task => {
1272 2 : if (state.update_task == task)
1273 2 : state.update_task = null;
1274 2 : }
1275 2 : );
1276 :
1277 2 : if (state.update_task)
1278 2 : state.update_task.abort();
1279 2 : state.update_task = task;
1280 2 : }
1281 :
1282 1 : _get_value_async(
1283 1 : state: DialogFieldState,
1284 1 : debounce: number,
1285 1 : func: (signal: AbortSignal) => Promise<void>
1286 1 : ): void {
1287 1 : const task = new DialogTask(
1288 1 : state_path(state) + ":get",
1289 1 : debounce,
1290 1 : async task => {
1291 1 : try {
1292 1 : await func(task.get_abort_signal());
1293 0 : } catch (ex) {
1294 0 : console.error(ex);
1295 0 : }
1296 1 : },
1297 1 : task => {
1298 1 : state.update_tasks.delete(task);
1299 1 : }
1300 1 : );
1301 :
1302 1 : state.update_tasks.add(task);
1303 1 : }
1304 :
1305 : /* The first thing run_action does is to trigger a new validation
1306 : round and then wait for all the asynchronous results to have
1307 : come in.
1308 :
1309 : If there are any DialogFieldState objects that are waiting
1310 : for a timeout, we want to abort those and start over, so that
1311 : their validation starts immediately. (Also, it would be hairy
1312 : to wait for those timeouts to be over from here.)
1313 : */
1314 :
1315 2 : async validate(): Promise<boolean> {
1316 2 : this.#online_validation = true;
1317 2 : this.#trigger_validation();
1318 2 : await this._run_all_tasks_now();
1319 2 : return !this.#validation_failed;
1320 2 : }
1321 :
1322 1 : set_cancel(cancel: (() => void) | null) {
1323 1 : this.#cancel_function = cancel;
1324 1 : this.#update();
1325 1 : }
1326 :
1327 2 : async run_action(func: (vals: V) => Promise<void>): Promise<boolean> {
1328 2 : this.error = null;
1329 2 : this.#cancel_function = null;
1330 2 : this.#action_running = true;
1331 2 : this.#update();
1332 1 : if (!await this.validate()) {
1333 1 : this.#action_running = false;
1334 1 : this.#update();
1335 1 : return false;
1336 1 : }
1337 :
1338 2 : try {
1339 2 : this.#block_updates = true;
1340 2 : await func(this.values);
1341 1 : } catch (ex) {
1342 1 : console.error(String(ex));
1343 1 : this.error = ex;
1344 1 : }
1345 :
1346 2 : this.#cancel_function = null;
1347 2 : this.#action_running = false;
1348 2 : this.#block_updates = false;
1349 2 : this.#update();
1350 :
1351 2 : return !this.error;
1352 2 : }
1353 :
1354 2 : cancel(onClose: () => void): void {
1355 1 : if (this.#action_running) {
1356 1 : if (this.#cancel_function)
1357 1 : this.#cancel_function();
1358 1 : } else {
1359 2 : this._abort_state_tasks(this.#top_state);
1360 2 : onClose();
1361 2 : }
1362 2 : }
1363 :
1364 2 : top(update_func?: ((val: V) => void) | undefined): DialogField<V> {
1365 2 : return new DialogField<V>(
1366 2 : this as DialogState<unknown>,
1367 2 : this.#top_state,
1368 2 : () => this.values,
1369 2 : (val) => {
1370 2 : debug("set", val);
1371 1 : if (this.#block_updates) {
1372 : // Deny state changes while actions run. This
1373 : // prevents the user from interacting with the
1374 : // dialog while it is busy. The alternative would
1375 : // be to officially disable all fields and prevent
1376 : // interactions that way, but that is visually
1377 : // very jarring and not something that we have
1378 : // been doing earlier.
1379 1 : debug("set denied");
1380 1 : return;
1381 1 : }
1382 2 : this.values = val;
1383 2 : this.#update();
1384 2 : if (this.#online_validation)
1385 1 : this.#trigger_validation();
1386 2 : if (update_func)
1387 1 : update_func(val);
1388 2 : },
1389 2 : );
1390 2 : }
1391 :
1392 2 : field<K extends keyof V>(tag: K, update_func?: ((val: V[K]) => void) | undefined): DialogField<V[K]> {
1393 2 : return this.top().sub(tag, update_func);
1394 2 : }
1395 37 : }
1396 :
1397 37 : export class DialogError {
1398 : title: string;
1399 : details: React.ReactNode;
1400 :
1401 1 : constructor(title: string, details?: React.ReactNode) {
1402 1 : this.title = title;
1403 1 : this.details = details;
1404 1 : }
1405 :
1406 1 : toString() {
1407 1 : return this.title + ": " + String(this.details);
1408 1 : }
1409 :
1410 1 : static fromError(title: string, err: unknown) {
1411 1 : if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
1412 1 : return new DialogError(title, err.message);
1413 1 : } else {
1414 1 : return new DialogError(title, String(err));
1415 1 : }
1416 1 : }
1417 37 : }
1418 :
1419 2 : export function useDialogState<V extends object>(
1420 2 : init: V | (() => V),
1421 2 : validate?: undefined | ((dlg: DialogState<V>) => void),
1422 2 : ) : DialogState<V> {
1423 2 : const dlg = useObject(
1424 2 : () => new DialogState(
1425 2 : typeof init == "function" ? init() : init,
1426 2 : validate
1427 2 : ),
1428 2 : null,
1429 2 : []
1430 2 : );
1431 2 : useOn(dlg, "changed");
1432 2 : return dlg;
1433 2 : }
1434 :
1435 1 : export function useDialogState_async<V extends object>(
1436 1 : init: () => Promise<V>,
1437 1 : validate?: undefined | ((dlg: DialogState<V>) => void),
1438 1 : ) : null | DialogError | DialogState<V> {
1439 1 : const [dlg, setDlg] = useState<null | DialogError | DialogState<V>>(null);
1440 1 : useOn((dlg instanceof DialogError ? null : dlg), "changed");
1441 1 : useInit(async () => {
1442 1 : try {
1443 1 : setDlg(new DialogState<V>(await init(), validate));
1444 1 : } catch (ex) {
1445 1 : if (ex instanceof DialogError)
1446 1 : setDlg(ex);
1447 : else
1448 1 : setDlg(DialogError.fromError(_("Error during initialization"), ex));
1449 1 : }
1450 1 : });
1451 1 : return dlg;
1452 1 : }
1453 :
1454 : // Common elements
1455 :
1456 2 : export function DialogErrorMessage<V>({
1457 2 : dialog,
1458 2 : } : {
1459 : dialog: DialogState<V> | DialogError | null,
1460 2 : }) {
1461 1 : const err = (!dialog || dialog instanceof DialogError) ? dialog : dialog.error;
1462 2 : if (!err)
1463 2 : return null;
1464 :
1465 1 : let title: string;
1466 1 : let details: React.ReactNode;
1467 :
1468 1 : if (err instanceof DialogError) {
1469 1 : title = err.title;
1470 1 : details = err.details;
1471 1 : } else if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
1472 1 : title = _("Failed");
1473 1 : details = err.message;
1474 1 : } else {
1475 1 : title = _("Failed");
1476 1 : details = String(err);
1477 1 : }
1478 :
1479 1 : return (
1480 1 : <Alert
1481 1 : ouiaId="dialog-error-message"
1482 1 : variant='danger'
1483 1 : isInline
1484 1 : title={title}
1485 : >
1486 1 : {details}
1487 1 : </Alert>
1488 : );
1489 2 : }
1490 :
1491 2 : export function DialogActionButton<V>({
1492 2 : dialog,
1493 2 : children,
1494 2 : action,
1495 2 : onClose = undefined,
1496 2 : ...props
1497 2 : } : {
1498 : dialog: DialogState<V> | DialogError | null,
1499 : children: React.ReactNode,
1500 : action: (values: V) => Promise<void>,
1501 : onClose?: undefined | (() => void)
1502 2 : } & Omit<ButtonProps, "id" | "action" | "isLoading" | "isDisabled" | "variant" | "onClick">) {
1503 2 : return (
1504 2 : <Button
1505 2 : ouiaId="dialog-apply"
1506 2 : isLoading={!!dialog && !(dialog instanceof DialogError) && dialog.busy}
1507 2 : isDisabled={!dialog || dialog instanceof DialogError || dialog.actions_disabled}
1508 2 : variant="primary"
1509 2 : onClick={async () => {
1510 2 : cockpit.assert(dialog && !(dialog instanceof DialogError));
1511 2 : if (await dialog.run_action(action) && onClose)
1512 2 : onClose();
1513 2 : }}
1514 2 : {...props}
1515 : >
1516 2 : {children}
1517 2 : </Button>
1518 : );
1519 2 : }
1520 :
1521 2 : export function DialogCancelButton<V>({
1522 2 : dialog,
1523 2 : onClose,
1524 2 : ...props
1525 2 : } : {
1526 : dialog: DialogState<V> | DialogError | null,
1527 : onClose: () => void
1528 2 : } & Omit<ButtonProps, "id" | "isDisabled" | "variant" | "onClick">) {
1529 2 : return (
1530 2 : <Button
1531 2 : ouiaId="dialog-cancel"
1532 2 : isDisabled={!dialog || (dialog instanceof DialogState && dialog.cancel_disabled)}
1533 2 : variant="link"
1534 1 : onClick={() => {
1535 1 : if (dialog instanceof DialogState)
1536 1 : dialog.cancel(onClose);
1537 : else
1538 1 : onClose();
1539 1 : }}
1540 2 : {...props}
1541 : >
1542 2 : {_("Cancel")}
1543 2 : </Button>
1544 : );
1545 2 : }
1546 :
1547 : /* Common dialog field implementations.
1548 : */
1549 :
1550 : type falsy = null | undefined | false;
1551 :
1552 2 : export function DialogHelperText<V>({
1553 2 : field,
1554 2 : excuse,
1555 2 : warning,
1556 2 : explanation,
1557 2 : } : {
1558 : field: DialogField<V>;
1559 : excuse?: string | falsy;
1560 : warning?: React.ReactNode;
1561 : explanation?: React.ReactNode;
1562 2 : }) {
1563 2 : let text: React.ReactNode = field.validation_text();
1564 2 : let variant: HelperTextItemProps["variant"] = "error";
1565 2 : if (!text && excuse) {
1566 2 : text = excuse;
1567 2 : variant = "default";
1568 2 : }
1569 1 : if (!text && warning) {
1570 1 : text = warning;
1571 1 : variant = "warning";
1572 1 : }
1573 2 : if (!text) {
1574 2 : text = explanation;
1575 2 : variant = "default";
1576 2 : }
1577 :
1578 2 : if (!text)
1579 2 : return null;
1580 :
1581 2 : return (
1582 2 : <FormHelperText>
1583 2 : <HelperText>
1584 2 : <HelperTextItem data-ouia-component-id={field.ouia_id("helper-text")} variant={variant}>
1585 2 : {text}
1586 2 : </HelperTextItem>
1587 2 : </HelperText>
1588 2 : </FormHelperText>
1589 : );
1590 2 : }
1591 :
1592 2 : export const OptionalFormGroup = ({
1593 2 : label,
1594 2 : children,
1595 2 : ...props
1596 2 : } : {
1597 : label: React.ReactNode,
1598 : children: React.ReactNode,
1599 2 : } & Omit<FormGroupProps, "label" | "children">) => {
1600 2 : if (label) {
1601 2 : return (
1602 2 : <FormGroup
1603 2 : label={label}
1604 2 : {...props}
1605 : >
1606 2 : {children}
1607 2 : </FormGroup>
1608 : );
1609 1 : } else {
1610 1 : return children;
1611 1 : }
1612 2 : };
1613 :
1614 2 : export const DialogTextInput = ({
1615 2 : label = null,
1616 2 : field,
1617 2 : excuse,
1618 2 : warning,
1619 2 : explanation,
1620 2 : isDisabled = false,
1621 2 : ...props
1622 2 : } : {
1623 : label?: React.ReactNode,
1624 : field: DialogField<string>,
1625 : excuse?: string | falsy,
1626 : warning?: React.ReactNode,
1627 : explanation?: React.ReactNode,
1628 : isDisabled?: boolean,
1629 2 : } & Omit<TextInputProps, "id" | "label" | "value" | "onChange">) => {
1630 2 : return (
1631 2 : <OptionalFormGroup label={label} fieldId={field.random_id()}>
1632 2 : <TextInput
1633 2 : id={field.random_id()}
1634 2 : ouiaId={field.ouia_id()}
1635 2 : value={field.get()}
1636 1 : onChange={(_event, val) => field.set(val)}
1637 2 : isDisabled={!!excuse || isDisabled}
1638 2 : {...props}
1639 2 : />
1640 2 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1641 2 : </OptionalFormGroup>
1642 : );
1643 2 : };
1644 :
1645 0 : export const DialogPasswordInput = ({
1646 0 : label = null,
1647 0 : field,
1648 0 : excuse,
1649 0 : warning,
1650 0 : explanation,
1651 0 : isDisabled = false,
1652 0 : ...props
1653 0 : } : {
1654 : label?: React.ReactNode,
1655 : field: DialogField<string>,
1656 : excuse?: string | falsy,
1657 : warning?: React.ReactNode,
1658 : explanation?: React.ReactNode,
1659 : isDisabled?: boolean,
1660 0 : } & Omit<TextInputProps, "id" | "label" | "value" | "onChange">) => {
1661 0 : const [visible, setVisible] = useState(false);
1662 :
1663 0 : return (
1664 0 : <OptionalFormGroup label={label} fieldId={field.random_id()}>
1665 0 : <InputGroup>
1666 0 : <InputGroupItem isFill>
1667 0 : <TextInput
1668 0 : id={field.random_id()}
1669 0 : ouiaId={field.ouia_id()}
1670 0 : type={visible ? "text" : "password"}
1671 0 : value={field.get()}
1672 0 : onChange={(_event, value) => field.set(value)}
1673 0 : isDisabled={!!excuse || isDisabled}
1674 0 : {...props}
1675 0 : />
1676 0 : </InputGroupItem>
1677 0 : <InputGroupItem>
1678 0 : <Button
1679 0 : variant="control"
1680 0 : aria-label={visible ? _("Hide password") : _("Show password")}
1681 0 : onClick={() => setVisible(!visible)}
1682 : >
1683 0 : {visible ? <EyeSlashIcon /> : <EyeIcon />}
1684 0 : </Button>
1685 0 : </InputGroupItem>
1686 0 : </InputGroup>
1687 0 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1688 0 : </OptionalFormGroup>
1689 : );
1690 0 : };
1691 :
1692 2 : export const DialogCheckbox = ({
1693 2 : field_label = null,
1694 2 : checkbox_label,
1695 2 : field,
1696 2 : excuse,
1697 2 : warning,
1698 2 : explanation,
1699 2 : } : {
1700 : field_label?: React.ReactNode,
1701 : checkbox_label: string,
1702 : field: DialogField<boolean>,
1703 : excuse?: string | falsy,
1704 : warning?: React.ReactNode,
1705 : explanation?: React.ReactNode,
1706 2 : }) => {
1707 2 : return (
1708 2 : <OptionalFormGroup label={field_label} hasNoPaddingTop>
1709 2 : <Checkbox
1710 2 : id={field.random_id()}
1711 2 : ouiaId={field.ouia_id()}
1712 2 : isChecked={field.get()}
1713 2 : label={checkbox_label}
1714 1 : onChange={(_event, checked) => field.set(checked)}
1715 2 : isDisabled={!!excuse}
1716 2 : />
1717 2 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1718 2 : </OptionalFormGroup>
1719 : );
1720 2 : };
1721 :
1722 : export interface DialogRadioSelectOption<T extends string> {
1723 : value: T,
1724 : label: React.ReactNode,
1725 : explanation?: React.ReactNode,
1726 : excuse?: string | falsy,
1727 : }
1728 :
1729 2 : export function DialogRadioSelect<T extends string>({
1730 2 : label = null,
1731 2 : field,
1732 2 : options,
1733 2 : warning,
1734 2 : explanation,
1735 2 : isInline = false,
1736 2 : } : {
1737 : label?: React.ReactNode,
1738 : field: DialogField<T>,
1739 : options: DialogRadioSelectOption<T>[],
1740 : warning?: React.ReactNode,
1741 : explanation?: React.ReactNode,
1742 : isInline?: boolean,
1743 2 : }) {
1744 2 : function makeLabel(o: DialogRadioSelectOption<T>, i: number) {
1745 2 : const exc = o.excuse ? <> ({o.excuse})</> : null;
1746 2 : const pad = (!isInline && i < options.length - 1) ? <><br />{"\u00A0"}</> : null;
1747 2 : const exp = o.explanation ? <><br /><small>{o.explanation}{pad}</small></> : null;
1748 2 : return <div data-ouia-component-id={field.ouia_id(o.value + "-label")}>{o.label}{exc}{exp}</div>;
1749 2 : }
1750 :
1751 2 : return (
1752 2 : <OptionalFormGroup
1753 2 : label={label}
1754 2 : hasNoPaddingTop
1755 2 : isInline={isInline}
1756 2 : data-ouia-component-id={field.ouia_id()}
1757 2 : data-value={field.get()}
1758 : >
1759 : {
1760 2 : options.map((o, i) =>
1761 2 : <Radio
1762 2 : key={o.value}
1763 2 : id={field.random_id() + o.value}
1764 2 : ouiaId={field.ouia_id(o.value)}
1765 2 : name={o.value}
1766 2 : isChecked={field.get() == o.value}
1767 2 : label={makeLabel(o, i)}
1768 1 : onChange={() => field.set(o.value)}
1769 2 : isDisabled={!!o.excuse}
1770 2 : />
1771 2 : )
1772 : }
1773 2 : <DialogHelperText explanation={explanation} warning={warning} field={field} />
1774 2 : </OptionalFormGroup>
1775 : );
1776 2 : }
1777 :
1778 : export interface DialogDropdownSelectOption<T extends string> {
1779 : value: T;
1780 : label: string;
1781 : }
1782 :
1783 2 : export function DialogDropdownSelect<T extends string>({
1784 2 : label,
1785 2 : field,
1786 2 : excuse,
1787 2 : warning,
1788 2 : explanation,
1789 2 : options,
1790 2 : ...props
1791 2 : } : {
1792 : label?: React.ReactNode,
1793 : field: DialogField<T>,
1794 : excuse?: string | falsy,
1795 : warning?: React.ReactNode,
1796 : explanation?: React.ReactNode,
1797 : options: DialogDropdownSelectOption<T>[],
1798 2 : } & Omit<FormSelectProps, "ref" | "children">) {
1799 2 : return (
1800 2 : <OptionalFormGroup label={label}>
1801 2 : <FormSelect
1802 2 : id={field.random_id()}
1803 2 : ouiaId={field.ouia_id()}
1804 1 : onChange={(_event, val) => field.set(val as T) }
1805 1 : validated={warning ? "warning" : undefined}
1806 2 : isDisabled={!!excuse}
1807 2 : value={field.get()}
1808 2 : {...props}
1809 : >
1810 : {
1811 2 : options.map(
1812 2 : o => {
1813 2 : return <FormSelectOption key={o.value} value={o.value} label={o.label} />;
1814 2 : }
1815 2 : )
1816 : }
1817 2 : </FormSelect>
1818 2 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1819 2 : </OptionalFormGroup>
1820 : );
1821 2 : }
1822 :
1823 2 : export function DialogDropdownSelectObject<T>({
1824 2 : label,
1825 2 : field,
1826 2 : excuse,
1827 2 : warning,
1828 2 : explanation,
1829 2 : options,
1830 2 : option_label = (o: T): string => { cockpit.assert(typeof o == "string"); return o },
1831 2 : ...props
1832 2 : } : {
1833 : label?: React.ReactNode,
1834 : field: DialogField<T>,
1835 : excuse?: string | falsy,
1836 : warning?: React.ReactNode,
1837 : explanation?: React.ReactNode,
1838 : options: T[],
1839 : option_label?: (o: T) => string,
1840 2 : } & Omit<FormSelectProps, "ref" | "children">) {
1841 2 : return (
1842 2 : <OptionalFormGroup label={label}>
1843 2 : <FormSelect
1844 2 : id={field.random_id()}
1845 2 : ouiaId={field.ouia_id()}
1846 1 : onChange={(_event, val) => {
1847 1 : const opt = options.find(o => option_label(o) == val);
1848 1 : field.set(opt!);
1849 1 : }}
1850 1 : validated={warning ? "warning" : undefined}
1851 2 : isDisabled={!!excuse}
1852 2 : value={option_label(field.get())}
1853 2 : {...props}
1854 : >
1855 : {
1856 2 : options.map(
1857 2 : o => {
1858 2 : const l = option_label(o);
1859 2 : return <FormSelectOption key={l} value={l} label={l} />;
1860 2 : }
1861 2 : )
1862 : }
1863 2 : </FormSelect>
1864 2 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1865 2 : </OptionalFormGroup>
1866 : );
1867 2 : }
|