Line data Source code
1 3 : /*
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, task) => ...)
226 : - handle.set_async(debounce, (val, task) => 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. Because of the way
251 : JavaScript works, the asynchronous functions keep running and they
252 : need to voluntarily call "task.is_cancelled()" to figure out when
253 : they should stop.
254 :
255 : As an example, here is how you might implement set_async on top of
256 : get_async:
257 :
258 : function set_async(handle, debounce, func) {
259 : handle.get_async(debounce, (val, task) => {
260 : const new_val = await func(val, task);
261 : if (!task.is_cancelled())
262 : handle.set(new_val);
263 : })
264 : }
265 :
266 : - handle.at(witness)
267 :
268 : Get a handle with a narrowed type for "handle". The new handle
269 : works like "handle" and modifies the same place in the dialog value
270 : object, but it's type will be the type of "witness". This is
271 : useful to carry over type inference into value handles. The
272 : general pattern is:
273 :
274 : const val = handle.get();
275 : if (some_type_narrowing_condition(val)) {
276 : const narrowed_handle = handle.at(val);
277 :
278 : ...
279 : }
280 :
281 : - handle.add(val)
282 :
283 : If the current value is an array, append "val" at the end.
284 :
285 : - handle.remove(index)
286 :
287 : If the current value is an array, remove the element at "index".
288 : It is important to use this function instead of just "handle.set()"
289 : with an appropriately modified array. By using this function, the
290 : plumbing is able to keep its internal state in synch, which is
291 : especially important for asynchronous validation and update
292 : functions.
293 :
294 : However, it is okay to just replace an array with a different
295 : array, so you are not strictly required to use this function. But
296 : doing so might look to the validation machinery as if each and
297 : every element of the array has just changed, and it will do a lot
298 : of needless validations all over again.
299 :
300 : - handle.map(func)
301 :
302 : If the current value is an array, map "func" over handles for its
303 : elements. This is nice for creating React components for arrays.
304 :
305 : - handle.forEach(func)
306 :
307 : If the current value is an array, call "func" with handles for each
308 : of its elements, in order. This is nice for "validate" functions.
309 :
310 : Now back to the fields and methods of the dialog state.
311 :
312 : - dlg.busy
313 : - dlg.actions_disabled
314 : - dlg.cancel_disabled
315 :
316 : Boolean flags that indicate which parts of the dialog should be
317 : disabled. The porcelain should of course look at these and do the
318 : right thing.
319 :
320 : - dlg.error
321 :
322 : The most recent error thrown by an action function. This can be
323 : any kind of JavaScript value, but the idea is that it is something
324 : with a "message" field, or a DialogError instance. The
325 : DialogErrorMessage porcelain component will do the right thing with
326 : these kind of error values.
327 :
328 : - dlg.run_action(func)
329 :
330 : Waits for all asynchronous updates and input validation to be done
331 : and if that was successful, calls "func" and puts the dialog into a
332 : "busy" state while it runs. When "func" throws an error, it is
333 : caught and stored in "dlg.error".
334 :
335 : "dlg.run_action" returns true when validation has passed and "func"
336 : has completed without throwing an error.
337 :
338 : All state changes via "field.set()" are denied while "func" is
339 : running. This is done to prevent the user from interacting with the
340 : dialog while an action runs. But there is nothing fundamentally
341 : wrong with programmatically changing dialog state as part of an
342 : action. If you want to do that, write code like
343 :
344 : if (dlg.run_action(...))
345 : dlg.field("xxx").set(...)
346 :
347 : - dlg.cancel(onClose)
348 :
349 : Does whatever should happen when the "Cancel" button is
350 : clicked. When an action is running, it will call the "cancel
351 : function" (see below). Otherwise all validation and update tasks
352 : are cancelled and the dialog is closed by calling "onClose".
353 :
354 : - dlg.set_cancel(func)
355 :
356 : Arranges for "func" to be called when the cancel button is clicked.
357 : You should call this only from a action function passed to
358 : "run_action" and you need to take care to reset this via
359 : "dlg.set_cancel(null)" once the cancel function should no longer be
360 : called. When a action funtion finishes or throws an error from
361 : within "dlg.run_action", the cancel function is automatically
362 : reset.
363 :
364 : - dlg.set_id_prefix(id_prefix)
365 :
366 : This sets the prefix used by the handle.id() function. This is only
367 : necessary when testing stacked dialogs, which should be rare.
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.id(tag)
493 :
494 : This will return a unique and predictable string for the value
495 : handle that will also include "tag". This is suitable for the "id"
496 : attribute of DOM elements associated with "value". The "tag"
497 : parameter can be used to generate multiple IDs if a component has
498 : multiple interesting DOM elements. The "tag" parameter defaults to
499 : "field", see below.
500 :
501 : There is a support library for use by the tests that can generate
502 : the same IDs, and there are also some guidelines for how to use
503 : these IDs:
504 :
505 : - The main input element (text input, form select, ...) should use
506 : the "field" tag.
507 :
508 : - The helper text should use the "helper-text" tag.
509 :
510 : - A set of radio buttons should use a different tag for each
511 : button. Whatever makes sense in the specific case.
512 :
513 : - ...
514 :
515 : PORCELAIN GALLERY
516 :
517 : Here are some noteworthy React components that integrate with the
518 : plumbing API.
519 :
520 : - <DialogErrorMessage dialog={dlg} />
521 :
522 : This creates an appropriate Alert for "dlg.error", if it is set. It
523 : works well with instances of DialogError, and all usual errors
524 : thrown by the Cockpit API.
525 :
526 : In addition to a proper DialogState, the "dialog" property can be
527 : anything returned by "use_DialogState_async".
528 :
529 : If given one the of Cockpit API errors, the title of the Alert will
530 : be a generic "Failed" text. If you want more control, use a
531 : DialogError.
532 :
533 : A DialogError contains a title and details, and the details can
534 : come from another error. For example:
535 :
536 : try {
537 : await cockpit.spawn(["/bin/frob", "--bars"])
538 : } catch (ex) {
539 : throw DialogError.fromError("Failed to frob the bars", ex);
540 : }
541 :
542 : You can also construct a DialogError directly from title and
543 : details:
544 :
545 : throw new DialogError("Failed to frob", <pre>...</pre>);
546 :
547 : In that case, the details can be any React node.
548 :
549 : - <DialogActionButton dialog={dlg} action={func} onClose={close_func}>
550 :
551 : This will produce a action button for a dialog that correctly disables
552 : itself according to the state of "dlg".
553 :
554 : In addition to a proper DialogState, the "dialog" property can be
555 : anything returned by "use_DialogState_async".
556 :
557 : When clicked, "func" will be run via "dlg.run_action". If "func"
558 : completes successfully, "close_func" is called to close the dialog.
559 :
560 : - <DialogCancelButton dialog={dlg} onClose={close_func} />
561 :
562 : This will produce a cancel button for a dialog that correctly
563 : disables itself according to the state of "dlg".
564 :
565 : In addition to a proper DialogState, the "dialog" property can be
566 : anything returned by "use_DialogState_async".
567 :
568 : Clicking it will either just close the dialog by calling
569 : "close_func", or run the cancel function provided by the currently
570 : running action function (if there is any).
571 :
572 : - <DialogTextInput label="Name" field={dlg.field("name")} ... />
573 :
574 : This will produce a TextInput in a (optional) FormGroup that will
575 : manage the given value handle. The "label" property is optional
576 : and omitting it will also omit the FormGroup.
577 :
578 : - <DialogCheckbox label= field= .../>
579 :
580 : For a single checkbox that drives a boolean.
581 :
582 : - <DialogRadioSelect label= field= options= .../>
583 :
584 : For a group of radio buttons. The options can be disabled and have
585 : explanations.
586 :
587 : - <DialogDropdownSelect label= field= options= .../> </>
588 :
589 : For a simple dropdown select. Options can not be disabled or have
590 : explanations.
591 :
592 : - <DialogDropdownSelectObject label= field= options= option_label= />
593 :
594 : A variant of the simple dropdown select from above where the options
595 : can be of any type whatsoever, such as something directly from your
596 : data model. A simple case is selecting from an array of strings. In
597 : that case you can omit the "option_label" function.
598 :
599 : */
600 :
601 3 : import React, { useState } from "react";
602 : import { useObject, useInit, useOn } from 'hooks';
603 : import { EventEmitter } from 'cockpit/event';
604 :
605 3 : import cockpit from "cockpit";
606 :
607 : import { Button, type ButtonProps } from "@patternfly/react-core/dist/esm/components/Button/index.js";
608 : import { FormGroup, type FormGroupProps, FormHelperText } from "@patternfly/react-core/dist/esm/components/Form";
609 : import { TextInput, type TextInputProps } from "@patternfly/react-core/dist/esm/components/TextInput";
610 : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
611 : import {
612 : HelperText, HelperTextItem, type HelperTextItemProps
613 : } from "@patternfly/react-core/dist/esm/components/HelperText";
614 : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
615 : import {
616 : FormSelect, FormSelectOption, type FormSelectProps,
617 : } from "@patternfly/react-core/dist/esm/components/FormSelect";
618 : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
619 : import { InputGroup, InputGroupItem } from "@patternfly/react-core/dist/esm/components/InputGroup/index.js";
620 : import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons";
621 :
622 3 : const _ = cockpit.gettext;
623 :
624 2 : function debug(...args: unknown[]) {
625 2 : if (window.debugging == "all" || window.debugging?.includes("dialog"))
626 2 : console.debug("dialog:", ...args);
627 2 : }
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 2 : function state_path(state: DialogFieldState): string {
679 2 : const p = state.parent ? state_path(state.parent) : "";
680 2 : const t = String(state.tag);
681 1 : return p ? `${p}.${t}` : t;
682 2 : }
683 :
684 3 : export class DialogField<T> {
685 : /* eslint-disable no-use-before-define */
686 2 : #dialog: DialogState<unknown>;
687 2 : #state: DialogFieldState;
688 : /* eslint-enable */
689 2 : #getter: () => T;
690 2 : #setter: (val: T) => void;
691 :
692 2 : constructor(
693 2 : dialog: DialogState<unknown>,
694 2 : state: DialogFieldState,
695 2 : getter: () => T,
696 2 : setter: (val: T) => void,
697 2 : ) {
698 2 : this.#dialog = dialog;
699 2 : this.#state = state;
700 2 : this.#getter = getter;
701 2 : this.#setter = setter;
702 2 : }
703 :
704 2 : validation_text(): string | undefined {
705 2 : return this.#state.validation_text;
706 2 : }
707 :
708 2 : get(): T {
709 2 : return this.#getter();
710 2 : }
711 :
712 2 : set(val: T): void {
713 2 : this.#dialog._cancel_state_tasks(this.#state, true);
714 2 : this.#setter(val);
715 2 : }
716 :
717 2 : id(tag: string = "field"): string {
718 2 : return this.#dialog.id_prefix + "-" + tag + "-" + state_path(this.#state);
719 2 : }
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._cancel_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 : this.#state.sub.delete(val.length - 1);
751 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 2 : sub<K extends keyof T>(tag: K, update_func?: ((val: T[K]) => void) | undefined): DialogField<T[K]> {
764 2 : const sub = this.#dialog._get_sub_state(this.#state, tag);
765 2 : return new DialogField<T[K]>(
766 2 : this.#dialog,
767 2 : sub,
768 2 : () => {
769 2 : const container = this.get();
770 1 : if (Array.isArray(container) && typeof sub.tag == "number") {
771 1 : return container[sub.tag];
772 1 : } else {
773 2 : return container[tag];
774 2 : }
775 2 : },
776 2 : (val) => {
777 2 : const container = this.get();
778 1 : if (Array.isArray(container) && typeof sub.tag == "number") {
779 1 : this.#setter(toSpliced(container, sub.tag, 1, val) as T);
780 1 : } else {
781 2 : this.#setter({ ...container, [tag]: val });
782 2 : }
783 2 : if (update_func)
784 2 : update_func(val);
785 2 : },
786 2 : );
787 2 : }
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 1 : validate(func: (val: T) => DialogValidationResult<T>): void {
795 1 : const val = this.get();
796 1 : this.#dialog._validate_value(this.#state, val, () => func(val));
797 1 : }
798 :
799 1 : validate_async(debounce: number, func: (val: T, task: DialogTask) => Promise<DialogValidationResult<T>>): void {
800 1 : const val = this.get();
801 1 : this.#dialog._validate_value_async(this.#state, val, debounce, task => func(val, task));
802 1 : }
803 :
804 2 : set_async(debounce: number, func: (val: T, task: DialogTask) => Promise<T>): void {
805 2 : const val = this.get();
806 2 : this.#dialog._update_value_async(this.#state, true, debounce, async task => {
807 2 : const new_val = await func(val, task);
808 2 : if (!task.is_cancelled())
809 2 : this.set(new_val);
810 2 : });
811 2 : }
812 :
813 1 : get_async(debounce: number, func: (val: T, task: DialogTask) => Promise<void>): void {
814 1 : const val = this.get();
815 1 : this.#dialog._update_value_async(this.#state, false, debounce, task => func(val, task));
816 1 : }
817 3 : }
818 :
819 1 : function get_validation_result_own_string(result: unknown): string | undefined {
820 1 : 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 1 : }
827 :
828 3 : export class DialogTask {
829 2 : #name: string;
830 2 : #cancelled: boolean = false;
831 2 : #on_cancel: (() => void) | null = null;
832 2 : #timeout_id: number = 0;
833 2 : #promise: Promise<void> | null = null;
834 2 : #start: () => void;
835 2 : #done: (task: DialogTask) => void;
836 :
837 2 : constructor(
838 2 : name: string,
839 2 : debounce: number,
840 2 : func: (task: DialogTask) => Promise<void>,
841 2 : done: (task: DialogTask) => void,
842 2 : ) {
843 2 : this.#name = name;
844 2 : this.#done = done;
845 2 : this.#start = () => {
846 2 : debug("starting task", this.#name);
847 2 : cockpit.assert(!this.#cancelled);
848 2 : this.#promise = func(this);
849 2 : this.#promise.finally(() => {
850 2 : debug("task done", this.#name);
851 2 : done(this);
852 2 : });
853 2 : };
854 2 : this.#timeout_id = window.setTimeout(this.#start, debounce);
855 2 : debug("creating task", this.#name, debounce);
856 2 : }
857 :
858 1 : start_now() {
859 1 : if (!this.#promise && !this.#cancelled) {
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 1 : set_cancel(cancel: (() => void) | null) {
874 1 : this.#on_cancel = cancel;
875 1 : }
876 :
877 2 : is_cancelled() {
878 2 : return this.#cancelled;
879 2 : }
880 :
881 2 : cancel() {
882 2 : debug("cancelling task", this.#name);
883 2 : window.clearTimeout(this.#timeout_id);
884 2 : if (this.#on_cancel)
885 1 : this.#on_cancel();
886 2 : this.#cancelled = true;
887 2 : if (!this.#promise) {
888 2 : debug("cancelled task done", this.#name);
889 2 : this.#done(this);
890 2 : }
891 2 : }
892 3 : }
893 :
894 : /* A DialogFieldState object holds all state for a field. Unlike
895 : handles, there is at most one of these objects for each field, and
896 : each handle for a given field refers to the exact same
897 : DialogFieldState object.
898 :
899 : DialogFieldStates are created on-demand and will over time form a
900 : tree via "parent" and "sub" that corresponds to the dialog value.
901 :
902 : The "tag" is used to access the dialog value. A handle constructed
903 : via dlg.field("name") will point to a state object with tag "name",
904 : for example, and calling handle.get() will return
905 : dlg.values["name"].
906 :
907 : Other members of a DialogFieldState relate to validation and
908 : asynchronous updates.
909 : */
910 :
911 : interface DialogFieldState {
912 : parent: DialogFieldState | null,
913 : tag: string | number | symbol;
914 : sub: Map<string | number | symbol, DialogFieldState>;
915 : // validation
916 : relevant: boolean;
917 : validation_text: string | undefined;
918 : cached_value: unknown;
919 : cached_result: unknown;
920 : validation_task: DialogTask | null;
921 : // updates
922 : update_task: DialogTask | null;
923 : update_tasks: Set<DialogTask>;
924 : }
925 :
926 : interface DialogStateEvents {
927 : changed(): void;
928 : }
929 :
930 2 : export class DialogState<V> extends EventEmitter<DialogStateEvents> {
931 : values: V;
932 :
933 2 : id_prefix: string = "dialog";
934 2 : busy: boolean = false;
935 2 : actions_disabled: boolean = false;
936 2 : cancel_disabled: boolean = false;
937 :
938 2 : error: unknown = null;
939 :
940 2 : #validation_failed: boolean = false;
941 2 : #online_validation: boolean = false;
942 2 : #action_running: boolean = false;
943 2 : #block_updates: boolean = false;
944 2 : #cancel_function: (() => void) | null = null;
945 :
946 2 : #top_state: DialogFieldState;
947 :
948 : /* eslint-disable no-use-before-define */
949 2 : #validate_callback: undefined | ((dlg: DialogState<V>) => void);
950 : /* eslint-enable */
951 :
952 2 : constructor(init: V, validate: undefined | ((dlg: DialogState<V>) => void)) {
953 2 : debug("open");
954 2 : super();
955 2 : this.#validate_callback = validate;
956 2 : this.values = init;
957 2 : this.#top_state = {
958 2 : parent: null,
959 2 : tag: "",
960 2 : sub: new Map(),
961 2 : relevant: false,
962 2 : validation_text: undefined,
963 2 : cached_value: undefined,
964 2 : cached_result: undefined,
965 2 : validation_task: null,
966 2 : update_task: null,
967 2 : update_tasks: new Set(),
968 2 : };
969 2 : }
970 :
971 2 : set_id_prefix(id_prefix: string): DialogState<V> {
972 2 : this.id_prefix = id_prefix;
973 2 : return this;
974 2 : }
975 :
976 2 : #update() {
977 2 : this.busy = this.#action_running;
978 2 : this.actions_disabled = this.#action_running || this.#validation_failed;
979 2 : this.cancel_disabled = this.#action_running && !this.#cancel_function;
980 2 : this.emit("changed");
981 2 : }
982 :
983 : /* FIELD STATES
984 :
985 : During validation and asynchronous updates, a lot is going on.
986 :
987 : We use a DialogFieldState object to keep the necessary
988 : state for that, such as cached results, and timeouts and
989 : promises.
990 :
991 : These state objects keep their identity when arrays elements
992 : move around. Their "index" field will be changed when that
993 : happens.
994 : */
995 :
996 2 : _get_sub_state(state: DialogFieldState, tag: string | number | symbol): DialogFieldState {
997 2 : let sub = state.sub.get(tag);
998 2 : if (!sub) {
999 2 : sub = {
1000 2 : parent: state,
1001 2 : tag,
1002 2 : sub: new Map(),
1003 2 : relevant: false,
1004 2 : validation_text: undefined,
1005 2 : cached_value: undefined,
1006 2 : cached_result: undefined,
1007 2 : validation_task: null,
1008 2 : update_task: null,
1009 2 : update_tasks: new Set(),
1010 2 : };
1011 2 : state.sub.set(tag, sub);
1012 2 : }
1013 2 : return sub;
1014 2 : }
1015 :
1016 2 : _for_each_field_state(func: (state: DialogFieldState) => void) {
1017 2 : function visit(state: DialogFieldState) {
1018 2 : func(state);
1019 2 : for (const sub of state.sub.values())
1020 2 : visit(sub);
1021 2 : }
1022 2 : visit(this.#top_state);
1023 2 : }
1024 :
1025 2 : async _for_each_field_state_async(func: (state: DialogFieldState) => Promise<void>) {
1026 2 : async function visit(state: DialogFieldState) {
1027 2 : await func(state);
1028 2 : for (const sub of state.sub.values())
1029 2 : await visit(sub);
1030 2 : }
1031 2 : await visit(this.#top_state);
1032 2 : }
1033 :
1034 : /* TASKS
1035 :
1036 : Tasks are a little abstraction that runs a asynchronous
1037 : function after a debounce timeout. Before running the action
1038 : function, we need to wait for them all to finish.
1039 : */
1040 :
1041 2 : async _run_all_tasks_now() {
1042 2 : let awaited: boolean = false;
1043 2 : do {
1044 2 : this._for_each_field_state(state => {
1045 2 : if (state.validation_task)
1046 1 : state.validation_task.start_now();
1047 2 : if (state.update_task)
1048 1 : state.update_task.start_now();
1049 2 : for (const task of state.update_tasks.values())
1050 1 : task.start_now();
1051 2 : });
1052 :
1053 2 : awaited = false;
1054 2 : await this._for_each_field_state_async(async state => {
1055 1 : if (state.validation_task) {
1056 1 : await state.validation_task.wait();
1057 1 : awaited = true;
1058 1 : }
1059 1 : if (state.update_task) {
1060 1 : await state.update_task.wait();
1061 1 : awaited = true;
1062 1 : }
1063 1 : for (const task of state.update_tasks.values()) {
1064 1 : await task.wait();
1065 1 : awaited = true;
1066 1 : }
1067 2 : });
1068 2 : } while (awaited);
1069 2 : }
1070 :
1071 2 : _cancel_state_tasks(state: DialogFieldState, only_updates: boolean = false) {
1072 2 : debug("cancelling state tasks", state_path(state), only_updates);
1073 1 : if (state.validation_task && !only_updates)
1074 1 : state.validation_task.cancel();
1075 2 : if (state.update_task)
1076 2 : state.update_task.cancel();
1077 2 : for (const task of state.update_tasks.values())
1078 1 : task.cancel();
1079 2 : for (const sub of state.sub.values())
1080 1 : this._cancel_state_tasks(sub, only_updates);
1081 2 : }
1082 :
1083 : /* VALIDATION
1084 :
1085 : Validation is started by calling the #trigger_validation
1086 : method. This will reset all validation errors and mark all
1087 : fields as "irrelevant". Then it calls the provided "validate"
1088 : callback, which in turn will (eventually but synchronously)
1089 : call the "_validate_value" or "_validate_value_async" methods
1090 : of all relevant value paths. Those functions will mark their
1091 : fields as relevant and eventually call #set_validation to
1092 : install the validation results in the field states.
1093 :
1094 : After this, all irrelevant asynchronous validation tasks are
1095 : cancelled.
1096 : */
1097 :
1098 2 : #validation_needed: boolean = false;
1099 2 : #validation_running: boolean = false;
1100 :
1101 2 : #trigger_validation(): void {
1102 2 : debug("trigger validation");
1103 2 : if (!this.#validate_callback)
1104 2 : return;
1105 :
1106 1 : this.#validation_needed = true;
1107 1 : if (this.#validation_running) {
1108 1 : debug("validation postponed");
1109 1 : return;
1110 1 : }
1111 :
1112 1 : this.#validation_running = true;
1113 1 : while (this.#validation_needed) {
1114 1 : debug("running validation");
1115 1 : this.#validation_needed = false;
1116 1 : this.#validation_failed = false;
1117 1 : this._for_each_field_state(state => {
1118 1 : state.relevant = false;
1119 1 : state.validation_text = undefined;
1120 1 : });
1121 1 : this.#validate_callback(this);
1122 1 : this._for_each_field_state(state => {
1123 1 : if (!state.relevant && state.validation_task) {
1124 1 : debug("cancelling irrelevant validation task", state_path(state));
1125 1 : state.validation_task.cancel();
1126 1 : }
1127 1 : });
1128 1 : }
1129 1 : this.#validation_running = false;
1130 :
1131 1 : this.#update();
1132 2 : }
1133 :
1134 1 : #set_validation(state: DialogFieldState, result: unknown) {
1135 1 : if (result) {
1136 1 : const own = get_validation_result_own_string(result);
1137 1 : if (own) {
1138 1 : state.validation_text = own;
1139 1 : this.#validation_failed = true;
1140 1 : this.#online_validation = true;
1141 1 : }
1142 1 : if (typeof result == "object") {
1143 1 : for (const [k, v] of Object.entries(result)) {
1144 1 : const sub = k && state.sub.get(k);
1145 1 : if (sub)
1146 1 : this.#set_validation(sub, v);
1147 1 : }
1148 1 : }
1149 1 : }
1150 1 : }
1151 :
1152 : /* The field state has a cache of the most recently validated
1153 : value. If the current value is still the same, actual
1154 : validation is skipped and the cached result from last time is
1155 : used.
1156 :
1157 : Calling #set_validation_state_result is the final thing that
1158 : should happen when validating a field. It will install the
1159 : result in the cache and then call #set_validation.
1160 : */
1161 :
1162 1 : #set_validation_state_result(
1163 1 : state: DialogFieldState,
1164 1 : val: unknown,
1165 1 : result: unknown,
1166 1 : ) {
1167 1 : state.cached_value = val;
1168 1 : state.cached_result = result;
1169 1 : this.#set_validation(state, result);
1170 1 : }
1171 :
1172 : /* The first thing should be of course to probe that cache. If we
1173 : get a hit, it is used immediately to call #set_validation.
1174 : */
1175 :
1176 1 : #probe_validation_state_cache(state: DialogFieldState, val: unknown): boolean {
1177 1 : if (Object.is(state.cached_value, val)) {
1178 1 : debug("cache hit", state_path(state), JSON.stringify(val), state.cached_result);
1179 1 : this.#set_validation(state, state.cached_result);
1180 1 : return true;
1181 1 : } else
1182 1 : return false;
1183 1 : }
1184 :
1185 : /* And in fact, _validate_value does exactly those two things.
1186 : */
1187 :
1188 1 : _validate_value(state: DialogFieldState, val: unknown, func: () => unknown): void {
1189 1 : state.relevant = true;
1190 1 : if (!this.#probe_validation_state_cache(state, val)) {
1191 1 : const result = func();
1192 1 : debug("sync validate", state_path(state), JSON.stringify(result));
1193 1 : this.#set_validation_state_result(state, val, result);
1194 1 : }
1195 1 : }
1196 :
1197 : /* Now asynchronous validation.
1198 :
1199 : If there was no cache hit, asynchronous validation starts with
1200 : a timeout, followed by letting a asynchronous function run to
1201 : resolution. This is managed by a DialogTask.
1202 :
1203 : Starting a new task of course cancels any previous one. It also
1204 : installs the current value in the cache, so that subsequent
1205 : validation rounds do nothing until the value actually changes.
1206 :
1207 : When the validation result has been computed, we need to check
1208 : whether we have been cancelled so that we don't install
1209 : out-dated results.
1210 : */
1211 :
1212 1 : _validate_value_async(
1213 1 : state: DialogFieldState,
1214 1 : val: unknown,
1215 1 : debounce: number,
1216 1 : func: (task: DialogTask) => Promise<unknown>
1217 1 : ): void {
1218 1 : state.relevant = true;
1219 1 : if (!this.#probe_validation_state_cache(state, val)) {
1220 1 : state.cached_value = val;
1221 1 : state.cached_result = undefined;
1222 :
1223 1 : if (state.validation_task)
1224 1 : state.validation_task.cancel();
1225 1 : state.validation_task = new DialogTask(
1226 1 : state_path(state) + ":validate",
1227 1 : debounce,
1228 1 : async task => {
1229 1 : let result;
1230 1 : try {
1231 1 : result = await func(task);
1232 1 : } catch (ex) {
1233 1 : console.error(ex);
1234 1 : }
1235 1 : if (!task.is_cancelled()) {
1236 1 : debug("async validate result", state_path(state), result);
1237 1 : this.#set_validation_state_result(state, val, result);
1238 1 : this.#update();
1239 1 : }
1240 1 : },
1241 1 : task => {
1242 1 : if (state.validation_task == task)
1243 1 : state.validation_task = null;
1244 1 : }
1245 1 : );
1246 1 : }
1247 1 : }
1248 :
1249 2 : _update_value_async(
1250 2 : state: DialogFieldState,
1251 2 : for_set: boolean,
1252 2 : debounce: number,
1253 2 : func: (task: DialogTask) => Promise<void>
1254 2 : ): void {
1255 2 : const task = new DialogTask(
1256 2 : state_path(state) + ":update",
1257 2 : debounce,
1258 2 : async ctxt => {
1259 2 : try {
1260 2 : await func(ctxt);
1261 0 : } catch (ex) {
1262 0 : console.error(ex);
1263 0 : }
1264 2 : },
1265 2 : task => {
1266 2 : if (for_set) {
1267 2 : if (state.update_task == task)
1268 2 : state.update_task = null;
1269 1 : } else {
1270 1 : state.update_tasks.delete(task);
1271 1 : }
1272 2 : }
1273 2 : );
1274 :
1275 2 : if (for_set) {
1276 2 : if (state.update_task)
1277 2 : state.update_task.cancel();
1278 2 : state.update_task = task;
1279 1 : } else {
1280 1 : state.update_tasks.add(task);
1281 1 : }
1282 2 : }
1283 :
1284 : /* The first thing run_action does is to trigger a new validation
1285 : round and then wait for all the asynchronous results to have
1286 : come in.
1287 :
1288 : If there are any DialogFieldState objects that are waiting
1289 : for a timeout, we want to cancel those and start over, so that
1290 : their validation starts immediately. (Also, it would be hairy
1291 : to wait for those timeouts to be over from here.)
1292 : */
1293 :
1294 2 : async validate(): Promise<boolean> {
1295 2 : this.#online_validation = true;
1296 2 : this.#trigger_validation();
1297 2 : await this._run_all_tasks_now();
1298 2 : return !this.#validation_failed;
1299 2 : }
1300 :
1301 1 : set_cancel(cancel: (() => void) | null) {
1302 1 : this.#cancel_function = cancel;
1303 1 : this.#update();
1304 1 : }
1305 :
1306 2 : async run_action(func: (vals: V) => Promise<void>): Promise<boolean> {
1307 2 : this.error = null;
1308 2 : this.#cancel_function = null;
1309 2 : this.#action_running = true;
1310 2 : this.#update();
1311 1 : if (!await this.validate()) {
1312 1 : this.#action_running = false;
1313 1 : this.#update();
1314 1 : return false;
1315 1 : }
1316 :
1317 2 : try {
1318 2 : this.#block_updates = true;
1319 2 : await func(this.values);
1320 1 : } catch (ex) {
1321 1 : console.error(String(ex));
1322 1 : this.error = ex;
1323 1 : }
1324 :
1325 2 : this.#cancel_function = null;
1326 2 : this.#action_running = false;
1327 2 : this.#block_updates = false;
1328 2 : this.#update();
1329 :
1330 2 : return !this.error;
1331 2 : }
1332 :
1333 1 : cancel(onClose: () => void): void {
1334 1 : if (this.#action_running) {
1335 1 : if (this.#cancel_function)
1336 1 : this.#cancel_function();
1337 1 : } else {
1338 1 : this._cancel_state_tasks(this.#top_state);
1339 1 : onClose();
1340 1 : }
1341 1 : }
1342 :
1343 2 : top(update_func?: ((val: V) => void) | undefined): DialogField<V> {
1344 2 : return new DialogField<V>(
1345 2 : this as DialogState<unknown>,
1346 2 : this.#top_state,
1347 2 : () => this.values,
1348 2 : (val) => {
1349 2 : debug("set", val);
1350 1 : if (this.#block_updates) {
1351 : // Deny state changes while actions run. This
1352 : // prevents the user from interacting with the
1353 : // dialog while it is busy. The alternative would
1354 : // be to officially disable all fields and prevent
1355 : // interactions that way, but that is visually
1356 : // very jarring and not something that we have
1357 : // been doing earlier.
1358 1 : debug("set denied");
1359 1 : return;
1360 1 : }
1361 2 : this.values = val;
1362 2 : this.#update();
1363 2 : if (this.#online_validation)
1364 1 : this.#trigger_validation();
1365 2 : if (update_func)
1366 1 : update_func(val);
1367 2 : },
1368 2 : );
1369 2 : }
1370 :
1371 2 : field<K extends keyof V>(tag: K, update_func?: ((val: V[K]) => void) | undefined): DialogField<V[K]> {
1372 2 : return this.top().sub(tag, update_func);
1373 2 : }
1374 3 : }
1375 :
1376 3 : export class DialogError {
1377 : title: string;
1378 : details: React.ReactNode;
1379 :
1380 1 : constructor(title: string, details?: React.ReactNode) {
1381 1 : this.title = title;
1382 1 : this.details = details;
1383 1 : }
1384 :
1385 1 : toString() {
1386 1 : return this.title + ": " + String(this.details);
1387 1 : }
1388 :
1389 1 : static fromError(title: string, err: unknown) {
1390 1 : if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
1391 1 : return new DialogError(title, err.message);
1392 1 : } else {
1393 1 : return new DialogError(title, String(err));
1394 1 : }
1395 1 : }
1396 3 : }
1397 :
1398 2 : export function useDialogState<V extends object>(
1399 2 : init: V | (() => V),
1400 2 : validate?: undefined | ((dlg: DialogState<V>) => void),
1401 2 : ) : DialogState<V> {
1402 2 : const dlg = useObject(
1403 2 : () => new DialogState(
1404 2 : typeof init == "function" ? init() : init,
1405 2 : validate
1406 2 : ),
1407 2 : null,
1408 2 : []
1409 2 : );
1410 2 : useOn(dlg, "changed");
1411 2 : return dlg;
1412 2 : }
1413 :
1414 1 : export function useDialogState_async<V extends object>(
1415 1 : init: () => Promise<V>,
1416 1 : validate?: undefined | ((dlg: DialogState<V>) => void),
1417 1 : ) : null | DialogError | DialogState<V> {
1418 1 : const [dlg, setDlg] = useState<null | DialogError | DialogState<V>>(null);
1419 1 : useOn((dlg instanceof DialogError ? null : dlg), "changed");
1420 1 : useInit(async () => {
1421 1 : try {
1422 1 : setDlg(new DialogState<V>(await init(), validate));
1423 1 : } catch (ex) {
1424 1 : if (ex instanceof DialogError)
1425 1 : setDlg(ex);
1426 : else
1427 1 : setDlg(DialogError.fromError(_("Error during initialization"), ex));
1428 1 : }
1429 1 : });
1430 1 : return dlg;
1431 1 : }
1432 :
1433 : // Common elements
1434 :
1435 2 : export function DialogErrorMessage<V>({
1436 2 : dialog,
1437 2 : } : {
1438 : dialog: DialogState<V> | DialogError | null,
1439 2 : }) {
1440 1 : const err = (!dialog || dialog instanceof DialogError) ? dialog : dialog.error;
1441 2 : if (!err)
1442 2 : return null;
1443 :
1444 1 : let title: string;
1445 1 : let details: React.ReactNode;
1446 :
1447 1 : if (err instanceof DialogError) {
1448 1 : title = err.title;
1449 1 : details = err.details;
1450 1 : } else if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
1451 1 : title = _("Failed");
1452 1 : details = err.message;
1453 1 : } else {
1454 1 : title = _("Failed");
1455 1 : details = String(err);
1456 1 : }
1457 :
1458 1 : const pfx = dialog instanceof DialogState ? dialog.id_prefix : "dialog";
1459 :
1460 2 : return (
1461 2 : <Alert
1462 2 : id={`${pfx}-error-message`}
1463 2 : variant='danger'
1464 2 : isInline
1465 2 : title={title}
1466 : >
1467 2 : {details}
1468 2 : </Alert>
1469 : );
1470 2 : }
1471 :
1472 2 : export function DialogActionButton<V>({
1473 2 : dialog,
1474 2 : children,
1475 2 : action,
1476 2 : onClose = undefined,
1477 2 : ...props
1478 2 : } : {
1479 : dialog: DialogState<V> | DialogError | null,
1480 : children: React.ReactNode,
1481 : action: (values: V) => Promise<void>,
1482 : onClose?: undefined | (() => void)
1483 2 : } & Omit<ButtonProps, "id" | "action" | "isLoading" | "isDisabled" | "variant" | "onClick">) {
1484 1 : const pfx = dialog instanceof DialogState ? dialog.id_prefix : "dialog";
1485 :
1486 2 : return (
1487 2 : <Button
1488 2 : id={`${pfx}-apply`}
1489 2 : isLoading={!!dialog && !(dialog instanceof DialogError) && dialog.busy}
1490 2 : isDisabled={!dialog || dialog instanceof DialogError || dialog.actions_disabled}
1491 2 : variant="primary"
1492 2 : onClick={async () => {
1493 2 : cockpit.assert(dialog && !(dialog instanceof DialogError));
1494 2 : if (await dialog.run_action(action) && onClose)
1495 2 : onClose();
1496 2 : }}
1497 2 : {...props}
1498 : >
1499 2 : {children}
1500 2 : </Button>
1501 : );
1502 2 : }
1503 :
1504 2 : export function DialogCancelButton<V>({
1505 2 : dialog,
1506 2 : onClose,
1507 2 : ...props
1508 2 : } : {
1509 : dialog: DialogState<V> | DialogError | null,
1510 : onClose: () => void
1511 2 : } & Omit<ButtonProps, "id" | "isDisabled" | "variant" | "onClick">) {
1512 1 : const pfx = dialog instanceof DialogState ? dialog.id_prefix : "dialog";
1513 :
1514 2 : return (
1515 2 : <Button
1516 2 : id={`${pfx}-cancel`}
1517 2 : isDisabled={!dialog || (dialog instanceof DialogState && dialog.cancel_disabled)}
1518 2 : variant="link"
1519 1 : onClick={() => {
1520 1 : if (dialog instanceof DialogState)
1521 1 : dialog.cancel(onClose);
1522 : else
1523 1 : onClose();
1524 1 : }}
1525 2 : {...props}
1526 : >
1527 2 : {_("Cancel")}
1528 2 : </Button>
1529 : );
1530 2 : }
1531 :
1532 : /* Common dialog field implementations.
1533 : */
1534 :
1535 : type falsy = null | undefined | false;
1536 :
1537 2 : export function DialogHelperText<V>({
1538 2 : field,
1539 2 : excuse,
1540 2 : warning,
1541 2 : explanation,
1542 2 : } : {
1543 : field: DialogField<V>;
1544 : excuse?: string | falsy;
1545 : warning?: React.ReactNode;
1546 : explanation?: React.ReactNode;
1547 2 : }) {
1548 2 : let text: React.ReactNode = field.validation_text();
1549 2 : let variant: HelperTextItemProps["variant"] = "error";
1550 2 : if (!text && excuse) {
1551 2 : text = excuse;
1552 2 : variant = "default";
1553 2 : }
1554 1 : if (!text && warning) {
1555 1 : text = warning;
1556 1 : variant = "warning";
1557 1 : }
1558 2 : if (!text) {
1559 2 : text = explanation;
1560 2 : variant = "default";
1561 2 : }
1562 :
1563 2 : if (!text)
1564 2 : return null;
1565 :
1566 2 : return (
1567 2 : <FormHelperText>
1568 2 : <HelperText>
1569 2 : <HelperTextItem id={field.id("helper-text")} variant={variant}>
1570 2 : {text}
1571 2 : </HelperTextItem>
1572 2 : </HelperText>
1573 2 : </FormHelperText>
1574 : );
1575 2 : }
1576 :
1577 2 : export const OptionalFormGroup = ({
1578 2 : label,
1579 2 : children,
1580 2 : ...props
1581 2 : } : {
1582 : label: React.ReactNode,
1583 : children: React.ReactNode,
1584 2 : } & Omit<FormGroupProps, "label" | "children">) => {
1585 2 : if (label) {
1586 2 : return (
1587 2 : <FormGroup
1588 2 : label={label}
1589 2 : {...props}
1590 : >
1591 2 : {children}
1592 2 : </FormGroup>
1593 : );
1594 1 : } else {
1595 1 : return children;
1596 1 : }
1597 2 : };
1598 :
1599 2 : export const DialogTextInput = ({
1600 2 : label = null,
1601 2 : field,
1602 2 : excuse,
1603 2 : warning,
1604 2 : explanation,
1605 2 : isDisabled = false,
1606 2 : ...props
1607 2 : } : {
1608 : label?: React.ReactNode,
1609 : field: DialogField<string>,
1610 : excuse?: string | falsy,
1611 : warning?: React.ReactNode,
1612 : explanation?: React.ReactNode,
1613 : isDisabled?: boolean,
1614 2 : } & Omit<TextInputProps, "id" | "label" | "value" | "onChange">) => {
1615 2 : return (
1616 2 : <OptionalFormGroup label={label} fieldId={field.id()}>
1617 2 : <TextInput
1618 2 : id={field.id()}
1619 2 : value={field.get()}
1620 1 : onChange={(_event, val) => field.set(val)}
1621 2 : isDisabled={!!excuse || isDisabled}
1622 2 : {...props}
1623 2 : />
1624 2 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1625 2 : </OptionalFormGroup>
1626 : );
1627 2 : };
1628 :
1629 0 : export const DialogPasswordInput = ({
1630 0 : label = null,
1631 0 : field,
1632 0 : excuse,
1633 0 : warning,
1634 0 : explanation,
1635 0 : isDisabled = false,
1636 0 : ...props
1637 0 : } : {
1638 : label?: React.ReactNode,
1639 : field: DialogField<string>,
1640 : excuse?: string | falsy,
1641 : warning?: React.ReactNode,
1642 : explanation?: React.ReactNode,
1643 : isDisabled?: boolean,
1644 0 : } & Omit<TextInputProps, "id" | "label" | "value" | "onChange">) => {
1645 0 : const [visible, setVisible] = useState(false);
1646 :
1647 0 : return (
1648 0 : <OptionalFormGroup label={label} fieldId={field.id()}>
1649 0 : <InputGroup>
1650 0 : <InputGroupItem isFill>
1651 0 : <TextInput
1652 0 : id={field.id()}
1653 0 : type={visible ? "text" : "password"}
1654 0 : value={field.get()}
1655 0 : onChange={(_event, value) => field.set(value)}
1656 0 : isDisabled={!!excuse || isDisabled}
1657 0 : {...props}
1658 0 : />
1659 0 : </InputGroupItem>
1660 0 : <InputGroupItem>
1661 0 : <Button
1662 0 : variant="control"
1663 0 : aria-label={visible ? _("Hide password") : _("Show password")}
1664 0 : onClick={() => setVisible(!visible)}
1665 : >
1666 0 : {visible ? <EyeSlashIcon /> : <EyeIcon />}
1667 0 : </Button>
1668 0 : </InputGroupItem>
1669 0 : </InputGroup>
1670 0 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1671 0 : </OptionalFormGroup>
1672 : );
1673 0 : };
1674 :
1675 2 : export const DialogCheckbox = ({
1676 2 : field_label = null,
1677 2 : checkbox_label,
1678 2 : field,
1679 2 : excuse,
1680 2 : warning,
1681 2 : explanation,
1682 2 : } : {
1683 : field_label?: React.ReactNode,
1684 : checkbox_label: string,
1685 : field: DialogField<boolean>,
1686 : excuse?: string | falsy,
1687 : warning?: React.ReactNode,
1688 : explanation?: React.ReactNode,
1689 2 : }) => {
1690 2 : return (
1691 2 : <OptionalFormGroup label={field_label} hasNoPaddingTop>
1692 2 : <Checkbox
1693 2 : id={field.id()}
1694 2 : isChecked={field.get()}
1695 2 : label={checkbox_label}
1696 1 : onChange={(_event, checked) => field.set(checked)}
1697 2 : isDisabled={!!excuse}
1698 2 : />
1699 2 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1700 2 : </OptionalFormGroup>
1701 : );
1702 2 : };
1703 :
1704 : export interface DialogRadioSelectOption<T extends string> {
1705 : value: T,
1706 : label: React.ReactNode,
1707 : explanation?: React.ReactNode,
1708 : excuse?: string | falsy,
1709 : }
1710 :
1711 2 : export function DialogRadioSelect<T extends string>({
1712 2 : label = null,
1713 2 : field,
1714 2 : options,
1715 2 : warning,
1716 2 : explanation,
1717 2 : isInline = false,
1718 2 : } : {
1719 : label?: React.ReactNode,
1720 : field: DialogField<T>,
1721 : options: DialogRadioSelectOption<T>[],
1722 : warning?: React.ReactNode,
1723 : explanation?: React.ReactNode,
1724 : isInline?: boolean,
1725 2 : }) {
1726 2 : function makeLabel(o: DialogRadioSelectOption<T>, i: number) {
1727 2 : const exc = o.excuse ? <> ({o.excuse})</> : null;
1728 2 : const pad = (!isInline && i < options.length - 1) ? <><br />{"\u00A0"}</> : null;
1729 2 : const exp = o.explanation ? <><br /><small>{o.explanation}{pad}</small></> : null;
1730 2 : return <div id={field.id(o.value + "-label")}>{o.label}{exc}{exp}</div>;
1731 2 : }
1732 :
1733 2 : return (
1734 2 : <OptionalFormGroup
1735 2 : label={label}
1736 2 : hasNoPaddingTop
1737 2 : isInline={isInline}
1738 2 : id={field.id()}
1739 2 : data-value={field.get()}
1740 : >
1741 : {
1742 2 : options.map((o, i) =>
1743 2 : <Radio
1744 2 : key={o.value}
1745 2 : id={field.id(o.value)}
1746 2 : name={o.value}
1747 2 : isChecked={field.get() == o.value}
1748 2 : label={makeLabel(o, i)}
1749 1 : onChange={() => field.set(o.value)}
1750 2 : isDisabled={!!o.excuse}
1751 2 : />
1752 2 : )
1753 : }
1754 2 : <DialogHelperText explanation={explanation} warning={warning} field={field} />
1755 2 : </OptionalFormGroup>
1756 : );
1757 2 : }
1758 :
1759 : export interface DialogDropdownSelectOption<T extends string> {
1760 : value: T;
1761 : label: string;
1762 : }
1763 :
1764 2 : export function DialogDropdownSelect<T extends string>({
1765 2 : label,
1766 2 : field,
1767 2 : excuse,
1768 2 : warning,
1769 2 : explanation,
1770 2 : options,
1771 2 : ...props
1772 2 : } : {
1773 : label?: React.ReactNode,
1774 : field: DialogField<T>,
1775 : excuse?: string | falsy,
1776 : warning?: React.ReactNode,
1777 : explanation?: React.ReactNode,
1778 : options: DialogDropdownSelectOption<T>[],
1779 2 : } & Omit<FormSelectProps, "ref" | "children">) {
1780 2 : return (
1781 2 : <OptionalFormGroup label={label}>
1782 2 : <FormSelect
1783 2 : id={field.id()}
1784 1 : onChange={(_event, val) => field.set(val as T) }
1785 1 : validated={warning ? "warning" : undefined}
1786 2 : isDisabled={!!excuse}
1787 2 : value={field.get()}
1788 2 : {...props}
1789 : >
1790 : {
1791 2 : options.map(
1792 2 : o => {
1793 2 : return <FormSelectOption key={o.value} value={o.value} label={o.label} />;
1794 2 : }
1795 2 : )
1796 : }
1797 2 : </FormSelect>
1798 2 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1799 2 : </OptionalFormGroup>
1800 : );
1801 2 : }
1802 :
1803 2 : export function DialogDropdownSelectObject<T>({
1804 2 : label,
1805 2 : field,
1806 2 : excuse,
1807 2 : warning,
1808 2 : explanation,
1809 2 : options,
1810 2 : option_label = (o: T): string => { cockpit.assert(typeof o == "string"); return o },
1811 2 : ...props
1812 2 : } : {
1813 : label?: React.ReactNode,
1814 : field: DialogField<T>,
1815 : excuse?: string | falsy,
1816 : warning?: React.ReactNode,
1817 : explanation?: React.ReactNode,
1818 : options: T[],
1819 : option_label?: (o: T) => string,
1820 2 : } & Omit<FormSelectProps, "ref" | "children">) {
1821 2 : return (
1822 2 : <OptionalFormGroup label={label}>
1823 2 : <FormSelect
1824 2 : id={field.id()}
1825 1 : onChange={(_event, val) => {
1826 1 : const opt = options.find(o => option_label(o) == val);
1827 1 : field.set(opt!);
1828 1 : }}
1829 1 : validated={warning ? "warning" : undefined}
1830 2 : isDisabled={!!excuse}
1831 2 : value={option_label(field.get())}
1832 2 : {...props}
1833 : >
1834 : {
1835 2 : options.map(
1836 2 : o => {
1837 2 : const l = option_label(o);
1838 2 : return <FormSelectOption key={l} value={l} label={l} />;
1839 2 : }
1840 2 : )
1841 : }
1842 2 : </FormSelect>
1843 2 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1844 2 : </OptionalFormGroup>
1845 : );
1846 2 : }
|