Clan-app: Flash improve form & file input

This commit is contained in:
Johannes Kirschbauer
2024-08-07 12:16:06 +02:00
parent 99dc5793b2
commit 7e9214053f
5 changed files with 362 additions and 263 deletions

View File

@@ -0,0 +1,91 @@
import cx from "classnames";
import { createMemo, JSX, Show, splitProps } from "solid-js";
interface FileInputProps {
ref: (element: HTMLInputElement) => void;
name: string;
value?: File[] | File;
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
onClick: JSX.EventHandler<HTMLInputElement, Event>;
onChange: JSX.EventHandler<HTMLInputElement, Event>;
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
accept?: string;
required?: boolean;
multiple?: boolean;
class?: string;
label?: string;
error?: string;
}
/**
* File input field that users can click or drag files into. Various
* decorations can be displayed in or around the field to communicate the entry
* requirements.
*/
export function FileInput(props: FileInputProps) {
// Split input element props
const [, inputProps] = splitProps(props, [
"class",
"value",
"label",
"error",
]);
// Create file list
const getFiles = createMemo(() =>
props.value
? Array.isArray(props.value)
? props.value
: [props.value]
: [],
);
return (
<div class={cx("form-control w-full", props.class)}>
<div class="label">
<span
class="label-text block"
classList={{
"after:ml-0.5 after:text-primary after:content-['*']":
props.required,
}}
>
{props.label}
</span>
</div>
<div
class={cx(
"relative flex min-h-[96px] w-full items-center justify-center rounded-2xl border-[3px] border-dashed p-8 text-center focus-within:ring-4 md:min-h-[112px] md:text-lg lg:min-h-[128px] lg:p-10 lg:text-xl",
!getFiles().length && "text-slate-500",
props.error
? "border-red-500/25 focus-within:border-red-500/50 focus-within:ring-red-500/10 hover:border-red-500/40 dark:border-red-400/25 dark:focus-within:border-red-400/50 dark:focus-within:ring-red-400/10 dark:hover:border-red-400/40"
: "border-slate-200 focus-within:border-sky-500/50 focus-within:ring-sky-500/10 hover:border-slate-300 dark:border-slate-800 dark:focus-within:border-sky-400/50 dark:focus-within:ring-sky-400/10 dark:hover:border-slate-700",
)}
>
<Show
when={getFiles().length}
fallback={<>Click to select file{props.multiple && "s"}</>}
>
Selected file{props.multiple && "s"}:{" "}
{getFiles()
.map(({ name }) => name)
.join(", ")}
</Show>
<input
{...inputProps}
// Disable drag n drop
onDrop={(e) => e.preventDefault()}
class="absolute size-full cursor-pointer opacity-0"
type="file"
id={props.name}
aria-invalid={!!props.error}
aria-errormessage={`${props.name}-error`}
/>
{props.error && (
<span class="label-text-alt font-bold text-error">{props.error}</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { FieldValues, FormStore, ResponseData } from "@modular-forms/solid";
import { type JSX } from "solid-js";
interface SelectInputProps<T extends FieldValues, R extends ResponseData> {
formStore: FormStore<T, R>;
value: string;
options: JSX.Element;
selectProps: JSX.HTMLAttributes<HTMLSelectElement>;
label: JSX.Element;
error?: string;
required?: boolean;
}
export function SelectInput<T extends FieldValues, R extends ResponseData>(
props: SelectInputProps<T, R>,
) {
return (
<label
class="form-control w-full"
aria-disabled={props.formStore.submitting}
>
<div class="label">
<span
class="label-text block"
classList={{
"after:ml-0.5 after:text-primary after:content-['*']":
props.required,
}}
>
{props.label}
</span>
</div>
<select
{...props.selectProps}
required={props.required}
class="select select-bordered w-full"
value={props.value}
>
{props.options}
</select>
{props.error && (
<span class="label-text-alt font-bold text-error">{props.error}</span>
)}
</label>
);
}

View File

@@ -0,0 +1,54 @@
import { FieldValues, FormStore, ResponseData } from "@modular-forms/solid";
import { type JSX } from "solid-js";
interface TextInputProps<T extends FieldValues, R extends ResponseData> {
formStore: FormStore<T, R>;
value: string;
inputProps: JSX.HTMLAttributes<HTMLInputElement>;
label: JSX.Element;
error?: string;
required?: boolean;
inlineLabel?: JSX.Element;
}
export function TextInput<T extends FieldValues, R extends ResponseData>(
props: TextInputProps<T, R>,
) {
return (
<label
class="form-control w-full"
aria-disabled={props.formStore.submitting}
>
<div class="label">
<span
class="label-text block"
classList={{
"after:ml-0.5 after:text-primary after:content-['*']":
props.required,
}}
>
{props.label}
</span>
</div>
<div class="input input-bordered flex items-center gap-2">
{props.inlineLabel}
<input
{...props.inputProps}
value={props.value}
type="text"
class="grow"
classList={{
"input-disabled": props.formStore.submitting,
}}
placeholder="name"
required
disabled={props.formStore.submitting}
/>
</div>
{props.error && (
<span class="label-text-alt font-bold text-error">{props.error}</span>
)}
</label>
);
}

View File

@@ -1,12 +1,11 @@
import { callApi, OperationResponse } from "@/src/api"; import { callApi, OperationResponse } from "@/src/api";
import { import { FileInput } from "@/src/components/FileInput";
createForm, import { SelectInput } from "@/src/components/SelectInput";
required, import { TextInput } from "@/src/components/TextInput";
FieldValues, import { createForm, required, FieldValues } from "@modular-forms/solid";
setValue,
} from "@modular-forms/solid";
import { createQuery } from "@tanstack/solid-query"; import { createQuery } from "@tanstack/solid-query";
import { createEffect, createSignal, For } from "solid-js"; import { For } from "solid-js";
import toast from "solid-toast";
interface FlashFormValues extends FieldValues { interface FlashFormValues extends FieldValues {
machine: { machine: {
@@ -16,58 +15,32 @@ interface FlashFormValues extends FieldValues {
disk: string; disk: string;
language: string; language: string;
keymap: string; keymap: string;
sshKeys: string[]; sshKeys: File[];
} }
type BlockDevices = Extract<
OperationResponse<"show_block_devices">,
{ status: "success" }
>["data"]["blockdevices"];
export const Flash = () => { export const Flash = () => {
const [formStore, { Form, Field }] = createForm<FlashFormValues>({}); const [formStore, { Form, Field }] = createForm<FlashFormValues>({
const [sshKeys, setSshKeys] = createSignal<string[]>([]); initialValues: {
const [isFlashing, setIsFlashing] = createSignal(false); machine: {
flake: "git+https://git.clan.lol/clan/clan-core",
const selectSshPubkey = async () => { devicePath: "flash-installer",
try { },
const loc = await callApi("open_file", { language: "en_US.UTF-8",
file_request: { keymap: "en",
title: "Select SSH Key", },
mode: "open_multiple_files",
filters: { patterns: ["*.pub"] },
initial_folder: "~/.ssh",
},
});
console.log({ loc }, loc.status);
if (loc.status === "success" && loc.data) {
setSshKeys(loc.data);
return loc.data;
}
} catch (e) {
//
}
};
// Create an effect that updates the form when externalUsername changes
createEffect(() => {
const newSshKeys = sshKeys();
if (newSshKeys) {
setValue(formStore, "sshKeys", newSshKeys);
}
}); });
const { data: devices, isFetching } = createQuery(() => ({ const deviceQuery = createQuery(() => ({
queryKey: ["block_devices"], queryKey: ["block_devices"],
queryFn: async () => { queryFn: async () => {
const result = await callApi("show_block_devices", {}); const result = await callApi("show_block_devices", {});
if (result.status === "error") throw new Error("Failed to fetch data"); if (result.status === "error") throw new Error("Failed to fetch data");
return result.data; return result.data;
}, },
staleTime: 1000 * 60 * 2, // 1 minutes staleTime: 1000 * 60 * 1, // 1 minutes
})); }));
const { data: keymaps, isFetching: isFetchingKeymaps } = createQuery(() => ({ const keymapQuery = createQuery(() => ({
queryKey: ["list_keymaps"], queryKey: ["list_keymaps"],
queryFn: async () => { queryFn: async () => {
const result = await callApi("list_possible_keymaps", {}); const result = await callApi("list_possible_keymaps", {});
@@ -77,20 +50,46 @@ export const Flash = () => {
staleTime: 1000 * 60 * 15, // 15 minutes staleTime: 1000 * 60 * 15, // 15 minutes
})); }));
const { data: languages, isFetching: isFetchingLanguages } = createQuery( const langQuery = createQuery(() => ({
() => ({ queryKey: ["list_languages"],
queryKey: ["list_languages"], queryFn: async () => {
queryFn: async () => { const result = await callApi("list_possible_languages", {});
const result = await callApi("list_possible_languages", {}); if (result.status === "error") throw new Error("Failed to fetch data");
if (result.status === "error") throw new Error("Failed to fetch data"); return result.data;
return result.data; },
staleTime: 1000 * 60 * 15, // 15 minutes
}));
/**
* Opens the custom file dialog
* Returns a native FileList to allow interaction with the native input type="file"
*/
const selectSshKeys = async (): Promise<FileList> => {
const dataTransfer = new DataTransfer();
const response = await callApi("open_file", {
file_request: {
title: "Select SSH Key",
mode: "open_multiple_files",
filters: { patterns: ["*.pub"] },
initial_folder: "~/.ssh",
}, },
staleTime: 1000 * 60 * 15, // 15 minutes });
}), if (response.status === "success" && response.data) {
); // Add synthetic files to the DataTransfer object
// FileList cannot be instantiated directly.
response.data.forEach((filename) => {
dataTransfer.items.add(new File([], filename));
});
}
return dataTransfer.files;
};
const handleSubmit = async (values: FlashFormValues) => { const handleSubmit = async (values: FlashFormValues) => {
setIsFlashing(true); console.log(
"Submit SSH Keys:",
values.sshKeys.map((file) => file.name),
);
try { try {
await callApi("flash_machine", { await callApi("flash_machine", {
machine: { machine: {
@@ -104,16 +103,15 @@ export const Flash = () => {
system_config: { system_config: {
language: values.language, language: values.language,
keymap: values.keymap, keymap: values.keymap,
ssh_keys_path: values.sshKeys, ssh_keys_path: values.sshKeys.map((file) => file.name),
}, },
dry_run: false, dry_run: false,
write_efi_boot_entries: false, write_efi_boot_entries: false,
debug: false, debug: false,
}); });
} catch (error) { } catch (error) {
toast.error(`Error could not flash disk: ${error}`);
console.error("Error submitting form:", error); console.error("Error submitting form:", error);
} finally {
setIsFlashing(false);
} }
}; };
@@ -126,24 +124,15 @@ export const Flash = () => {
> >
{(field, props) => ( {(field, props) => (
<> <>
<label class="input input-bordered flex items-center gap-2"> <TextInput
<span class="material-icons">file_download</span> formStore={formStore}
<input inputProps={props}
type="text" label="Installer (flake URL)"
class="grow" value={String(field.value)}
//placeholder="machine.flake" inlineLabel={<span class="material-icons">file_download</span>}
value="git+https://git.clan.lol/clan/clan-core" error={field.error}
required required
{...props} />
/>
</label>
<div class="label">
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</> </>
)} )}
</Field> </Field>
@@ -153,155 +142,125 @@ export const Flash = () => {
> >
{(field, props) => ( {(field, props) => (
<> <>
<label class="input input-bordered flex items-center gap-2"> <TextInput
<span class="material-icons">devices</span> formStore={formStore}
<input inputProps={props}
type="text" label="Installer Image (attribute name)"
class="grow" value={String(field.value)}
value="flash-installer" inlineLabel={<span class="material-icons">devices</span>}
required error={field.error}
{...props} required
/> />
</label>
<div class="label">
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</> </>
)} )}
</Field> </Field>
<Field name="disk" validate={[required("This field is required")]}> <Field name="disk" validate={[required("This field is required")]}>
{(field, props) => ( {(field, props) => (
<> <SelectInput
<label class="form-control input-bordered flex w-full items-center gap-2"> formStore={formStore}
<select selectProps={props}
required label="Flash Disk"
class="select select-bordered w-full" value={String(field.value)}
{...props} error={field.error}
> required
options={
<>
<option value="" disabled> <option value="" disabled>
Select a disk Select a disk
</option> </option>
<For each={devices?.blockdevices}> <For each={deviceQuery.data?.blockdevices}>
{(device) => ( {(device) => (
<option value={device.path}> <option value={device.path}>
{device.path} -- {device.size} bytes {device.path} -- {device.size} bytes
</option> </option>
)} )}
</For> </For>
</select> </>
<div class="label"> }
{isFetching && ( />
<span class="label-text-alt">
<span class="loading loading-bars"></span>
</span>
)}
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</label>
</>
)} )}
</Field> </Field>
<Field name="language" validate={[required("This field is required")]}> <Field name="language" validate={[required("This field is required")]}>
{(field, props) => ( {(field, props) => (
<> <>
<label class="form-control input-bordered flex w-full items-center gap-2"> <SelectInput
<select formStore={formStore}
required selectProps={props}
class="select select-bordered w-full" label="Language"
{...props} value={String(field.value)}
> error={field.error}
<option>en_US.UTF-8</option> required
<For each={languages}> options={
<For each={langQuery.data}>
{(language) => <option value={language}>{language}</option>} {(language) => <option value={language}>{language}</option>}
</For> </For>
</select> }
<div class="label"> />
{isFetchingLanguages && (
<span class="label-text-alt">
<span class="loading loading-bars">en_US.UTF-8</span>
</span>
)}
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</label>
</> </>
)} )}
</Field> </Field>
<Field name="keymap" validate={[required("This field is required")]}> <Field name="keymap" validate={[required("This field is required")]}>
{(field, props) => ( {(field, props) => (
<> <>
<label class="form-control input-bordered flex w-full items-center gap-2"> <SelectInput
<select formStore={formStore}
required selectProps={props}
class="select select-bordered w-full" label="Keymap"
{...props} value={String(field.value)}
> error={field.error}
<option>en</option> required
<For each={keymaps}> options={
<For each={keymapQuery.data}>
{(keymap) => <option value={keymap}>{keymap}</option>} {(keymap) => <option value={keymap}>{keymap}</option>}
</For> </For>
</select> }
<div class="label"> />
{isFetchingKeymaps && (
<span class="label-text-alt">
<span class="loading loading-bars"></span>
</span>
)}
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</label>
</> </>
)} )}
</Field> </Field>
<Field name="sshKeys" validate={[]} type="string[]">
<Field name="sshKeys" type="File[]">
{(field, props) => ( {(field, props) => (
<> <>
<label class="input input-bordered flex items-center gap-2"> <FileInput
<span class="material-icons">key</span> {...props}
<input onClick={async (event) => {
type="text" event.preventDefault(); // Prevent the native file dialog from opening
class="grow" const input = event.target;
placeholder="Select SSH Key" const files = await selectSshKeys();
value={field.value ? field.value.join(", ") : ""}
readOnly // Set the files
onClick={() => selectSshPubkey()} Object.defineProperty(input, "files", {
required value: files,
{...props} writable: true,
/> });
</label> // Define the files property on the input element
<div class="label"> const changeEvent = new Event("input", {
{field.error && ( bubbles: true,
<span class="label-text-alt font-bold text-error"> cancelable: true,
{field.error} });
</span> input.dispatchEvent(changeEvent);
)} }}
</div> value={field.value}
error={field.error}
label="Authorized SSH Keys"
multiple
required
/>
</> </>
)} )}
</Field> </Field>
<button class="btn btn-error" type="submit" disabled={isFlashing()}> <button
{isFlashing() ? ( class="btn btn-error"
type="submit"
disabled={formStore.submitting}
>
{formStore.submitting ? (
<span class="loading loading-spinner"></span> <span class="loading loading-spinner"></span>
) : ( ) : (
<span class="material-icons">bolt</span> <span class="material-icons">bolt</span>
)} )}
{isFlashing() ? "Flashing..." : "Flash Installer"} {formStore.submitting ? "Flashing..." : "Flash Installer"}
</button> </button>
</Form> </Form>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { callApi, OperationArgs, pyApi, OperationResponse } from "@/src/api"; import { callApi, OperationArgs, pyApi, OperationResponse } from "@/src/api";
import { activeURI, setRoute } from "@/src/App"; import { activeURI, setRoute } from "@/src/App";
import { TextInput } from "@/src/components/TextInput";
import { createForm, required, reset } from "@modular-forms/solid"; import { createForm, required, reset } from "@modular-forms/solid";
import { createQuery, useQueryClient } from "@tanstack/solid-query"; import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { Match, Switch } from "solid-js"; import { Match, Switch } from "solid-js";
@@ -57,93 +58,44 @@ export function CreateMachine() {
}; };
return ( return (
<div class="px-1"> <div class="px-1">
Create new Machine <span class="px-2">Create new Machine</span>
<button
onClick={() => {
reset(formStore);
}}
>
reset
</button>
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Field <Field
name="machine.name" name="machine.name"
validate={[required("This field is required")]} validate={[required("This field is required")]}
> >
{(field, props) => ( {(field, props) => (
<> <TextInput
<label inputProps={props}
class="input input-bordered flex items-center gap-2" formStore={formStore}
classList={{ value={`${field.value}`}
"input-disabled": formStore.submitting, label={"name"}
}} error={field.error}
> required
<input />
{...props}
value={field.value}
type="text"
class="grow"
placeholder="name"
required
disabled={formStore.submitting}
/>
</label>
<div class="label">
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</>
)} )}
</Field> </Field>
<Field name="machine.description"> <Field name="machine.description">
{(field, props) => ( {(field, props) => (
<> <TextInput
<label inputProps={props}
class="input input-bordered flex items-center gap-2" formStore={formStore}
classList={{ value={`${field.value}`}
"input-disabled": formStore.submitting, label={"description"}
}} error={field.error}
> />
<input
value={String(field.value)}
type="text"
class="grow"
placeholder="description"
required
{...props}
/>
</label>
<div class="label">
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div>
</>
)} )}
</Field> </Field>
<Field name="machine.deploy.targetHost"> <Field name="machine.deploy.targetHost">
{(field, props) => ( {(field, props) => (
<> <>
<label <TextInput
class="input input-bordered flex items-center gap-2" inputProps={props}
classList={{ formStore={formStore}
"input-disabled": formStore.submitting, value={`${field.value}`}
}} label={"Deployment target"}
> error={field.error}
<input />
value={String(field.value)}
type="text"
class="grow"
placeholder="root@flash-installer.local"
required
{...props}
/>
</label>
<div class="label"> <div class="label">
<span class="label-text-alt text-neutral"> <span class="label-text-alt text-neutral">
Must be set before deployment for the following tasks: Must be set before deployment for the following tasks:
@@ -159,11 +111,6 @@ export function CreateMachine() {
</li> </li>
</ul> </ul>
</span> </span>
{field.error && (
<span class="label-text-alt font-bold text-error">
{field.error}
</span>
)}
</div> </div>
</> </>
)} )}