Merge pull request 'ui/HostFileInput: refactor' (#5280) from hgl-hostfileinput into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5280
Reviewed-by: hsjobeki <hsjobeki@gmail.com>
This commit is contained in:
hsjobeki
2025-09-26 13:53:02 +00:00
5 changed files with 90 additions and 123 deletions

View File

@@ -64,11 +64,7 @@ export const Default: Story = {
label="Bio"
input={{ placeholder: "Tell us a bit about yourself", rows: 8 }}
/>
<HostFileInput
{...props}
label="Profile pic"
onSelectFile={async () => "/home/foo/bar/baz/fizz/buzz/bla/bizz"}
/>
<HostFileInput {...props} name="profile_pic" label="Profile pic" />
<Checkbox {...props} label="Accept Terms" required={true} />
</>
),

View File

@@ -57,12 +57,7 @@ export type Story = StoryObj<typeof meta>;
export const Bare: Story = {
args: {
onSelectFile: async () => {
return "/home/github/clans/my-clan/foo/bar/baz/fizz/buzz";
},
input: {
placeholder: "e.g. 11/06/89",
},
placeholder: "e.g. 11/06/89",
},
};

View File

@@ -1,92 +1,114 @@
import {
TextField,
TextFieldInputProps,
TextFieldRootProps,
} from "@kobalte/core/text-field";
import { TextField } from "@kobalte/core/text-field";
import cx from "classnames";
import { Label } from "./Label";
import { Button } from "../Button/Button";
import styles from "./HostFileInput.module.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import { createSignal, mergeProps, splitProps } from "solid-js";
import { createSignal, JSX, splitProps } from "solid-js";
import { Tooltip } from "@kobalte/core/tooltip";
import { Typography } from "@/src/components/Typography/Typography";
import { keepTruthy } from "@/src/util";
import { callApi } from "@/src/hooks/api";
export type HostFileInputProps = FieldProps &
TextFieldRootProps & {
onSelectFile: () => Promise<string>;
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
placeholder?: string;
};
export type HostFileInputProps = FieldProps & {
name: string;
windowTitle?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
initialFolder?: string;
readOnly?: boolean;
defaultValue?: string;
ref?: (element: HTMLInputElement) => void;
onInput?: JSX.EventHandler<
HTMLInputElement | HTMLTextAreaElement,
InputEvent
>;
onChange?: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, Event>;
onBlur?: JSX.EventHandler<HTMLInputElement | HTMLTextAreaElement, FocusEvent>;
};
export const HostFileInput = (props: HostFileInputProps) => {
const withDefaults = mergeProps({ value: "" } as const, props);
const [local, other] = splitProps(withDefaults, [
"size",
"orientation",
"inverted",
"ghost",
]);
const [value, setValue] = createSignal<string>(other.value);
const [rootProps, inputProps, local, labelProps] = splitProps(
props,
["name", "defaultValue", "required", "disabled"],
["onInput", "onChange", "onBlur"],
["windowTitle", "initialFolder", "readOnly", "ref", "placeholder"],
);
let actualInputElement: HTMLInputElement | undefined;
let inputElement!: HTMLInputElement;
const [value, setValue] = createSignal(rootProps.defaultValue || "");
const selectFile = async () => {
try {
console.log("selecting file", props.onSelectFile);
setValue(await props.onSelectFile());
actualInputElement?.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true }),
);
} catch (error) {
console.log("Error selecting file", error);
// todo work out how to display the error
const onSelectFile = async () => {
if (local.readOnly) {
return;
}
const req = callApi("get_system_file", {
file_request: {
mode: "select_folder",
title: local.windowTitle || labelProps.label,
initial_folder: local.initialFolder,
},
});
const resp = await req.result;
// TOOD: When a user clicks cancel button in the file picker, an error will
// be return, the backend should provide more data so we can target the
// cancellation specifically and not swallow other errors
if (resp.status === "error") {
return;
}
setValue(resp.data![0]);
inputElement.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true }),
);
};
return (
<TextField {...other}>
<TextField {...rootProps} value={value()}>
<Orienter
orientation={local.orientation}
align={local.orientation == "horizontal" ? "center" : "start"}
orientation={labelProps.orientation}
align={labelProps.orientation == "horizontal" ? "center" : "start"}
>
<Label
{...labelProps}
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
in={keepTruthy(
local.orientation == "horizontal" && "Orienter-horizontal",
labelProps.orientation == "horizontal" && "Orienter-horizontal",
)}
{...withDefaults}
/>
<TextField.Input
{...inputProps}
hidden={true}
value={value()}
ref={(el: HTMLInputElement) => {
actualInputElement = el; // Capture for local use
inputElement = el;
local.ref?.(el);
}}
{...props.input}
/>
{!value() && (
<Button
hierarchy="secondary"
size={local.size}
size={labelProps.size}
icon="Folder"
onClick={selectFile}
disabled={other.disabled || other.readOnly}
elasticity={local.orientation === "vertical" ? "fit" : undefined}
onClick={onSelectFile}
disabled={rootProps.disabled || local.readOnly}
elasticity={
labelProps.orientation === "vertical" ? "fit" : undefined
}
in={
local.orientation == "horizontal"
? `HostFileInput-${local.orientation}`
labelProps.orientation == "horizontal"
? `HostFileInput-${labelProps.orientation}`
: undefined
}
>
{props.placeholder || "No Selection"}
{local.placeholder || "No Selection"}
</Button>
)}
@@ -95,14 +117,14 @@ export const HostFileInput = (props: HostFileInputProps) => {
<Tooltip.Portal>
<Tooltip.Content
class={cx(styles.tooltipContent, {
[styles.inverted]: local.inverted,
[styles.inverted]: labelProps.inverted,
})}
>
<Typography
hierarchy="body"
size="xs"
weight="medium"
inverted={!local.inverted}
inverted={!labelProps.inverted}
>
{value()}
</Typography>
@@ -111,17 +133,19 @@ export const HostFileInput = (props: HostFileInputProps) => {
</Tooltip.Portal>
<Tooltip.Trigger
as={Button}
elasticity={local.orientation === "vertical" ? "fit" : undefined}
elasticity={
labelProps.orientation === "vertical" ? "fit" : undefined
}
in={
local.orientation == "horizontal"
? `HostFileInput-${local.orientation}`
labelProps.orientation == "horizontal"
? `HostFileInput-${labelProps.orientation}`
: undefined
}
hierarchy="secondary"
size={local.size}
size={labelProps.size}
icon="Folder"
onClick={selectFile}
disabled={props.disabled || props.readOnly}
onClick={onSelectFile}
disabled={rootProps.disabled || local.readOnly}
>
{value()}
</Tooltip.Trigger>

View File

@@ -34,7 +34,6 @@ import { TextArea } from "@/src/components/Form/TextArea";
import { Fieldset } from "@/src/components/Form/Fieldset";
import * as v from "valibot";
import { HostFileInput } from "@/src/components/Form/HostFileInput";
import { callApi } from "@/src/hooks/api";
import { Creating } from "./Creating";
import { useApiClient } from "@/src/hooks/ApiClient";
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
@@ -51,12 +50,7 @@ const SetupSchema = v.object({
),
),
description: v.pipe(v.string(), v.nonEmpty("Please describe your clan.")),
directory: v.pipe(
// initial value is undefined, and I can't see how to handle this better in valibot, so for now when the type
// is incorrect we treat it as empty
v.string("Please select a directory."),
v.nonEmpty("Please select a directory."),
),
directory: v.pipe(v.string(), v.nonEmpty("Please select a directory.")),
});
type SetupForm = v.InferInput<typeof SetupSchema>;
@@ -195,35 +189,7 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
const formError = () => {
const formErrors = getErrors(setupForm);
return (
formErrors.name ||
formErrors.description ||
formErrors.directory ||
undefined
);
};
const onSelectFile = async () => {
const req = callApi("get_system_file", {
file_request: {
mode: "select_folder",
title: "Select a folder for you new Clan",
},
});
const resp = await req.result;
if (resp.status === "error") {
// just throw the first error, I can't imagine why there would be multiple
// errors for this call
throw new Error(resp.errors[0].message);
}
if (resp.status === "success" && resp.data) {
return resp.data[0];
}
throw new Error("No data returned from api call");
return formErrors.name || formErrors.description || formErrors.directory;
};
const client = useApiClient();
@@ -317,16 +283,14 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
type="error"
icon="Info"
title="Form error"
description={formError() || ""}
description={formError()}
/>
)}
<Fieldset name="meta">
<Field name="name">
{(field, input) => (
<TextInput
{...field}
label="Name"
value={field.value}
required
orientation="horizontal"
validationState={
@@ -343,8 +307,6 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
<Field name="description">
{(field, input) => (
<TextArea
{...field}
value={field.value}
label="Description"
required
orientation="horizontal"
@@ -361,18 +323,13 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
<Fieldset name="location">
<Field name="directory">
{(field, input) => (
{(field, props) => (
<HostFileInput
onSelectFile={onSelectFile}
{...field}
value={field.value}
{...props}
windowTitle="Select a folder for you new Clan"
label="Select directory"
orientation="horizontal"
required={true}
validationState={
getError(setupForm, "directory") ? "invalid" : "valid"
}
input={input}
/>
)}
</Field>

View File

@@ -147,20 +147,15 @@ const ConfigureImage = () => {
<div class="flex flex-col gap-2">
<Fieldset>
<Field name="ssh_key">
{(field, input) => (
{(field, props) => (
<HostFileInput
{...props}
description="Public Key for connecting to the machine"
onSelectFile={onSelectFile}
{...field}
value={field.value}
label="Public Key"
orientation="horizontal"
placeholder="Select SSH Key"
initialFolder="~/.ssh"
required={true}
validationState={
getError(formStore, "ssh_key") ? "invalid" : "valid"
}
input={input}
/>
)}
</Field>