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:
@@ -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} />
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user