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