Line data Source code
1 : /*
2 : * Copyright (C) 2025 Red Hat, Inc.
3 : *
4 : * SPDX-License-Identifier: LGPL-2.1-or-later
5 : */
6 :
7 1 : import React, { useState, useReducer, useId } from "react";
8 1 : import { createRoot } from 'react-dom/client';
9 1 : import cockpit from 'cockpit';
10 :
11 : import '../lib/patternfly/patternfly-6-cockpit.scss';
12 :
13 : import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
14 : import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye";
15 : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
16 : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
17 : import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js";
18 : import { Modal, ModalBody, ModalHeader, ModalFooter } from '@patternfly/react-core/dist/esm/components/Modal';
19 : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
20 : import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
21 : import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner";
22 :
23 : import { WithDialogs, useDialogs } from 'dialogs';
24 :
25 : import {
26 : useDialogState, DialogState,
27 : useDialogState_async,
28 : DialogError,
29 : DialogErrorMessage,
30 : DialogField,
31 : DialogCheckbox,
32 : DialogTextInput,
33 : DialogRadioSelect,
34 : DialogDropdownSelect, DialogDropdownSelectObject,
35 : DialogHelperText,
36 : DialogActionButton, DialogCancelButton,
37 : } from 'cockpit/dialog';
38 :
39 : import 'cockpit-dark-theme'; // once per page
40 : import 'page.scss';
41 :
42 1 : function List<T>({
43 1 : label,
44 1 : field,
45 1 : Component,
46 1 : init,
47 1 : } : {
48 : label: string
49 : field: DialogField<T[]>,
50 : Component: ({ field } : { field: DialogField<T> }) => React.ReactNode,
51 : init: T,
52 1 : }) {
53 1 : return (
54 1 : <FormGroup label={label} data-ouia-component-id={field.ouia_id()}>
55 1 : { field.map((f, i) => (
56 1 : <Split key={i}>
57 1 : <SplitItem isFilled>
58 1 : <Component field={f} />
59 1 : </SplitItem>
60 1 : <SplitItem>
61 1 : <Button
62 1 : ouiaId={f.ouia_id("remove")}
63 1 : variant="link"
64 1 : onClick={() => field.remove(i)}
65 1 : >
66 : Remove
67 1 : </Button>
68 1 : </SplitItem>
69 1 : </Split>
70 1 : ))}
71 1 : <DialogHelperText field={field} />
72 1 : <Button
73 1 : ouiaId={field.ouia_id("add")}
74 1 : variant="link"
75 1 : onClick={() => field.add(init)}
76 1 : >
77 : Add
78 1 : </Button>
79 1 : </FormGroup>
80 : );
81 1 : }
82 :
83 1 : const StringList = ({
84 1 : field,
85 1 : label,
86 1 : } : {
87 : field: DialogField<string[]>,
88 : label: string
89 1 : }) => {
90 1 : return (
91 1 : <List
92 1 : label={label}
93 1 : field={field}
94 1 : Component={DialogTextInput}
95 1 : init=""
96 1 : />
97 : );
98 1 : };
99 :
100 : interface Name {
101 : name: string;
102 : _length: number;
103 : }
104 :
105 1 : const NameInput = ({
106 1 : field,
107 1 : } : {
108 : field: DialogField<Name>,
109 1 : }) => {
110 1 : return <DialogTextInput field={field.sub("name")} debounce={1000} />;
111 1 : };
112 :
113 1 : function validate_Name(field: DialogField<Name>, countAsyncValidation: () => void) {
114 1 : field.sub("name").validate_async(async (n, signal) => {
115 1 : await async_sleep(2000);
116 1 : countAsyncValidation();
117 1 : if (!signal.aborted)
118 1 : field.sub("_length").set(n.length);
119 1 : if (n.length % 2)
120 1 : return "Must have even number of characters";
121 1 : });
122 1 : }
123 :
124 1 : const NameList = ({
125 1 : field,
126 1 : label,
127 1 : } : {
128 : field: DialogField<Name[]>,
129 : label: string
130 1 : }) => {
131 1 : return (
132 1 : <List
133 1 : label={label}
134 1 : field={field}
135 1 : Component={NameInput}
136 1 : init={{ name: "", _length: 0 }}
137 1 : />
138 : );
139 1 : };
140 :
141 1 : const OptionalTextInput = ({
142 1 : field_label,
143 1 : checkbox_label,
144 1 : field,
145 1 : } : {
146 : field_label: string,
147 : checkbox_label: string;
148 : field: DialogField<false | string>,
149 1 : }) => {
150 1 : const id = useId();
151 1 : const val = field.get();
152 1 : let body;
153 :
154 1 : if (val === false) {
155 1 : body = (
156 1 : <Checkbox
157 1 : id={id}
158 1 : ouiaId={field.ouia_id("checkbox")}
159 1 : isChecked={false}
160 1 : label={checkbox_label}
161 1 : onChange={() => field.set("")}
162 1 : />
163 : );
164 1 : } else {
165 1 : body = (
166 1 : <>
167 1 : <Checkbox
168 1 : id={id}
169 1 : ouiaId={field.ouia_id("checkbox")}
170 1 : isChecked
171 1 : label={checkbox_label}
172 1 : onChange={() => field.set(false)}
173 1 : />
174 1 : <DialogTextInput field={field.at(val)} />
175 1 : </>
176 : );
177 1 : }
178 :
179 1 : return (
180 1 : <FormGroup
181 1 : label={field_label}
182 : >
183 1 : {body}
184 1 : <DialogHelperText field={field} />
185 1 : </FormGroup>
186 : );
187 1 : };
188 :
189 1 : function async_sleep(n: number) {
190 1 : return new Promise(resolve => {
191 1 : window.setTimeout(resolve, n);
192 1 : });
193 1 : }
194 :
195 : interface Color {
196 : name: string,
197 : red: number,
198 : green: number,
199 : blue: number,
200 : }
201 :
202 1 : const colors: Color[] = [
203 1 : { name: "red", red: 1, green: 0, blue: 0 },
204 1 : { name: "green", red: 0, green: 1, blue: 0 },
205 1 : { name: "blue", red: 0, green: 0, blue: 1 },
206 1 : ];
207 :
208 : interface ExampleValues {
209 : flag: boolean;
210 : text: string;
211 : text2: string;
212 : radio: string;
213 : dropdown: string;
214 : text3: string;
215 : color: Color,
216 : list: string[];
217 : async: Name[];
218 : alternative: false | string;
219 : error: string;
220 : allow_force: boolean;
221 : }
222 :
223 1 : const ExampleDialog = ({
224 1 : setResult,
225 1 : countAsyncValidation,
226 1 : countAsyncUpdate,
227 1 : countAsyncCancel,
228 1 : } : {
229 : setResult: (values: ExampleValues) => void,
230 : countAsyncValidation: () => void,
231 : countAsyncUpdate: () => void,
232 : countAsyncCancel: () => void,
233 1 : }) => {
234 1 : const Dialogs = useDialogs();
235 :
236 1 : const init: ExampleValues = {
237 1 : flag: false,
238 1 : text: "",
239 1 : text2: "",
240 1 : radio: "one",
241 1 : dropdown: "one",
242 1 : text3: "",
243 1 : color: colors[0],
244 1 : list: [],
245 1 : async: [],
246 1 : alternative: false,
247 1 : error: "none",
248 1 : allow_force: false,
249 1 : };
250 :
251 1 : function validate(dlg: DialogState<ExampleValues>) {
252 1 : if (dlg.values.flag) {
253 1 : dlg.field("text").validate(v => {
254 1 : if (!v)
255 1 : return "Text can not be empty";
256 1 : });
257 1 : }
258 1 : if (dlg.values.dropdown == "three") {
259 1 : dlg.field("text3").validate(v => {
260 1 : if (!v)
261 1 : return "Can't be empty";
262 1 : });
263 1 : }
264 1 : dlg.field("list").forEach(v => {
265 1 : v.validate(vv => {
266 1 : if (vv == "magic")
267 1 : dlg.field("text").set("magic");
268 1 : if (vv == ".")
269 0 : return "No dots";
270 1 : });
271 1 : });
272 1 : dlg.field("async").forEach(v => validate_Name(v, countAsyncValidation));
273 1 : }
274 :
275 1 : const dlg = useDialogState(init, validate);
276 :
277 1 : async function apply(values: ExampleValues, variant: string) {
278 1 : setResult(values);
279 :
280 1 : if (variant == "force")
281 1 : return;
282 :
283 1 : if (values.error == "custom") {
284 1 : throw new DialogError("This is a failure", <code>1234-567-98A</code>);
285 1 : } else if (values.error == "from") {
286 1 : const err = new Error("no such file or scraper");
287 1 : throw DialogError.fromError("Tool not found", err);
288 1 : } else if (values.error == "from-random") {
289 1 : const err = [1, 2, 3, 4];
290 1 : throw DialogError.fromError("Too random", err);
291 1 : } else if (values.error == "message") {
292 : // eslint-disable-next-line no-throw-literal
293 1 : throw { message: "segmentation fault" };
294 1 : } else if (values.error == "spawn") {
295 0 : await cockpit.spawn(["ls", "--no-such-option"], { err: "message" });
296 0 : } else if (values.error == "random") {
297 : // eslint-disable-next-line no-throw-literal
298 1 : throw [1, 2, 3, 4];
299 1 : }
300 1 : }
301 :
302 1 : function color_changed() {
303 1 : dlg.field("color").get_async(async (val, signal) => {
304 1 : signal.onabort = countAsyncCancel;
305 1 : await async_sleep(2000);
306 1 : if (!signal.aborted) {
307 1 : countAsyncUpdate();
308 1 : dlg.field("text").set(val.name);
309 1 : }
310 1 : });
311 1 : }
312 :
313 1 : function dropdown_changed(val: string) {
314 1 : dlg.field("text2").set_async(async () => {
315 1 : await async_sleep(2000);
316 1 : return val;
317 1 : });
318 1 : }
319 :
320 1 : return (
321 1 : <Modal
322 1 : id="dialog"
323 1 : position="top"
324 1 : variant="medium"
325 1 : isOpen
326 1 : onClose={Dialogs.close}
327 : >
328 1 : <ModalHeader title="Demo" />
329 1 : <ModalBody>
330 1 : <DialogErrorMessage dialog={dlg} />
331 1 : <Form isHorizontal>
332 1 : <DialogCheckbox
333 1 : field_label="Checkbox"
334 1 : checkbox_label="Enable text"
335 1 : field={dlg.field("flag")}
336 1 : />
337 1 : <DialogTextInput
338 1 : label="Text"
339 1 : field={dlg.field("text")}
340 1 : debounce={0}
341 1 : excuse={!dlg.values.flag && "Disabled"}
342 1 : explanation="Explanation"
343 1 : warning={dlg.values.text == "warn" ? "Warning" : null}
344 1 : />
345 1 : <DialogTextInput
346 1 : label="Text2"
347 1 : field={dlg.field("text2")}
348 1 : />
349 : {
350 : // Calling "map" on a non-array should just do nothing.
351 0 : dlg.field("text").map((v, i) => <span key={i}>{v.get()}</span>)
352 : }
353 1 : <DialogRadioSelect
354 1 : label="Radio"
355 1 : field={dlg.field("radio")}
356 1 : options={
357 1 : [
358 1 : {
359 1 : value: "one",
360 1 : label: "Eins",
361 1 : explanation: "One explanation"
362 1 : },
363 1 : {
364 1 : value: "two",
365 1 : label: "Zwei",
366 1 : explanation: "Two explanation",
367 1 : excuse: "disabled",
368 1 : },
369 1 : {
370 1 : value: "three",
371 1 : label: "Drei",
372 1 : },
373 1 : ]
374 : }
375 1 : />
376 1 : <DialogDropdownSelect
377 1 : label="Dropdown"
378 1 : field={dlg.field("dropdown", dropdown_changed)}
379 1 : options={
380 1 : [
381 1 : { value: "one", label: "Eins" },
382 1 : { value: "two", label: "Zwei" },
383 1 : { value: "three", label: "Drei" },
384 1 : ]
385 : }
386 1 : warning={dlg.field("dropdown").get() == "two" ? "There is a discount if you buy three." : null}
387 1 : />
388 : {
389 1 : dlg.values.dropdown == "three" &&
390 1 : <DialogTextInput label="Text3" field={dlg.field("text3")} debounce={1000} />
391 : }
392 1 : <DialogDropdownSelectObject
393 1 : label="DropdownObject"
394 1 : field={dlg.field("color").notify(color_changed)}
395 1 : options={colors}
396 1 : option_label={c => c.name}
397 1 : />
398 1 : <StringList label="List" field={dlg.field("list")} />
399 1 : <NameList label="Async" field={dlg.field("async")} />
400 1 : <OptionalTextInput
401 1 : field_label="Alternative"
402 1 : checkbox_label="Custom value"
403 1 : field={dlg.field("alternative")}
404 1 : />
405 1 : <DialogDropdownSelectObject
406 1 : label="Error"
407 1 : field={dlg.field("error")}
408 1 : options={["none", "custom", "from", "from-random", "message", "spawn", "random"]}
409 1 : warning={dlg.field("error").get() != "none" ? "There will be an error unless you apply with force" : null}
410 1 : />
411 1 : <DialogCheckbox
412 1 : field_label="Action options"
413 1 : checkbox_label="Allow force"
414 1 : field={dlg.field("allow_force")}
415 1 : />
416 1 : </Form>
417 1 : </ModalBody>
418 1 : <ModalFooter>
419 1 : <DialogActionButton
420 1 : dialog={dlg}
421 1 : action={values => apply(values, "main")}
422 1 : onClose={Dialogs.close}
423 1 : >
424 : Apply
425 1 : </DialogActionButton>
426 1 : <DialogActionButton
427 1 : dialog={dlg}
428 1 : action={values => apply(values, "force")}
429 1 : ouiaId="apply-force"
430 1 : variant="danger"
431 1 : onClose={Dialogs.close}
432 1 : excuse={dlg.values.allow_force ? undefined : "Force not allowed"}
433 1 : >
434 : Apply with force
435 1 : </DialogActionButton>
436 1 : <DialogCancelButton dialog={dlg} onClose={Dialogs.close}>
437 : CANCEL!
438 1 : </DialogCancelButton>
439 1 : </ModalFooter>
440 1 : </Modal>
441 : );
442 1 : };
443 :
444 1 : const ExampleButton = () => {
445 1 : const Dialogs = useDialogs();
446 1 : const [values, setValues] = useState<ExampleValues | null>(null);
447 1 : const [asyncValidationsBase, setAsyncValidationsBase] = useState<number>(0);
448 1 : const [asyncValidations, countAsyncValidation] = useReducer(x => x + 1, 0);
449 1 : const [asyncUpdatesBase, setAsyncUpdatesBase] = useState<number>(0);
450 1 : const [asyncUpdates, countAsyncUpdate] = useReducer(x => x + 1, 0);
451 1 : const [asyncCancelsBase, setAsyncCancelsBase] = useState<number>(0);
452 1 : const [asyncCancels, countAsyncCancel] = useReducer(x => x + 1, 0);
453 :
454 1 : function entry(id: string, val: string) {
455 1 : return (
456 1 : <DescriptionListGroup>
457 1 : <DescriptionListTerm>{id}</DescriptionListTerm>
458 1 : <DescriptionListDescription id={id}>{val}</DescriptionListDescription>
459 1 : </DescriptionListGroup>
460 : );
461 1 : }
462 :
463 1 : return (
464 1 : <>
465 1 : <Button
466 1 : id="open"
467 1 : onClick={
468 1 : () => {
469 1 : setAsyncValidationsBase(asyncValidations);
470 1 : setAsyncUpdatesBase(asyncUpdates);
471 1 : setAsyncCancelsBase(asyncCancels);
472 1 : Dialogs.show(
473 1 : <ExampleDialog
474 1 : setResult={setValues}
475 1 : countAsyncValidation={countAsyncValidation}
476 1 : countAsyncUpdate={countAsyncUpdate}
477 1 : countAsyncCancel={countAsyncCancel}
478 1 : />
479 1 : );
480 1 : }
481 : }
482 1 : >
483 : Open dialog
484 1 : </Button>
485 1 : { values &&
486 1 : <DescriptionList isHorizontal>
487 1 : { entry("flag", String(values.flag)) }
488 1 : { values.flag && entry("text", values.text) }
489 1 : { entry("text2", values.text2) }
490 1 : { entry("radio", values.radio) }
491 1 : { entry("dropdown", values.dropdown) }
492 1 : { entry("color", values.color.red + "/" + values.color.green + "/" + values.color.blue) }
493 1 : { entry("list", values.list.join("/")) }
494 1 : { entry("async", values.async.map(n => n.name + ":" + String(n._length)).join("/")) }
495 1 : { entry("asyncVals", String(asyncValidations - asyncValidationsBase)) }
496 1 : { entry("asyncUps", String(asyncUpdates - asyncUpdatesBase)) }
497 1 : { entry("asyncCancels", String(asyncCancels - asyncCancelsBase)) }
498 1 : { entry("alternative", JSON.stringify(values.alternative)) }
499 1 : </DescriptionList>
500 : }
501 1 : </>
502 : );
503 1 : };
504 :
505 : interface ExampleWithInitFuncValues {
506 : text: string;
507 : text2: string;
508 : }
509 :
510 1 : const ExampleDialogWithInitFunc = () => {
511 1 : const Dialogs = useDialogs();
512 :
513 1 : function init(): ExampleWithInitFuncValues {
514 1 : return {
515 1 : text: "foo",
516 1 : text2: "bar",
517 1 : };
518 1 : }
519 :
520 1 : function validate(dlg: DialogState<ExampleWithInitFuncValues>) {
521 1 : dlg.top().validate(v => {
522 1 : if (v.text == "foo" && v.text2 != "bar") {
523 1 : return {
524 1 : text: "No foo without bar",
525 1 : };
526 1 : }
527 1 : if (v.text2 == "bar" && v.text != "foo") {
528 1 : return {
529 1 : text2: { "": "No bar without foo" },
530 1 : };
531 1 : }
532 1 : });
533 1 : }
534 :
535 1 : const dlg = useDialogState(init, validate);
536 :
537 1 : return (
538 1 : <Modal
539 1 : id="dialog"
540 1 : position="top"
541 1 : variant="medium"
542 1 : isOpen
543 1 : onClose={Dialogs.close}
544 : >
545 1 : <ModalHeader title="Demo" />
546 1 : <ModalBody>
547 1 : <DialogErrorMessage dialog={dlg} />
548 1 : <Form isHorizontal>
549 1 : <DialogTextInput
550 1 : label="Text"
551 1 : field={dlg.field("text")}
552 1 : />
553 1 : <DialogTextInput
554 1 : label="Text 2"
555 1 : field={dlg.field("text2")}
556 1 : />
557 1 : </Form>
558 1 : </ModalBody>
559 1 : <ModalFooter>
560 0 : <DialogActionButton dialog={dlg} action={async () => {}} onClose={Dialogs.close}>
561 : Apply
562 1 : </DialogActionButton>
563 1 : <DialogCancelButton dialog={dlg} onClose={Dialogs.close} />
564 1 : </ModalFooter>
565 1 : </Modal>
566 : );
567 1 : };
568 :
569 : interface AsyncExampleValues {
570 : text: string;
571 : }
572 :
573 1 : const AsyncExampleDialog = ({
574 1 : throwError = 0,
575 1 : cancelCallback = null,
576 1 : } : {
577 : throwError?: number,
578 : cancelCallback?: null | (() => void),
579 1 : }) => {
580 1 : const Dialogs = useDialogs();
581 :
582 1 : async function init(): Promise<AsyncExampleValues> {
583 1 : if (throwError == 1)
584 1 : throw new Error("can't get the thing");
585 1 : else if (throwError == 2)
586 1 : throw new DialogError("Getting the thing failed", <i>can't get it</i>);
587 :
588 1 : await async_sleep(500);
589 1 : return {
590 1 : text: "",
591 1 : };
592 1 : }
593 :
594 1 : function validate(dlg: DialogState<AsyncExampleValues>) {
595 1 : dlg.field("text").validate_async(async () => {
596 1 : throw Error("upps");
597 1 : });
598 1 : }
599 :
600 1 : const dlg = useDialogState_async(init, validate);
601 :
602 1 : async function apply() {
603 1 : cockpit.assert(dlg instanceof DialogState);
604 :
605 1 : dlg.set_cancel(cancelCallback);
606 :
607 1 : await async_sleep(1000);
608 1 : Dialogs.close();
609 1 : }
610 :
611 1 : function top_changed(values: AsyncExampleValues) {
612 1 : console.log("TOP", JSON.stringify(values));
613 1 : }
614 :
615 1 : let body;
616 1 : if (!dlg) {
617 1 : body = (
618 1 : <Bullseye>
619 1 : <Spinner />
620 1 : </Bullseye>
621 : );
622 1 : } else if (dlg instanceof DialogError) {
623 1 : body = null;
624 1 : } else if (dlg instanceof DialogState) {
625 1 : const fields = dlg.top(top_changed);
626 1 : body = (
627 1 : <Form isHorizontal>
628 1 : <DialogTextInput label="Text" field={fields.sub("text")} />
629 1 : </Form>
630 : );
631 1 : }
632 :
633 1 : return (
634 1 : <Modal
635 1 : id="dialog"
636 1 : position="top"
637 1 : variant="medium"
638 1 : isOpen
639 1 : onClose={Dialogs.close}
640 : >
641 1 : <ModalHeader title="Async Demo" />
642 1 : <ModalBody>
643 1 : <DialogErrorMessage dialog={dlg} />
644 1 : { body }
645 1 : </ModalBody>
646 1 : <ModalFooter>
647 1 : <DialogActionButton dialog={dlg} action={apply}>
648 : Apply
649 1 : </DialogActionButton>
650 1 : <DialogCancelButton dialog={dlg} onClose={Dialogs.close} />
651 1 : </ModalFooter>
652 1 : </Modal>
653 : );
654 1 : };
655 :
656 1 : const SimpleExampleButtons = () => {
657 1 : const Dialogs = useDialogs();
658 1 : const [cancelled, setCancelled] = useState(false);
659 :
660 1 : return (
661 1 : <>
662 1 : <Button
663 1 : id="open-with-func"
664 1 : onClick={() => Dialogs.show(<ExampleDialogWithInitFunc />)}
665 1 : >
666 : Open init-func dialog
667 1 : </Button>
668 1 : <Button
669 1 : id="open-async"
670 1 : onClick={
671 1 : () => {
672 1 : setCancelled(false);
673 1 : Dialogs.show(<AsyncExampleDialog cancelCallback={() => setCancelled(true)} />);
674 1 : }
675 : }
676 1 : >
677 : Open async dialog
678 1 : </Button>
679 1 : <div id="cancelled">
680 1 : Cancelled: {cancelled ? "yes" : "no"}
681 1 : </div>
682 1 : <Button
683 1 : id="open-error"
684 1 : onClick={() => Dialogs.show(<AsyncExampleDialog throwError={1} />)}
685 1 : >
686 : Open Error dialog
687 1 : </Button>
688 1 : <Button
689 1 : id="open-dialog-error"
690 1 : onClick={() => Dialogs.show(<AsyncExampleDialog throwError={2} />)}
691 1 : >
692 : Open DialogError dialog
693 1 : </Button>
694 1 : </>
695 : );
696 1 : };
697 :
698 1 : const Demo = () => {
699 1 : return (
700 1 : <WithDialogs>
701 1 : <Page isContentFilled className="no-masthead-sidebar">
702 1 : <PageSection>
703 1 : <ExampleButton />
704 1 : <SimpleExampleButtons />
705 1 : </PageSection>
706 1 : </Page>
707 1 : </WithDialogs>
708 : );
709 1 : };
710 :
711 1 : document.addEventListener("DOMContentLoaded", function() {
712 1 : window.debugging = "dialog";
713 1 : createRoot(document.getElementById('app')!).render(<Demo />);
714 1 : });
|