ui/HostFileInput: refactor

- Contain api call within itself
- Flatten input attributes
- Fix directory validation type error
This commit is contained in:
Glen Huang
2025-09-26 10:51:24 +08:00
parent 587dde157f
commit 54c39edafd
5 changed files with 90 additions and 123 deletions

View File

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

View File

@@ -57,13 +57,8 @@ export type Story = StoryObj<typeof meta>;
export const Bare: Story = { export const Bare: Story = {
args: { 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",
}, },
},
}; };
export const Label: Story = { export const Label: Story = {

View File

@@ -1,92 +1,114 @@
import { import { TextField } from "@kobalte/core/text-field";
TextField,
TextFieldInputProps,
TextFieldRootProps,
} from "@kobalte/core/text-field";
import cx from "classnames"; import cx from "classnames";
import { Label } from "./Label"; import { Label } from "./Label";
import { Button } from "../Button/Button"; import { Button } from "../Button/Button";
import styles from "./HostFileInput.module.css"; import styles from "./HostFileInput.module.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { Orienter } from "./Orienter"; 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 { Tooltip } from "@kobalte/core/tooltip";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { keepTruthy } from "@/src/util"; import { keepTruthy } from "@/src/util";
import { callApi } from "@/src/hooks/api";
export type HostFileInputProps = FieldProps & export type HostFileInputProps = FieldProps & {
TextFieldRootProps & { name: string;
onSelectFile: () => Promise<string>; windowTitle?: string;
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>; required?: boolean;
disabled?: boolean;
placeholder?: string; 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) => { export const HostFileInput = (props: HostFileInputProps) => {
const withDefaults = mergeProps({ value: "" } as const, props); const [rootProps, inputProps, local, labelProps] = splitProps(
const [local, other] = splitProps(withDefaults, [ props,
"size", ["name", "defaultValue", "required", "disabled"],
"orientation", ["onInput", "onChange", "onBlur"],
"inverted", ["windowTitle", "initialFolder", "readOnly", "ref", "placeholder"],
"ghost", );
]);
const [value, setValue] = createSignal<string>(other.value);
let actualInputElement: HTMLInputElement | undefined; let inputElement!: HTMLInputElement;
const [value, setValue] = createSignal(rootProps.defaultValue || "");
const selectFile = async () => { const onSelectFile = async () => {
try { if (local.readOnly) {
console.log("selecting file", props.onSelectFile); return;
setValue(await props.onSelectFile()); }
actualInputElement?.dispatchEvent( 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 }), new Event("input", { bubbles: true, cancelable: true }),
); );
} catch (error) {
console.log("Error selecting file", error);
// todo work out how to display the error
}
}; };
return ( return (
<TextField {...other}> <TextField {...rootProps} value={value()}>
<Orienter <Orienter
orientation={local.orientation} orientation={labelProps.orientation}
align={local.orientation == "horizontal" ? "center" : "start"} align={labelProps.orientation == "horizontal" ? "center" : "start"}
> >
<Label <Label
{...labelProps}
labelComponent={TextField.Label} labelComponent={TextField.Label}
descriptionComponent={TextField.Description} descriptionComponent={TextField.Description}
in={keepTruthy( in={keepTruthy(
local.orientation == "horizontal" && "Orienter-horizontal", labelProps.orientation == "horizontal" && "Orienter-horizontal",
)} )}
{...withDefaults}
/> />
<TextField.Input <TextField.Input
{...inputProps}
hidden={true} hidden={true}
value={value()}
ref={(el: HTMLInputElement) => { ref={(el: HTMLInputElement) => {
actualInputElement = el; // Capture for local use inputElement = el;
local.ref?.(el);
}} }}
{...props.input}
/> />
{!value() && ( {!value() && (
<Button <Button
hierarchy="secondary" hierarchy="secondary"
size={local.size} size={labelProps.size}
icon="Folder" icon="Folder"
onClick={selectFile} onClick={onSelectFile}
disabled={other.disabled || other.readOnly} disabled={rootProps.disabled || local.readOnly}
elasticity={local.orientation === "vertical" ? "fit" : undefined} elasticity={
labelProps.orientation === "vertical" ? "fit" : undefined
}
in={ in={
local.orientation == "horizontal" labelProps.orientation == "horizontal"
? `HostFileInput-${local.orientation}` ? `HostFileInput-${labelProps.orientation}`
: undefined : undefined
} }
> >
{props.placeholder || "No Selection"} {local.placeholder || "No Selection"}
</Button> </Button>
)} )}
@@ -95,14 +117,14 @@ export const HostFileInput = (props: HostFileInputProps) => {
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content <Tooltip.Content
class={cx(styles.tooltipContent, { class={cx(styles.tooltipContent, {
[styles.inverted]: local.inverted, [styles.inverted]: labelProps.inverted,
})} })}
> >
<Typography <Typography
hierarchy="body" hierarchy="body"
size="xs" size="xs"
weight="medium" weight="medium"
inverted={!local.inverted} inverted={!labelProps.inverted}
> >
{value()} {value()}
</Typography> </Typography>
@@ -111,17 +133,19 @@ export const HostFileInput = (props: HostFileInputProps) => {
</Tooltip.Portal> </Tooltip.Portal>
<Tooltip.Trigger <Tooltip.Trigger
as={Button} as={Button}
elasticity={local.orientation === "vertical" ? "fit" : undefined} elasticity={
labelProps.orientation === "vertical" ? "fit" : undefined
}
in={ in={
local.orientation == "horizontal" labelProps.orientation == "horizontal"
? `HostFileInput-${local.orientation}` ? `HostFileInput-${labelProps.orientation}`
: undefined : undefined
} }
hierarchy="secondary" hierarchy="secondary"
size={local.size} size={labelProps.size}
icon="Folder" icon="Folder"
onClick={selectFile} onClick={onSelectFile}
disabled={props.disabled || props.readOnly} disabled={rootProps.disabled || local.readOnly}
> >
{value()} {value()}
</Tooltip.Trigger> </Tooltip.Trigger>

View File

@@ -34,7 +34,6 @@ import { TextArea } from "@/src/components/Form/TextArea";
import { Fieldset } from "@/src/components/Form/Fieldset"; import { Fieldset } from "@/src/components/Form/Fieldset";
import * as v from "valibot"; import * as v from "valibot";
import { HostFileInput } from "@/src/components/Form/HostFileInput"; import { HostFileInput } from "@/src/components/Form/HostFileInput";
import { callApi } from "@/src/hooks/api";
import { Creating } from "./Creating"; import { Creating } from "./Creating";
import { useApiClient } from "@/src/hooks/ApiClient"; import { useApiClient } from "@/src/hooks/ApiClient";
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal"; 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.")), description: v.pipe(v.string(), v.nonEmpty("Please describe your clan.")),
directory: v.pipe( directory: v.pipe(v.string(), v.nonEmpty("Please select a directory.")),
// 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."),
),
}); });
type SetupForm = v.InferInput<typeof SetupSchema>; type SetupForm = v.InferInput<typeof SetupSchema>;
@@ -195,35 +189,7 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
const formError = () => { const formError = () => {
const formErrors = getErrors(setupForm); const formErrors = getErrors(setupForm);
return ( return formErrors.name || formErrors.description || formErrors.directory;
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");
}; };
const client = useApiClient(); const client = useApiClient();
@@ -317,16 +283,14 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
type="error" type="error"
icon="Info" icon="Info"
title="Form error" title="Form error"
description={formError() || ""} description={formError()}
/> />
)} )}
<Fieldset name="meta"> <Fieldset name="meta">
<Field name="name"> <Field name="name">
{(field, input) => ( {(field, input) => (
<TextInput <TextInput
{...field}
label="Name" label="Name"
value={field.value}
required required
orientation="horizontal" orientation="horizontal"
validationState={ validationState={
@@ -343,8 +307,6 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
<Field name="description"> <Field name="description">
{(field, input) => ( {(field, input) => (
<TextArea <TextArea
{...field}
value={field.value}
label="Description" label="Description"
required required
orientation="horizontal" orientation="horizontal"
@@ -361,18 +323,13 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
<Fieldset name="location"> <Fieldset name="location">
<Field name="directory"> <Field name="directory">
{(field, input) => ( {(field, props) => (
<HostFileInput <HostFileInput
onSelectFile={onSelectFile} {...props}
{...field} windowTitle="Select a folder for you new Clan"
value={field.value}
label="Select directory" label="Select directory"
orientation="horizontal" orientation="horizontal"
required={true} required={true}
validationState={
getError(setupForm, "directory") ? "invalid" : "valid"
}
input={input}
/> />
)} )}
</Field> </Field>

View File

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