* Copyright (C) 2025 Red Hat, Inc.
*
* SPDX-License-Identifier: LGPL-2.1-or-later
*/
import React, { useState, useReducer, useId } from "react";
import { createRoot } from 'react-dom/client';
import cockpit from 'cockpit';
import '../lib/patternfly/patternfly-6-cockpit.scss';
import { Page, PageSection } from "@patternfly/react-core/dist/esm/components/Page/index.js";
import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye";
import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js";
import { Modal, ModalBody, ModalHeader, ModalFooter } from '@patternfly/react-core/dist/esm/components/Modal';
import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList/index.js";
import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner";
import { WithDialogs, useDialogs } from 'dialogs';
import {
useDialogState, DialogState,
useDialogState_async,
DialogError,
DialogErrorMessage,
DialogField,
DialogCheckbox,
DialogTextInput,
DialogRadioSelect,
DialogDropdownSelect, DialogDropdownSelectObject,
DialogHelperText,
DialogActionButton, DialogCancelButton,
} from 'cockpit/dialog';
import { FileChooser, DialogFileChooserInput } from "cockpit/react/FileChooser";
import 'cockpit-dark-theme';
import 'page.scss';
function List<T>({
label,
field,
Component,
init,
} : {
label: string
field: DialogField<T[]>,
Component: ({ field } : { field: DialogField<T> }) => React.ReactNode,
init: T,
}) {
return (
<FormGroup label={label} ouiaId={field.ouia_id()}>
{ field.map((f, i) => (
<Split key={i}>
<SplitItem isFilled>
<Component field={f} />
</SplitItem>
<SplitItem>
<Button
ouiaId={f.ouia_id("remove")}
variant="link"
onClick={() => field.remove(i)}
>
Remove
</Button>
</SplitItem>
</Split>
))}
<DialogHelperText field={field} />
<Button
ouiaId={field.ouia_id("add")}
variant="link"
onClick={() => field.add(init)}
>
Add
</Button>
</FormGroup>
);
}
const StringList = ({
field,
label,
} : {
field: DialogField<string[]>,
label: string
}) => {
return (
<List
label={label}
field={field}
Component={DialogTextInput}
init=""
/>
);
};
interface Name {
name: string;
_length: number;
}
const NameInput = ({
field,
} : {
field: DialogField<Name>,
}) => {
return <DialogTextInput field={field.sub("name")} debounce={1000} />;
};
function validate_Name(field: DialogField<Name>, countAsyncValidation: () => void) {
field.sub("name").validate_async(async (n, signal) => {
await async_sleep(2000);
countAsyncValidation();
if (!signal.aborted)
field.sub("_length").set(n.length);
if (n.length % 2)
return "Must have even number of characters";
});
}
const NameList = ({
field,
label,
} : {
field: DialogField<Name[]>,
label: string
}) => {
return (
<List
label={label}
field={field}
Component={NameInput}
init={{ name: "", _length: 0 }}
/>
);
};
const OptionalTextInput = ({
field_label,
checkbox_label,
field,
} : {
field_label: string,
checkbox_label: string;
field: DialogField<false | string>,
}) => {
const id = useId();
const val = field.get();
let body;
if (val === false) {
body = (
<Checkbox
id={id}
ouiaId={field.ouia_id("checkbox")}
isChecked={false}
label={checkbox_label}
onChange={() => field.set("")}
/>
);
} else {
body = (
<>
<Checkbox
id={id}
ouiaId={field.ouia_id("checkbox")}
isChecked
label={checkbox_label}
onChange={() => field.set(false)}
/>
<DialogTextInput field={field.at(val)} />
</>
);
}
return (
<FormGroup
label={field_label}
>
{body}
<DialogHelperText field={field} />
</FormGroup>
);
};
function async_sleep(n: number) {
return new Promise(resolve => {
window.setTimeout(resolve, n);
});
}
interface Color {
name: string,
red: number,
green: number,
blue: number,
}
const colors: Color[] = [
{ name: "red", red: 1, green: 0, blue: 0 },
{ name: "green", red: 0, green: 1, blue: 0 },
{ name: "blue", red: 0, green: 0, blue: 1 },
];
interface ExampleValues {
flag: boolean;
text: string;
text2: string;
radio: string;
dropdown: string;
text3: string;
color: Color,
list: string[];
async: Name[];
alternative: false | string;
error: string;
allow_force: boolean;
file: string;
file_explanation: string;
dir: string;
path: string;
}
const ExampleDialog = ({
setResult,
countAsyncValidation,
countAsyncUpdate,
countAsyncCancel,
} : {
setResult: (values: ExampleValues) => void,
countAsyncValidation: () => void,
countAsyncUpdate: () => void,
countAsyncCancel: () => void,
}) => {
const Dialogs = useDialogs();
const init: ExampleValues = {
flag: false,
text: "",
text2: "",
radio: "one",
dropdown: "one",
text3: "",
color: colors[0],
list: [],
async: [],
alternative: false,
error: "none",
allow_force: false,
file: "",
file_explanation: "",
dir: "",
path: "",
};
function validate(dlg: DialogState<ExampleValues>) {
if (dlg.values.flag) {
dlg.field("text").validate(v => {
if (!v)
return "Text can not be empty";
});
}
if (dlg.values.dropdown === "three") {
dlg.field("text3").validate(v => {
if (!v)
return "Can't be empty";
});
}
dlg.field("list").forEach(v => {
v.validate(vv => {
if (vv === "magic")
dlg.field("text").set("magic");
if (vv === ".")
return "No dots";
});
});
dlg.field("async").forEach(v => validate_Name(v, countAsyncValidation));
dlg.field("file").validate(v => {
if (v && v[0] !== "/")
return "Must be absolute";
});
}
const dlg = useDialogState(init, validate);
async function apply(values: ExampleValues, variant: string) {
setResult(values);
if (variant === "force")
return;
if (values.error === "custom") {
throw new DialogError("This is a failure", <code>1234-567-98A</code>);
} else if (values.error === "from") {
const err = new Error("no such file or scraper");
throw DialogError.fromError("Tool not found", err);
} else if (values.error === "from-random") {
const err = [1, 2, 3, 4];
throw DialogError.fromError("Too random", err);
} else if (values.error === "message") {
throw { message: "segmentation fault" };
} else if (values.error === "spawn") {
await cockpit.spawn(["ls", "--no-such-option"], { err: "message" });
} else if (values.error === "random") {
throw [1, 2, 3, 4];
}
}
function color_changed() {
dlg.field("color").get_async(async (val, signal) => {
signal.onabort = countAsyncCancel;
await async_sleep(2000);
if (!signal.aborted) {
countAsyncUpdate();
dlg.field("text").set(val.name);
}
});
}
function dropdown_changed(val: string) {
dlg.field("text2").set_async(async () => {
await async_sleep(2000);
return val;
});
}
function file_changed(val: string) {
dlg.field("file_explanation").set_async(async () => {
if (val[0] === "/")
return cockpit.spawn(["file", "-b", val], { superuser: "try" });
else
return "--";
});
}
return (
<Modal
id="dialog"
position="top"
variant="medium"
isOpen
onClose={Dialogs.close}
>
<ModalHeader title="Demo" />
<ModalBody>
<DialogErrorMessage dialog={dlg} />
<Form isHorizontal>
<DialogCheckbox
field_label="Checkbox"
checkbox_label="Enable text"
field={dlg.field("flag")}
/>
<DialogTextInput
label="Text"
field={dlg.field("text")}
debounce={0}
excuse={!dlg.values.flag && "Disabled"}
explanation="Explanation"
warning={dlg.values.text === "warn" ? "Warning" : null}
/>
<DialogTextInput
label="Text2"
field={dlg.field("text2")}
/>
{
dlg.field("text").map((v, i) => <span key={i}>{v.get()}</span>)
}
<DialogRadioSelect
label="Radio"
field={dlg.field("radio")}
options={
[
{
value: "one",
label: "Eins",
explanation: "One explanation"
},
{
value: "two",
label: "Zwei",
explanation: "Two explanation",
excuse: "disabled",
},
{
value: "three",
label: "Drei",
},
]
}
/>
<DialogDropdownSelect
label="Dropdown"
field={dlg.field("dropdown", dropdown_changed)}
options={
[
{ value: "one", label: "Eins" },
{ value: "two", label: "Zwei" },
{ value: "three", label: "Drei" },
]
}
warning={dlg.field("dropdown").get() === "two" ? "There is a discount if you buy three." : null}
/>
{
dlg.values.dropdown === "three" &&
<DialogTextInput label="Text3" field={dlg.field("text3")} debounce={1000} />
}
<DialogDropdownSelectObject
label="DropdownObject"
field={dlg.field("color").notify(color_changed)}
options={colors}
option_label={c => c.name}
/>
<StringList label="List" field={dlg.field("list")} />
<NameList label="Async" field={dlg.field("async")} />
<OptionalTextInput
field_label="Alternative"
checkbox_label="Custom value"
field={dlg.field("alternative")}
/>
<DialogDropdownSelectObject
label="Error"
field={dlg.field("error")}
options={["none", "custom", "from", "from-random", "message", "spawn", "random"]}
warning={dlg.field("error").get() !== "none" ? "There will be an error unless you apply with force" : null}
/>
<DialogCheckbox
field_label="Action options"
checkbox_label="Allow force"
field={dlg.field("allow_force")}
/>
<DialogFileChooserInput
label="File"
field={dlg.field("file", file_changed)}
explanation={dlg.values.file_explanation}
fileChooserProps={
{
title: "Select a file",
superuser: "try",
filters: [
{ label: "No dots", filter: n => !n.includes(".") },
],
shortcuts: [
{ label: "Test files", path: "/var/lib/cockpittest" }
],
collections: [
{
label: "Some files",
emptyLabel: "Nothing there",
list: async () => {
return [
"/var/lib/cockpittest/file-chooser-test/dots.txt",
"/var/lib/cockpittest/file-chooser-test/foo",
];
}
}
]
}
}
/>
<DialogFileChooserInput
label="Directory"
field={dlg.field("dir")}
fileChooserProps={
{
title: "Select a directory",
onlyDirectories: true,
superuser: "try",
shortcuts: [
{ label: "Test files", path: "/var/lib/cockpittest" }
],
}
}
/>
<DialogFileChooserInput
label="File or directory"
field={dlg.field("path")}
fileChooserProps={
{
title: "Select a path",
allowDirectories: true,
superuser: "try",
}
}
/>
</Form>
</ModalBody>
<ModalFooter>
<DialogActionButton
dialog={dlg}
action={values => apply(values, "main")}
onClose={Dialogs.close}
>
Apply
</DialogActionButton>
<DialogActionButton
dialog={dlg}
action={values => apply(values, "force")}
ouiaId="apply-force"
variant="danger"
onClose={Dialogs.close}
excuse={dlg.values.allow_force ? undefined : "Force not allowed"}
>
Apply with force
</DialogActionButton>
<DialogCancelButton dialog={dlg} onClose={Dialogs.close}>
CANCEL!
</DialogCancelButton>
</ModalFooter>
</Modal>
);
};
const ExampleButton = () => {
const Dialogs = useDialogs();
const [values, setValues] = useState<ExampleValues | null>(null);
const [asyncValidationsBase, setAsyncValidationsBase] = useState<number>(0);
const [asyncValidations, countAsyncValidation] = useReducer(x => x + 1, 0);
const [asyncUpdatesBase, setAsyncUpdatesBase] = useState<number>(0);
const [asyncUpdates, countAsyncUpdate] = useReducer(x => x + 1, 0);
const [asyncCancelsBase, setAsyncCancelsBase] = useState<number>(0);
const [asyncCancels, countAsyncCancel] = useReducer(x => x + 1, 0);
function entry(id: string, val: string) {
return (
<DescriptionListGroup>
<DescriptionListTerm>{id}</DescriptionListTerm>
<DescriptionListDescription id={id}>{val}</DescriptionListDescription>
</DescriptionListGroup>
);
}
return (
<>
<Button
id="open"
onClick={
() => {
setAsyncValidationsBase(asyncValidations);
setAsyncUpdatesBase(asyncUpdates);
setAsyncCancelsBase(asyncCancels);
Dialogs.show(
<ExampleDialog
setResult={setValues}
countAsyncValidation={countAsyncValidation}
countAsyncUpdate={countAsyncUpdate}
countAsyncCancel={countAsyncCancel}
/>
);
}
}
>
Open dialog
</Button>
{ values &&
<DescriptionList isHorizontal>
{ entry("flag", String(values.flag)) }
{ values.flag && entry("text", values.text) }
{ entry("text2", values.text2) }
{ entry("radio", values.radio) }
{ entry("dropdown", values.dropdown) }
{ entry("color", values.color.red + "/" + values.color.green + "/" + values.color.blue) }
{ entry("list", values.list.join("/")) }
{ entry("async", values.async.map(n => n.name + ":" + String(n._length)).join("/")) }
{ entry("asyncVals", String(asyncValidations - asyncValidationsBase)) }
{ entry("asyncUps", String(asyncUpdates - asyncUpdatesBase)) }
{ entry("asyncCancels", String(asyncCancels - asyncCancelsBase)) }
{ entry("alternative", JSON.stringify(values.alternative)) }
</DescriptionList>
}
</>
);
};
interface ExampleWithInitFuncValues {
text: string;
text2: string;
}
const ExampleDialogWithInitFunc = () => {
const Dialogs = useDialogs();
function init(): ExampleWithInitFuncValues {
return {
text: "foo",
text2: "bar",
};
}
function validate(dlg: DialogState<ExampleWithInitFuncValues>) {
dlg.top().validate(v => {
if (v.text === "foo" && v.text2 !== "bar") {
return {
text: "No foo without bar",
};
}
if (v.text2 === "bar" && v.text !== "foo") {
return {
text2: { "": "No bar without foo" },
};
}
});
}
const dlg = useDialogState(init, validate);
return (
<Modal
id="dialog"
position="top"
variant="medium"
isOpen
onClose={Dialogs.close}
>
<ModalHeader title="Demo" />
<ModalBody>
<DialogErrorMessage dialog={dlg} />
<Form isHorizontal>
<DialogTextInput
label="Text"
field={dlg.field("text")}
/>
<DialogTextInput
label="Text 2"
field={dlg.field("text2")}
/>
</Form>
</ModalBody>
<ModalFooter>
<DialogActionButton dialog={dlg} action={async () => {}} onClose={Dialogs.close}>
Apply
</DialogActionButton>
<DialogCancelButton dialog={dlg} onClose={Dialogs.close} />
</ModalFooter>
</Modal>
);
};
interface AsyncExampleValues {
text: string;
}
const AsyncExampleDialog = ({
throwError = 0,
cancelCallback = null,
} : {
throwError?: number,
cancelCallback?: null | (() => void),
}) => {
const Dialogs = useDialogs();
async function init(): Promise<AsyncExampleValues> {
if (throwError === 1)
throw new Error("can't get the thing");
else if (throwError === 2)
throw new DialogError("Getting the thing failed", <i>can't get it</i>);
await async_sleep(500);
return {
text: "",
};
}
function validate(dlg: DialogState<AsyncExampleValues>) {
dlg.field("text").validate_async(async () => {
throw Error("upps");
});
}
const dlg = useDialogState_async(init, validate);
async function apply() {
cockpit.assert(dlg instanceof DialogState);
dlg.set_cancel(cancelCallback);
await async_sleep(1000);
Dialogs.close();
}
function top_changed(values: AsyncExampleValues) {
console.log("TOP", JSON.stringify(values));
}
let body;
if (!dlg) {
body = (
<Bullseye>
<Spinner />
</Bullseye>
);
} else if (dlg instanceof DialogError) {
body = null;
} else if (dlg instanceof DialogState) {
const fields = dlg.top(top_changed);
body = (
<Form isHorizontal>
<DialogTextInput label="Text" field={fields.sub("text")} />
</Form>
);
}
return (
<Modal
id="dialog"
position="top"
variant="medium"
isOpen
onClose={Dialogs.close}
>
<ModalHeader title="Async Demo" />
<ModalBody>
<DialogErrorMessage dialog={dlg} />
{ body }
</ModalBody>
<ModalFooter>
<DialogActionButton dialog={dlg} action={apply}>
Apply
</DialogActionButton>
<DialogCancelButton dialog={dlg} onClose={Dialogs.close} />
</ModalFooter>
</Modal>
);
};
const SimpleExampleButtons = () => {
const Dialogs = useDialogs();
const [cancelled, setCancelled] = useState(false);
return (
<>
<Button
id="open-with-func"
onClick={() => Dialogs.show(<ExampleDialogWithInitFunc />)}
>
Open init-func dialog
</Button>
<Button
id="open-async"
onClick={
() => {
setCancelled(false);
Dialogs.show(<AsyncExampleDialog cancelCallback={() => setCancelled(true)} />);
}
}
>
Open async dialog
</Button>
<div id="cancelled">
Cancelled: {cancelled ? "yes" : "no"}
</div>
<Button
id="open-error"
onClick={() => Dialogs.show(<AsyncExampleDialog throwError={1} />)}
>
Open Error dialog
</Button>
<Button
id="open-dialog-error"
onClick={() => Dialogs.show(<AsyncExampleDialog throwError={2} />)}
>
Open DialogError dialog
</Button>
</>
);
};
const FileChooserButton = () => {
const Dialogs = useDialogs();
async function loadFile(path: string) {
const data = await cockpit.file(path).read();
if (!data.startsWith("foo"))
throw new Error("Does not start with \"foo\"");
}
return (
<Button
id="open-file-chooser"
onClick={
() => Dialogs.show(
<FileChooser
title={"Select a file that starts with \"foo\""}
actionLabel="Load"
action={loadFile}
filters={
[
{
label: "TXT files",
filter: (name, type) => type === "reg" && !!name.match("\\.txt$")
},
]
}
shortcuts={
async () => {
await async_sleep(500);
return [
{ label: "Test files", path: "/var/lib/cockpittest" }
];
}
}
collections={
async () => {
return [
{
label: "Some TXT files",
emptyLabel: "Nothing there",
list: async () => {
return [
"/var/lib/cockpittest/file-chooser-test/text/foo.txt",
"/var/lib/cockpittest/file-chooser-test/no-such-file.txt"
];
}
}
];
}
}
/>
)
}
>
Open FileChooser
</Button>
);
};
const Demo = () => {
return (
<WithDialogs>
<Page isContentFilled className="no-masthead-sidebar">
<PageSection>
<ExampleButton />
<SimpleExampleButtons />
<FileChooserButton />
</PageSection>
</Page>
</WithDialogs>
);
};
document.addEventListener("DOMContentLoaded", function() {
window.debugging = "dialog";
createRoot(document.getElementById('app')!).render(<Demo />);
});