Line data Source code
1 36 : /*
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.value("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.at(witness)
226 :
227 : Get a handle with a narrowed type for "handle". The new handle
228 : works like "handle" and modifies the same place in the dialog value
229 : object, but it's type will be the type of "witness". This is
230 : useful to carry over type inference into value handles. The
231 : general pattern is:
232 :
233 : const val = handle.get();
234 : if (some_type_narrowing_condition(val)) {
235 : const narrowed_handle = handle.at(val);
236 :
237 : ...
238 : }
239 :
240 : - handle.add(val)
241 :
242 : If the current value is an array, append "val" at the end.
243 :
244 : - handle.remove(index)
245 :
246 : If the current value is an array, remove the element at "index".
247 : It is important to use this function instead of just "handle.set()"
248 : with an appropriately modified array. By using this function, the
249 : plumbing is able to keep its internal state in synch, which is
250 : especially important for asynchronous validation functions.
251 :
252 : However, it is okay to just replace an array with a different
253 : array, so you are not strictly required to use this function. But
254 : doing so might look to the validation machinery as if each and
255 : every element of the array has just changed, and it will do a lot
256 : of needless validations all over again.
257 :
258 : - handle.map(func)
259 :
260 : If the current value is an array, map "func" over handles for its
261 : elements. This is nice for creating React components for arrays.
262 :
263 : - handle.forEach(func)
264 :
265 : If the current value is an array, call "func" with handles for each
266 : of its elements, in order. This is nice for "validate" functions.
267 :
268 : Now back to the fields and methods of the dialog state.
269 :
270 : - dlg.busy
271 : - dlg.actions_disabled
272 : - dlg.cancel_disabled
273 :
274 : Boolean flags that indicate which parts of the dialog should be
275 : disabled. The porcelain should of course look at these and do the
276 : right thing.
277 :
278 : - dlg.error
279 :
280 : The most recent error thrown by an action function. This can be
281 : any kind of JavaScript value, but the idea is that it is something
282 : with a "message" field, or a DialogError instance. The
283 : DialogErrorMessage porcelain component will do the right thing with
284 : these kind of error values.
285 :
286 : - dlg.run_action(func)
287 :
288 : Performs input validation (if necessary) and if that was
289 : successful, calls "func" and puts the dialog into a "busy" state
290 : while it runs. When "func" throws an error, it is caught and stored
291 : in "dlg.error".
292 :
293 : "dlg.run_action" returns true when validation has passed and "func"
294 : has completed without throwing an error.
295 :
296 : All state changes via "field.set()" are denied while
297 : "dlg.run_action" is running. This is done to prevent the user from
298 : interacting with the dialog while an action runs. But there is
299 : nothing fundamentally wrong with programmatically changing dialog
300 : state as part of an action. If you want to do that, write code like
301 :
302 : if (dlg.run_action(...))
303 : dlg.field("xxx").set(...)
304 :
305 : - dlg.set_cancel(func)
306 :
307 : Arranges for "func" to be called when the cancel button is clicked.
308 : You should call this only from a action function passed to
309 : "run_action" and you need to take care to reset this via
310 : "dlg.set_cancel(null)" once the cancel function should no longer be
311 : called. When a action funtion finishes or throws an error from
312 : within "dlg.run_action", the cancel function is automatically
313 : reset.
314 :
315 : Let's now finally talk about input validation.
316 :
317 : Input validation is done by a single, central function for the
318 : whole dialog. This has been done so that there is a central place
319 : that establishes the "shape" of the dialog values. This is
320 : important for dialogs that have expander areas or other optional
321 : things.
322 :
323 : If such an optional part of the values has failed validation
324 : earlier, but has subsequently been removed from the dialog by the
325 : user, the plumbing needs to know that it should now ignore this
326 : failed validation. But the code that knows what is currently in the
327 : dialog is the render function that instantiates all the field input
328 : components (like TextInput). This hacker here has found no reliable
329 : and non-magical way to connect what the render function actually
330 : does with the plumbing machinery. So everyone has to write a big
331 : validate function now that duplicates this, sorry!
332 :
333 : The formal job of the validation function is to call the "validate"
334 : method (or "validate_async") of all relevant dialog value handles.
335 : If and only if the render function instantiates a component for a
336 : dialog value, should the validate function visit it.
337 :
338 : - handle.validate(v => ...)
339 :
340 : This might call the given function with the current value of the
341 : handle. If it passes validation, the function should return
342 : "undefined". If it fails, the function should return a string with
343 : the appropriate message. This message will be available from the
344 : "handle.validation_text" method and should be shown by the React
345 : component for this value, of course.
346 :
347 : The "v => ..." function is only called when necessary, when the
348 : value has actually changed.
349 :
350 : The "v => ..." function should not make any modifications to
351 : anything involved in the dialog. Specifically, it should not call
352 : "set()" on any value handle.
353 :
354 : A validation function can also return an object with validation
355 : errors for its sub-fields. This is useful if multiple fields need
356 : to be validated together. Consider this example:
357 :
358 : field.validate(v => {
359 : if (v.mode != "auto" && v.size == 0)
360 : return { "size": "Can't be zero in manual mode." }
361 : });
362 :
363 : If your validation function needs to communicate out-of-band with
364 : your action function (maybe to pass the results of some expensive
365 : operations that you don't want to repeat in your action function),
366 : then you need to find some other way. Maybe with a memoized
367 : function or an explicit cache.
368 :
369 : - handle.validate_async(debounce, async v >= ...)
370 :
371 : Calls the given async function "debounce" milliseconds after the
372 : value represented by the handle has last been changed. (Or
373 : immediately when the apply button is clicked.) When the function
374 : throws an exception, the validation is considered to have been
375 : successful.
376 :
377 : See the documentation for "handle.validate" above for more rules
378 : that apply to validation functions.
379 :
380 : TESTING
381 :
382 : Our automated tests will want to drive the dialogs created by this
383 : framework, of course. To support this, the various DOM elements
384 : instantiated for a dialog should be decorated with structured "id"
385 : attributes. The code that instantiates an element can get suitable
386 : IDs for dialog value handles with the following function:
387 :
388 : - handle.id(tag)
389 :
390 : This will return a unique and predictable string for the value
391 : handle that will also include "tag". This is suitable for the "id"
392 : attribute of DOM elements associated with "value". The "tag"
393 : parameter can be used to generate multiple IDs if a component has
394 : multiple interesting DOM elements. The "tag" parameter defaults to
395 : "field", see below.
396 :
397 : There is a support library for use by the tests that can generate
398 : the same IDs, and there are also some guidelines for how to use
399 : these IDs:
400 :
401 : - The main input element (text input, form select, ...) should use
402 : the "field" tag.
403 :
404 : - The helper text should use the "helper-text" tag.
405 :
406 : - A set of radio buttons should use a different tag for each
407 : button. Whatever makes sense in the specific case.
408 :
409 : - ...
410 :
411 : PORCELAIN GALLERY
412 :
413 : Here are some noteworthy React components that integrate with the
414 : plumbing API.
415 :
416 : - <DialogErrorMessage dialog={dlg} />
417 :
418 : This creates an appropriate Alert for "dlg.error", if it is set. It
419 : works well with instances of DialogError, and all usual errors
420 : thrown by the Cockpit API.
421 :
422 : In addition to a proper DialogState, the "dialog" property can be
423 : anything returned by "use_DialogState_async".
424 :
425 : If given one the of Cockpit API errors, the title of the Alert will
426 : be a generic "Failed" text. If you want more control, use a
427 : DialogError.
428 :
429 : A DialogError contains a title and details, and the details can
430 : come from another error. For example:
431 :
432 : try {
433 : await cockpit.spawn(["/bin/frob", "--bars"])
434 : } catch (ex) {
435 : throw DialogError.fromError("Failed to frob the bars", ex);
436 : }
437 :
438 : You can also construct a DialogError directly from title and
439 : details:
440 :
441 : throw new DialogError("Failed to frob", <pre>...</pre>);
442 :
443 : In that case, the details can be any React node.
444 :
445 : - <DialogActionButton dialog={dlg} action={func} onClose={close_func}>
446 :
447 : This will produce a action button for a dialog that correctly disables
448 : itself according to the state of "dlg".
449 :
450 : In addition to a proper DialogState, the "dialog" property can be
451 : anything returned by "use_DialogState_async".
452 :
453 : When clicked, "func" will be run via "dlg.run_action". If "func"
454 : completes successfully, "close_func" is called to close the dialog.
455 :
456 : - <DialogCancelButton dialog={dlg} onClose={close_func} />
457 :
458 : This will produce a cancel button for a dialog that correctly
459 : disables itself according to the state of "dlg".
460 :
461 : In addition to a proper DialogState, the "dialog" property can be
462 : anything returned by "use_DialogState_async".
463 :
464 : Clicking it will either just close the dialog by calling
465 : "close_func", or run the cancel function provided by the currently
466 : running action function (if there is any).
467 :
468 : - <DialogTextInput label="Name" field={dlg.field("name")} ... />
469 :
470 : This will produce a TextInput in a (optional) FormGroup that will
471 : manage the given value handle. The "label" property is optional
472 : and omitting it will also omit the FormGroup.
473 :
474 : - <DialogCheckbox label= field= .../>
475 :
476 : For a single checkbox that drives a boolean.
477 :
478 : - <DialogRadioSelect label= field= options= .../>
479 :
480 : For a group of radio buttons. The options can be disabled and have
481 : explanations.
482 :
483 : - <DialogDropdownSelect label= field= options= .../> </>
484 :
485 : For a simple dropdown select. Options can not be disabled or have
486 : explanations.
487 :
488 : - <DialogDropdownSelectObject label= field= options= option_label= />
489 :
490 : A variant of the simple dropdown select from above where the options
491 : can be of any type whatsoever, such as something directly from your
492 : data model. A simple case is selecting from an array of strings. In
493 : that case you can omit the "option_label" function.
494 :
495 : WRITING COMPLEX PORCELAIN COMPONENTS
496 :
497 : Here is a pattern that you might want to follow when writing
498 : complicated components. Even if they are not meant to be reused
499 : much, it pays of to try to encapsulate their behavior.
500 :
501 : Let's write a component for two level selection. Parameter is
502 : something like
503 :
504 : {
505 : "Fruit": [ "Apple", "Banana" ],
506 : "Bread": [ "Toast", "Rye" ],
507 : "Meat": [ "Chicken", "Pork" ],
508 : }
509 :
510 : and there will be two dropdowns in the dialog, one for selecting
511 : between "Fruit", "Bread", and "Meat"; and one for selecting "Apple"
512 : or "Banana" when the first is "Fruit", etc.
513 :
514 : First, declare the type of the value that the component works with.
515 : It should store everything needed by the component, to simplify
516 : initialization and validation.
517 :
518 : export interface TwoLevelSelectValue {
519 : first: string;
520 : second: string;
521 :
522 : _firsts: string[],
523 : _options: Record<string, string[]>,
524 : }
525 :
526 : Write a "init" function to create such a value:
527 :
528 : export function init_TwoLevelSelect(options: Record<string, string[]>): TwoLevelValue {
529 : const _firsts = Object.keys(options);
530 : const _seconds = options[_firsts[0]];
531 :
532 : return {
533 : first: _firsts[0],
534 : second: _seconds[0],
535 :
536 : _firsts,
537 : _seconds,
538 : _options: options,
539 : };
540 : }
541 :
542 : And the component itself:
543 :
544 : export const TwoLevelSelect = ({ field } : { field: DialogField<TwoLevelSelectValue> }) => {
545 : const { _firsts, _seconds, _options } = field.get();
546 :
547 : function update_first(f: string) {
548 : const _seconds = _options[f];
549 : value.sub("second").set(_seconds[0]);
550 : value.sub("_seconds").set(_seconds);
551 : }
552 :
553 : return (
554 : <>
555 : <DialogDropdownSelectObject
556 : label="First"
557 : field={field.sub("first", update_first)}
558 : options={_firsts}
559 : />
560 : <DialogDropdownSelectObject
561 : label="Second"
562 : field={field.sub("second")}
563 : options={_seconds}
564 : />
565 : </>
566 : );
567 : }
568 :
569 : It would be used in a dialog like this:
570 :
571 : interface DialogValues {
572 : food: TwoLevelSelectValue;
573 : }
574 :
575 : function init() {
576 : return {
577 : food: init_TwoLevelSelect({ "Fruit": [ "Apple", "Banana" ], "Bread": [ "Toast", "Rye" ], "Meat": [ "Chicken", "Pork" ] }),
578 : }
579 : }
580 :
581 : const dlg = useDialogState(init);
582 :
583 : return (
584 : ...
585 : <TwoLevelSelect field={dlg.field("food")} />
586 : ...
587 : );
588 :
589 : Here is a pattern for handling types that include alternatives, such
590 : as "TwoLevelSelectValue | string". This could be used to encode
591 : either the state for a working TwoLevelSelect component, or an
592 : excuse message that explains why it can't work.
593 :
594 : function init_TwoLevelSelect(options: Record<string, string[]>): TwoLevelSelectValue | string {
595 : if (Object.keys(options).length == 0)
596 : return _("Nothing to select.");
597 :
598 : return { ... };
599 : }
600 :
601 : export const TwoLevel = ({ field } : { field: DialogField<TwoLevelValue | string> }) => {
602 : const val = field.get();
603 : if (typeof val == "string")
604 : return null;
605 :
606 : const tls_field = field.at(val);
607 :
608 : const { _firsts, _seconds, _options } = tls_field.get();
609 : ...
610 : }
611 :
612 : Note the use of the "field.at()" function to get a handle for a
613 : TwoLevelSelectValue that can be used to access the "first" sub
614 : value, etc.
615 : */
616 :
617 36 : import React, { useState } from "react";
618 : import { useObject, useInit, useOn } from 'hooks';
619 : import { EventEmitter } from 'cockpit/event';
620 :
621 36 : import cockpit from "cockpit";
622 :
623 : import { Button, type ButtonProps } from "@patternfly/react-core/dist/esm/components/Button/index.js";
624 : import { FormGroup, type FormGroupProps, FormHelperText } from "@patternfly/react-core/dist/esm/components/Form";
625 : import { TextInput, type TextInputProps } from "@patternfly/react-core/dist/esm/components/TextInput";
626 : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
627 : import {
628 : HelperText, HelperTextItem, type HelperTextItemProps
629 : } from "@patternfly/react-core/dist/esm/components/HelperText";
630 : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
631 : import {
632 : FormSelect, FormSelectOption, type FormSelectProps,
633 : } from "@patternfly/react-core/dist/esm/components/FormSelect";
634 : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
635 : import { InputGroup, InputGroupItem } from "@patternfly/react-core/dist/esm/components/InputGroup/index.js";
636 : import { EyeIcon, EyeSlashIcon } from "@patternfly/react-icons";
637 :
638 36 : const _ = cockpit.gettext;
639 :
640 1 : function debug(...args: unknown[]) {
641 1 : if (window.debugging == "all" || window.debugging?.includes("dialog"))
642 1 : console.debug("dialog:", ...args);
643 1 : }
644 :
645 : type ArrayElement<ArrayType> =
646 : ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
647 :
648 1 : function toSpliced<T>(arr: T[], start: number, deleteCount: number, ...rest: T[]): T[] {
649 1 : const copy = [...arr];
650 1 : copy.splice(start, deleteCount, ...rest);
651 1 : return copy;
652 1 : }
653 :
654 : /* It would be nice to make it so that TypeScript complains when you
655 : try to return errors for sub-fields that don't exist. We can
656 : construct a type that describes this (by recursively mapping all
657 : field to strings, essentially), but TypeScript will not ordinarily
658 : prevent a validation function from returning something that also
659 : has other fields, because it does only do "excessive property
660 : checks" for literals that are assigned to locations with a known
661 : type. Example:
662 :
663 : interface ComboValue {
664 : size: number;
665 : unit: string;
666 : }
667 :
668 : dlg.field("combo").validate(v => {
669 : return {
670 : size: true, // TypeScript error here as expected, because true is not a string
671 : unt: "No such unit", // Want a TypeScript error here bc there is no "unt" in the type, but wont happen
672 : }
673 : });
674 :
675 : We would need to explicitly annotate the validation function with
676 : the right return type like so:
677 :
678 : dlg.field("combo").validate((v): DialogValidationResult<ComboValue> => {
679 : return {
680 : unt: "No such unit", // Now we get an error.
681 : }
682 : });
683 :
684 : But I doubt that people will be happy to write out their type like
685 : this every time...
686 : */
687 :
688 : export type DialogValidationResult<T> = (
689 : T extends object
690 : ? ({ [Property in keyof T]+?: DialogValidationResult<T[Property]> } & { ""?: undefined | string }) | undefined | string
691 : : undefined | string | { ""?: undefined | string }
692 : );
693 :
694 36 : export class DialogField<T> {
695 : /* eslint-disable no-use-before-define */
696 1 : #dialog: DialogState<unknown>;
697 : /* eslint-enable */
698 1 : #getter: () => T;
699 1 : #setter: (val: T) => void;
700 1 : #path: string;
701 :
702 1 : constructor(
703 1 : dialog: DialogState<unknown>,
704 1 : getter: () => T,
705 1 : setter: (val: T) => void,
706 1 : path: string
707 1 : ) {
708 1 : this.#dialog = dialog;
709 1 : this.#getter = getter;
710 1 : this.#setter = setter;
711 1 : this.#path = path;
712 1 : }
713 :
714 1 : validation_text(): string | undefined {
715 1 : return this.#dialog._get_validation(this.#path);
716 1 : }
717 :
718 1 : get(): T {
719 1 : return this.#getter();
720 1 : }
721 :
722 1 : set(val: T): void {
723 1 : this.#setter(val);
724 1 : }
725 :
726 1 : id(tag: string = "field"): string {
727 1 : return "dialog-" + tag + "-" + this.#path;
728 1 : }
729 :
730 1 : map<X>(func: (val: DialogField<ArrayElement<T>>, index: number) => X): X[] {
731 1 : const val = this.get();
732 1 : if (Array.isArray(val)) {
733 1 : return val.map((_, i) => func(this.sub(i as keyof T) as DialogField<ArrayElement<T>>, i));
734 1 : } else
735 1 : return [];
736 1 : }
737 :
738 1 : forEach(func: (val: DialogField<ArrayElement<T>>, index: number) => void): void {
739 1 : const val = this.get();
740 1 : if (Array.isArray(val)) {
741 1 : val.forEach((_, i) => func(this.sub(i as keyof T) as DialogField<ArrayElement<T>>, i));
742 1 : }
743 1 : }
744 :
745 1 : remove(index: number) {
746 1 : const val = this.get();
747 1 : if (Array.isArray(val)) {
748 1 : for (let j = index; j < val.length - 1; j++)
749 1 : this.#dialog._rename_validation_state(this.#path, j + 1, j);
750 1 : this.set(toSpliced(val, index, 1) as T);
751 1 : }
752 1 : }
753 :
754 1 : add(item: ArrayElement<T>) {
755 1 : const val = this.get();
756 1 : if (Array.isArray(val)) {
757 1 : this.set(val.concat(item) as T);
758 1 : }
759 1 : }
760 :
761 1 : sub<K extends keyof T>(tag: K, update_func?: ((val: T[K]) => void) | undefined): DialogField<T[K]> {
762 1 : return new DialogField<T[K]>(
763 1 : this.#dialog,
764 1 : () => this.get()[tag],
765 1 : (val) => {
766 1 : const container = this.get();
767 1 : if (Array.isArray(container) && typeof tag == "number")
768 1 : this.#setter(toSpliced(container, tag, 1, val) as T);
769 : else
770 1 : this.#setter({ ...container, [tag]: val });
771 1 : if (update_func)
772 1 : update_func(val);
773 1 : },
774 1 : this.#path ? this.#path + "." + String(tag) : String(tag)
775 1 : );
776 1 : }
777 :
778 1 : at<TT extends T>(witness: TT): DialogField<TT> {
779 1 : cockpit.assert(Object.is(witness, this.get()));
780 1 : return new DialogField<TT>(
781 1 : this.#dialog,
782 1 : () => this.get() as TT,
783 1 : (val) => {
784 1 : this.#setter(val);
785 1 : },
786 1 : this.#path,
787 1 : );
788 1 : }
789 :
790 1 : validate(func: (val: T) => DialogValidationResult<T>): void {
791 1 : const val = this.get();
792 1 : this.#dialog._validate_value(this.#path, val, () => func(val));
793 1 : }
794 :
795 1 : validate_async(debounce: number, func: (val: T) => Promise<DialogValidationResult<T>>): void {
796 1 : const val = this.get();
797 1 : this.#dialog._validate_value_async(this.#path, val, debounce, () => func(val));
798 1 : }
799 36 : }
800 :
801 1 : function get_validation_result_own_string(result: unknown): string | undefined {
802 1 : if (typeof result == "string")
803 1 : return result;
804 1 : else if (result && typeof result == "object" && "" in result && typeof result[""] == "string")
805 1 : return result[""];
806 : else
807 1 : return undefined;
808 1 : }
809 :
810 : interface DialogValidationState {
811 : path: string;
812 : cached_value: unknown;
813 : cached_result: unknown;
814 : timeout_id: number;
815 : promise: Promise<void> | undefined;
816 : round_id: unknown;
817 : }
818 :
819 : interface DialogStateEvents {
820 : changed(): void;
821 : }
822 :
823 1 : export class DialogState<V> extends EventEmitter<DialogStateEvents> {
824 : values: V;
825 :
826 1 : busy: boolean = false;
827 1 : actions_disabled: boolean = false;
828 1 : cancel_disabled: boolean = false;
829 1 : cancel_function: (() => void) | null = null;
830 :
831 1 : error: unknown = null;
832 :
833 1 : #validation_failed: boolean = false;
834 1 : #online_validation: boolean = false;
835 1 : #action_running: boolean = false;
836 1 : #validation: Record<string, string | undefined> = { };
837 1 : #validation_state: Record<string, DialogValidationState> = { };
838 :
839 : /* eslint-disable no-use-before-define */
840 1 : #validate_callback: undefined | ((dlg: DialogState<V>) => void);
841 : /* eslint-enable */
842 :
843 1 : constructor(init: V, validate: undefined | ((dlg: DialogState<V>) => void)) {
844 1 : super();
845 1 : this.#validate_callback = validate;
846 1 : this.values = init;
847 1 : }
848 :
849 1 : #update() {
850 1 : this.busy = this.#action_running;
851 1 : this.actions_disabled = this.#action_running || this.#validation_failed;
852 1 : this.cancel_disabled = this.#action_running && !this.cancel_function;
853 1 : this.emit("changed");
854 1 : }
855 :
856 : /* VALIDATION
857 : */
858 :
859 : /* Validation is started by calling the #trigger_validation
860 : method. This will reset all validation errors and then call the
861 : provided "validate" callback, which in turn will (eventually
862 : but synchronously) call the "_validate_value" or
863 : "_validate_value_async" methods of all relevant value paths.
864 : Those functions will eventually call #set_validation to install
865 : the validation results in the fresh #validation object created
866 : by #trigger_validation.
867 : */
868 :
869 1 : #trigger_validation(): void {
870 1 : debug("trigger validation");
871 1 : if (!this.#validate_callback)
872 1 : return;
873 1 : this.#validation = { };
874 1 : this.#validation_failed = false;
875 1 : this.#validate_callback(this);
876 1 : this.#update();
877 1 : }
878 :
879 1 : #set_validation(path: string, result: unknown) {
880 1 : if (result) {
881 1 : const own = get_validation_result_own_string(result);
882 1 : if (own) {
883 1 : this.#validation[path] = own;
884 1 : this.#validation_failed = true;
885 1 : this.#online_validation = true;
886 1 : this.#update();
887 1 : }
888 1 : if (typeof result == "object") {
889 1 : for (const [k, v] of Object.entries(result)) {
890 1 : if (k)
891 0 : this.#set_validation(path ? path + "." + k : k, v);
892 1 : }
893 1 : }
894 1 : }
895 1 : }
896 :
897 1 : _get_validation(path: string): string | undefined {
898 1 : if (path in this.#validation)
899 1 : return this.#validation[path];
900 : else
901 1 : return undefined;
902 1 : }
903 :
904 : /* In between #trigger_validation and #set_validation, a lot is
905 : going on, especially with asynchronous validation.
906 :
907 : We use a DialogValidationState object to keep the necessary
908 : state for that, such as cached results, and timeouts and
909 : promises.
910 :
911 : Note that a DialogValidationState object can change which path
912 : it is for, see _rename_validation_state below. So we have to be
913 : careful to always get the path out of the DialogValidationState
914 : object.
915 : */
916 :
917 1 : #get_validation_state(path: string): DialogValidationState {
918 1 : if (!(path in this.#validation_state))
919 1 : this.#validation_state[path] = {
920 1 : path,
921 1 : cached_value: undefined,
922 1 : cached_result: undefined,
923 1 : timeout_id: 0,
924 1 : promise: undefined,
925 1 : round_id: undefined,
926 1 : };
927 1 : return this.#validation_state[path];
928 1 : }
929 :
930 : /* Calling #set_validation_state_result is the final thing that
931 : should happen when validating a given path. It will install the
932 : result in the cache and then call #set_validation.
933 : */
934 :
935 1 : #set_validation_state_result(
936 1 : state: DialogValidationState,
937 1 : val: unknown,
938 1 : result: unknown,
939 1 : ) {
940 1 : state.cached_value = val;
941 1 : state.cached_result = result;
942 1 : state.timeout_id = 0;
943 1 : state.promise = undefined;
944 1 : state.round_id = undefined;
945 1 : this.#set_validation(state.path, result);
946 1 : }
947 :
948 : /* The first thing should be of course to probe that cache. If we
949 : get a hit, it is used immediately to call #set_validation.
950 :
951 : In that case, the DialogValidationState is also made part of
952 : the current round since any asynchronous validation that is
953 : currently running is still relevant. See below for more about
954 : that.
955 : */
956 :
957 1 : #probe_validation_state_cache(state: DialogValidationState, val: unknown): boolean {
958 1 : if (Object.is(state.cached_value, val)) {
959 1 : state.round_id = this.#get_current_validation_round_id();
960 1 : debug("cache hit", state.path, state.cached_result);
961 1 : this.#set_validation(state.path, state.cached_result);
962 1 : return true;
963 1 : } else
964 1 : return false;
965 1 : }
966 :
967 : /* And in fact, _validate_value does exactly those two things.
968 : */
969 :
970 1 : _validate_value(path: string, val: unknown, func: () => unknown): void {
971 1 : const state = this.#get_validation_state(path);
972 1 : if (!this.#probe_validation_state_cache(state, val)) {
973 1 : const result = func();
974 1 : debug("sync validate", state.path, result);
975 1 : this.#set_validation_state_result(state, val, result);
976 1 : }
977 1 : }
978 :
979 : /* Now asynchronous validation.
980 :
981 : Each call to #trigger_validation starts a new "validation
982 : round" and a DialogValidationState keeps track to which round
983 : it applies to. This matters of course for asynchronous
984 : validation: If async validation for a given path was started in
985 : one round, and then the next round happens but the path is no
986 : longer enumerated by the validation callback (i.e., its value
987 : is no longer relevant for the dialog), then this asynchronous
988 : validation should have no effect.
989 :
990 : We use the #validation object as the round identifier, since it
991 : is created fresh by each call to #trigger_validation.
992 : */
993 :
994 1 : #get_current_validation_round_id(): unknown {
995 1 : return this.#validation;
996 1 : }
997 :
998 1 : #is_current_validation_round_id(id: unknown): boolean {
999 1 : return Object.is(id, this.#validation);
1000 1 : }
1001 :
1002 : /* If there was no cache hit, asynchronous validation starts with
1003 : a timeout, followed by letting a asynchronous function run to
1004 : resolution.
1005 :
1006 : Setting a new timeout of course cancels any previously set
1007 : one. It also installs the current value in the cache, so that
1008 : subsequent validation rounds do nothing until the value
1009 : actually changes.
1010 :
1011 : One interesting thing to note is that when doing the final
1012 : validation before running an action function, no debouncing
1013 : delay should be applied of course. We want to get on with
1014 : validation immediately.
1015 : */
1016 :
1017 1 : #set_validation_state_timeout(
1018 1 : state: DialogValidationState,
1019 1 : val: unknown,
1020 1 : delay: number,
1021 1 : func: () => void,
1022 1 : ) {
1023 1 : if (state.timeout_id) {
1024 1 : debug("timeout cancel", state.path);
1025 1 : window.clearTimeout(state.timeout_id);
1026 1 : state.timeout_id = 0;
1027 1 : }
1028 1 : if (this.#action_running || delay == 0) {
1029 1 : func();
1030 1 : } else {
1031 1 : state.cached_value = val;
1032 1 : state.cached_result = undefined;
1033 1 : state.timeout_id = window.setTimeout(
1034 1 : () => {
1035 1 : debug("timeout", state.path);
1036 1 : if (!this.#validation_state_is_current(state)) {
1037 1 : debug("timeout outdated", state.path);
1038 1 : return;
1039 1 : }
1040 1 : func();
1041 1 : },
1042 1 : delay);
1043 1 : state.promise = undefined;
1044 1 : state.round_id = this.#get_current_validation_round_id();
1045 1 : }
1046 1 : }
1047 :
1048 : /* Once the timeout is over (and the path is still relevant to the
1049 : current round), the actual asynchronous validation is launched.
1050 : This promise that represents it is simply installed in the
1051 : DialogValidationState.
1052 : */
1053 :
1054 1 : #set_validation_state_promise(
1055 1 : state: DialogValidationState,
1056 1 : val: unknown,
1057 1 : prom: Promise<void>,
1058 1 : ) {
1059 1 : state.cached_value = val;
1060 1 : state.cached_result = undefined;
1061 1 : state.timeout_id = 0;
1062 1 : state.promise = prom;
1063 1 : state.round_id = this.#get_current_validation_round_id();
1064 1 : }
1065 :
1066 : /* Unlike with the timeout, we can not cancel the old promise when
1067 : installing a new one. Instead we check at the end whether it is
1068 : still really us that is supposed to deliver the result, by
1069 : comparing promises.
1070 :
1071 : To summarize:
1072 :
1073 : - The round id check will fail if the value is no longer
1074 : relevant to the dialog. For example, say there is a text
1075 : input that can be toggled in and out of the dialog via a
1076 : checkbox. Now a validation round is started while the text
1077 : input is part of the dialog. During the debounce timeout or
1078 : while the asynchronous validation function runs, the user
1079 : toggles the checkbox (which triggers a new validation round)
1080 : and the text input is no longer part of the dialog. Now when
1081 : the timeout or validation for the text input concludes, the
1082 : round id check fails and the result is ignored, as it should.
1083 :
1084 : - The promise check will fail when a asynchronous validation
1085 : takes longer than the debounce timeout. Let's say there is a
1086 : text input with a debounce timeout of 1 second and a
1087 : validation function that takes 2 seconds. The user makes a
1088 : change that triggers validation and then remains idle for
1089 : more than a second. After one second, the timeout expires and
1090 : the promise is created and starts running. It will finish at
1091 : second 3, but we are not there yet. At second 1.5 the user
1092 : makes another change, a new timeout expires at 2.5 and a new
1093 : promise is created. At second 3 the original promise finally
1094 : comes to a conclusion, and the path is still relevant to the
1095 : dialog, but this promise is no longer the current
1096 : promise. Its result will be ignored, as it should.
1097 : */
1098 :
1099 1 : #validation_state_is_current(state: DialogValidationState, prom?: Promise<void>): boolean {
1100 1 : return (
1101 1 : (!prom || Object.is(state.promise, prom)) &&
1102 1 : this.#is_current_validation_round_id(state.round_id)
1103 : );
1104 1 : }
1105 :
1106 : /* _validate_value_async puts this all together.
1107 : */
1108 :
1109 1 : _validate_value_async(path: string, val: unknown, debounce: number, func: () => Promise<unknown>): void {
1110 1 : const state = this.#get_validation_state(path);
1111 1 : if (!this.#probe_validation_state_cache(state, val)) {
1112 1 : debug("async validate start debounce", state.path, val);
1113 1 : this.#set_validation_state_timeout(
1114 1 : state,
1115 1 : val,
1116 1 : debounce,
1117 1 : () => {
1118 1 : debug("async validate start promise", state.path, val);
1119 1 : const prom =
1120 1 : func()
1121 1 : .catch(
1122 1 : ex => {
1123 1 : console.error(ex);
1124 1 : return undefined;
1125 1 : }
1126 1 : )
1127 1 : .then(
1128 1 : result => {
1129 1 : if (this.#validation_state_is_current(state, prom)) {
1130 1 : debug("async validate done", state.path, result);
1131 1 : this.#set_validation_state_result(state, val, result);
1132 1 : } else {
1133 1 : debug("promise outdated", state.path);
1134 1 : }
1135 1 : }
1136 1 : );
1137 1 : this.#set_validation_state_promise(state, val, prom);
1138 1 : }
1139 : );
1140 1 : }
1141 1 : }
1142 :
1143 : /* Since the DialogValidationState for a path is so important, it
1144 : is also important to keep them firmly associated with each
1145 : other when the path of a value changes.
1146 :
1147 : A path might change when there are arrays involved, and
1148 : elements get new indices without actually changing identity.
1149 : */
1150 :
1151 1 : _rename_validation_state(path: string, from: number, to: number) {
1152 1 : const from_path = path + "." + String(from);
1153 1 : const to_path = path + "." + String(to);
1154 1 : if (from_path in this.#validation_state) {
1155 1 : debug("rename", from_path, to_path);
1156 1 : this.#validation_state[to_path] = this.#validation_state[from_path];
1157 1 : this.#validation_state[to_path].path = to_path;
1158 1 : delete this.#validation_state[from_path];
1159 1 : }
1160 1 : for (const k in this.#validation_state) {
1161 1 : if (k.indexOf(from_path + ".") == 0) {
1162 1 : const to = to_path + k.substring(from_path.length);
1163 1 : debug("rename", k, to);
1164 1 : this.#validation_state[to] = this.#validation_state[k];
1165 1 : this.#validation_state[to].path = to;
1166 1 : delete this.#validation_state[k];
1167 1 : }
1168 1 : }
1169 1 : }
1170 :
1171 : /* The first thing run_action does is to trigger a new validation
1172 : round and then wait for all the asynchronous results to have
1173 : come in.
1174 :
1175 : If there are any DialogValidationState objects that are waiting
1176 : for a timeout, we want to cancel those and start over, so that
1177 : their validation starts immediately. (Also, it would be hairy
1178 : to wait for those timeouts to be over from here.)
1179 : */
1180 :
1181 1 : async validate(): Promise<boolean> {
1182 1 : this.#cancel_all_validation_timeouts();
1183 1 : this.#trigger_validation();
1184 1 : await this.#wait_for_validation_promises();
1185 1 : return !this.#validation_failed;
1186 1 : }
1187 :
1188 1 : #cancel_all_validation_timeouts() {
1189 1 : for (const p in this.#validation_state) {
1190 1 : const state = this.#validation_state[p];
1191 1 : if (state.timeout_id) {
1192 1 : debug("timeout bulk cancel", p);
1193 1 : window.clearTimeout(state.timeout_id);
1194 1 : delete this.#validation_state[p];
1195 1 : }
1196 1 : }
1197 1 : }
1198 :
1199 1 : async #wait_for_validation_promises(): Promise<void> {
1200 1 : for (const path in this.#validation_state) {
1201 1 : const state = this.#validation_state[path];
1202 1 : if (state.promise) {
1203 1 : debug("waiting for promise", path);
1204 1 : await state.promise;
1205 1 : debug("waiting for promise done", path);
1206 1 : }
1207 1 : }
1208 1 : }
1209 :
1210 0 : set_cancel(cancel: (() => void) | null) {
1211 0 : this.cancel_function = cancel;
1212 0 : this.#update();
1213 0 : }
1214 :
1215 1 : async run_action(func: (vals: V) => Promise<void>): Promise<boolean> {
1216 1 : this.error = null;
1217 1 : this.cancel_function = null;
1218 1 : this.#action_running = true;
1219 1 : this.#update();
1220 1 : if (!await this.validate()) {
1221 1 : this.#action_running = false;
1222 1 : this.#update();
1223 1 : return false;
1224 1 : }
1225 :
1226 1 : try {
1227 1 : await func(this.values);
1228 1 : } catch (ex) {
1229 1 : console.error(String(ex));
1230 1 : this.error = ex;
1231 1 : }
1232 :
1233 1 : this.cancel_function = null;
1234 1 : this.#action_running = false;
1235 1 : this.#update();
1236 :
1237 1 : return !this.error;
1238 1 : }
1239 :
1240 1 : top(update_func?: ((val: V) => void) | undefined): DialogField<V> {
1241 1 : return new DialogField<V>(
1242 1 : this as DialogState<unknown>,
1243 1 : () => this.values,
1244 1 : (val) => {
1245 1 : debug("set", val);
1246 1 : if (this.#action_running) {
1247 : // Deny state changes while actions run. This
1248 : // prevents the user from interacting with the
1249 : // dialog while it is busy. The alternative would
1250 : // be to officially disable all fields and prevent
1251 : // interactions that way, but that is visually
1252 : // very jarring and not something that we have
1253 : // been doing earlier.
1254 1 : debug("set denied");
1255 1 : return;
1256 1 : }
1257 1 : this.values = val;
1258 1 : this.#update();
1259 1 : if (this.#online_validation)
1260 1 : this.#trigger_validation();
1261 1 : if (update_func)
1262 1 : update_func(val);
1263 1 : },
1264 1 : "");
1265 1 : }
1266 :
1267 1 : field<K extends keyof V>(tag: K, update_func?: ((val: V[K]) => void) | undefined): DialogField<V[K]> {
1268 1 : return this.top().sub(tag, update_func);
1269 1 : }
1270 36 : }
1271 :
1272 36 : export class DialogError {
1273 : title: string;
1274 : details: React.ReactNode;
1275 :
1276 1 : constructor(title: string, details?: React.ReactNode) {
1277 1 : this.title = title;
1278 1 : this.details = details;
1279 1 : }
1280 :
1281 1 : toString() {
1282 1 : return this.title + ": " + String(this.details);
1283 1 : }
1284 :
1285 1 : static fromError(title: string, err: unknown) {
1286 1 : if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
1287 1 : return new DialogError(title, err.message);
1288 1 : } else {
1289 1 : return new DialogError(title, String(err));
1290 1 : }
1291 1 : }
1292 36 : }
1293 :
1294 1 : export function useDialogState<V extends object>(
1295 1 : init: V | (() => V),
1296 1 : validate?: undefined | ((dlg: DialogState<V>) => void),
1297 1 : ) : DialogState<V> {
1298 1 : const dlg = useObject(
1299 1 : () => new DialogState(
1300 1 : typeof init == "function" ? init() : init,
1301 1 : validate
1302 1 : ),
1303 1 : null,
1304 1 : []
1305 1 : );
1306 1 : useOn(dlg, "changed");
1307 1 : return dlg;
1308 1 : }
1309 :
1310 1 : export function useDialogState_async<V extends object>(
1311 1 : init: () => Promise<V>,
1312 1 : validate?: undefined | ((dlg: DialogState<V>) => void),
1313 1 : ) : null | DialogError | DialogState<V> {
1314 1 : const [dlg, setDlg] = useState<null | DialogError | DialogState<V>>(null);
1315 1 : useOn((dlg instanceof DialogError ? null : dlg), "changed");
1316 1 : useInit(async () => {
1317 1 : try {
1318 1 : setDlg(new DialogState<V>(await init(), validate));
1319 1 : } catch (ex) {
1320 1 : if (ex instanceof DialogError)
1321 1 : setDlg(ex);
1322 : else
1323 1 : setDlg(DialogError.fromError(_("Error during initialization"), ex));
1324 1 : }
1325 1 : });
1326 1 : return dlg;
1327 1 : }
1328 :
1329 : // Common elements
1330 :
1331 1 : export function DialogErrorMessage<V>({
1332 1 : dialog,
1333 1 : } : {
1334 : dialog: DialogState<V> | DialogError | null,
1335 1 : }) {
1336 1 : const err = (!dialog || dialog instanceof DialogError) ? dialog : dialog.error;
1337 1 : if (!err)
1338 1 : return null;
1339 :
1340 1 : let title: string;
1341 1 : let details: React.ReactNode;
1342 :
1343 1 : if (err instanceof DialogError) {
1344 1 : title = err.title;
1345 1 : details = err.details;
1346 1 : } else if (err && typeof err == "object" && "message" in err && typeof err.message == "string") {
1347 1 : title = _("Failed");
1348 1 : details = err.message;
1349 1 : } else {
1350 1 : title = _("Failed");
1351 1 : details = String(err);
1352 1 : }
1353 :
1354 1 : return (
1355 1 : <Alert
1356 1 : id="dialog-error-message"
1357 1 : variant='danger'
1358 1 : isInline
1359 1 : title={title}
1360 : >
1361 1 : {details}
1362 1 : </Alert>
1363 : );
1364 1 : }
1365 :
1366 1 : export function DialogActionButton<V>({
1367 1 : dialog,
1368 1 : children,
1369 1 : action,
1370 1 : onClose = undefined,
1371 1 : ...props
1372 1 : } : {
1373 : dialog: DialogState<V> | DialogError | null,
1374 : children: React.ReactNode,
1375 : action: (values: V) => Promise<void>,
1376 : onClose?: undefined | (() => void)
1377 1 : } & Omit<ButtonProps, "id" | "action" | "isLoading" | "isDisabled" | "variant" | "onClick">) {
1378 1 : return (
1379 1 : <Button
1380 1 : id="dialog-apply"
1381 1 : isLoading={!!dialog && !(dialog instanceof DialogError) && dialog.busy}
1382 1 : isDisabled={!dialog || dialog instanceof DialogError || dialog.actions_disabled}
1383 1 : variant="primary"
1384 1 : onClick={async () => {
1385 1 : cockpit.assert(dialog && !(dialog instanceof DialogError));
1386 1 : if (await dialog.run_action(action) && onClose)
1387 1 : onClose();
1388 1 : }}
1389 1 : {...props}
1390 : >
1391 1 : {children}
1392 1 : </Button>
1393 : );
1394 1 : }
1395 :
1396 1 : export function DialogCancelButton<V>({
1397 1 : dialog,
1398 1 : onClose,
1399 1 : ...props
1400 1 : } : {
1401 : dialog: DialogState<V> | DialogError | null,
1402 : onClose: () => void
1403 1 : } & Omit<ButtonProps, "id" | "isDisabled" | "variant" | "onClick">) {
1404 1 : return (
1405 1 : <Button
1406 1 : id="dialog-cancel"
1407 1 : isDisabled={!dialog || (dialog instanceof DialogState && dialog.cancel_disabled)}
1408 1 : variant="link"
1409 1 : onClick={() => {
1410 1 : if (dialog instanceof DialogState && dialog.cancel_function)
1411 0 : dialog.cancel_function();
1412 : else
1413 1 : onClose();
1414 1 : }}
1415 1 : {...props}
1416 : >
1417 1 : {_("Cancel")}
1418 1 : </Button>
1419 : );
1420 1 : }
1421 :
1422 : /* Common dialog field implementations.
1423 : */
1424 :
1425 : type falsy = null | undefined | false;
1426 :
1427 1 : export function DialogHelperText<V>({
1428 1 : field,
1429 1 : excuse,
1430 1 : warning,
1431 1 : explanation,
1432 1 : } : {
1433 : field: DialogField<V>;
1434 : excuse?: string | falsy;
1435 : warning?: React.ReactNode;
1436 : explanation?: React.ReactNode;
1437 1 : }) {
1438 1 : let text: React.ReactNode = field.validation_text();
1439 1 : let variant: HelperTextItemProps["variant"] = "error";
1440 1 : if (!text && excuse) {
1441 1 : text = excuse;
1442 1 : variant = "default";
1443 1 : }
1444 1 : if (!text && warning) {
1445 1 : text = warning;
1446 1 : variant = "warning";
1447 1 : }
1448 1 : if (!text) {
1449 1 : text = explanation;
1450 1 : variant = "default";
1451 1 : }
1452 :
1453 1 : if (!text)
1454 1 : return null;
1455 :
1456 1 : return (
1457 1 : <FormHelperText>
1458 1 : <HelperText>
1459 1 : <HelperTextItem id={field.id("helper-text")} variant={variant}>
1460 1 : {text}
1461 1 : </HelperTextItem>
1462 1 : </HelperText>
1463 1 : </FormHelperText>
1464 : );
1465 1 : }
1466 :
1467 1 : export const OptionalFormGroup = ({
1468 1 : label,
1469 1 : children,
1470 1 : ...props
1471 1 : } : {
1472 : label: React.ReactNode,
1473 : children: React.ReactNode,
1474 1 : } & Omit<FormGroupProps, "label" | "children">) => {
1475 1 : if (label) {
1476 1 : return (
1477 1 : <FormGroup
1478 1 : label={label}
1479 1 : {...props}
1480 : >
1481 1 : {children}
1482 1 : </FormGroup>
1483 : );
1484 1 : } else {
1485 1 : return children;
1486 1 : }
1487 1 : };
1488 :
1489 1 : export const DialogTextInput = ({
1490 1 : label = null,
1491 1 : field,
1492 1 : excuse,
1493 1 : warning,
1494 1 : explanation,
1495 1 : isDisabled = false,
1496 1 : ...props
1497 1 : } : {
1498 : label?: React.ReactNode,
1499 : field: DialogField<string>,
1500 : excuse?: string | falsy,
1501 : warning?: React.ReactNode,
1502 : explanation?: React.ReactNode,
1503 : isDisabled?: boolean,
1504 1 : } & Omit<TextInputProps, "id" | "label" | "value" | "onChange">) => {
1505 1 : return (
1506 1 : <OptionalFormGroup label={label} fieldId={field.id()}>
1507 1 : <TextInput
1508 1 : id={field.id()}
1509 1 : value={field.get()}
1510 1 : onChange={(_event, val) => field.set(val)}
1511 1 : isDisabled={!!excuse || isDisabled}
1512 1 : {...props}
1513 1 : />
1514 1 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1515 1 : </OptionalFormGroup>
1516 : );
1517 1 : };
1518 :
1519 0 : export const DialogPasswordInput = ({
1520 0 : label = null,
1521 0 : field,
1522 0 : excuse,
1523 0 : warning,
1524 0 : explanation,
1525 0 : isDisabled = false,
1526 0 : ...props
1527 0 : } : {
1528 : label?: React.ReactNode,
1529 : field: DialogField<string>,
1530 : excuse?: string | falsy,
1531 : warning?: React.ReactNode,
1532 : explanation?: React.ReactNode,
1533 : isDisabled?: boolean,
1534 0 : } & Omit<TextInputProps, "id" | "label" | "value" | "onChange">) => {
1535 0 : const [visible, setVisible] = useState(false);
1536 :
1537 0 : return (
1538 0 : <OptionalFormGroup label={label} fieldId={field.id()}>
1539 0 : <InputGroup>
1540 0 : <InputGroupItem isFill>
1541 0 : <TextInput
1542 0 : id={field.id()}
1543 0 : type={visible ? "text" : "password"}
1544 0 : value={field.get()}
1545 0 : onChange={(_event, value) => field.set(value)}
1546 0 : isDisabled={!!excuse || isDisabled}
1547 0 : {...props}
1548 0 : />
1549 0 : </InputGroupItem>
1550 0 : <InputGroupItem>
1551 0 : <Button
1552 0 : variant="control"
1553 0 : aria-label={visible ? _("Hide password") : _("Show password")}
1554 0 : onClick={() => setVisible(!visible)}
1555 : >
1556 0 : {visible ? <EyeSlashIcon /> : <EyeIcon />}
1557 0 : </Button>
1558 0 : </InputGroupItem>
1559 0 : </InputGroup>
1560 0 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1561 0 : </OptionalFormGroup>
1562 : );
1563 0 : };
1564 :
1565 1 : export const DialogCheckbox = ({
1566 1 : field_label = null,
1567 1 : checkbox_label,
1568 1 : field,
1569 1 : excuse,
1570 1 : warning,
1571 1 : explanation,
1572 1 : } : {
1573 : field_label?: React.ReactNode,
1574 : checkbox_label: string,
1575 : field: DialogField<boolean>,
1576 : excuse?: string | falsy,
1577 : warning?: React.ReactNode,
1578 : explanation?: React.ReactNode,
1579 1 : }) => {
1580 1 : return (
1581 1 : <OptionalFormGroup label={field_label} hasNoPaddingTop>
1582 1 : <Checkbox
1583 1 : id={field.id()}
1584 1 : isChecked={field.get()}
1585 1 : label={checkbox_label}
1586 1 : onChange={(_event, checked) => field.set(checked)}
1587 1 : isDisabled={!!excuse}
1588 1 : />
1589 1 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1590 1 : </OptionalFormGroup>
1591 : );
1592 1 : };
1593 :
1594 : export interface DialogRadioSelectOption<T extends string> {
1595 : value: T,
1596 : label: React.ReactNode,
1597 : explanation?: React.ReactNode,
1598 : excuse?: string | falsy,
1599 : }
1600 :
1601 1 : export function DialogRadioSelect<T extends string>({
1602 1 : label = null,
1603 1 : field,
1604 1 : options,
1605 1 : warning,
1606 1 : explanation,
1607 1 : isInline = false,
1608 1 : } : {
1609 : label?: React.ReactNode,
1610 : field: DialogField<T>,
1611 : options: DialogRadioSelectOption<T>[],
1612 : warning?: React.ReactNode,
1613 : explanation?: React.ReactNode,
1614 : isInline?: boolean,
1615 1 : }) {
1616 1 : function makeLabel(o: DialogRadioSelectOption<T>, i: number) {
1617 1 : const exc = o.excuse ? <> ({o.excuse})</> : null;
1618 1 : const pad = (!isInline && i < options.length - 1) ? <><br />{"\u00A0"}</> : null;
1619 1 : const exp = o.explanation ? <><br /><small>{o.explanation}{pad}</small></> : null;
1620 1 : return <div id={field.id(o.value + "-label")}>{o.label}{exc}{exp}</div>;
1621 1 : }
1622 :
1623 1 : return (
1624 1 : <OptionalFormGroup
1625 1 : label={label}
1626 1 : hasNoPaddingTop
1627 1 : isInline={isInline}
1628 1 : id={field.id()}
1629 1 : data-value={field.get()}
1630 : >
1631 : {
1632 1 : options.map((o, i) =>
1633 1 : <Radio
1634 1 : key={o.value}
1635 1 : id={field.id(o.value)}
1636 1 : name={o.value}
1637 1 : isChecked={field.get() == o.value}
1638 1 : label={makeLabel(o, i)}
1639 1 : onChange={() => field.set(o.value)}
1640 1 : isDisabled={!!o.excuse}
1641 1 : />
1642 1 : )
1643 : }
1644 1 : <DialogHelperText explanation={explanation} warning={warning} field={field} />
1645 1 : </OptionalFormGroup>
1646 : );
1647 1 : }
1648 :
1649 : export interface DialogDropdownSelectOption<T extends string> {
1650 : value: T;
1651 : label: string;
1652 : }
1653 :
1654 1 : export function DialogDropdownSelect<T extends string>({
1655 1 : label,
1656 1 : field,
1657 1 : excuse,
1658 1 : warning,
1659 1 : explanation,
1660 1 : options,
1661 1 : ...props
1662 1 : } : {
1663 : label?: React.ReactNode,
1664 : field: DialogField<T>,
1665 : excuse?: string | falsy,
1666 : warning?: React.ReactNode,
1667 : explanation?: React.ReactNode,
1668 : options: DialogDropdownSelectOption<T>[],
1669 1 : } & Omit<FormSelectProps, "ref" | "children">) {
1670 1 : return (
1671 1 : <OptionalFormGroup label={label}>
1672 1 : <FormSelect
1673 1 : id={field.id()}
1674 1 : onChange={(_event, val) => field.set(val as T) }
1675 1 : validated={warning ? "warning" : undefined}
1676 1 : isDisabled={!!excuse}
1677 1 : value={field.get()}
1678 1 : {...props}
1679 : >
1680 : {
1681 1 : options.map(
1682 1 : o => {
1683 1 : return <FormSelectOption key={o.value} value={o.value} label={o.label} />;
1684 1 : }
1685 1 : )
1686 : }
1687 1 : </FormSelect>
1688 1 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1689 1 : </OptionalFormGroup>
1690 : );
1691 1 : }
1692 :
1693 1 : export function DialogDropdownSelectObject<T>({
1694 1 : label,
1695 1 : field,
1696 1 : excuse,
1697 1 : warning,
1698 1 : explanation,
1699 1 : options,
1700 1 : option_label = (o: T): string => { cockpit.assert(typeof o == "string"); return o },
1701 1 : ...props
1702 1 : } : {
1703 : label?: React.ReactNode,
1704 : field: DialogField<T>,
1705 : excuse?: string | falsy,
1706 : warning?: React.ReactNode,
1707 : explanation?: React.ReactNode,
1708 : options: T[],
1709 : option_label?: (o: T) => string,
1710 1 : } & Omit<FormSelectProps, "ref" | "children">) {
1711 1 : return (
1712 1 : <OptionalFormGroup label={label}>
1713 1 : <FormSelect
1714 1 : id={field.id()}
1715 1 : onChange={(_event, val) => {
1716 1 : const opt = options.find(o => option_label(o) == val);
1717 1 : field.set(opt!);
1718 1 : }}
1719 1 : validated={warning ? "warning" : undefined}
1720 1 : isDisabled={!!excuse}
1721 1 : value={option_label(field.get())}
1722 1 : {...props}
1723 : >
1724 : {
1725 1 : options.map(
1726 1 : o => {
1727 1 : const l = option_label(o);
1728 1 : return <FormSelectOption key={l} value={l} label={l} />;
1729 1 : }
1730 1 : )
1731 : }
1732 1 : </FormSelect>
1733 1 : <DialogHelperText explanation={explanation} warning={warning} excuse={excuse} field={field} />
1734 1 : </OptionalFormGroup>
1735 : );
1736 1 : }
|