From 7e9214053f329abd1cf41e425e750e89affe200a Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 7 Aug 2024 12:16:06 +0200 Subject: [PATCH] Clan-app: Flash improve form & file input --- .../app/src/components/FileInput.tsx | 91 +++++ .../app/src/components/SelectInput.tsx | 48 +++ .../app/src/components/TextInput.tsx | 54 +++ pkgs/webview-ui/app/src/routes/flash/view.tsx | 331 ++++++++---------- .../app/src/routes/machines/create.tsx | 101 ++---- 5 files changed, 362 insertions(+), 263 deletions(-) create mode 100644 pkgs/webview-ui/app/src/components/FileInput.tsx create mode 100644 pkgs/webview-ui/app/src/components/SelectInput.tsx create mode 100644 pkgs/webview-ui/app/src/components/TextInput.tsx diff --git a/pkgs/webview-ui/app/src/components/FileInput.tsx b/pkgs/webview-ui/app/src/components/FileInput.tsx new file mode 100644 index 000000000..601ee632d --- /dev/null +++ b/pkgs/webview-ui/app/src/components/FileInput.tsx @@ -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; + onClick: JSX.EventHandler; + onChange: JSX.EventHandler; + onBlur: JSX.EventHandler; + 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 ( +
+
+ + {props.label} + +
+ +
+ Click to select file{props.multiple && "s"}} + > + Selected file{props.multiple && "s"}:{" "} + {getFiles() + .map(({ name }) => name) + .join(", ")} + + 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 && ( + {props.error} + )} +
+
+ ); +} diff --git a/pkgs/webview-ui/app/src/components/SelectInput.tsx b/pkgs/webview-ui/app/src/components/SelectInput.tsx new file mode 100644 index 000000000..073ae2a62 --- /dev/null +++ b/pkgs/webview-ui/app/src/components/SelectInput.tsx @@ -0,0 +1,48 @@ +import { FieldValues, FormStore, ResponseData } from "@modular-forms/solid"; +import { type JSX } from "solid-js"; + +interface SelectInputProps { + formStore: FormStore; + value: string; + options: JSX.Element; + selectProps: JSX.HTMLAttributes; + label: JSX.Element; + error?: string; + required?: boolean; +} + +export function SelectInput( + props: SelectInputProps, +) { + return ( + + ); +} diff --git a/pkgs/webview-ui/app/src/components/TextInput.tsx b/pkgs/webview-ui/app/src/components/TextInput.tsx new file mode 100644 index 000000000..c1146ec8f --- /dev/null +++ b/pkgs/webview-ui/app/src/components/TextInput.tsx @@ -0,0 +1,54 @@ +import { FieldValues, FormStore, ResponseData } from "@modular-forms/solid"; +import { type JSX } from "solid-js"; + +interface TextInputProps { + formStore: FormStore; + value: string; + inputProps: JSX.HTMLAttributes; + label: JSX.Element; + error?: string; + required?: boolean; + inlineLabel?: JSX.Element; +} + +export function TextInput( + props: TextInputProps, +) { + return ( + + ); +} diff --git a/pkgs/webview-ui/app/src/routes/flash/view.tsx b/pkgs/webview-ui/app/src/routes/flash/view.tsx index c5ae34d24..1e2ec26cf 100644 --- a/pkgs/webview-ui/app/src/routes/flash/view.tsx +++ b/pkgs/webview-ui/app/src/routes/flash/view.tsx @@ -1,12 +1,11 @@ import { callApi, OperationResponse } from "@/src/api"; -import { - createForm, - required, - FieldValues, - setValue, -} from "@modular-forms/solid"; +import { FileInput } from "@/src/components/FileInput"; +import { SelectInput } from "@/src/components/SelectInput"; +import { TextInput } from "@/src/components/TextInput"; +import { createForm, required, FieldValues } from "@modular-forms/solid"; 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 { machine: { @@ -16,58 +15,32 @@ interface FlashFormValues extends FieldValues { disk: string; language: string; keymap: string; - sshKeys: string[]; + sshKeys: File[]; } -type BlockDevices = Extract< - OperationResponse<"show_block_devices">, - { status: "success" } ->["data"]["blockdevices"]; - export const Flash = () => { - const [formStore, { Form, Field }] = createForm({}); - const [sshKeys, setSshKeys] = createSignal([]); - const [isFlashing, setIsFlashing] = createSignal(false); - - const selectSshPubkey = async () => { - try { - const loc = await callApi("open_file", { - file_request: { - 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 [formStore, { Form, Field }] = createForm({ + initialValues: { + machine: { + flake: "git+https://git.clan.lol/clan/clan-core", + devicePath: "flash-installer", + }, + language: "en_US.UTF-8", + keymap: "en", + }, }); - const { data: devices, isFetching } = createQuery(() => ({ + const deviceQuery = createQuery(() => ({ queryKey: ["block_devices"], queryFn: async () => { const result = await callApi("show_block_devices", {}); if (result.status === "error") throw new Error("Failed to fetch 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"], queryFn: async () => { const result = await callApi("list_possible_keymaps", {}); @@ -77,20 +50,46 @@ export const Flash = () => { staleTime: 1000 * 60 * 15, // 15 minutes })); - const { data: languages, isFetching: isFetchingLanguages } = createQuery( - () => ({ - queryKey: ["list_languages"], - queryFn: async () => { - const result = await callApi("list_possible_languages", {}); - if (result.status === "error") throw new Error("Failed to fetch data"); - return result.data; + const langQuery = createQuery(() => ({ + queryKey: ["list_languages"], + queryFn: async () => { + const result = await callApi("list_possible_languages", {}); + if (result.status === "error") throw new Error("Failed to fetch 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 => { + 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) => { - setIsFlashing(true); + console.log( + "Submit SSH Keys:", + values.sshKeys.map((file) => file.name), + ); try { await callApi("flash_machine", { machine: { @@ -104,16 +103,15 @@ export const Flash = () => { system_config: { language: values.language, keymap: values.keymap, - ssh_keys_path: values.sshKeys, + ssh_keys_path: values.sshKeys.map((file) => file.name), }, dry_run: false, write_efi_boot_entries: false, debug: false, }); } catch (error) { + toast.error(`Error could not flash disk: ${error}`); console.error("Error submitting form:", error); - } finally { - setIsFlashing(false); } }; @@ -126,24 +124,15 @@ export const Flash = () => { > {(field, props) => ( <> - -
- {field.error && ( - - {field.error} - - )} -
+ file_download} + error={field.error} + required + /> )} @@ -153,155 +142,125 @@ export const Flash = () => { > {(field, props) => ( <> - -
- {field.error && ( - - {field.error} - - )} -
+ devices} + error={field.error} + required + /> )} {(field, props) => ( - <> - - + + } + /> )} {(field, props) => ( <> - + } + /> )} {(field, props) => ( <> - + } + /> )} - + + {(field, props) => ( <> - -
- {field.error && ( - - {field.error} - - )} -
+ { + event.preventDefault(); // Prevent the native file dialog from opening + const input = event.target; + const files = await selectSshKeys(); + + // Set the files + Object.defineProperty(input, "files", { + value: files, + writable: true, + }); + // Define the files property on the input element + const changeEvent = new Event("input", { + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(changeEvent); + }} + value={field.value} + error={field.error} + label="Authorized SSH Keys" + multiple + required + /> )}
- diff --git a/pkgs/webview-ui/app/src/routes/machines/create.tsx b/pkgs/webview-ui/app/src/routes/machines/create.tsx index 7f8cfd4f2..fbd0d7a5e 100644 --- a/pkgs/webview-ui/app/src/routes/machines/create.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/create.tsx @@ -1,5 +1,6 @@ import { callApi, OperationArgs, pyApi, OperationResponse } from "@/src/api"; import { activeURI, setRoute } from "@/src/App"; +import { TextInput } from "@/src/components/TextInput"; import { createForm, required, reset } from "@modular-forms/solid"; import { createQuery, useQueryClient } from "@tanstack/solid-query"; import { Match, Switch } from "solid-js"; @@ -57,93 +58,44 @@ export function CreateMachine() { }; return (
- Create new Machine - + Create new Machine
{(field, props) => ( - <> - -
- {field.error && ( - - {field.error} - - )} -
- + )}
{(field, props) => ( - <> - -
- {field.error && ( - - {field.error} - - )} -
- + )}
{(field, props) => ( <> - +
Must be set before deployment for the following tasks: @@ -159,11 +111,6 @@ export function CreateMachine() { - {field.error && ( - - {field.error} - - )}
)}