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"
|
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} />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -57,12 +57,7 @@ export type Story = StoryObj<typeof meta>;
|
|||||||
|
|
||||||
export const Bare: Story = {
|
export const Bare: Story = {
|
||||||
args: {
|
args: {
|
||||||
onSelectFile: async () => {
|
placeholder: "e.g. 11/06/89",
|
||||||
return "/home/github/clans/my-clan/foo/bar/baz/fizz/buzz";
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
placeholder: "e.g. 11/06/89",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
placeholder?: string;
|
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) => {
|
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(
|
|
||||||
new Event("input", { bubbles: true, cancelable: true }),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.log("Error selecting file", error);
|
|
||||||
// todo work out how to display the error
|
|
||||||
}
|
}
|
||||||
|
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 (
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user