Line data Source code
1 : diff --git a/pkg/lib/cockpit/dialog.tsx b/pkg/lib/cockpit/dialog.tsx
2 : index 43c396556..3009dbe91 100644
3 : --- a/pkg/lib/cockpit/dialog.tsx
4 : +++ b/pkg/lib/cockpit/dialog.tsx
5 : @@ -44,7 +44,7 @@
6 : const Dialogs = useDialogs();
7 :
8 : function validate() {
9 : - dlg.value("text").validate(v => {
10 : + dlg.field("text").validate(v => {
11 : if (!v)
12 : return "Text can not be empty";
13 : });
14 : @@ -222,6 +222,47 @@
15 : array, pass the index of the desired element. See "dlg.field()"
16 : above for more information about handles.
17 :
18 : + - handle.get_async(debounce, (val, task) => ...)
19 : + - handle.set_async(debounce, (val, task) => new_val)
20 : +
21 : + These are for running debounced, asynchronous code. Both functions
22 : + will run the given function after "debounce" milliseconds, but only
23 : + if the value of the field hasn't changed in the meantime. The
24 : + dialog waits for all asynchronous tasks started by these functions
25 : + to be finished before running the action function. When the dialog
26 : + is cancelled, they all get cancelled.
27 : +
28 : + The return value of "handle.set_async" is made the new value of the
29 : + field, but only if the value of the field hasn't changed in the
30 : + meantime. There can only be one currently active "set_async" call.
31 : + If you call it again before the previous one has finished, that
32 : + previous call will be cancelled at that point, just as if the field
33 : + value had changed.
34 : +
35 : + The "handle.get_async" function is a slight variation on this. It
36 : + is meant to perform asynchronous computations that do not modify
37 : + the field value itself, but have some other side effects. Maybe
38 : + they modify multiple other field values or some React state. There
39 : + can be more than one call active at a given time. They only get
40 : + cancelled when the value of the field changes.
41 : +
42 : + The asynchronous tasks must be careful to only perform their side
43 : + effects when they have not been cancelled yet. Because of the way
44 : + JavaScript works, the asynchronous functions keep running and they
45 : + need to voluntarily call "task.is_cancelled()" to figure out when
46 : + they should stop.
47 : +
48 : + As an example, here is how you might implement set_async on top of
49 : + get_async:
50 : +
51 : + function set_async(handle, debounce, func) {
52 : + handle.get_async(debounce, (val, task) => {
53 : + const new_val = await func(val, task);
54 : + if (!task.is_cancelled())
55 : + handle.set(new_val);
56 : + })
57 : + }
58 : +
59 : - handle.at(witness)
60 :
61 : Get a handle with a narrowed type for "handle". The new handle
62 : @@ -247,7 +288,8 @@
63 : It is important to use this function instead of just "handle.set()"
64 : with an appropriately modified array. By using this function, the
65 : plumbing is able to keep its internal state in synch, which is
66 : - especially important for asynchronous validation functions.
67 : + especially important for asynchronous validation and update
68 : + functions.
69 :
70 : However, it is okay to just replace an array with a different
71 : array, so you are not strictly required to use this function. But
72 : @@ -285,23 +327,30 @@
73 :
74 : - dlg.run_action(func)
75 :
76 : - Performs input validation (if necessary) and if that was
77 : - successful, calls "func" and puts the dialog into a "busy" state
78 : - while it runs. When "func" throws an error, it is caught and stored
79 : - in "dlg.error".
80 : + Waits for all asynchronous updates and input validation to be done
81 : + and if that was successful, calls "func" and puts the dialog into a
82 : + "busy" state while it runs. When "func" throws an error, it is
83 : + caught and stored in "dlg.error".
84 :
85 : "dlg.run_action" returns true when validation has passed and "func"
86 : has completed without throwing an error.
87 :
88 : - All state changes via "field.set()" are denied while
89 : - "dlg.run_action" is running. This is done to prevent the user from
90 : - interacting with the dialog while an action runs. But there is
91 : - nothing fundamentally wrong with programmatically changing dialog
92 : - state as part of an action. If you want to do that, write code like
93 : + All state changes via "field.set()" are denied while "func" is
94 : + running. This is done to prevent the user from interacting with the
95 : + dialog while an action runs. But there is nothing fundamentally
96 : + wrong with programmatically changing dialog state as part of an
97 : + action. If you want to do that, write code like
98 :
99 : if (dlg.run_action(...))
100 : dlg.field("xxx").set(...)
101 :
102 : + - dlg.cancel(onClose)
103 : +
104 : + Does whatever should happen when the "Cancel" button is
105 : + clicked. When an action is running, it will call the "cancel
106 : + function" (see below). Otherwise all validation and update tasks
107 : + are cancelled and the dialog is closed by calling "onClose".
108 : +
109 : - dlg.set_cancel(func)
110 :
111 : Arranges for "func" to be called when the cancel button is clicked.
112 : @@ -312,13 +361,17 @@
113 : within "dlg.run_action", the cancel function is automatically
114 : reset.
115 :
116 : - Let's now finally talk about input validation.
117 : + - dlg.set_id_prefix(id_prefix)
118 : +
119 : + This sets the prefix used by the handle.id() function. This is only
120 : + necessary when testing stacked dialogs, which should be rare.
121 : +
122 : + VALIDATION
123 :
124 : Input validation is done by a single, central function for the
125 : whole dialog. This has been done so that there is a central place
126 : that establishes the "shape" of the dialog values. This is
127 : - important for dialogs that have expander areas or other optional
128 : - things.
129 : + important for dialogs that have optional parts.
130 :
131 : If such an optional part of the values has failed validation
132 : earlier, but has subsequently been removed from the dialog by the
133 : @@ -332,8 +385,8 @@
134 :
135 : The formal job of the validation function is to call the "validate"
136 : method (or "validate_async") of all relevant dialog value handles.
137 : - If and only if the render function instantiates a component for a
138 : - dialog value, should the validate function visit it.
139 : + If and only if a validation failure of a field should prevent
140 : + running the action function, should the validate function visit it.
141 :
142 : - handle.validate(v => ...)
143 :
144 : @@ -342,15 +395,12 @@
145 : "undefined". If it fails, the function should return a string with
146 : the appropriate message. This message will be available from the
147 : "handle.validation_text" method and should be shown by the React
148 : - component for this value, of course.
149 : + component for this value, of course. Returning an error here will
150 : + also disable the action buttons.
151 :
152 : The "v => ..." function is only called when necessary, when the
153 : value has actually changed.
154 :
155 : - The "v => ..." function should not make any modifications to
156 : - anything involved in the dialog. Specifically, it should not call
157 : - "set()" on any value handle.
158 : -
159 : A validation function can also return an object with validation
160 : errors for its sub-fields. This is useful if multiple fields need
161 : to be validated together. Consider this example:
162 : @@ -363,10 +413,10 @@
163 : If your validation function needs to communicate out-of-band with
164 : your action function (maybe to pass the results of some expensive
165 : operations that you don't want to repeat in your action function),
166 : - then you need to find some other way. Maybe with a memoized
167 : - function or an explicit cache.
168 : + then you can modify field values via calls to "handle.set". (Be
169 : + careful not to create endless validation loops!)
170 :
171 : - - handle.validate_async(debounce, async v >= ...)
172 : + - handle.validate_async(debounce, async (v, task) >= ...)
173 :
174 : Calls the given async function "debounce" milliseconds after the
175 : value represented by the handle has last been changed. (Or
176 : @@ -377,6 +427,60 @@
177 : See the documentation for "handle.validate" above for more rules
178 : that apply to validation functions.
179 :
180 : + UPDATES
181 : +
182 : + Sometimes dialog values need to be changed in reaction to other
183 : + changes. For example, when the user selects a ISO for creating a
184 : + new virtual machine, you might want to run some code that detects
185 : + the OS on that ISO and then adapts the rest of the dialog to the
186 : + minimum storage requirements of the OS. Sometimes you can do
187 : + everything at render time, but sometimes you might want to run some
188 : + code as part of the event handler for the user action, and
189 : + sometimes you need to run asynchornous code.
190 : +
191 : + (Don't use useEffect, please, just stick the code into the event
192 : + handler.)
193 : +
194 : + It's okay and simplest to just put that code right next to the call
195 : + to "handler.set()". If that call is in a porcelain component (as
196 : + it probably often will be), you can pass a "update_func" when
197 : + creating the handle for that porcelain component with
198 : + "handler.sub()" or "dialog.field()". For example:
199 : +
200 : + function on_plate_change(val: string) {
201 : + console.log("NEW LICENSE PLATE", val);
202 : + }
203 : +
204 : + return (
205 : + <DialogTextInput
206 : + label="License plate number"
207 : + field={dlg.field("plate", on_plate_change)}
208 : + />
209 : + );
210 : +
211 : + The function "on_plate_change" will be called whenever the user
212 : + changes the "plate" field via the DialogTextInput. The
213 : + "on_plate_change" function will not be called when the "plate" is
214 : + changed in other places. If that should happen, you have to
215 : + arrange for it explicitly.
216 : +
217 : + Functions like "on_plate_change" can and should modify the dialog
218 : + fields via calls to "handle.set()".
219 : +
220 : + If you want to run asynchronous code, you can do so with
221 : + "handle.set_async()" or "handle.get_async()". For example, if you
222 : + want to asynchronously fetch the car model for a given license
223 : + plate from a database, you can do it like this:
224 : +
225 : + function on_plate_change(val: string) {
226 : + dlg.field("model").set_async(1000, async () => await fetch_model(val));
227 : + }
228 : +
229 : + When arrays are involved, dialog fields can move around while your
230 : + asynchronous update function runs. To help with this, handles will
231 : + keep referring to the same field even if it moves around in its
232 : + array.
233 : +
234 : TESTING
235 :
236 : Our automated tests will want to drive the dialogs created by this
237 : @@ -492,126 +596,6 @@
238 : data model. A simple case is selecting from an array of strings. In
239 : that case you can omit the "option_label" function.
240 :
241 : - WRITING COMPLEX PORCELAIN COMPONENTS
242 : -
243 : - Here is a pattern that you might want to follow when writing
244 : - complicated components. Even if they are not meant to be reused
245 : - much, it pays of to try to encapsulate their behavior.
246 : -
247 : - Let's write a component for two level selection. Parameter is
248 : - something like
249 : -
250 : - {
251 : - "Fruit": [ "Apple", "Banana" ],
252 : - "Bread": [ "Toast", "Rye" ],
253 : - "Meat": [ "Chicken", "Pork" ],
254 : - }
255 : -
256 : - and there will be two dropdowns in the dialog, one for selecting
257 : - between "Fruit", "Bread", and "Meat"; and one for selecting "Apple"
258 : - or "Banana" when the first is "Fruit", etc.
259 : -
260 : - First, declare the type of the value that the component works with.
261 : - It should store everything needed by the component, to simplify
262 : - initialization and validation.
263 : -
264 : - export interface TwoLevelSelectValue {
265 : - first: string;
266 : - second: string;
267 : -
268 : - _firsts: string[],
269 : - _options: Record<string, string[]>,
270 : - }
271 : -
272 : - Write a "init" function to create such a value:
273 : -
274 : - export function init_TwoLevelSelect(options: Record<string, string[]>): TwoLevelValue {
275 : - const _firsts = Object.keys(options);
276 : - const _seconds = options[_firsts[0]];
277 : -
278 : - return {
279 : - first: _firsts[0],
280 : - second: _seconds[0],
281 : -
282 : - _firsts,
283 : - _seconds,
284 : - _options: options,
285 : - };
286 : - }
287 : -
288 : - And the component itself:
289 : -
290 : - export const TwoLevelSelect = ({ field } : { field: DialogField<TwoLevelSelectValue> }) => {
291 : - const { _firsts, _seconds, _options } = field.get();
292 : -
293 : - function update_first(f: string) {
294 : - const _seconds = _options[f];
295 : - value.sub("second").set(_seconds[0]);
296 : - value.sub("_seconds").set(_seconds);
297 : - }
298 : -
299 : - return (
300 : - <>
301 : - <DialogDropdownSelectObject
302 : - label="First"
303 : - field={field.sub("first", update_first)}
304 : - options={_firsts}
305 : - />
306 : - <DialogDropdownSelectObject
307 : - label="Second"
308 : - field={field.sub("second")}
309 : - options={_seconds}
310 : - />
311 : - </>
312 : - );
313 : - }
314 : -
315 : - It would be used in a dialog like this:
316 : -
317 : - interface DialogValues {
318 : - food: TwoLevelSelectValue;
319 : - }
320 : -
321 : - function init() {
322 : - return {
323 : - food: init_TwoLevelSelect({ "Fruit": [ "Apple", "Banana" ], "Bread": [ "Toast", "Rye" ], "Meat": [ "Chicken", "Pork" ] }),
324 : - }
325 : - }
326 : -
327 : - const dlg = useDialogState(init);
328 : -
329 : - return (
330 : - ...
331 : - <TwoLevelSelect field={dlg.field("food")} />
332 : - ...
333 : - );
334 : -
335 : - Here is a pattern for handling types that include alternatives, such
336 : - as "TwoLevelSelectValue | string". This could be used to encode
337 : - either the state for a working TwoLevelSelect component, or an
338 : - excuse message that explains why it can't work.
339 : -
340 : - function init_TwoLevelSelect(options: Record<string, string[]>): TwoLevelSelectValue | string {
341 : - if (Object.keys(options).length == 0)
342 : - return _("Nothing to select.");
343 : -
344 : - return { ... };
345 : - }
346 : -
347 : - export const TwoLevel = ({ field } : { field: DialogField<TwoLevelValue | string> }) => {
348 : - const val = field.get();
349 : - if (typeof val == "string")
350 : - return null;
351 : -
352 : - const tls_field = field.at(val);
353 : -
354 : - const { _firsts, _seconds, _options } = tls_field.get();
355 : - ...
356 : - }
357 : -
358 : - Note the use of the "field.at()" function to get a handle for a
359 : - TwoLevelSelectValue that can be used to access the "first" sub
360 : - value, etc.
361 : */
362 :
363 : import React, { useState } from "react";
364 : @@ -691,28 +675,34 @@ export type DialogValidationResult<T> = (
365 : : undefined | string | { ""?: undefined | string }
366 : );
367 :
368 2 : +function state_path(state: DialogFieldState): string {
369 2 : + const p = state.parent ? state_path(state.parent) : "";
370 2 : + const t = String(state.tag);
371 1 : + return p ? `${p}.${t}` : t;
372 2 : +}
373 : +
374 : export class DialogField<T> {
375 : /* eslint-disable no-use-before-define */
376 : #dialog: DialogState<unknown>;
377 2 : + #state: DialogFieldState;
378 : /* eslint-enable */
379 : #getter: () => T;
380 : #setter: (val: T) => void;
381 : - #path: string;
382 :
383 : constructor(
384 : dialog: DialogState<unknown>,
385 2 : + state: DialogFieldState,
386 : getter: () => T,
387 : setter: (val: T) => void,
388 : - path: string
389 : ) {
390 : this.#dialog = dialog;
391 2 : + this.#state = state;
392 : this.#getter = getter;
393 : this.#setter = setter;
394 : - this.#path = path;
395 : }
396 :
397 : validation_text(): string | undefined {
398 : - return this.#dialog._get_validation(this.#path);
399 2 : + return this.#state.validation_text;
400 : }
401 :
402 : get(): T {
403 : @@ -720,11 +710,12 @@ export class DialogField<T> {
404 : }
405 :
406 : set(val: T): void {
407 2 : + this.#dialog._cancel_state_tasks(this.#state, true);
408 : this.#setter(val);
409 : }
410 :
411 : id(tag: string = "field"): string {
412 : - return "dialog-" + tag + "-" + this.#path;
413 2 : + return this.#dialog.id_prefix + "-" + tag + "-" + state_path(this.#state);
414 : }
415 :
416 : map<X>(func: (val: DialogField<ArrayElement<T>>, index: number) => X): X[] {
417 : @@ -745,56 +736,83 @@ export class DialogField<T> {
418 : remove(index: number) {
419 : const val = this.get();
420 : if (Array.isArray(val)) {
421 : - for (let j = index; j < val.length - 1; j++)
422 : - this.#dialog._rename_validation_state(this.#path, j + 1, j);
423 : - this.set(toSpliced(val, index, 1) as T);
424 1 : + const sub = this.#state.sub.get(index);
425 1 : + if (sub) {
426 1 : + this.#dialog._cancel_state_tasks(sub);
427 1 : + sub.tag = -1;
428 1 : + }
429 1 : + for (let j = index; j < val.length - 1; j++) {
430 1 : + const sub = this.#state.sub.get(j + 1);
431 1 : + if (sub) {
432 1 : + sub.tag = j;
433 1 : + this.#state.sub.set(j, sub);
434 1 : + }
435 1 : + this.#state.sub.delete(val.length - 1);
436 1 : + }
437 1 : + this.#setter(toSpliced(val, index, 1) as T);
438 : }
439 : }
440 :
441 : add(item: ArrayElement<T>) {
442 : const val = this.get();
443 : if (Array.isArray(val)) {
444 : - this.set(val.concat(item) as T);
445 1 : + this.#setter(val.concat(item) as T);
446 : }
447 : }
448 :
449 : sub<K extends keyof T>(tag: K, update_func?: ((val: T[K]) => void) | undefined): DialogField<T[K]> {
450 2 : + const sub = this.#dialog._get_sub_state(this.#state, tag);
451 : return new DialogField<T[K]>(
452 : this.#dialog,
453 : - () => this.get()[tag],
454 2 : + sub,
455 2 : + () => {
456 2 : + const container = this.get();
457 1 : + if (Array.isArray(container) && typeof sub.tag == "number") {
458 1 : + return container[sub.tag];
459 1 : + } else {
460 2 : + return container[tag];
461 2 : + }
462 2 : + },
463 : (val) => {
464 : const container = this.get();
465 : - if (Array.isArray(container) && typeof tag == "number")
466 : - this.#setter(toSpliced(container, tag, 1, val) as T);
467 : - else
468 1 : + if (Array.isArray(container) && typeof sub.tag == "number") {
469 1 : + this.#setter(toSpliced(container, sub.tag, 1, val) as T);
470 1 : + } else {
471 : this.#setter({ ...container, [tag]: val });
472 2 : + }
473 : if (update_func)
474 : update_func(val);
475 : },
476 : - this.#path ? this.#path + "." + String(tag) : String(tag)
477 : );
478 : }
479 :
480 : at<TT extends T>(witness: TT): DialogField<TT> {
481 : cockpit.assert(Object.is(witness, this.get()));
482 : - return new DialogField<TT>(
483 : - this.#dialog,
484 : - () => this.get() as TT,
485 : - (val) => {
486 : - this.#setter(val);
487 : - },
488 : - this.#path,
489 : - );
490 1 : + return this as unknown as DialogField<TT>;
491 : }
492 :
493 : validate(func: (val: T) => DialogValidationResult<T>): void {
494 : const val = this.get();
495 : - this.#dialog._validate_value(this.#path, val, () => func(val));
496 1 : + this.#dialog._validate_value(this.#state, val, () => func(val));
497 : }
498 :
499 : - validate_async(debounce: number, func: (val: T) => Promise<DialogValidationResult<T>>): void {
500 1 : + validate_async(debounce: number, func: (val: T, task: DialogTask) => Promise<DialogValidationResult<T>>): void {
501 : const val = this.get();
502 : - this.#dialog._validate_value_async(this.#path, val, debounce, () => func(val));
503 1 : + this.#dialog._validate_value_async(this.#state, val, debounce, task => func(val, task));
504 1 : + }
505 : +
506 2 : + set_async(debounce: number, func: (val: T, task: DialogTask) => Promise<T>): void {
507 2 : + const val = this.get();
508 2 : + this.#dialog._update_value_async(this.#state, true, debounce, async task => {
509 2 : + const new_val = await func(val, task);
510 2 : + if (!task.is_cancelled())
511 2 : + this.set(new_val);
512 2 : + });
513 2 : + }
514 : +
515 1 : + get_async(debounce: number, func: (val: T, task: DialogTask) => Promise<void>): void {
516 1 : + const val = this.get();
517 1 : + this.#dialog._update_value_async(this.#state, false, debounce, task => func(val, task));
518 : }
519 : }
520 :
521 : @@ -807,13 +825,102 @@ function get_validation_result_own_string(result: unknown): string | undefined {
522 : return undefined;
523 : }
524 :
525 : -interface DialogValidationState {
526 : - path: string;
527 3 : +export class DialogTask {
528 2 : + #name: string;
529 2 : + #cancelled: boolean = false;
530 2 : + #on_cancel: (() => void) | null = null;
531 2 : + #timeout_id: number = 0;
532 2 : + #promise: Promise<void> | null = null;
533 2 : + #start: () => void;
534 2 : + #done: (task: DialogTask) => void;
535 : +
536 2 : + constructor(
537 2 : + name: string,
538 2 : + debounce: number,
539 2 : + func: (task: DialogTask) => Promise<void>,
540 2 : + done: (task: DialogTask) => void,
541 2 : + ) {
542 2 : + this.#name = name;
543 2 : + this.#done = done;
544 2 : + this.#start = () => {
545 2 : + debug("starting task", this.#name);
546 2 : + cockpit.assert(!this.#cancelled);
547 2 : + this.#promise = func(this);
548 2 : + this.#promise.finally(() => {
549 2 : + debug("task done", this.#name);
550 2 : + done(this);
551 2 : + });
552 2 : + };
553 2 : + this.#timeout_id = window.setTimeout(this.#start, debounce);
554 2 : + debug("creating task", this.#name, debounce);
555 2 : + }
556 : +
557 1 : + start_now() {
558 1 : + if (!this.#promise && !this.#cancelled) {
559 1 : + debug("skipping debounce of task", this.#name);
560 1 : + window.clearTimeout(this.#timeout_id);
561 1 : + this.#start();
562 1 : + }
563 1 : + }
564 : +
565 1 : + async wait() {
566 : + // Waiting is only allowed for tasks that have actually been started.
567 1 : + cockpit.assert(this.#promise);
568 1 : + debug("waiting for task", this.#name);
569 1 : + await this.#promise;
570 1 : + }
571 : +
572 1 : + set_cancel(cancel: (() => void) | null) {
573 1 : + this.#on_cancel = cancel;
574 1 : + }
575 : +
576 2 : + is_cancelled() {
577 2 : + return this.#cancelled;
578 2 : + }
579 : +
580 2 : + cancel() {
581 2 : + debug("cancelling task", this.#name);
582 2 : + window.clearTimeout(this.#timeout_id);
583 2 : + if (this.#on_cancel)
584 1 : + this.#on_cancel();
585 2 : + this.#cancelled = true;
586 2 : + if (!this.#promise) {
587 2 : + debug("cancelled task done", this.#name);
588 2 : + this.#done(this);
589 2 : + }
590 2 : + }
591 3 : +}
592 : +
593 : +/* A DialogFieldState object holds all state for a field. Unlike
594 : + handles, there is at most one of these objects for each field, and
595 : + each handle for a given field refers to the exact same
596 : + DialogFieldState object.
597 : +
598 : + DialogFieldStates are created on-demand and will over time form a
599 : + tree via "parent" and "sub" that corresponds to the dialog value.
600 : +
601 : + The "tag" is used to access the dialog value. A handle constructed
602 : + via dlg.field("name") will point to a state object with tag "name",
603 : + for example, and calling handle.get() will return
604 : + dlg.values["name"].
605 : +
606 : + Other members of a DialogFieldState relate to validation and
607 : + asynchronous updates.
608 : + */
609 : +
610 : +interface DialogFieldState {
611 : + parent: DialogFieldState | null,
612 : + tag: string | number | symbol;
613 : + sub: Map<string | number | symbol, DialogFieldState>;
614 : + // validation
615 : + relevant: boolean;
616 : + validation_text: string | undefined;
617 : cached_value: unknown;
618 : cached_result: unknown;
619 : - timeout_id: number;
620 : - promise: Promise<void> | undefined;
621 : - round_id: unknown;
622 : + validation_task: DialogTask | null;
623 : + // updates
624 : + update_task: DialogTask | null;
625 : + update_tasks: Set<DialogTask>;
626 : }
627 :
628 : interface DialogStateEvents {
629 : @@ -823,142 +930,253 @@ interface DialogStateEvents {
630 : export class DialogState<V> extends EventEmitter<DialogStateEvents> {
631 : values: V;
632 :
633 2 : + id_prefix: string = "dialog";
634 : busy: boolean = false;
635 : actions_disabled: boolean = false;
636 : cancel_disabled: boolean = false;
637 : - cancel_function: (() => void) | null = null;
638 :
639 : error: unknown = null;
640 :
641 : #validation_failed: boolean = false;
642 : #online_validation: boolean = false;
643 : #action_running: boolean = false;
644 : - #validation: Record<string, string | undefined> = { };
645 : - #validation_state: Record<string, DialogValidationState> = { };
646 2 : + #block_updates: boolean = false;
647 2 : + #cancel_function: (() => void) | null = null;
648 : +
649 2 : + #top_state: DialogFieldState;
650 :
651 : /* eslint-disable no-use-before-define */
652 : #validate_callback: undefined | ((dlg: DialogState<V>) => void);
653 : /* eslint-enable */
654 :
655 : constructor(init: V, validate: undefined | ((dlg: DialogState<V>) => void)) {
656 2 : + debug("open");
657 : super();
658 : this.#validate_callback = validate;
659 : this.values = init;
660 2 : + this.#top_state = {
661 2 : + parent: null,
662 2 : + tag: "",
663 2 : + sub: new Map(),
664 2 : + relevant: false,
665 2 : + validation_text: undefined,
666 2 : + cached_value: undefined,
667 2 : + cached_result: undefined,
668 2 : + validation_task: null,
669 2 : + update_task: null,
670 2 : + update_tasks: new Set(),
671 2 : + };
672 2 : + }
673 : +
674 2 : + set_id_prefix(id_prefix: string): DialogState<V> {
675 2 : + this.id_prefix = id_prefix;
676 2 : + return this;
677 : }
678 :
679 : #update() {
680 : this.busy = this.#action_running;
681 : this.actions_disabled = this.#action_running || this.#validation_failed;
682 : - this.cancel_disabled = this.#action_running && !this.cancel_function;
683 2 : + this.cancel_disabled = this.#action_running && !this.#cancel_function;
684 : this.emit("changed");
685 : }
686 :
687 : + /* FIELD STATES
688 : +
689 : + During validation and asynchronous updates, a lot is going on.
690 : +
691 : + We use a DialogFieldState object to keep the necessary
692 : + state for that, such as cached results, and timeouts and
693 : + promises.
694 : +
695 : + These state objects keep their identity when arrays elements
696 : + move around. Their "index" field will be changed when that
697 : + happens.
698 : + */
699 : +
700 2 : + _get_sub_state(state: DialogFieldState, tag: string | number | symbol): DialogFieldState {
701 2 : + let sub = state.sub.get(tag);
702 2 : + if (!sub) {
703 2 : + sub = {
704 2 : + parent: state,
705 2 : + tag,
706 2 : + sub: new Map(),
707 2 : + relevant: false,
708 2 : + validation_text: undefined,
709 2 : + cached_value: undefined,
710 2 : + cached_result: undefined,
711 2 : + validation_task: null,
712 2 : + update_task: null,
713 2 : + update_tasks: new Set(),
714 2 : + };
715 2 : + state.sub.set(tag, sub);
716 2 : + }
717 2 : + return sub;
718 2 : + }
719 : +
720 2 : + _for_each_field_state(func: (state: DialogFieldState) => void) {
721 2 : + function visit(state: DialogFieldState) {
722 2 : + func(state);
723 2 : + for (const sub of state.sub.values())
724 2 : + visit(sub);
725 2 : + }
726 2 : + visit(this.#top_state);
727 2 : + }
728 : +
729 2 : + async _for_each_field_state_async(func: (state: DialogFieldState) => Promise<void>) {
730 2 : + async function visit(state: DialogFieldState) {
731 2 : + await func(state);
732 2 : + for (const sub of state.sub.values())
733 2 : + await visit(sub);
734 2 : + }
735 2 : + await visit(this.#top_state);
736 2 : + }
737 : +
738 : + /* TASKS
739 : +
740 : + Tasks are a little abstraction that runs a asynchronous
741 : + function after a debounce timeout. Before running the action
742 : + function, we need to wait for them all to finish.
743 : + */
744 : +
745 2 : + async _run_all_tasks_now() {
746 2 : + let awaited: boolean = false;
747 2 : + do {
748 2 : + this._for_each_field_state(state => {
749 2 : + if (state.validation_task)
750 1 : + state.validation_task.start_now();
751 2 : + if (state.update_task)
752 1 : + state.update_task.start_now();
753 2 : + for (const task of state.update_tasks.values())
754 1 : + task.start_now();
755 2 : + });
756 : +
757 2 : + awaited = false;
758 2 : + await this._for_each_field_state_async(async state => {
759 1 : + if (state.validation_task) {
760 1 : + await state.validation_task.wait();
761 1 : + awaited = true;
762 1 : + }
763 1 : + if (state.update_task) {
764 1 : + await state.update_task.wait();
765 1 : + awaited = true;
766 1 : + }
767 1 : + for (const task of state.update_tasks.values()) {
768 1 : + await task.wait();
769 1 : + awaited = true;
770 1 : + }
771 2 : + });
772 2 : + } while (awaited);
773 2 : + }
774 : +
775 2 : + _cancel_state_tasks(state: DialogFieldState, only_updates: boolean = false) {
776 2 : + debug("cancelling state tasks", state_path(state), only_updates);
777 1 : + if (state.validation_task && !only_updates)
778 1 : + state.validation_task.cancel();
779 2 : + if (state.update_task)
780 2 : + state.update_task.cancel();
781 2 : + for (const task of state.update_tasks.values())
782 1 : + task.cancel();
783 2 : + for (const sub of state.sub.values())
784 1 : + this._cancel_state_tasks(sub, only_updates);
785 2 : + }
786 : +
787 : /* VALIDATION
788 : - */
789 :
790 : - /* Validation is started by calling the #trigger_validation
791 : - method. This will reset all validation errors and then call the
792 : - provided "validate" callback, which in turn will (eventually
793 : - but synchronously) call the "_validate_value" or
794 : - "_validate_value_async" methods of all relevant value paths.
795 : - Those functions will eventually call #set_validation to install
796 : - the validation results in the fresh #validation object created
797 : - by #trigger_validation.
798 : + Validation is started by calling the #trigger_validation
799 : + method. This will reset all validation errors and mark all
800 : + fields as "irrelevant". Then it calls the provided "validate"
801 : + callback, which in turn will (eventually but synchronously)
802 : + call the "_validate_value" or "_validate_value_async" methods
803 : + of all relevant value paths. Those functions will mark their
804 : + fields as relevant and eventually call #set_validation to
805 : + install the validation results in the field states.
806 : +
807 : + After this, all irrelevant asynchronous validation tasks are
808 : + cancelled.
809 : */
810 :
811 2 : + #validation_needed: boolean = false;
812 2 : + #validation_running: boolean = false;
813 : +
814 : #trigger_validation(): void {
815 : debug("trigger validation");
816 : if (!this.#validate_callback)
817 : return;
818 : - this.#validation = { };
819 : - this.#validation_failed = false;
820 : - this.#validate_callback(this);
821 : +
822 1 : + this.#validation_needed = true;
823 1 : + if (this.#validation_running) {
824 1 : + debug("validation postponed");
825 1 : + return;
826 1 : + }
827 : +
828 1 : + this.#validation_running = true;
829 1 : + while (this.#validation_needed) {
830 1 : + debug("running validation");
831 1 : + this.#validation_needed = false;
832 1 : + this.#validation_failed = false;
833 1 : + this._for_each_field_state(state => {
834 1 : + state.relevant = false;
835 1 : + state.validation_text = undefined;
836 1 : + });
837 1 : + this.#validate_callback(this);
838 1 : + this._for_each_field_state(state => {
839 1 : + if (!state.relevant && state.validation_task) {
840 1 : + debug("cancelling irrelevant validation task", state_path(state));
841 1 : + state.validation_task.cancel();
842 1 : + }
843 1 : + });
844 1 : + }
845 1 : + this.#validation_running = false;
846 : +
847 : this.#update();
848 : }
849 :
850 : - #set_validation(path: string, result: unknown) {
851 1 : + #set_validation(state: DialogFieldState, result: unknown) {
852 : if (result) {
853 : const own = get_validation_result_own_string(result);
854 : if (own) {
855 : - this.#validation[path] = own;
856 1 : + state.validation_text = own;
857 : this.#validation_failed = true;
858 : this.#online_validation = true;
859 : - this.#update();
860 : }
861 : if (typeof result == "object") {
862 : for (const [k, v] of Object.entries(result)) {
863 : - if (k)
864 : - this.#set_validation(path ? path + "." + k : k, v);
865 1 : + const sub = k && state.sub.get(k);
866 1 : + if (sub)
867 1 : + this.#set_validation(sub, v);
868 : }
869 : }
870 : }
871 : }
872 :
873 : - _get_validation(path: string): string | undefined {
874 : - if (path in this.#validation)
875 : - return this.#validation[path];
876 : - else
877 : - return undefined;
878 : - }
879 : + /* The field state has a cache of the most recently validated
880 : + value. If the current value is still the same, actual
881 : + validation is skipped and the cached result from last time is
882 : + used.
883 :
884 : - /* In between #trigger_validation and #set_validation, a lot is
885 : - going on, especially with asynchronous validation.
886 : -
887 : - We use a DialogValidationState object to keep the necessary
888 : - state for that, such as cached results, and timeouts and
889 : - promises.
890 : -
891 : - Note that a DialogValidationState object can change which path
892 : - it is for, see _rename_validation_state below. So we have to be
893 : - careful to always get the path out of the DialogValidationState
894 : - object.
895 : - */
896 : -
897 : - #get_validation_state(path: string): DialogValidationState {
898 : - if (!(path in this.#validation_state))
899 : - this.#validation_state[path] = {
900 : - path,
901 : - cached_value: undefined,
902 : - cached_result: undefined,
903 : - timeout_id: 0,
904 : - promise: undefined,
905 : - round_id: undefined,
906 : - };
907 : - return this.#validation_state[path];
908 : - }
909 : -
910 : - /* Calling #set_validation_state_result is the final thing that
911 : - should happen when validating a given path. It will install the
912 : + Calling #set_validation_state_result is the final thing that
913 : + should happen when validating a field. It will install the
914 : result in the cache and then call #set_validation.
915 : */
916 :
917 : #set_validation_state_result(
918 : - state: DialogValidationState,
919 1 : + state: DialogFieldState,
920 : val: unknown,
921 : result: unknown,
922 : ) {
923 : state.cached_value = val;
924 : state.cached_result = result;
925 : - state.timeout_id = 0;
926 : - state.promise = undefined;
927 : - state.round_id = undefined;
928 : - this.#set_validation(state.path, result);
929 1 : + this.#set_validation(state, result);
930 : }
931 :
932 : /* The first thing should be of course to probe that cache. If we
933 : get a hit, it is used immediately to call #set_validation.
934 : -
935 : - In that case, the DialogValidationState is also made part of
936 : - the current round since any asynchronous validation that is
937 : - currently running is still relevant. See below for more about
938 : - that.
939 : */
940 :
941 : - #probe_validation_state_cache(state: DialogValidationState, val: unknown): boolean {
942 1 : + #probe_validation_state_cache(state: DialogFieldState, val: unknown): boolean {
943 : if (Object.is(state.cached_value, val)) {
944 : - state.round_id = this.#get_current_validation_round_id();
945 : - debug("cache hit", state.path, state.cached_result);
946 : - this.#set_validation(state.path, state.cached_result);
947 1 : + debug("cache hit", state_path(state), JSON.stringify(val), state.cached_result);
948 1 : + this.#set_validation(state, state.cached_result);
949 : return true;
950 : } else
951 : return false;
952 : @@ -967,204 +1185,99 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
953 : /* And in fact, _validate_value does exactly those two things.
954 : */
955 :
956 : - _validate_value(path: string, val: unknown, func: () => unknown): void {
957 : - const state = this.#get_validation_state(path);
958 1 : + _validate_value(state: DialogFieldState, val: unknown, func: () => unknown): void {
959 1 : + state.relevant = true;
960 : if (!this.#probe_validation_state_cache(state, val)) {
961 : const result = func();
962 : - debug("sync validate", state.path, result);
963 1 : + debug("sync validate", state_path(state), JSON.stringify(result));
964 : this.#set_validation_state_result(state, val, result);
965 : }
966 : }
967 :
968 : /* Now asynchronous validation.
969 :
970 : - Each call to #trigger_validation starts a new "validation
971 : - round" and a DialogValidationState keeps track to which round
972 : - it applies to. This matters of course for asynchronous
973 : - validation: If async validation for a given path was started in
974 : - one round, and then the next round happens but the path is no
975 : - longer enumerated by the validation callback (i.e., its value
976 : - is no longer relevant for the dialog), then this asynchronous
977 : - validation should have no effect.
978 : -
979 : - We use the #validation object as the round identifier, since it
980 : - is created fresh by each call to #trigger_validation.
981 : - */
982 : -
983 : - #get_current_validation_round_id(): unknown {
984 : - return this.#validation;
985 : - }
986 : -
987 : - #is_current_validation_round_id(id: unknown): boolean {
988 : - return Object.is(id, this.#validation);
989 : - }
990 : -
991 : - /* If there was no cache hit, asynchronous validation starts with
992 : + If there was no cache hit, asynchronous validation starts with
993 : a timeout, followed by letting a asynchronous function run to
994 : - resolution.
995 : + resolution. This is managed by a DialogTask.
996 :
997 : - Setting a new timeout of course cancels any previously set
998 : - one. It also installs the current value in the cache, so that
999 : - subsequent validation rounds do nothing until the value
1000 : - actually changes.
1001 : + Starting a new task of course cancels any previous one. It also
1002 : + installs the current value in the cache, so that subsequent
1003 : + validation rounds do nothing until the value actually changes.
1004 :
1005 : - One interesting thing to note is that when doing the final
1006 : - validation before running an action function, no debouncing
1007 : - delay should be applied of course. We want to get on with
1008 : - validation immediately.
1009 : + When the validation result has been computed, we need to check
1010 : + whether we have been cancelled so that we don't install
1011 : + out-dated results.
1012 : */
1013 :
1014 : - #set_validation_state_timeout(
1015 : - state: DialogValidationState,
1016 1 : + _validate_value_async(
1017 1 : + state: DialogFieldState,
1018 : val: unknown,
1019 : - delay: number,
1020 : - func: () => void,
1021 : - ) {
1022 : - if (state.timeout_id) {
1023 : - debug("timeout cancel", state.path);
1024 : - window.clearTimeout(state.timeout_id);
1025 : - state.timeout_id = 0;
1026 : - }
1027 : - if (this.#action_running || delay == 0) {
1028 : - func();
1029 : - } else {
1030 1 : + debounce: number,
1031 1 : + func: (task: DialogTask) => Promise<unknown>
1032 1 : + ): void {
1033 1 : + state.relevant = true;
1034 1 : + if (!this.#probe_validation_state_cache(state, val)) {
1035 : state.cached_value = val;
1036 : state.cached_result = undefined;
1037 : - state.timeout_id = window.setTimeout(
1038 : - () => {
1039 : - debug("timeout", state.path);
1040 : - if (!this.#validation_state_is_current(state)) {
1041 : - debug("timeout outdated", state.path);
1042 : - return;
1043 : - }
1044 : - func();
1045 : - },
1046 : - delay);
1047 : - state.promise = undefined;
1048 : - state.round_id = this.#get_current_validation_round_id();
1049 : - }
1050 : - }
1051 :
1052 : - /* Once the timeout is over (and the path is still relevant to the
1053 : - current round), the actual asynchronous validation is launched.
1054 : - This promise that represents it is simply installed in the
1055 : - DialogValidationState.
1056 : - */
1057 : -
1058 : - #set_validation_state_promise(
1059 : - state: DialogValidationState,
1060 : - val: unknown,
1061 : - prom: Promise<void>,
1062 : - ) {
1063 : - state.cached_value = val;
1064 : - state.cached_result = undefined;
1065 : - state.timeout_id = 0;
1066 : - state.promise = prom;
1067 : - state.round_id = this.#get_current_validation_round_id();
1068 : - }
1069 : -
1070 : - /* Unlike with the timeout, we can not cancel the old promise when
1071 : - installing a new one. Instead we check at the end whether it is
1072 : - still really us that is supposed to deliver the result, by
1073 : - comparing promises.
1074 : -
1075 : - To summarize:
1076 : -
1077 : - - The round id check will fail if the value is no longer
1078 : - relevant to the dialog. For example, say there is a text
1079 : - input that can be toggled in and out of the dialog via a
1080 : - checkbox. Now a validation round is started while the text
1081 : - input is part of the dialog. During the debounce timeout or
1082 : - while the asynchronous validation function runs, the user
1083 : - toggles the checkbox (which triggers a new validation round)
1084 : - and the text input is no longer part of the dialog. Now when
1085 : - the timeout or validation for the text input concludes, the
1086 : - round id check fails and the result is ignored, as it should.
1087 : -
1088 : - - The promise check will fail when a asynchronous validation
1089 : - takes longer than the debounce timeout. Let's say there is a
1090 : - text input with a debounce timeout of 1 second and a
1091 : - validation function that takes 2 seconds. The user makes a
1092 : - change that triggers validation and then remains idle for
1093 : - more than a second. After one second, the timeout expires and
1094 : - the promise is created and starts running. It will finish at
1095 : - second 3, but we are not there yet. At second 1.5 the user
1096 : - makes another change, a new timeout expires at 2.5 and a new
1097 : - promise is created. At second 3 the original promise finally
1098 : - comes to a conclusion, and the path is still relevant to the
1099 : - dialog, but this promise is no longer the current
1100 : - promise. Its result will be ignored, as it should.
1101 : - */
1102 : -
1103 : - #validation_state_is_current(state: DialogValidationState, prom?: Promise<void>): boolean {
1104 : - return (
1105 : - (!prom || Object.is(state.promise, prom)) &&
1106 : - this.#is_current_validation_round_id(state.round_id)
1107 : - );
1108 : - }
1109 : -
1110 : - /* _validate_value_async puts this all together.
1111 : - */
1112 : -
1113 : - _validate_value_async(path: string, val: unknown, debounce: number, func: () => Promise<unknown>): void {
1114 : - const state = this.#get_validation_state(path);
1115 : - if (!this.#probe_validation_state_cache(state, val)) {
1116 : - debug("async validate start debounce", state.path, val);
1117 : - this.#set_validation_state_timeout(
1118 : - state,
1119 : - val,
1120 1 : + if (state.validation_task)
1121 1 : + state.validation_task.cancel();
1122 1 : + state.validation_task = new DialogTask(
1123 1 : + state_path(state) + ":validate",
1124 : debounce,
1125 : - () => {
1126 : - debug("async validate start promise", state.path, val);
1127 : - const prom =
1128 : - func()
1129 : - .catch(
1130 : - ex => {
1131 : - console.error(ex);
1132 : - return undefined;
1133 : - }
1134 : - )
1135 : - .then(
1136 : - result => {
1137 : - if (this.#validation_state_is_current(state, prom)) {
1138 : - debug("async validate done", state.path, result);
1139 : - this.#set_validation_state_result(state, val, result);
1140 : - } else {
1141 : - debug("promise outdated", state.path);
1142 : - }
1143 : - }
1144 : - );
1145 : - this.#set_validation_state_promise(state, val, prom);
1146 1 : + async task => {
1147 1 : + let result;
1148 1 : + try {
1149 1 : + result = await func(task);
1150 1 : + } catch (ex) {
1151 1 : + console.error(ex);
1152 1 : + }
1153 1 : + if (!task.is_cancelled()) {
1154 1 : + debug("async validate result", state_path(state), result);
1155 1 : + this.#set_validation_state_result(state, val, result);
1156 1 : + this.#update();
1157 1 : + }
1158 1 : + },
1159 1 : + task => {
1160 1 : + if (state.validation_task == task)
1161 1 : + state.validation_task = null;
1162 : }
1163 : );
1164 : }
1165 : }
1166 :
1167 : - /* Since the DialogValidationState for a path is so important, it
1168 : - is also important to keep them firmly associated with each
1169 : - other when the path of a value changes.
1170 : -
1171 : - A path might change when there are arrays involved, and
1172 : - elements get new indices without actually changing identity.
1173 : - */
1174 : -
1175 : - _rename_validation_state(path: string, from: number, to: number) {
1176 : - const from_path = path + "." + String(from);
1177 : - const to_path = path + "." + String(to);
1178 : - if (from_path in this.#validation_state) {
1179 : - debug("rename", from_path, to_path);
1180 : - this.#validation_state[to_path] = this.#validation_state[from_path];
1181 : - this.#validation_state[to_path].path = to_path;
1182 : - delete this.#validation_state[from_path];
1183 : - }
1184 : - for (const k in this.#validation_state) {
1185 : - if (k.indexOf(from_path + ".") == 0) {
1186 : - const to = to_path + k.substring(from_path.length);
1187 : - debug("rename", k, to);
1188 : - this.#validation_state[to] = this.#validation_state[k];
1189 : - this.#validation_state[to].path = to;
1190 : - delete this.#validation_state[k];
1191 2 : + _update_value_async(
1192 2 : + state: DialogFieldState,
1193 2 : + for_set: boolean,
1194 2 : + debounce: number,
1195 2 : + func: (task: DialogTask) => Promise<void>
1196 2 : + ): void {
1197 2 : + const task = new DialogTask(
1198 2 : + state_path(state) + ":update",
1199 2 : + debounce,
1200 2 : + async ctxt => {
1201 2 : + try {
1202 2 : + await func(ctxt);
1203 0 : + } catch (ex) {
1204 0 : + console.error(ex);
1205 0 : + }
1206 2 : + },
1207 2 : + task => {
1208 2 : + if (for_set) {
1209 2 : + if (state.update_task == task)
1210 2 : + state.update_task = null;
1211 1 : + } else {
1212 1 : + state.update_tasks.delete(task);
1213 1 : + }
1214 : }
1215 2 : + );
1216 : +
1217 2 : + if (for_set) {
1218 2 : + if (state.update_task)
1219 2 : + state.update_task.cancel();
1220 2 : + state.update_task = task;
1221 1 : + } else {
1222 1 : + state.update_tasks.add(task);
1223 : }
1224 : }
1225 :
1226 : @@ -1172,49 +1285,27 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
1227 : round and then wait for all the asynchronous results to have
1228 : come in.
1229 :
1230 : - If there are any DialogValidationState objects that are waiting
1231 : + If there are any DialogFieldState objects that are waiting
1232 : for a timeout, we want to cancel those and start over, so that
1233 : their validation starts immediately. (Also, it would be hairy
1234 : to wait for those timeouts to be over from here.)
1235 : */
1236 :
1237 : async validate(): Promise<boolean> {
1238 : - this.#cancel_all_validation_timeouts();
1239 2 : + this.#online_validation = true;
1240 : this.#trigger_validation();
1241 : - await this.#wait_for_validation_promises();
1242 2 : + await this._run_all_tasks_now();
1243 : return !this.#validation_failed;
1244 : }
1245 :
1246 : - #cancel_all_validation_timeouts() {
1247 : - for (const p in this.#validation_state) {
1248 : - const state = this.#validation_state[p];
1249 : - if (state.timeout_id) {
1250 : - debug("timeout bulk cancel", p);
1251 : - window.clearTimeout(state.timeout_id);
1252 : - delete this.#validation_state[p];
1253 : - }
1254 : - }
1255 : - }
1256 : -
1257 : - async #wait_for_validation_promises(): Promise<void> {
1258 : - for (const path in this.#validation_state) {
1259 : - const state = this.#validation_state[path];
1260 : - if (state.promise) {
1261 : - debug("waiting for promise", path);
1262 : - await state.promise;
1263 : - debug("waiting for promise done", path);
1264 : - }
1265 : - }
1266 : - }
1267 : -
1268 : set_cancel(cancel: (() => void) | null) {
1269 : - this.cancel_function = cancel;
1270 1 : + this.#cancel_function = cancel;
1271 : this.#update();
1272 : }
1273 :
1274 : async run_action(func: (vals: V) => Promise<void>): Promise<boolean> {
1275 : this.error = null;
1276 : - this.cancel_function = null;
1277 2 : + this.#cancel_function = null;
1278 : this.#action_running = true;
1279 : this.#update();
1280 : if (!await this.validate()) {
1281 : @@ -1224,26 +1315,39 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
1282 : }
1283 :
1284 : try {
1285 2 : + this.#block_updates = true;
1286 : await func(this.values);
1287 : } catch (ex) {
1288 : console.error(String(ex));
1289 : this.error = ex;
1290 : }
1291 :
1292 : - this.cancel_function = null;
1293 2 : + this.#cancel_function = null;
1294 : this.#action_running = false;
1295 2 : + this.#block_updates = false;
1296 : this.#update();
1297 :
1298 : return !this.error;
1299 : }
1300 :
1301 1 : + cancel(onClose: () => void): void {
1302 1 : + if (this.#action_running) {
1303 1 : + if (this.#cancel_function)
1304 1 : + this.#cancel_function();
1305 1 : + } else {
1306 1 : + this._cancel_state_tasks(this.#top_state);
1307 1 : + onClose();
1308 1 : + }
1309 1 : + }
1310 : +
1311 : top(update_func?: ((val: V) => void) | undefined): DialogField<V> {
1312 : return new DialogField<V>(
1313 : this as DialogState<unknown>,
1314 2 : + this.#top_state,
1315 : () => this.values,
1316 : (val) => {
1317 : debug("set", val);
1318 : - if (this.#action_running) {
1319 1 : + if (this.#block_updates) {
1320 : // Deny state changes while actions run. This
1321 : // prevents the user from interacting with the
1322 : // dialog while it is busy. The alternative would
1323 : @@ -1261,7 +1365,7 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
1324 : if (update_func)
1325 : update_func(val);
1326 : },
1327 : - "");
1328 2 : + );
1329 : }
1330 :
1331 : field<K extends keyof V>(tag: K, update_func?: ((val: V[K]) => void) | undefined): DialogField<V[K]> {
1332 : @@ -1351,9 +1455,11 @@ export function DialogErrorMessage<V>({
1333 : details = String(err);
1334 : }
1335 :
1336 1 : + const pfx = dialog instanceof DialogState ? dialog.id_prefix : "dialog";
1337 : +
1338 : return (
1339 : <Alert
1340 : - id="dialog-error-message"
1341 2 : + id={`${pfx}-error-message`}
1342 : variant='danger'
1343 : isInline
1344 : title={title}
1345 : @@ -1375,9 +1481,11 @@ export function DialogActionButton<V>({
1346 : action: (values: V) => Promise<void>,
1347 : onClose?: undefined | (() => void)
1348 : } & Omit<ButtonProps, "id" | "action" | "isLoading" | "isDisabled" | "variant" | "onClick">) {
1349 1 : + const pfx = dialog instanceof DialogState ? dialog.id_prefix : "dialog";
1350 : +
1351 : return (
1352 : <Button
1353 : - id="dialog-apply"
1354 2 : + id={`${pfx}-apply`}
1355 : isLoading={!!dialog && !(dialog instanceof DialogError) && dialog.busy}
1356 : isDisabled={!dialog || dialog instanceof DialogError || dialog.actions_disabled}
1357 : variant="primary"
1358 : @@ -1401,14 +1509,16 @@ export function DialogCancelButton<V>({
1359 : dialog: DialogState<V> | DialogError | null,
1360 : onClose: () => void
1361 : } & Omit<ButtonProps, "id" | "isDisabled" | "variant" | "onClick">) {
1362 1 : + const pfx = dialog instanceof DialogState ? dialog.id_prefix : "dialog";
1363 : +
1364 : return (
1365 : <Button
1366 : - id="dialog-cancel"
1367 2 : + id={`${pfx}-cancel`}
1368 : isDisabled={!dialog || (dialog instanceof DialogState && dialog.cancel_disabled)}
1369 : variant="link"
1370 : onClick={() => {
1371 : - if (dialog instanceof DialogState && dialog.cancel_function)
1372 : - dialog.cancel_function();
1373 1 : + if (dialog instanceof DialogState)
1374 1 : + dialog.cancel(onClose);
1375 : else
1376 : onClose();
1377 : }}
1378 : diff --git a/pkg/lib/cockpit/file-chooser.css b/pkg/lib/cockpit/file-chooser.css
1379 : new file mode 100644
1380 : index 000000000..ca557fa94
1381 : --- /dev/null
1382 : +++ b/pkg/lib/cockpit/file-chooser.css
1383 : @@ -0,0 +1,64 @@
1384 : +/*
1385 : + * Copyright (C) 2026 Red Hat, Inc.
1386 : + * SPDX-License-Identifier: LGPL-2.1-or-later
1387 : + */
1388 : +
1389 : +.file-chooser-body {
1390 : + display: grid;
1391 : + grid-template-columns: minmax(15em, auto) 1fr;
1392 : + grid-template-rows: auto auto 1fr;
1393 : + column-gap: var(--pf-t--global--spacer--md);
1394 : + row-gap: var(--pf-t--global--spacer--md);
1395 : + block-size: 60ex;
1396 : +}
1397 : +
1398 : +.file-chooser-sidebar {
1399 : + grid-column: 1 / 2;
1400 : + grid-row: 1 / 4;
1401 : + overflow-y: scroll;
1402 : + border-inline-end: solid 2px var(--pf-t--global--background--color--disabled--default);
1403 : + padding-inline-end: var(--pf-t--global--spacer--md);
1404 : +}
1405 : +
1406 : +.file-chooser-listing-header {
1407 : + grid-column: 2 / 3;
1408 : + grid-row: 1 / 2;
1409 : +}
1410 : +
1411 : +.file-chooser-listing-header > div {
1412 : + block-size: 100%;
1413 : +}
1414 : +
1415 : +.file-chooser-listing-breadcrumbs {
1416 : + grid-column: 2 / 3;
1417 : + grid-row: 2 / 3;
1418 : + /* align left of breadcrumb with left of table content */
1419 : + padding-inline-start: var(--pf-t--global--spacer--inset--page-chrome);
1420 : +}
1421 : +
1422 : +.file-chooser-listing-body {
1423 : + grid-column: 2 / 3;
1424 : + grid-row: 3 / 4;
1425 : + overflow-y: scroll;
1426 : +}
1427 : +
1428 : +@media (width < 768px) {
1429 : + .file-chooser-body {
1430 : + grid-template-columns: 0 1fr;
1431 : + }
1432 : +
1433 : + .file-chooser-sidebar {
1434 : + display: none;
1435 : + }
1436 : +}
1437 : +
1438 : +@media (width >= 768px) {
1439 : + .file-chooser-kebab {
1440 : + display: none;
1441 : + }
1442 : +}
1443 : +
1444 : +.pf-v6-c-table tr.file-chooser-selected:where(.pf-v6-c-table__tr) > :where(th, td) {
1445 : + background: var(--pf-t--global--color--nonstatus--blue--default);
1446 : + color: black;
1447 : +}
1448 : diff --git a/pkg/lib/cockpit/file-chooser.tsx b/pkg/lib/cockpit/file-chooser.tsx
1449 : new file mode 100644
1450 : index 000000000..61c20a64e
1451 : --- /dev/null
1452 : +++ b/pkg/lib/cockpit/file-chooser.tsx
1453 : @@ -0,0 +1,709 @@
1454 : +/*
1455 : + * Copyright (C) 2026 Red Hat, Inc.
1456 : + * SPDX-License-Identifier: LGPL-2.1-or-later
1457 : + */
1458 : +
1459 : +/* This is a file chooser dialog that can be used with "Dialogs.show".
1460 : +
1461 : + It only implements things that are actually needed right now in
1462 : + Cockpit and it will be extened as those needs grow.
1463 : +
1464 : + Here is a list of notable features that are not implemented yet but
1465 : + have been prototyped elsewhere:
1466 : +
1467 : + - Configurable shortcuts instead of the currently hard-coded "Home"
1468 : + and "Downloads" ones.
1469 : +
1470 : + - Selecting a directory instead of a regular file (or device file
1471 : + etc).
1472 : +
1473 : + - Support for arbitrary collections in addition to the special
1474 : + "Recent" one.
1475 : +
1476 : + - Support for using the dialog stand-alone without the
1477 : + FileChooserInput widget. This includes running arbitrary actions
1478 : + right in the dialog and displaying their errors.
1479 : +
1480 : + - Creating new files in a "Save as" scenario.
1481 : +
1482 : + - Autocompletion in the FileChooserInput.
1483 : + */
1484 : +
1485 2 : +import cockpit from "cockpit";
1486 2 : +import React, { useRef, useEffect } from "react";
1487 : +
1488 : +import { Modal, ModalBody, ModalHeader, ModalFooter } from '@patternfly/react-core/dist/esm/components/Modal';
1489 : +import { Table, Caption, Tbody, Tr, Td } from '@patternfly/react-table';
1490 : +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
1491 : +import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
1492 : +import { EmptyState, EmptyStateActions, EmptyStateProps } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
1493 : +import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js';
1494 : +import { Spinner } from '@patternfly/react-core/dist/esm/components/Spinner/index.js';
1495 : +import { FolderIcon, FolderOpenIcon, DesktopIcon, SearchIcon } from '@patternfly/react-icons';
1496 : +import {
1497 : + TextInputGroup, TextInputGroupMain, TextInputGroupUtilities
1498 : +} from '@patternfly/react-core/dist/esm/components/TextInputGroup/index.js';
1499 : +import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core/dist/esm/components/ToggleGroup/index.js';
1500 : +import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput/index.js';
1501 : +import { DropdownItem } from "@patternfly/react-core/dist/esm/components/Dropdown";
1502 : +
1503 : +import { KebabDropdown } from "cockpit-components-dropdown";
1504 : +
1505 : +import { useDialogs, WithDialogs } from 'dialogs';
1506 : +import { useInit } from "hooks";
1507 : +import { fsinfo, FsInfoError } from "cockpit/fsinfo";
1508 : +import { basename, dirname } from "cockpit-path";
1509 : +
1510 : +import {
1511 : + useDialogState,
1512 : + DialogField,
1513 : + DialogErrorMessage,
1514 : + DialogHelperText,
1515 : + OptionalFormGroup,
1516 : + DialogActionButton,
1517 : +} from 'cockpit/dialog';
1518 : +
1519 : +import "./file-chooser.css";
1520 : +
1521 2 : +const _ = cockpit.gettext;
1522 : +
1523 1 : +const FileIcon = () => {
1524 1 : + return (
1525 1 : + <svg
1526 1 : + height="1em"
1527 1 : + width="1em"
1528 1 : + xmlns="http://www.w3.org/2000/svg"
1529 1 : + viewBox="0 0 1536 1792"
1530 1 : + fill="currentColor"
1531 : + >
1532 1 : + <path d="M1468 380c37 37 68 111 68 164v1152c0 53-43 96-96 96H96c-53 0-96-43-96-96V96C0 43 43 0 96 0h896c53 0 127 31 164 68zm-444-244v376h376c-6-17-15-34-22-41l-313-313c-7-7-24-16-41-22zm384 1528V640H992c-53 0-96-43-96-96V128H128v1536z" />
1533 1 : + </svg>
1534 : + );
1535 1 : +};
1536 : +
1537 1 : +function path_join(dir: string, base: string) {
1538 1 : + return (dir == "/" ? "" : dir) + "/" + base;
1539 1 : +}
1540 : +
1541 : +interface FileInfo {
1542 : + type: string;
1543 : + name: string;
1544 : +}
1545 : +
1546 1 : +function is_FileInfo(obj: unknown): obj is FileInfo {
1547 1 : + return (
1548 1 : + !!obj &&
1549 1 : + typeof obj == "object" &&
1550 1 : + "name" in obj &&
1551 1 : + typeof obj.name == "string" &&
1552 1 : + "type" in obj &&
1553 1 : + typeof obj.type == "string"
1554 : + );
1555 1 : +}
1556 : +
1557 2 : +class FileError {
1558 : + message: string;
1559 : +
1560 1 : + constructor(message: string) {
1561 1 : + this.message = message;
1562 1 : + }
1563 2 : +}
1564 : +
1565 1 : +async function listFiles(path: string, superuser: cockpit.SuperuserMode, recentKey: string): Promise<FileError | FileInfo[]> {
1566 1 : + if (path == "") {
1567 : + // Recent
1568 1 : + const recent = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
1569 1 : + if (Array.isArray(recent)) {
1570 1 : + return recent.filter(is_FileInfo);
1571 0 : + } else {
1572 0 : + return [];
1573 0 : + }
1574 1 : + }
1575 : +
1576 1 : + let info;
1577 1 : + try {
1578 1 : + info = await fsinfo(
1579 1 : + path,
1580 1 : + ["type", "entries", "target", "targets"],
1581 1 : + {
1582 1 : + follow: true,
1583 0 : + ...(superuser ? { superuser } : { })
1584 1 : + }
1585 1 : + );
1586 1 : + } catch (ex) {
1587 1 : + return new FileError((ex as FsInfoError).message);
1588 1 : + }
1589 : +
1590 1 : + if (!(info.type && info.entries && info.targets)) {
1591 1 : + return new FileError(_("Access denied"));
1592 1 : + }
1593 : +
1594 0 : + if (info.type != "dir") {
1595 0 : + return new FileError(_("Not a directory"));
1596 0 : + }
1597 : +
1598 1 : + const result: FileInfo[] = [];
1599 1 : + for (const name in info.entries) {
1600 1 : + let entry = info.entries[name];
1601 1 : + if (entry.type == "lnk" && entry.target)
1602 1 : + entry = info.entries[entry.target] || info.targets[entry.target];
1603 : +
1604 1 : + cockpit.assert(entry.type);
1605 1 : + result.push({ type: entry.type, name });
1606 1 : + }
1607 : +
1608 1 : + result.sort((a, b) => (a.type + a.name).localeCompare(b.type + b.name));
1609 1 : + return result;
1610 1 : +}
1611 : +
1612 1 : +function boldify(name: string, filterText: string): React.ReactNode {
1613 1 : + if (!filterText)
1614 1 : + return name;
1615 1 : + const parts: React.ReactNode[] = [];
1616 1 : + let pos;
1617 1 : + while ((pos = name.indexOf(filterText)) >= 0) {
1618 1 : + parts.push(name.substring(0, pos));
1619 1 : + parts.push(<u key={pos}>{name.substring(pos, pos + filterText.length)}</u>);
1620 1 : + name = name.substring(pos + filterText.length);
1621 1 : + }
1622 1 : + if (name)
1623 1 : + parts.push(name);
1624 1 : + return parts;
1625 1 : +}
1626 : +
1627 : +export interface FileChooserFilter {
1628 : + label: string;
1629 : + filter: (name: string, type: string) => boolean,
1630 : +}
1631 : +
1632 : +export function regexFilter(label: string, regex: string): FileChooserFilter {
1633 : + return {
1634 : + label,
1635 : + filter: n => !!n.match(regex),
1636 : + };
1637 : +}
1638 : +
1639 : +interface FileChooserShortcut {
1640 : + label: string;
1641 : + path: string;
1642 : +}
1643 : +
1644 : +interface FileChooserModalValues {
1645 : + path: string;
1646 : + files: null | FileError | FileInfo[];
1647 : + selected: null | FileInfo;
1648 : + textFilter: string;
1649 : + filters: FileChooserFilter[];
1650 : + filter: FileChooserFilter;
1651 : +}
1652 : +
1653 1 : +const FileChooserModal = ({
1654 1 : + title,
1655 1 : + path = "",
1656 1 : + shortcuts = [],
1657 1 : + filters = [],
1658 1 : + superuser,
1659 1 : + recentKey = "recent-files",
1660 1 : + onChoose,
1661 1 : +} : {
1662 : + title: React.ReactNode,
1663 : + path?: string,
1664 : + shortcuts?: FileChooserShortcut[],
1665 : + filters?: FileChooserFilter[],
1666 : + superuser?: cockpit.SuperuserMode,
1667 : + recentKey?: string,
1668 : + onChoose: (path: string) => void,
1669 1 : +}) => {
1670 1 : + const Dialogs = useDialogs();
1671 1 : + const textInputRef = useRef<HTMLInputElement>(null);
1672 : +
1673 1 : + function focusFilter() {
1674 1 : + textInputRef.current?.focus();
1675 1 : + }
1676 : +
1677 1 : + useEffect(() => {
1678 0 : + textInputRef.current?.focus();
1679 1 : + }, []);
1680 : +
1681 1 : + function init(): FileChooserModalValues {
1682 1 : + const all_filters = filters.concat([{ label: _("All files"), filter: _n => true }]);
1683 1 : + return {
1684 1 : + path,
1685 1 : + files: null,
1686 1 : + selected: null,
1687 1 : + textFilter: "",
1688 1 : + filters: all_filters,
1689 1 : + filter: all_filters[0],
1690 1 : + };
1691 1 : + }
1692 : +
1693 1 : + const dlg = useDialogState(init).set_id_prefix("file-chooser");
1694 1 : + useInit(() => { setPath(dlg.values.path) });
1695 : +
1696 1 : + function full_path(path: string, selected: string) {
1697 1 : + if (path == "")
1698 1 : + return selected;
1699 : + else
1700 1 : + return path_join(path, selected);
1701 1 : + }
1702 : +
1703 1 : + async function onAction(values: FileChooserModalValues) {
1704 1 : + cockpit.assert(values.selected);
1705 1 : + const full = full_path(values.path, values.selected.name);
1706 1 : + rememberRecent(full, values.selected.type, recentKey);
1707 1 : + onChoose(full);
1708 1 : + }
1709 : +
1710 1 : + function onSelect(f: FileInfo) {
1711 1 : + dlg.field("selected").set(f);
1712 1 : + }
1713 : +
1714 1 : + function setPath(path: string) {
1715 1 : + dlg.field("path").set(path);
1716 1 : + dlg.field("selected").set(null);
1717 1 : + dlg.field("files").set(null);
1718 1 : + dlg.field("files").set_async(0, () => listFiles(path, superuser, recentKey));
1719 1 : + }
1720 : +
1721 1 : + function onNavigate(f: FileInfo) {
1722 1 : + if (f.type == "dir") {
1723 1 : + setPath(full_path(dlg.values.path, f.name));
1724 1 : + }
1725 1 : + }
1726 : +
1727 1 : + function breadcrumbs() {
1728 1 : + const { path } = dlg.values;
1729 : +
1730 1 : + if (path == "") {
1731 : + // Recent
1732 1 : + return null;
1733 1 : + } else {
1734 1 : + const dirs = ["/"].concat(path.split("/").filter(d => !!d));
1735 1 : + const crumbs: React.ReactNode[] = [];
1736 1 : + let full = "/";
1737 1 : + dirs.forEach((d, i) => {
1738 1 : + if (d != "/")
1739 1 : + full = path_join(full, d);
1740 1 : + const path = full;
1741 1 : + crumbs.push(
1742 1 : + <BreadcrumbItem
1743 1 : + key={i}
1744 1 : + to="#"
1745 1 : + onClick={
1746 1 : + (event) => {
1747 1 : + setPath(path);
1748 1 : + event.preventDefault();
1749 1 : + }
1750 : + }
1751 1 : + isActive={i == dirs.length - 1}
1752 : + >
1753 1 : + { d == "/" ? <DesktopIcon /> : d }
1754 1 : + </BreadcrumbItem>
1755 1 : + );
1756 1 : + });
1757 : +
1758 1 : + if (crumbs.length > 0) {
1759 1 : + return (
1760 1 : + <Breadcrumb>
1761 1 : + {crumbs}
1762 1 : + </Breadcrumb>
1763 : + );
1764 1 : + }
1765 1 : + }
1766 1 : + }
1767 : +
1768 1 : + function header() {
1769 1 : + const preparedFilters = (
1770 1 : + dlg.values.filters.length > 1 &&
1771 1 : + <ToggleGroup>
1772 : + {
1773 1 : + dlg.values.filters.map(f => {
1774 1 : + return (
1775 1 : + <ToggleGroupItem
1776 1 : + key={f.label}
1777 1 : + isSelected={f == dlg.values.filter}
1778 1 : + onChange={() => {
1779 1 : + dlg.field("filter").set(f);
1780 1 : + focusFilter();
1781 1 : + }}
1782 1 : + text={f.label}
1783 1 : + />
1784 : + );
1785 1 : + })
1786 : + }
1787 1 : + </ToggleGroup>
1788 : + );
1789 : +
1790 1 : + const textFilter = (
1791 1 : + <TextInput
1792 1 : + ref={textInputRef}
1793 1 : + placeholder={_("Type to filter")}
1794 1 : + value={dlg.values.textFilter}
1795 1 : + onChange={(_event, value) => dlg.field("textFilter").set(value)}
1796 1 : + />
1797 : + );
1798 : +
1799 1 : + function shortcut(sc: FileChooserShortcut) {
1800 1 : + return (
1801 1 : + <DropdownItem
1802 1 : + key={sc.label}
1803 0 : + onClick={() => setPath(sc.path)}
1804 : + >
1805 1 : + {sc.label}
1806 1 : + </DropdownItem>
1807 : + );
1808 1 : + }
1809 : +
1810 1 : + return (
1811 1 : + <Flex>
1812 1 : + <FlexItem>
1813 1 : + {textFilter}
1814 1 : + </FlexItem>
1815 1 : + <FlexItem>
1816 1 : + {preparedFilters}
1817 1 : + </FlexItem>
1818 1 : + <FlexItem className="file-chooser-kebab" align={{ default: 'alignRight' }}>
1819 1 : + <KebabDropdown
1820 1 : + dropdownItems={
1821 1 : + [
1822 1 : + shortcut({ label: _("Recent"), path: "" }),
1823 1 : + ...shortcuts.map(shortcut),
1824 1 : + shortcut({ label: _("Filesystem"), path: "/" }),
1825 1 : + ]
1826 : + }
1827 1 : + />
1828 1 : + </FlexItem>
1829 1 : + </Flex>
1830 : + );
1831 1 : + }
1832 : +
1833 1 : + function emptyState(content: string, icon: EmptyStateProps["icon"], clearFilters: number = 0) {
1834 1 : + return (
1835 1 : + <Caption>
1836 1 : + <EmptyState
1837 1 : + titleText={content}
1838 0 : + {...icon ? { icon } : {}}
1839 : + >
1840 1 : + { (clearFilters > 0) &&
1841 1 : + <EmptyStateActions>
1842 1 : + <Button
1843 1 : + variant="link"
1844 1 : + onClick={() => {
1845 1 : + dlg.field("textFilter").set("");
1846 1 : + if (clearFilters > 1)
1847 1 : + dlg.field("filter").set(dlg.values.filters[dlg.values.filters.length - 1]);
1848 1 : + focusFilter();
1849 1 : + }}
1850 : + >
1851 1 : + {_("Clear filters")}
1852 1 : + </Button>
1853 1 : + </EmptyStateActions>
1854 : + }
1855 1 : + </EmptyState>
1856 1 : + </Caption>
1857 : + );
1858 1 : + }
1859 : +
1860 1 : + function formatIcon(f: FileInfo): React.ReactNode {
1861 : + // XXX - icons for device files and others?
1862 1 : + if (f.type == "dir")
1863 1 : + return <FolderIcon />;
1864 : + else
1865 1 : + return <FileIcon />;
1866 1 : + }
1867 : +
1868 1 : + function sidebar() {
1869 1 : + function shortcut(sc: FileChooserShortcut) {
1870 1 : + return (
1871 1 : + <Tr
1872 1 : + key={sc.label}
1873 1 : + isClickable
1874 1 : + isSelectable
1875 1 : + isRowSelected={dlg.values.path == sc.path}
1876 1 : + onRowClick={
1877 1 : + () => {
1878 1 : + setPath(sc.path);
1879 1 : + focusFilter();
1880 1 : + }
1881 : + }
1882 : + >
1883 1 : + <Td>{sc.label}</Td>
1884 1 : + </Tr>
1885 : + );
1886 1 : + }
1887 : +
1888 1 : + return (
1889 1 : + <Table variant="compact" borders={false}>
1890 1 : + <Tbody>
1891 1 : + { shortcut({ label: _("Recent"), path: "" }) }
1892 1 : + { shortcuts.map(shortcut) }
1893 1 : + { shortcut({ label: _("Filesystem"), path: "/" }) }
1894 1 : + </Tbody>
1895 1 : + </Table>
1896 : + );
1897 1 : + }
1898 : +
1899 1 : + function listing() {
1900 1 : + function listingBody() {
1901 1 : + const files = dlg.values.files;
1902 : +
1903 1 : + if (files == null)
1904 1 : + return emptyState("", Spinner);
1905 : +
1906 1 : + if (files instanceof FileError)
1907 1 : + return emptyState(files.message, FolderIcon);
1908 : +
1909 1 : + if (files.length == 0) {
1910 1 : + if (dlg.values.path == "")
1911 1 : + return emptyState(_("No recent files"), FolderIcon);
1912 : + else
1913 1 : + return emptyState(_("Folder is empty"), FolderIcon);
1914 1 : + }
1915 : +
1916 1 : + const preFiltered = files.filter(f => f.type == "dir" || dlg.values.filter.filter(f.name, f.type));
1917 1 : + if (preFiltered.length == 0)
1918 1 : + return emptyState(_("No matching results"), SearchIcon, 2);
1919 : +
1920 1 : + const filtered = preFiltered.filter(f => f.name.includes(dlg.values.textFilter));
1921 1 : + if (filtered.length == 0)
1922 1 : + return emptyState(_("No matching results"), SearchIcon, 1);
1923 : +
1924 1 : + return (
1925 1 : + <Tbody>
1926 : + {
1927 1 : + filtered.map(
1928 1 : + (f, idx) => {
1929 1 : + let name, location;
1930 1 : + if (dlg.values.path == "") {
1931 1 : + name = basename(f.name);
1932 1 : + location = dirname(f.name);
1933 1 : + } else {
1934 1 : + name = f.name;
1935 1 : + }
1936 1 : + return (
1937 1 : + <Tr
1938 1 : + className={f.name == dlg.values.selected?.name ? "file-chooser-selected" : ""}
1939 1 : + key={idx}
1940 1 : + data-name={name}
1941 1 : + onRowClick={
1942 1 : + () => {
1943 1 : + onSelect(f);
1944 1 : + focusFilter();
1945 1 : + }
1946 : + }
1947 1 : + onDoubleClick={
1948 1 : + event => {
1949 1 : + event.preventDefault();
1950 1 : + onNavigate(f);
1951 1 : + dlg.field("textFilter").set("");
1952 1 : + focusFilter();
1953 1 : + }
1954 : + }
1955 1 : + isClickable
1956 : + >
1957 1 : + <Td>
1958 1 : + {formatIcon(f)}
1959 : +
1960 1 : + {boldify(name, dlg.values.textFilter)}
1961 1 : + </Td>
1962 1 : + { location && <Td>{location}</Td> }
1963 1 : + </Tr>
1964 : + );
1965 1 : + }
1966 1 : + )
1967 : + }
1968 1 : + </Tbody>
1969 : + );
1970 1 : + }
1971 : +
1972 1 : + return (
1973 1 : + <Table variant="compact" borders={false}>
1974 1 : + { listingBody() }
1975 1 : + </Table>
1976 : + );
1977 1 : + }
1978 : +
1979 1 : + return (
1980 1 : + <Modal
1981 1 : + isOpen
1982 1 : + variant="large"
1983 1 : + position="top"
1984 1 : + onClose={Dialogs.close}
1985 1 : + className="file-chooser"
1986 : + >
1987 1 : + <ModalHeader
1988 1 : + title={title}
1989 1 : + description={<DialogErrorMessage dialog={dlg} />}
1990 1 : + />
1991 1 : + <ModalBody>
1992 1 : + <div className="file-chooser-body">
1993 1 : + <div className="file-chooser-sidebar">
1994 1 : + { sidebar() }
1995 1 : + </div>
1996 1 : + <div className="file-chooser-listing-header">
1997 1 : + { header() }
1998 1 : + </div>
1999 1 : + <div className="file-chooser-listing-breadcrumbs">
2000 1 : + { breadcrumbs() }
2001 1 : + </div>
2002 1 : + <div className="file-chooser-listing-body">
2003 1 : + { listing() }
2004 1 : + </div>
2005 1 : + </div>
2006 1 : + </ModalBody>
2007 1 : + <ModalFooter>
2008 1 : + <DialogActionButton
2009 1 : + dialog={dlg}
2010 1 : + isAriaDisabled={!dlg.values.selected || dlg.values.selected.type == "dir"}
2011 1 : + action={onAction}
2012 1 : + onClose={Dialogs.close}
2013 : + >
2014 1 : + {_("Select")}
2015 1 : + </DialogActionButton>
2016 1 : + </ModalFooter>
2017 1 : + </Modal>
2018 : + );
2019 1 : +};
2020 : +
2021 1 : +async function getHomeDir(): Promise<string> {
2022 1 : + if (!cockpit.info.user)
2023 1 : + await cockpit.init();
2024 1 : + return cockpit.info.user.home;
2025 1 : +}
2026 : +
2027 1 : +async function getDownloadDir(): Promise<string | null> {
2028 1 : + try {
2029 0 : + return (await cockpit.spawn(["xdg-user-dir", "DOWNLOAD"])).trim();
2030 0 : + } catch (ex) {
2031 1 : + console.warn("Can't determine downloads directory", String(ex));
2032 1 : + return null;
2033 1 : + }
2034 1 : +}
2035 : +
2036 2 : +const FileChooserButton = ({
2037 2 : + title,
2038 2 : + filters,
2039 2 : + value,
2040 2 : + onChoose,
2041 2 : + superuser,
2042 2 : +} : {
2043 : + title: string,
2044 : + filters: FileChooserFilter[],
2045 : + value: string,
2046 : + onChoose: (path: string) => void,
2047 : + superuser?: cockpit.SuperuserMode,
2048 2 : +}) => {
2049 2 : + const Dialogs = useDialogs();
2050 : +
2051 2 : + return (
2052 2 : + <Button
2053 2 : + variant="plain"
2054 2 : + icon={<FolderOpenIcon />}
2055 2 : + onClick={
2056 1 : + async () => {
2057 1 : + const home = await getHomeDir();
2058 1 : + const dd = await getDownloadDir();
2059 1 : + Dialogs.show(
2060 1 : + <FileChooserModal
2061 1 : + title={title}
2062 1 : + filters={filters}
2063 1 : + shortcuts={
2064 1 : + [
2065 1 : + { label: _("Home"), path: home },
2066 0 : + ...(dd && dd != home ? [{ label: _("Downloads"), path: dd }] : []),
2067 1 : + ]
2068 : + }
2069 1 : + path={value[0] == "/" ? dirname(value) : ""}
2070 1 : + onChoose={onChoose}
2071 1 : + superuser={superuser}
2072 1 : + />
2073 1 : + );
2074 1 : + }
2075 : + }
2076 2 : + />
2077 : + );
2078 2 : +};
2079 : +
2080 2 : +export const FileChooserInput = ({
2081 2 : + id,
2082 2 : + title,
2083 2 : + placeholder = "",
2084 2 : + filters = [],
2085 2 : + value,
2086 2 : + onChange,
2087 2 : + superuser,
2088 2 : +} : {
2089 : + id?: undefined | string;
2090 : + title: string,
2091 : + placeholder?: string,
2092 : + filters?: FileChooserFilter[],
2093 : + value: string,
2094 : + onChange: (path: string) => void,
2095 : + superuser?: cockpit.SuperuserMode,
2096 2 : +}) => {
2097 2 : + return (
2098 2 : + <TextInputGroup id={id}>
2099 2 : + <TextInputGroupMain
2100 2 : + value={value}
2101 2 : + placeholder={placeholder}
2102 1 : + onChange={(_event, value) => onChange(value)}
2103 2 : + autoComplete="off"
2104 2 : + />
2105 2 : + <TextInputGroupUtilities>
2106 2 : + <WithDialogs>
2107 2 : + <FileChooserButton
2108 2 : + title={title}
2109 2 : + filters={filters}
2110 2 : + value={value}
2111 2 : + onChoose={onChange}
2112 2 : + superuser={superuser}
2113 2 : + />
2114 2 : + </WithDialogs>
2115 2 : + </TextInputGroupUtilities>
2116 2 : + </TextInputGroup>
2117 : + );
2118 2 : +};
2119 : +
2120 2 : +export const DialogFileChooserInput = ({
2121 2 : + field,
2122 2 : + label,
2123 2 : + dialogTitle,
2124 2 : + placeholder = "",
2125 2 : + explanation,
2126 2 : + filters = [],
2127 2 : + superuser,
2128 2 : +} : {
2129 : + field: DialogField<string>,
2130 : + label: string,
2131 : + dialogTitle: string
2132 : + placeholder?: string,
2133 : + explanation?: React.ReactNode,
2134 : + filters?: FileChooserFilter[],
2135 : + superuser?: cockpit.SuperuserMode,
2136 2 : +}) => {
2137 2 : + return (
2138 2 : + <OptionalFormGroup
2139 2 : + label={label}
2140 : + >
2141 2 : + <FileChooserInput
2142 2 : + id={field.id()}
2143 2 : + title={dialogTitle}
2144 2 : + placeholder={placeholder}
2145 2 : + filters={filters}
2146 2 : + value={field.get()}
2147 1 : + onChange={val => field.set(val)}
2148 2 : + superuser={superuser}
2149 2 : + />
2150 2 : + <DialogHelperText field={field} explanation={explanation} />
2151 2 : + </OptionalFormGroup>
2152 : + );
2153 2 : +};
2154 : +
2155 1 : +export function rememberRecent(name: string, type: string, recentKey: string = "recent-files") {
2156 1 : + const value = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
2157 1 : + if (Array.isArray(value)) {
2158 1 : + const recent = value.filter(is_FileInfo).filter(f => f.name != name);
2159 1 : + recent.unshift({ name, type });
2160 1 : + window.localStorage.setItem(recentKey, JSON.stringify(recent.slice(0, 20)));
2161 1 : + }
2162 1 : +}
2163 : diff --git a/pkg/playground/dialog.tsx b/pkg/playground/dialog.tsx
2164 : index d5ca451df..29abb233a 100644
2165 : --- a/pkg/playground/dialog.tsx
2166 : +++ b/pkg/playground/dialog.tsx
2167 : @@ -36,6 +36,8 @@ import {
2168 : DialogActionButton, DialogCancelButton,
2169 : } from 'cockpit/dialog';
2170 :
2171 : +import { DialogFileChooserInput } from "cockpit/file-chooser";
2172 : +
2173 : import 'cockpit-dark-theme'; // once per page
2174 : import 'page.scss';
2175 :
2176 : @@ -99,7 +101,7 @@ const StringList = ({
2177 :
2178 : interface Name {
2179 : name: string;
2180 : - _length_cache: Record<string, number>;
2181 : + _length: number;
2182 : }
2183 :
2184 : const NameInput = ({
2185 : @@ -111,11 +113,11 @@ const NameInput = ({
2186 : };
2187 :
2188 : function validate_Name(field: DialogField<Name>, countAsyncValidation: () => void) {
2189 : - const { _length_cache } = field.get();
2190 : - field.sub("name").validate_async(1000, async n => {
2191 1 : + field.sub("name").validate_async(1000, async (n, task) => {
2192 : await async_sleep(2000);
2193 : countAsyncValidation();
2194 : - _length_cache[n] = n.length;
2195 1 : + if (!task.is_cancelled())
2196 1 : + field.sub("_length").set(n.length);
2197 : if (n.length % 2)
2198 : return "Must have even number of characters";
2199 : });
2200 : @@ -133,7 +135,7 @@ const NameList = ({
2201 : label={label}
2202 : field={field}
2203 : Component={NameInput}
2204 : - init={{ name: "", _length_cache: { } }}
2205 2 : + init={{ name: "", _length: 0 }}
2206 : />
2207 : );
2208 : };
2209 : @@ -205,34 +207,46 @@ const colors: Color[] = [
2210 : interface ExampleValues {
2211 : flag: boolean;
2212 : text: string;
2213 : + text2: string;
2214 : radio: string;
2215 : dropdown: string;
2216 : + text3: string;
2217 : color: Color,
2218 : list: string[];
2219 : async: Name[];
2220 : alternative: false | string;
2221 : error: string;
2222 : + file: string;
2223 : + file_explanation: string;
2224 : }
2225 :
2226 : const ExampleDialog = ({
2227 : setResult,
2228 : countAsyncValidation,
2229 2 : + countAsyncUpdate,
2230 2 : + countAsyncCancel,
2231 : } : {
2232 : setResult: (values: ExampleValues) => void,
2233 : countAsyncValidation: () => void,
2234 : + countAsyncUpdate: () => void,
2235 : + countAsyncCancel: () => void,
2236 : }) => {
2237 : const Dialogs = useDialogs();
2238 :
2239 : const init: ExampleValues = {
2240 : flag: false,
2241 : text: "",
2242 2 : + text2: "",
2243 : radio: "one",
2244 : dropdown: "one",
2245 2 : + text3: "",
2246 : color: colors[0],
2247 : list: [],
2248 : async: [],
2249 : alternative: false,
2250 : error: "none",
2251 2 : + file: "",
2252 2 : + file_explanation: "",
2253 : };
2254 :
2255 : function validate(dlg: DialogState<ExampleValues>) {
2256 : @@ -242,16 +256,28 @@ const ExampleDialog = ({
2257 : return "Text can not be empty";
2258 : });
2259 : }
2260 1 : + if (dlg.values.dropdown == "three") {
2261 1 : + dlg.field("text3").validate_async(1000, async v => {
2262 1 : + if (!v)
2263 1 : + return "Can't be empty";
2264 1 : + });
2265 1 : + }
2266 : dlg.field("list").forEach(v => {
2267 : v.validate(vv => {
2268 1 : + if (vv == "magic")
2269 1 : + dlg.field("text").set("magic");
2270 : if (vv == ".")
2271 : return "No dots";
2272 : });
2273 : });
2274 : dlg.field("async").forEach(v => validate_Name(v, countAsyncValidation));
2275 1 : + dlg.field("file").validate(v => {
2276 0 : + if (v && v[0] != "/")
2277 0 : + return "Must be absolute";
2278 1 : + });
2279 : }
2280 :
2281 : - const dlg = useDialogState(init, validate);
2282 2 : + const dlg = useDialogState(init, validate).set_id_prefix("example");
2283 :
2284 : async function apply(values: ExampleValues) {
2285 : setResult(values);
2286 : @@ -275,8 +301,31 @@ const ExampleDialog = ({
2287 : }
2288 : }
2289 :
2290 : - function update_color(color: Color) {
2291 : - dlg.field("text").set(color.name);
2292 1 : + function update_color() {
2293 1 : + dlg.field("color").get_async(0, async (val, task) => {
2294 1 : + task.set_cancel(countAsyncCancel);
2295 1 : + await async_sleep(2000);
2296 1 : + if (!task.is_cancelled()) {
2297 1 : + countAsyncUpdate();
2298 1 : + dlg.field("text").set(val.name);
2299 1 : + }
2300 1 : + });
2301 1 : + }
2302 : +
2303 1 : + function update_dropdown(val: string) {
2304 1 : + dlg.field("text2").set_async(0, async () => {
2305 1 : + await async_sleep(2000);
2306 1 : + return val;
2307 1 : + });
2308 1 : + }
2309 : +
2310 1 : + function update_file(val: string) {
2311 1 : + dlg.field("file_explanation").set_async(250, async () => {
2312 1 : + if (val[0] == "/")
2313 0 : + return cockpit.spawn(["file", "-b", val], { superuser: "try" });
2314 : + else
2315 0 : + return "--";
2316 1 : + });
2317 : }
2318 :
2319 : return (
2320 : @@ -303,6 +352,10 @@ const ExampleDialog = ({
2321 : explanation="Explanation"
2322 : warning={dlg.values.text == "warn" ? "Warning" : null}
2323 : />
2324 2 : + <DialogTextInput
2325 2 : + label="Text2"
2326 2 : + field={dlg.field("text2")}
2327 2 : + />
2328 : {
2329 : // Calling "map" on a non-array should just do nothing.
2330 : dlg.field("text").map((v, i) => <span key={i}>{v.get()}</span>)
2331 : @@ -332,7 +385,7 @@ const ExampleDialog = ({
2332 : />
2333 : <DialogDropdownSelect
2334 : label="Dropdown"
2335 : - field={dlg.field("dropdown")}
2336 2 : + field={dlg.field("dropdown", update_dropdown)}
2337 : options={
2338 : [
2339 : { value: "one", label: "Eins" },
2340 : @@ -342,6 +395,10 @@ const ExampleDialog = ({
2341 : }
2342 : warning={dlg.field("dropdown").get() == "two" ? "There is a discount if you buy three." : null}
2343 : />
2344 : + {
2345 2 : + dlg.values.dropdown == "three" &&
2346 1 : + <DialogTextInput label="Text3" field={dlg.field("text3")} />
2347 : + }
2348 : <DialogDropdownSelectObject
2349 : label="DropdownObject"
2350 : field={dlg.field("color", update_color)}
2351 : @@ -361,6 +418,21 @@ const ExampleDialog = ({
2352 : options={["none", "custom", "from", "from-random", "message", "spawn", "random"]}
2353 : warning={dlg.field("error").get() != "none" ? "There will be an error" : null}
2354 : />
2355 2 : + <DialogFileChooserInput
2356 2 : + label="File"
2357 2 : + dialogTitle="Select a file"
2358 2 : + filters={
2359 2 : + [
2360 2 : + {
2361 2 : + label: "No dots",
2362 1 : + filter: n => !n.includes("."),
2363 2 : + }
2364 2 : + ]
2365 : + }
2366 2 : + field={dlg.field("file", update_file)}
2367 2 : + explanation={dlg.values.file_explanation}
2368 2 : + superuser="try"
2369 2 : + />
2370 : </Form>
2371 : </ModalBody>
2372 : <ModalFooter>
2373 : @@ -376,8 +448,12 @@ const ExampleDialog = ({
2374 : const ExampleButton = () => {
2375 : const Dialogs = useDialogs();
2376 : const [values, setValues] = useState<ExampleValues | null>(null);
2377 : - const [asyncValidationsBase, setAsycountAsyncValidationsBase] = useState<number>(0);
2378 2 : + const [asyncValidationsBase, setAsyncValidationsBase] = useState<number>(0);
2379 : const [asyncValidations, countAsyncValidation] = useReducer(x => x + 1, 0);
2380 2 : + const [asyncUpdatesBase, setAsyncUpdatesBase] = useState<number>(0);
2381 1 : + const [asyncUpdates, countAsyncUpdate] = useReducer(x => x + 1, 0);
2382 2 : + const [asyncCancelsBase, setAsyncCancelsBase] = useState<number>(0);
2383 1 : + const [asyncCancels, countAsyncCancel] = useReducer(x => x + 1, 0);
2384 :
2385 : function entry(id: string, val: string) {
2386 : return (
2387 : @@ -394,11 +470,15 @@ const ExampleButton = () => {
2388 : id="open"
2389 : onClick={
2390 : () => {
2391 : - setAsycountAsyncValidationsBase(asyncValidations);
2392 2 : + setAsyncValidationsBase(asyncValidations);
2393 2 : + setAsyncUpdatesBase(asyncUpdates);
2394 2 : + setAsyncCancelsBase(asyncCancels);
2395 : Dialogs.show(
2396 : <ExampleDialog
2397 : setResult={setValues}
2398 : countAsyncValidation={countAsyncValidation}
2399 2 : + countAsyncUpdate={countAsyncUpdate}
2400 2 : + countAsyncCancel={countAsyncCancel}
2401 : />
2402 : );
2403 : }
2404 : @@ -410,12 +490,15 @@ const ExampleButton = () => {
2405 : <DescriptionList isHorizontal>
2406 : { entry("flag", String(values.flag)) }
2407 : { values.flag && entry("text", values.text) }
2408 1 : + { entry("text2", values.text2) }
2409 : { entry("radio", values.radio) }
2410 : { entry("dropdown", values.dropdown) }
2411 : { entry("color", values.color.red + "/" + values.color.green + "/" + values.color.blue) }
2412 : { entry("list", values.list.join("/")) }
2413 : - { entry("async", values.async.map(n => n.name + ":" + String(n._length_cache[n.name])).join("/")) }
2414 1 : + { entry("async", values.async.map(n => n.name + ":" + String(n._length)).join("/")) }
2415 : { entry("asyncVals", String(asyncValidations - asyncValidationsBase)) }
2416 1 : + { entry("asyncUps", String(asyncUpdates - asyncUpdatesBase)) }
2417 1 : + { entry("asyncCancels", String(asyncCancels - asyncCancelsBase)) }
2418 : { entry("alternative", JSON.stringify(values.alternative)) }
2419 : </DescriptionList>
2420 : }
2421 : @@ -493,8 +576,10 @@ interface AsyncExampleValues {
2422 :
2423 : const AsyncExampleDialog = ({
2424 : throwError = 0,
2425 1 : + cancelCallback = null,
2426 : } : {
2427 : throwError?: number,
2428 : + cancelCallback?: null | (() => void),
2429 : }) => {
2430 : const Dialogs = useDialogs();
2431 :
2432 : @@ -519,6 +604,10 @@ const AsyncExampleDialog = ({
2433 : const dlg = useDialogState_async(init, validate);
2434 :
2435 : async function apply() {
2436 1 : + cockpit.assert(dlg instanceof DialogState);
2437 : +
2438 1 : + dlg.set_cancel(cancelCallback);
2439 : +
2440 : await async_sleep(1000);
2441 : Dialogs.close();
2442 : }
2443 : @@ -570,6 +659,7 @@ const AsyncExampleDialog = ({
2444 :
2445 : const SimpleExampleButtons = () => {
2446 : const Dialogs = useDialogs();
2447 2 : + const [cancelled, setCancelled] = useState(false);
2448 :
2449 : return (
2450 : <>
2451 : @@ -581,10 +671,18 @@ const SimpleExampleButtons = () => {
2452 : </Button>
2453 : <Button
2454 : id="open-async"
2455 : - onClick={() => Dialogs.show(<AsyncExampleDialog />)}
2456 2 : + onClick={
2457 1 : + () => {
2458 1 : + setCancelled(false);
2459 1 : + Dialogs.show(<AsyncExampleDialog cancelCallback={() => setCancelled(true)} />);
2460 1 : + }
2461 : + }
2462 : >
2463 : Open async dialog
2464 : </Button>
2465 2 : + <div id="cancelled">
2466 1 : + Cancelled: {cancelled ? "yes" : "no"}
2467 2 : + </div>
2468 : <Button
2469 : id="open-error"
2470 : onClick={() => Dialogs.show(<AsyncExampleDialog throwError={1} />)}
2471 : diff --git a/test/common/dialoglib.py b/test/common/dialoglib.py
2472 : index d5c37fad7..0dd5f6605 100644
2473 : --- a/test/common/dialoglib.py
2474 : +++ b/test/common/dialoglib.py
2475 : @@ -65,11 +65,12 @@ def css_escape(x: str) -> str:
2476 :
2477 :
2478 : class DialogHelpers:
2479 : - def __init__(self, b: testlib.Browser):
2480 : + def __init__(self, b: testlib.Browser, prefix: str = "dialog"):
2481 : self.browser = b
2482 : + self.prefix = prefix
2483 :
2484 : def id(self, path: str, tag: str) -> str:
2485 : - return f"#dialog-{tag}-{css_escape(path)}"
2486 : + return f"#{self.prefix}-{tag}-{css_escape(path)}"
2487 :
2488 : def field(self, path: str) -> str:
2489 : return self.id(path, "field")
2490 : @@ -78,13 +79,13 @@ class DialogHelpers:
2491 : return self.id(path, "helper-text")
2492 :
2493 : def error(self) -> str:
2494 : - return "#dialog-error-message"
2495 : + return f"#{self.prefix}-error-message"
2496 :
2497 : def apply_button(self) -> str:
2498 : - return "#dialog-apply"
2499 : + return f"#{self.prefix}-apply"
2500 :
2501 : def cancel_button(self) -> str:
2502 : - return "#dialog-cancel"
2503 : + return f"#{self.prefix}-cancel"
2504 :
2505 : # TextInput
2506 :
2507 : @@ -131,3 +132,14 @@ class DialogHelpers:
2508 :
2509 : def set_DropdownSelect(self, path: str, val: str) -> None:
2510 : self.browser.select_from_dropdown(self.field(path), val)
2511 : +
2512 : + # FileChooserInput
2513 : +
2514 : + def get_FileChooserInput(self, path: str) -> str:
2515 : + return self.browser.val(self.field(path) + " input")
2516 : +
2517 : + def wait_FileChooserInput(self, path: str, val: str):
2518 : + self.browser.wait_val(self.field(path) + " input", val)
2519 : +
2520 : + def set_FileChooserInput(self, path: str, val: str) -> None:
2521 : + self.browser.set_input_text(self.field(path) + " input", val)
2522 : diff --git a/test/verify/check-dialog b/test/verify/check-dialog
2523 : index 7edd39e66..ab3ea0c34 100755
2524 : --- a/test/verify/check-dialog
2525 : +++ b/test/verify/check-dialog
2526 : @@ -11,7 +11,7 @@ class TestDialog(testlib.MachineCase):
2527 :
2528 : def test(self):
2529 : b = self.browser
2530 : - d = dialoglib.DialogHelpers(b)
2531 : + d = dialoglib.DialogHelpers(b, "example")
2532 :
2533 : # Missing coverage:
2534 : #
2535 : @@ -86,26 +86,74 @@ class TestDialog(testlib.MachineCase):
2536 :
2537 : b.click("#open")
2538 : d.wait_DropdownSelect("dropdown", "one")
2539 : - d.set_DropdownSelect("dropdown", "two")
2540 : + d.set_DropdownSelect("dropdown", "three") # first call to set_async
2541 : + d.set_DropdownSelect("dropdown", "two") # second call, will cancel first
2542 : self.assertEqual(d.get_DropdownSelect("dropdown"), "two")
2543 : b.wait_in_text(d.helper_text("dropdown"), "discount")
2544 : b.click(d.apply_button())
2545 : b.wait_not_present("#dialog")
2546 :
2547 : b.wait_text("#dropdown", "two")
2548 : + b.wait_text("#text2", "two")
2549 :
2550 : - # DialogDropdownSelectObject, with update_func
2551 : + # Cancelling of irrelevant validations
2552 :
2553 : b.click("#open")
2554 : + d.set_DropdownSelect("dropdown", "three")
2555 : + d.wait_TextInput("text2", "three") # wait for async update to be done
2556 : + b.click(d.apply_button())
2557 : + b.wait_in_text(d.helper_text("text3"), "Can't be empty")
2558 : + # start a debounced validation of text3 and remove it from the
2559 : + # dialog before it has finished.
2560 : + d.set_TextInput("text3", "x")
2561 : + time.sleep(0.5)
2562 : + d.set_TextInput("text3", "")
2563 : + d.set_DropdownSelect("dropdown", "one")
2564 : + b.click(d.apply_button())
2565 : + b.wait_not_present("#dialog")
2566 : +
2567 : + # DialogDropdownSelectObject, with asynchronous updates
2568 : +
2569 : + b.click("#open")
2570 : + d.set_Checkbox("flag", val=True)
2571 : d.wait_DropdownSelect("color", "red")
2572 : self.assertEqual(d.get_TextInput("text"), "")
2573 : d.set_DropdownSelect("color", "green")
2574 : self.assertEqual(d.get_DropdownSelect("color"), "green")
2575 : - self.assertEqual(d.get_TextInput("text"), "green")
2576 : + # Text does not react immediately.
2577 : + self.assertEqual(d.get_TextInput("text"), "")
2578 : + # Wait a bit and then change color again. This cancels the update.
2579 : + time.sleep(1)
2580 : + d.set_DropdownSelect("color", "blue")
2581 : + self.assertEqual(d.get_DropdownSelect("color"), "blue")
2582 : + self.assertEqual(d.get_TextInput("text"), "")
2583 : + # Apply while the update is still running. It should finish (1 update)
2584 : b.click(d.apply_button())
2585 : b.wait_not_present("#dialog")
2586 :
2587 : - b.wait_text("#color", "0/1/0")
2588 : + b.wait_text("#text", "blue")
2589 : + b.wait_text("#color", "0/0/1")
2590 : + b.wait_text("#asyncUps", "1")
2591 : + b.wait_text("#asyncCancels", "1")
2592 : +
2593 : + # Cancelling of asynchronous tasks when the dialog is
2594 : + # cancelled
2595 : +
2596 : + b.click("#open")
2597 : + d.wait_DropdownSelect("color", "red")
2598 : + self.assertEqual(d.get_TextInput("text"), "")
2599 : + d.set_DropdownSelect("color", "green")
2600 : + self.assertEqual(d.get_DropdownSelect("color"), "green")
2601 : + # Text does not react immediately.
2602 : + self.assertEqual(d.get_TextInput("text"), "")
2603 : + b.click(d.cancel_button())
2604 : + b.wait_not_present("#dialog")
2605 : +
2606 : + # Wait for update to definitely be done if it wouldn't have
2607 : + # been cancelled
2608 : + time.sleep(3)
2609 : + b.wait_text("#asyncUps", "0")
2610 : + b.wait_text("#asyncCancels", "1")
2611 :
2612 : # List of DialogTextInputs
2613 :
2614 : @@ -126,11 +174,12 @@ class TestDialog(testlib.MachineCase):
2615 : d.wait_TextInput("list.1", "bar")
2616 : b.wait_not_present(d.field("list.2"))
2617 : b.click(d.id("list", "add"))
2618 : - d.set_TextInput("list.2", "baz")
2619 : + d.set_TextInput("list.2", "magic")
2620 : + d.wait_TextInput("text", "magic")
2621 : b.click(d.apply_button())
2622 : b.wait_not_present("#dialog")
2623 :
2624 : - b.wait_text("#list", "foo/bar/baz")
2625 : + b.wait_text("#list", "foo/bar/magic")
2626 :
2627 : # Debounced and asynchronous validation
2628 :
2629 : @@ -266,6 +315,9 @@ class TestDialog(testlib.MachineCase):
2630 : b.click(d.cancel_button())
2631 : b.wait_not_present("#dialog")
2632 :
2633 : + # back to standard prefix
2634 : + d = dialoglib.DialogHelpers(b)
2635 : +
2636 : # init func and sub-field validation
2637 :
2638 : b.click("#open-with-func")
2639 : @@ -285,6 +337,9 @@ class TestDialog(testlib.MachineCase):
2640 : b.click("#open-async")
2641 : d.set_TextInput("text", "1234")
2642 : b.click(d.apply_button())
2643 : + b.wait_text("#cancelled", "Cancelled: no")
2644 : + b.click(d.cancel_button())
2645 : + b.wait_text("#cancelled", "Cancelled: yes")
2646 : b.set_input_text(d.field("text"), "", value_check=False)
2647 : d.wait_TextInput("text", "1234")
2648 : b.wait_not_present("#dialog")
2649 : @@ -301,6 +356,176 @@ class TestDialog(testlib.MachineCase):
2650 : b.click(d.cancel_button())
2651 : b.wait_not_present("#dialog")
2652 :
2653 : + def testFileChooser(self):
2654 : + b = self.browser
2655 : + m = self.machine
2656 : + d = dialoglib.DialogHelpers(b, "example")
2657 : + df = dialoglib.DialogHelpers(b, "file-chooser")
2658 : +
2659 : + self.login_and_go("/playground/dialog", superuser=False)
2660 : +
2661 : + b.click("#open")
2662 : +
2663 : + # Use the get_FileChooserInput method so that Vulture doesn't
2664 : + # complain about it being unused.
2665 : +
2666 : + self.assertEqual(d.get_FileChooserInput("file"), "")
2667 : +
2668 : + # The first open has a empty Recent tab.
2669 : +
2670 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
2671 : + b.wait_in_text(".file-chooser-listing-body", "No recent files")
2672 : + b.click(".file-chooser .pf-v6-c-modal-box__close button")
2673 : + b.wait_not_present(".file-chooser")
2674 : +
2675 : + # Basic interaction with the text input
2676 : +
2677 : + d.set_FileChooserInput("file", "/home/non-existent/foo")
2678 : + b.wait_in_text(d.helper_text("file"), "(No such file or directory)")
2679 : +
2680 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
2681 : + b.wait_in_text(".file-chooser-listing-body", "No such file or directory")
2682 : + b.click(".file-chooser .pf-v6-c-modal-box__close button")
2683 : + b.wait_not_present(".file-chooser")
2684 : +
2685 : + m.upload(["verify/files/file-chooser-test/"], self.vm_tmpdir)
2686 : + m.execute(f"mkdir '{self.vm_tmpdir}/file-chooser-test/empty'")
2687 : + d.set_FileChooserInput("file", self.vm_tmpdir)
2688 : + b.wait_in_text(d.helper_text("file"), "directory")
2689 : +
2690 : + def file(name):
2691 : + return f".file-chooser-listing-body tr[data-name='{name}']"
2692 : +
2693 : + # Navigate to empty directory
2694 : +
2695 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
2696 : + b.wait_visible(".file-chooser")
2697 : + b.mouse(file("cockpittest"), "dblclick")
2698 : + b.mouse(file("file-chooser-test"), "dblclick")
2699 : + b.mouse(file("empty"), "dblclick")
2700 : + b.wait_in_text(".file-chooser-listing-body", "Folder is empty")
2701 : +
2702 : + # Go up and choose tmpdir/file-chooser-test/foo
2703 : +
2704 : + b.click(".file-chooser-listing-breadcrumbs a:contains('file-chooser-test')")
2705 : + b.assert_pixels(".file-chooser", "basic")
2706 : + b.mouse(file("foo"), "click")
2707 : + b.click(df.apply_button())
2708 : +
2709 : + d.wait_FileChooserInput("file", self.vm_tmpdir + "/file-chooser-test/foo")
2710 : + b.wait_in_text(d.helper_text("file"), "ASCII text")
2711 : +
2712 : + # "foo" should now be in "Recent"
2713 : +
2714 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
2715 : + b.wait_visible(".file-chooser-listing-breadcrumbs nav")
2716 : + b.wait_visible(file("foo"))
2717 : + b.click(".file-chooser-sidebar tr:contains('Recent')")
2718 : + b.wait_not_present(".file-chooser-listing-breadcrumbs nav")
2719 : + b.wait_visible(file("foo"))
2720 : + b.wait_in_text(file("foo"), self.vm_tmpdir + "/file-chooser-test")
2721 : + b.mouse(file("foo"), "click")
2722 : + b.click(df.apply_button())
2723 : + d.wait_FileChooserInput("file", self.vm_tmpdir + "/file-chooser-test/foo")
2724 : + b.wait_in_text(d.helper_text("file"), "ASCII text")
2725 : +
2726 : + # Check that "Home" has some expected files
2727 : +
2728 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
2729 : + b.click(".file-chooser-sidebar tr:contains('Home')")
2730 : + b.wait_text(".file-chooser-listing-breadcrumbs", "homeadmin")
2731 : + b.click(".file-chooser-listing-breadcrumbs a:contains('home')")
2732 : + b.mouse(file("admin"), "dblclick")
2733 : + b.mouse(file(".ssh"), "dblclick")
2734 : + b.mouse(file("authorized_keys"), "click")
2735 : + b.click(df.apply_button())
2736 : +
2737 : + d.wait_FileChooserInput("file", "/home/admin/.ssh/authorized_keys")
2738 : + b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key")
2739 : +
2740 : + # Check that we can't read /root
2741 : +
2742 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
2743 : + b.click(".file-chooser-sidebar tr:contains('Filesystem')")
2744 : + b.mouse(file("root"), "dblclick")
2745 : + b.wait_in_text(".file-chooser-listing-body", "Access denied")
2746 : + b.assert_pixels(".file-chooser", "denied")
2747 : + b.click(".file-chooser .pf-v6-c-modal-box__close button")
2748 : + b.wait_not_present(".file-chooser")
2749 : +
2750 : + # Free text filtering
2751 : +
2752 : + d.set_FileChooserInput("file", self.vm_tmpdir + "/file-chooser-test/foo")
2753 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
2754 : +
2755 : + b.wait_visible(file("bar"))
2756 : + b.wait_visible(file("foo"))
2757 : + b.wait_visible(file("foobar"))
2758 : +
2759 : + b.set_input_text(".file-chooser-listing-header input", "fo")
2760 : + b.wait_visible(file("foo"))
2761 : + b.wait_not_present(file("bar"))
2762 : + b.wait_visible(file("foobar"))
2763 : + b.assert_pixels(".file-chooser", "filtered")
2764 : +
2765 : + b.set_input_text(".file-chooser-listing-header input", "ba")
2766 : + b.wait_not_present(file("foo"))
2767 : + b.wait_visible(file("bar"))
2768 : + b.wait_visible(file("foobar"))
2769 : +
2770 : + b.set_input_text(".file-chooser-listing-header input", "x")
2771 : + b.wait_in_text(".file-chooser-listing-body", "No matching results")
2772 : + b.click(".file-chooser-listing-body button:contains('Clear filters')")
2773 : +
2774 : + b.wait_visible(file("bar"))
2775 : + b.wait_visible(file("foo"))
2776 : + b.wait_visible(file("foobar"))
2777 : +
2778 : + # Prepared filtering.
2779 : +
2780 : + # "No dots" was already active all the time, switch it off to
2781 : + # reveal more files.
2782 : +
2783 : + b.click(".file-chooser-listing-header button:contains('All files')")
2784 : +
2785 : + b.wait_visible(file("bar"))
2786 : + b.wait_visible(file("foo"))
2787 : + b.wait_visible(file("foobar"))
2788 : + b.wait_visible(file("dots.txt"))
2789 : + b.wait_visible(file("only.dots"))
2790 : +
2791 : + b.mouse(file("only.dots"), "dblclick")
2792 : + b.wait_visible(file("one.dot"))
2793 : + b.wait_visible(file("two.dots"))
2794 : +
2795 : + b.click(".file-chooser-listing-header button:contains('No dots')")
2796 : +
2797 : + b.wait_in_text(".file-chooser-listing-body", "No matching results")
2798 : +
2799 : + # Filter even more, this should get cleared as well
2800 : + b.set_input_text(".file-chooser-listing-header input", "x")
2801 : +
2802 : + b.click(".file-chooser-listing-body button:contains('Clear filters')")
2803 : +
2804 : + b.wait_visible(file("one.dot"))
2805 : + b.wait_visible(file("two.dots"))
2806 : +
2807 : + b.click(".file-chooser .pf-v6-c-modal-box__close button")
2808 : + b.wait_not_present(".file-chooser")
2809 : +
2810 : + # Become superuser and access /root/.ssh
2811 : +
2812 : + b.become_superuser()
2813 : +
2814 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
2815 : + b.click(".file-chooser-sidebar tr:contains('Filesystem')")
2816 : + b.mouse(file("root"), "dblclick")
2817 : + b.mouse(file(".ssh"), "dblclick")
2818 : + b.mouse(file("authorized_keys"), "click")
2819 : + b.click(df.apply_button())
2820 : + d.wait_FileChooserInput("file", "/root/.ssh/authorized_keys")
2821 : + b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key")
2822 : +
2823 :
2824 : if __name__ == '__main__':
2825 : testlib.test_main()
2826 : diff --git a/test/verify/files/file-chooser-test/bar b/test/verify/files/file-chooser-test/bar
2827 : new file mode 100644
2828 : index 000000000..de345c341
2829 : --- /dev/null
2830 : +++ b/test/verify/files/file-chooser-test/bar
2831 : @@ -0,0 +1 @@
2832 : +Nothing to see.
2833 : diff --git a/test/verify/files/file-chooser-test/dots.txt b/test/verify/files/file-chooser-test/dots.txt
2834 : new file mode 100644
2835 : index 000000000..0aadcf89b
2836 : --- /dev/null
2837 : +++ b/test/verify/files/file-chooser-test/dots.txt
2838 : @@ -0,0 +1 @@
2839 : +A file with a dot in its name.
2840 : diff --git a/test/verify/files/file-chooser-test/foo b/test/verify/files/file-chooser-test/foo
2841 : new file mode 100644
2842 : index 000000000..8159b424a
2843 : --- /dev/null
2844 : +++ b/test/verify/files/file-chooser-test/foo
2845 : @@ -0,0 +1 @@
2846 : +A file of no consequence.
2847 : diff --git a/test/verify/files/file-chooser-test/foobar b/test/verify/files/file-chooser-test/foobar
2848 : new file mode 100644
2849 : index 000000000..896416923
2850 : --- /dev/null
2851 : +++ b/test/verify/files/file-chooser-test/foobar
2852 : @@ -0,0 +1 @@
2853 : +Can't you think of any other names?
2854 : diff --git a/test/verify/files/file-chooser-test/only.dots/one.dot b/test/verify/files/file-chooser-test/only.dots/one.dot
2855 : new file mode 100644
2856 : index 000000000..e69de29bb
2857 : diff --git a/test/verify/files/file-chooser-test/only.dots/two.dots b/test/verify/files/file-chooser-test/only.dots/two.dots
2858 : new file mode 100644
2859 : index 000000000..e69de29bb
|