feat(ui): add sidebar pane for machine detail

This commit is contained in:
Brian McGee
2025-07-25 11:20:48 +01:00
parent 2c3b0f3771
commit f677d96acf
19 changed files with 526 additions and 176 deletions

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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>
</>
),

View File

@@ -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;

View File

@@ -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>
);
};

View 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>
);
}

View File

@@ -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",
};

View File

@@ -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;

View 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],
});
},
}));

View File

@@ -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"],

View File

@@ -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";

View File

@@ -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>
);

View 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>
);
};

View File

@@ -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";

View File

@@ -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";

View 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>,
);