feat(ui): add sidebar pane for machine detail
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
Checkbox as KCheckbox,
|
||||
CheckboxInputProps as KCheckboxInputProps,
|
||||
CheckboxRootProps as KCheckboxRootProps,
|
||||
} from "@kobalte/core/checkbox";
|
||||
|
||||
import { Checkbox as KCheckbox } from "@kobalte/core";
|
||||
|
||||
import Icon from "@/src/components/Icon/Icon";
|
||||
|
||||
import cx from "classnames";
|
||||
@@ -11,7 +13,7 @@ import { PolymorphicProps } from "@kobalte/core/polymorphic";
|
||||
import "./Checkbox.css";
|
||||
import { FieldProps } from "./Field";
|
||||
import { Orienter } from "./Orienter";
|
||||
import { Show } from "solid-js";
|
||||
import { Match, splitProps, Switch } from "solid-js";
|
||||
|
||||
export type CheckboxProps = FieldProps &
|
||||
KCheckboxRootProps & {
|
||||
@@ -19,6 +21,9 @@ export type CheckboxProps = FieldProps &
|
||||
};
|
||||
|
||||
export const Checkbox = (props: CheckboxProps) => {
|
||||
// we need to separate output the input otherwise it interferes with prop binding
|
||||
const [_, rootProps] = splitProps(props, ["input"]);
|
||||
|
||||
const alignment = () =>
|
||||
(props.orientation || "vertical") == "vertical" ? "start" : "center";
|
||||
|
||||
@@ -41,13 +46,14 @@ export const Checkbox = (props: CheckboxProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<KCheckbox
|
||||
<KCheckbox.Root
|
||||
class={cx("form-field", "checkbox", props.size, props.orientation, {
|
||||
inverted: props.inverted,
|
||||
ghost: props.ghost,
|
||||
})}
|
||||
{...props}
|
||||
{...rootProps}
|
||||
>
|
||||
{(state) => (
|
||||
<Orienter orientation={props.orientation} align={alignment()}>
|
||||
<Label
|
||||
labelComponent={KCheckbox.Label}
|
||||
@@ -56,19 +62,20 @@ export const Checkbox = (props: CheckboxProps) => {
|
||||
/>
|
||||
<KCheckbox.Input {...props.input} />
|
||||
<KCheckbox.Control class="checkbox-control">
|
||||
{props.readOnly && (
|
||||
<Show
|
||||
when={props.checked || props.defaultChecked}
|
||||
fallback={iconUnchecked}
|
||||
>
|
||||
{iconChecked}
|
||||
</Show>
|
||||
)}
|
||||
{!props.readOnly && (
|
||||
<Switch>
|
||||
<Match when={!props.readOnly}>
|
||||
<KCheckbox.Indicator>{iconChecked}</KCheckbox.Indicator>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.readOnly && state.checked()}>
|
||||
{iconChecked}
|
||||
</Match>
|
||||
<Match when={props.readOnly && !state.checked()}>
|
||||
{iconUnchecked}
|
||||
</Match>
|
||||
</Switch>
|
||||
</KCheckbox.Control>
|
||||
</Orienter>
|
||||
</KCheckbox>
|
||||
)}
|
||||
</KCheckbox.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,8 @@ export type TextInputProps = FieldProps &
|
||||
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
|
||||
};
|
||||
|
||||
export const TextInput = (props: TextInputProps) => (
|
||||
export const TextInput = (props: TextInputProps) => {
|
||||
return (
|
||||
<TextField
|
||||
class={cx("form-field", "text", props.size, props.orientation, {
|
||||
inverted: props.inverted,
|
||||
@@ -48,3 +49,4 @@ export const TextInput = (props: TextInputProps) => (
|
||||
</Orienter>
|
||||
</TextField>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { For } from "solid-js";
|
||||
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
||||
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
|
||||
import { useMachinesQuery } from "@/src/queries/queries";
|
||||
import { useMachinesQuery } from "@/src/hooks/queries";
|
||||
import { SidebarProps } from "./Sidebar";
|
||||
|
||||
interface MachineProps {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DropdownMenu } from "@kobalte/core/dropdown-menu";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { Typography } from "../Typography/Typography";
|
||||
import { createSignal, For, Suspense } from "solid-js";
|
||||
import { useClanListQuery } from "@/src/queries/queries";
|
||||
import { useClanListQuery } from "@/src/hooks/queries";
|
||||
import { navigateToClan, useClanURI } from "@/src/hooks/clan";
|
||||
import { clanURIs } from "@/src/stores/clan";
|
||||
|
||||
|
||||
@@ -8,7 +8,32 @@ import { Divider } from "@/src/components/Divider/Divider";
|
||||
import { TextInput } from "@/src/components/Form/TextInput";
|
||||
import { TextArea } from "@/src/components/Form/TextArea";
|
||||
import { Checkbox } from "@/src/components/Form/Checkbox";
|
||||
import { Combobox } from "../Form/Combobox";
|
||||
import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm";
|
||||
import * as v from "valibot";
|
||||
import { splitProps } from "solid-js";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
|
||||
type Story = StoryObj<SidebarPaneProps>;
|
||||
|
||||
const schema = v.object({
|
||||
firstName: v.pipe(v.string(), v.nonEmpty("Please enter a first name.")),
|
||||
lastName: v.pipe(v.string(), v.nonEmpty("Please enter a last name.")),
|
||||
bio: v.string(),
|
||||
shareProfile: v.optional(v.boolean()),
|
||||
tags: v.pipe(v.optional(v.array(v.string()))),
|
||||
});
|
||||
|
||||
const clanURI = "/home/brian/clans/my-clan";
|
||||
|
||||
const profiles = {
|
||||
ron: {
|
||||
firstName: "Ron",
|
||||
lastName: "Burgundy",
|
||||
bio: "It's actually an optical illusion, it's the pattern on the pants.",
|
||||
shareProfile: true,
|
||||
tags: ["All", "Home Server", "Backup", "Random"],
|
||||
},
|
||||
};
|
||||
|
||||
const meta: Meta<SidebarPaneProps> = {
|
||||
title: "Components/SidebarPane",
|
||||
@@ -17,8 +42,6 @@ const meta: Meta<SidebarPaneProps> = {
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<SidebarPaneProps>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: "Neptune",
|
||||
@@ -27,87 +50,115 @@ export const Default: Story = {
|
||||
},
|
||||
children: (
|
||||
<>
|
||||
<SidebarSection
|
||||
<SidebarSectionForm
|
||||
title="General"
|
||||
onSave={async () => {
|
||||
schema={schema}
|
||||
initialValues={profiles.ron}
|
||||
onSubmit={async () => {
|
||||
console.log("saving general");
|
||||
}}
|
||||
>
|
||||
{(editing) => (
|
||||
<form class="flex flex-col gap-3">
|
||||
{({ editing, Field }) => (
|
||||
<div class="flex flex-col gap-3">
|
||||
<Field name="firstName">
|
||||
{(field, input) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
size="s"
|
||||
inverted
|
||||
label="First Name"
|
||||
size="s"
|
||||
inverted={true}
|
||||
required={true}
|
||||
value={field.value}
|
||||
required
|
||||
readOnly={!editing}
|
||||
orientation="horizontal"
|
||||
input={{ value: "Ron" }}
|
||||
input={input}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Divider />
|
||||
<Field name="lastName">
|
||||
{(field, input) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
size="s"
|
||||
inverted
|
||||
label="Last Name"
|
||||
size="s"
|
||||
inverted={true}
|
||||
required={true}
|
||||
value={field.value}
|
||||
required
|
||||
readOnly={!editing}
|
||||
orientation="horizontal"
|
||||
input={{ value: "Burgundy" }}
|
||||
input={input}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Divider />
|
||||
<Field name="bio">
|
||||
{(field, input) => (
|
||||
<TextArea
|
||||
{...field}
|
||||
value={field.value}
|
||||
size="s"
|
||||
label="Bio"
|
||||
size="s"
|
||||
inverted={true}
|
||||
inverted
|
||||
readOnly={!editing}
|
||||
orientation="horizontal"
|
||||
input={{
|
||||
value:
|
||||
"It's actually an optical illusion, it's the pattern on the pants.",
|
||||
rows: 4,
|
||||
}}
|
||||
input={{ ...input, rows: 4 }}
|
||||
/>
|
||||
<Divider />
|
||||
)}
|
||||
</Field>
|
||||
<Field name="shareProfile" type="boolean">
|
||||
{(field, input) => {
|
||||
return (
|
||||
<Checkbox
|
||||
{...splitProps(field, ["value"])[1]}
|
||||
defaultChecked={field.value}
|
||||
size="s"
|
||||
label="Share Profile"
|
||||
required={true}
|
||||
inverted={true}
|
||||
readOnly={!editing}
|
||||
checked={true}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</SidebarSection>
|
||||
<SidebarSection
|
||||
title="Tags"
|
||||
onSave={async () => {
|
||||
console.log("saving general");
|
||||
}}
|
||||
>
|
||||
{(editing) => (
|
||||
<form class="flex flex-col gap-3">
|
||||
<Combobox
|
||||
size="s"
|
||||
inverted={true}
|
||||
required={true}
|
||||
label="Share"
|
||||
inverted
|
||||
readOnly={!editing}
|
||||
orientation="horizontal"
|
||||
multiple={true}
|
||||
options={["All", "Home Server", "Backup", "Random"]}
|
||||
defaultValue={["All", "Home Server", "Backup", "Random"]}
|
||||
input={input}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</SidebarSection>
|
||||
<SidebarSection
|
||||
title="Advanced Settings"
|
||||
onSave={async () => {
|
||||
console.log("saving general");
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(editing) => <></>}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</SidebarSectionForm>
|
||||
{/* todo fix tags component */}
|
||||
{/*<SidebarSectionForm*/}
|
||||
{/* title="Tags"*/}
|
||||
{/* schema={schema}*/}
|
||||
{/* initialValues={profiles.ron}*/}
|
||||
{/* onSubmit={async () => {*/}
|
||||
{/* console.log("saving general");*/}
|
||||
{/* }}*/}
|
||||
{/*>*/}
|
||||
{/* {({ editing, Field }) => (*/}
|
||||
{/* <Field name="tags">*/}
|
||||
{/* {(field, input) => (*/}
|
||||
{/* <Combobox*/}
|
||||
{/* {...field}*/}
|
||||
{/* value={field.value}*/}
|
||||
{/* options={field.value || []}*/}
|
||||
{/* size="s"*/}
|
||||
{/* inverted*/}
|
||||
{/* required*/}
|
||||
{/* readOnly={!editing}*/}
|
||||
{/* orientation="horizontal"*/}
|
||||
{/* multiple*/}
|
||||
{/* />*/}
|
||||
{/* )}*/}
|
||||
{/* </Field>*/}
|
||||
{/* )}*/}
|
||||
{/*</SidebarSectionForm>*/}
|
||||
<SidebarSection title="Simple" class="flex flex-col">
|
||||
<Typography tag="h2" hierarchy="title" size="m" inverted>
|
||||
Static Content
|
||||
</Typography>
|
||||
<Typography hierarchy="label" size="s" inverted>
|
||||
This is a non-form section with static content
|
||||
</Typography>
|
||||
</SidebarSection>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
div.sidebar-section {
|
||||
@apply flex flex-col gap-2 w-full h-full;
|
||||
@apply flex flex-col gap-2 w-full h-fit;
|
||||
|
||||
& > div.header {
|
||||
@apply flex items-center justify-between px-1.5;
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import { createSignal, JSX } from "solid-js";
|
||||
import { JSX } from "solid-js";
|
||||
import "./SidebarSection.css";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { Button as KButton } from "@kobalte/core/button";
|
||||
import Icon from "../Icon/Icon";
|
||||
import cx from "classnames";
|
||||
|
||||
export interface SidebarSectionProps {
|
||||
title: string;
|
||||
onSave: () => Promise<void>;
|
||||
children: (editing: boolean) => JSX.Element;
|
||||
class?: string;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export const SidebarSection = (props: SidebarSectionProps) => {
|
||||
const [editing, setEditing] = createSignal(false);
|
||||
|
||||
const save = async () => {
|
||||
// todo how do we surface errors?
|
||||
await props.onSave();
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="sidebar-section">
|
||||
<div class={cx("sidebar-section", props.class)}>
|
||||
<div class="header">
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
@@ -33,29 +24,8 @@ export const SidebarSection = (props: SidebarSectionProps) => {
|
||||
>
|
||||
{props.title}
|
||||
</Typography>
|
||||
<div class="controls">
|
||||
{editing() && (
|
||||
<KButton>
|
||||
<Icon
|
||||
icon="Checkmark"
|
||||
color="tertiary"
|
||||
size="0.75rem"
|
||||
inverted={true}
|
||||
onClick={save}
|
||||
/>
|
||||
</KButton>
|
||||
)}
|
||||
<KButton onClick={() => setEditing(!editing())}>
|
||||
<Icon
|
||||
icon={editing() ? "Close" : "Edit"}
|
||||
color="tertiary"
|
||||
size="0.75rem"
|
||||
inverted={true}
|
||||
/>
|
||||
</KButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">{props.children(editing())}</div>
|
||||
<div class="content">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
119
pkgs/clan-app/ui/src/components/Sidebar/SidebarSectionForm.tsx
Normal file
119
pkgs/clan-app/ui/src/components/Sidebar/SidebarSectionForm.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { createSignal, JSX, Show } from "solid-js";
|
||||
import {
|
||||
createForm,
|
||||
FieldValues,
|
||||
getErrors,
|
||||
Maybe,
|
||||
PartialValues,
|
||||
reset,
|
||||
SubmitHandler,
|
||||
valiForm,
|
||||
} from "@modular-forms/solid";
|
||||
import { OperationNames, SuccessData } from "@/src/hooks/api";
|
||||
import { GenericSchema, GenericSchemaAsync } from "valibot";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { Button as KButton } from "@kobalte/core/button";
|
||||
import Icon from "@/src/components/Icon/Icon";
|
||||
|
||||
import "./SidebarSection.css";
|
||||
import { Loader } from "../../components/Loader/Loader";
|
||||
|
||||
export interface SidebarSectionFormProps<FormValues extends FieldValues> {
|
||||
title: string;
|
||||
schema: GenericSchema<FormValues> | GenericSchemaAsync<FormValues>;
|
||||
initialValues: PartialValues<FormValues>;
|
||||
onSubmit: (values: FormValues) => Promise<void>;
|
||||
children: (ctx: {
|
||||
editing: boolean;
|
||||
Field: ReturnType<typeof createForm<FormValues>>[1]["Field"];
|
||||
}) => JSX.Element;
|
||||
}
|
||||
|
||||
export function SidebarSectionForm<
|
||||
T extends OperationNames,
|
||||
FormValues extends FieldValues = SuccessData<T> extends FieldValues
|
||||
? SuccessData<T>
|
||||
: never,
|
||||
>(props: SidebarSectionFormProps<FormValues>) {
|
||||
const [editing, setEditing] = createSignal(false);
|
||||
|
||||
const [formStore, { Form, Field }] = createForm<FormValues>({
|
||||
initialValues: props.initialValues,
|
||||
validate: valiForm<FormValues>(props.schema),
|
||||
});
|
||||
|
||||
const editOrClose = () => {
|
||||
if (editing()) {
|
||||
reset(formStore, props.initialValues);
|
||||
setEditing(false);
|
||||
} else {
|
||||
setEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit: SubmitHandler<FormValues> = async (values, event) => {
|
||||
await props.onSubmit(values);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const errorMessage = (): Maybe<string> => {
|
||||
const formErrors = getErrors(formStore);
|
||||
|
||||
const firstFormError = Object.values(formErrors).find(
|
||||
(value) => value,
|
||||
) as Maybe<string>;
|
||||
|
||||
return firstFormError || formStore.response.message;
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div class="sidebar-section">
|
||||
<div class="header">
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
family="mono"
|
||||
weight="light"
|
||||
transform="uppercase"
|
||||
color="tertiary"
|
||||
inverted
|
||||
>
|
||||
{props.title}
|
||||
</Typography>
|
||||
<div class="controls">
|
||||
{editing() && !formStore.submitting && (
|
||||
<KButton type="submit">
|
||||
<Icon
|
||||
icon="Checkmark"
|
||||
color="tertiary"
|
||||
size="0.75rem"
|
||||
inverted
|
||||
/>
|
||||
</KButton>
|
||||
)}
|
||||
{editing() && formStore.submitting && <Loader />}
|
||||
<KButton onClick={editOrClose}>
|
||||
<Icon
|
||||
icon={editing() ? "Close" : "Edit"}
|
||||
color="tertiary"
|
||||
size="0.75rem"
|
||||
inverted
|
||||
/>
|
||||
</KButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<Show when={editing() && formStore.dirty && errorMessage()}>
|
||||
<div class="mb-2.5" role="alert" aria-live="assertive">
|
||||
<Typography hierarchy="body" size="xs" inverted color="error">
|
||||
{errorMessage()}
|
||||
</Typography>
|
||||
</div>
|
||||
</Show>
|
||||
{props.children({ editing: editing(), Field })}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ const invertedColorMap: Record<Color, string> = {
|
||||
secondary: "fg-inv-2",
|
||||
tertiary: "fg-inv-3",
|
||||
quaternary: "fg-inv-4",
|
||||
error: "fg-semantic-error-2",
|
||||
error: "fg-semantic-error-1",
|
||||
inherit: "text-inherit",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { API } from "@/api/API";
|
||||
import { Schema as Inventory } from "@/api/Inventory";
|
||||
|
||||
type OperationNames = keyof API;
|
||||
export type OperationNames = keyof API;
|
||||
type Services = NonNullable<Inventory["services"]>;
|
||||
type ServiceNames = keyof Services;
|
||||
|
||||
|
||||
25
pkgs/clan-app/ui/src/hooks/mutations.ts
Normal file
25
pkgs/clan-app/ui/src/hooks/mutations.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/solid-query";
|
||||
import { callApi, OperationArgs } from "@/src/hooks/api";
|
||||
import { encodeBase64 } from "@/src/hooks/clan";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
export const updateMachine = useMutation(() => ({
|
||||
mutationFn: async (args: OperationArgs<"set_machine">) => {
|
||||
const call = callApi("set_machine", args);
|
||||
return {
|
||||
args,
|
||||
...call,
|
||||
};
|
||||
},
|
||||
onSuccess: async ({ args }) => {
|
||||
const {
|
||||
name,
|
||||
flake: { identifier },
|
||||
} = args.machine;
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["clans", encodeBase64(identifier), "machine", name],
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -5,6 +5,7 @@ import { encodeBase64 } from "@/src/hooks/clan";
|
||||
export type ClanDetails = SuccessData<"get_clan_details">;
|
||||
export type ClanDetailsWithURI = ClanDetails & { uri: string };
|
||||
|
||||
export type Machine = SuccessData<"get_machine">;
|
||||
export type ListMachines = SuccessData<"list_machines">;
|
||||
export type MachineDetails = SuccessData<"get_machine_details">;
|
||||
|
||||
@@ -29,6 +30,50 @@ export const useMachinesQuery = (clanURI: string) =>
|
||||
},
|
||||
}));
|
||||
|
||||
export const useMachineQuery = (clanURI: string, machineName: string) =>
|
||||
useQuery<Machine>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
|
||||
queryFn: async () => {
|
||||
const call = callApi("get_machine", {
|
||||
name: machineName,
|
||||
flake: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await call.result;
|
||||
if (result.status === "error") {
|
||||
throw new Error("Error fetching machine: " + result.errors[0].message);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
|
||||
export const useMachineDetailsQuery = (clanURI: string, machineName: string) =>
|
||||
useQuery<MachineDetails>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName],
|
||||
queryFn: async () => {
|
||||
const call = callApi("get_machine_details", {
|
||||
machine: {
|
||||
name: machineName,
|
||||
flake: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await call.result;
|
||||
if (result.status === "error") {
|
||||
throw new Error(
|
||||
"Error fetching machine details: " + result.errors[0].message,
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
|
||||
export const useClanDetailsQuery = (clanURI: string) =>
|
||||
useQuery<ClanDetails>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "details"],
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
MachinesQueryResult,
|
||||
useClanListQuery,
|
||||
useMachinesQuery,
|
||||
} from "@/src/queries/queries";
|
||||
} from "@/src/hooks/queries";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { store, setStore, clanURIs } from "@/src/stores/clan";
|
||||
import { produce } from "solid-js/store";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||
import { SidebarPane } from "@/src/components/Sidebar/SidebarPane";
|
||||
import { navigateToClan, useClanURI, useMachineName } from "@/src/hooks/clan";
|
||||
import { Show } from "solid-js";
|
||||
import { SectionGeneral } from "./SectionGeneral";
|
||||
|
||||
export const Machine = (props: RouteSectionProps) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -17,7 +18,7 @@ export const Machine = (props: RouteSectionProps) => {
|
||||
return (
|
||||
<Show when={useMachineName()} keyed>
|
||||
<SidebarPane title={useMachineName()} onClose={onClose}>
|
||||
<h1>Hello world</h1>
|
||||
<SectionGeneral />
|
||||
</SidebarPane>
|
||||
</Show>
|
||||
);
|
||||
|
||||
122
pkgs/clan-app/ui/src/routes/Machine/SectionGeneral.tsx
Normal file
122
pkgs/clan-app/ui/src/routes/Machine/SectionGeneral.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import * as v from "valibot";
|
||||
import { TextInput } from "@/src/components/Form/TextInput";
|
||||
import { Divider } from "@/src/components/Divider/Divider";
|
||||
import { TextArea } from "@/src/components/Form/TextArea";
|
||||
import { Show, splitProps } from "solid-js";
|
||||
import { useMachineQuery } from "@/src/hooks/queries";
|
||||
import { useClanURI, useMachineName } from "@/src/hooks/clan";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm";
|
||||
import { pick } from "@/src/util";
|
||||
|
||||
const schema = v.object({
|
||||
name: v.pipe(v.optional(v.string()), v.readonly()),
|
||||
description: v.nullish(v.string()),
|
||||
machineClass: v.optional(v.picklist(["nixos", "darwin"])),
|
||||
});
|
||||
|
||||
type FormValues = v.InferInput<typeof schema>;
|
||||
|
||||
export const SectionGeneral = () => {
|
||||
const clanURI = useClanURI();
|
||||
const machineName = useMachineName();
|
||||
|
||||
const machineQuery = useMachineQuery(clanURI, machineName);
|
||||
|
||||
const initialValues = () => {
|
||||
if (!machineQuery.isSuccess) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return pick(machineQuery.data, [
|
||||
"name",
|
||||
"description",
|
||||
"machineClass",
|
||||
]) satisfies FormValues;
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
const call = callApi("set_machine", {
|
||||
machine: {
|
||||
name: machineName,
|
||||
flake: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
...machineQuery.data,
|
||||
...values,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await call.result;
|
||||
if (result.status === "error") {
|
||||
throw new Error(result.errors[0].message);
|
||||
}
|
||||
|
||||
// refresh the query
|
||||
await machineQuery.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={machineQuery.isSuccess}>
|
||||
<SidebarSectionForm
|
||||
title="General"
|
||||
schema={schema}
|
||||
onSubmit={onSubmit}
|
||||
initialValues={initialValues()}
|
||||
>
|
||||
{({ editing, Field }) => (
|
||||
<div class="flex flex-col gap-3">
|
||||
<Field name="name">
|
||||
{(field, input) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
size="s"
|
||||
inverted
|
||||
label="Name"
|
||||
required
|
||||
readOnly
|
||||
orientation="horizontal"
|
||||
input={input}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Divider />
|
||||
<Field name="machineClass">
|
||||
{(field, input) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
size="s"
|
||||
inverted
|
||||
label="Class"
|
||||
required
|
||||
readOnly
|
||||
orientation="horizontal"
|
||||
input={input}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Divider />
|
||||
<Field name="description">
|
||||
{(field, input) => (
|
||||
<TextArea
|
||||
{...splitProps(field, ["value"])[1]}
|
||||
defaultValue={field.value ?? ""}
|
||||
size="s"
|
||||
label="Description"
|
||||
inverted
|
||||
readOnly={!editing}
|
||||
orientation="horizontal"
|
||||
input={{ ...input, rows: 4, placeholder: "No description" }}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</SidebarSectionForm>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { Accessor, createEffect, createRoot } from "solid-js";
|
||||
import { MachineRepr } from "./MachineRepr";
|
||||
import * as THREE from "three";
|
||||
import { SceneData } from "../stores/clan";
|
||||
import { MachinesQueryResult } from "../queries/queries";
|
||||
import { MachinesQueryResult } from "../hooks/queries";
|
||||
import { ObjectRegistry } from "./ObjectRegistry";
|
||||
import { renderLoop } from "./RenderLoop";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
||||
import { Toolbar } from "../components/Toolbar/Toolbar";
|
||||
import { ToolbarButton } from "../components/Toolbar/ToolbarButton";
|
||||
import { Divider } from "../components/Divider/Divider";
|
||||
import { MachinesQueryResult } from "../queries/queries";
|
||||
import { MachinesQueryResult } from "../hooks/queries";
|
||||
import { SceneData } from "../stores/clan";
|
||||
import { Accessor } from "solid-js";
|
||||
import { renderLoop } from "./RenderLoop";
|
||||
|
||||
8
pkgs/clan-app/ui/src/util.ts
Normal file
8
pkgs/clan-app/ui/src/util.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const pick = <T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> =>
|
||||
keys.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = obj[key];
|
||||
return acc;
|
||||
},
|
||||
{} as Pick<T, K>,
|
||||
);
|
||||
Reference in New Issue
Block a user