feat(ui): integrate machine writeability

This commit is contained in:
Brian McGee
2025-07-31 11:28:14 +01:00
parent deecb966ce
commit bf0691587d
5 changed files with 148 additions and 77 deletions

View File

@@ -0,0 +1,29 @@
import { FieldSchema } from "@/src/hooks/queries";
import { Maybe } from "@modular-forms/solid";
export const tooltipText = <T extends object, K extends keyof T>(
name: K,
schema: FieldSchema<T>,
staticValue: Maybe<string> = undefined,
): Maybe<string> => {
const entry = schema[name];
// return the static value if there is no field schema entry, or the entry
// indicates the field is writeable
if (!(entry && entry.readonly)) {
return staticValue;
}
const components: string[] = [];
if (staticValue) {
components.push(staticValue);
}
components.push(`This field is read-only`);
if (entry.reason) {
components.push(entry.reason);
}
return components.join(". ");
};

View File

@@ -6,10 +6,22 @@ import { useApiClient } from "./ApiClient";
export type ClanDetails = SuccessData<"get_clan_details">;
export type ClanDetailsWithURI = ClanDetails & { uri: string };
export type FieldSchema<T> = {
[K in keyof T]: {
readonly: boolean;
reason?: string;
};
};
export type Machine = SuccessData<"get_machine">;
export type ListMachines = SuccessData<"list_machines">;
export type MachineDetails = SuccessData<"get_machine_details">;
export interface MachineDetail {
machine: Machine;
fieldsSchema: FieldSchema<Machine>;
}
export type MachinesQueryResult = UseQueryResult<ListMachines>;
export type ClanListQueryResult = UseQueryResult<ClanDetailsWithURI>[];
@@ -35,22 +47,43 @@ export const useMachinesQuery = (clanURI: string) => {
export const useMachineQuery = (clanURI: string, machineName: string) => {
const client = useApiClient();
return useQuery<Machine>(() => ({
return useQuery<MachineDetail>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
queryFn: async () => {
const call = client.fetch("get_machine", {
const [machineCall, schemaCall] = await Promise.all([
client.fetch("get_machine", {
name: machineName,
flake: {
identifier: clanURI,
},
});
}),
client.fetch("get_machine_fields_schema", {
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);
const machine = await machineCall.result;
if (machine.status === "error") {
throw new Error("Error fetching machine: " + machine.errors[0].message);
}
return result.data;
const writeSchema = await schemaCall.result;
if (writeSchema.status === "error") {
throw new Error(
"Error fetching machine fields schema: " +
writeSchema.errors[0].message,
);
}
return {
machine: machine.data,
fieldsSchema: writeSchema.data,
};
},
}));
};

View File

@@ -5,8 +5,9 @@ import { createSignal, Show } from "solid-js";
import { SectionGeneral } from "./SectionGeneral";
import { InstallModal } from "@/src/workflows/Install/install";
import { Button } from "@/src/components/Button/Button";
import { useMachineQuery } from "@/src/hooks/queries";
import { Machine as MachineModel, useMachineQuery } from "@/src/hooks/queries";
import { SectionTags } from "@/src/routes/Machine/SectionTags";
import { callApi } from "@/src/hooks/api";
export const Machine = (props: RouteSectionProps) => {
const navigate = useNavigate();
@@ -18,9 +19,38 @@ export const Machine = (props: RouteSectionProps) => {
};
const [showInstall, setShowModal] = createSignal(false);
let container: Node;
const sidebarPane = (machineName: string) => {
const machineQuery = useMachineQuery(clanURI, machineName);
const sectionProps = { clanURI, machineName, machineQuery };
// we have to update the whole machine model rather than just the sub fields that were changed
// for that reason we pass in this common submit handler to each machine sub section
const onSubmit = async (values: Partial<MachineModel>) => {
const call = callApi("set_machine", {
machine: {
name: machineName,
flake: {
identifier: clanURI,
},
},
update: {
...machineQuery.data?.machine,
...values,
},
});
const result = await call.result;
if (result.status === "error") {
throw new Error(result.errors[0].message);
}
// refresh the query
await machineQuery.refetch();
};
const sectionProps = { clanURI, machineName, onSubmit, machineQuery };
return (
<SidebarPane title={machineName} onClose={onClose}>
@@ -30,7 +60,6 @@ export const Machine = (props: RouteSectionProps) => {
);
};
let container: Node;
return (
<Show when={useMachineName()} keyed>
<Button

View File

@@ -3,24 +3,30 @@ 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 { Machine } from "@/src/hooks/queries";
import { callApi } from "@/src/hooks/api";
import { MachineDetail } from "@/src/hooks/queries";
import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm";
import { pick } from "@/src/util";
import { UseQueryResult } from "@tanstack/solid-query";
import { tooltipText } from "@/src/components/Form";
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"])),
machineClass: v.pipe(
v.optional(v.picklist(["nixos", "darwin"])),
v.readonly(),
),
});
type FieldNames = "name" | "description" | "machineClass";
type FormValues = v.InferInput<typeof schema>;
export interface SectionGeneralProps {
clanURI: string;
machineName: string;
machineQuery: UseQueryResult<Machine>;
onSubmit: (values: FormValues) => Promise<void>;
machineQuery: UseQueryResult<MachineDetail>;
}
export const SectionGeneral = (props: SectionGeneralProps) => {
@@ -31,34 +37,27 @@ export const SectionGeneral = (props: SectionGeneralProps) => {
return {};
}
return pick(machineQuery.data, [
return pick(machineQuery.data.machine, [
"name",
"description",
"machineClass",
]) satisfies FormValues;
};
const onSubmit = async (values: FormValues) => {
const call = callApi("set_machine", {
machine: {
name: props.machineName,
flake: {
identifier: props.clanURI,
},
},
update: {
...machineQuery.data,
...values,
},
});
const result = await call.result;
if (result.status === "error") {
throw new Error(result.errors[0].message);
const fieldsSchema = () => {
if (!machineQuery.isSuccess) {
return undefined;
}
// refresh the query
await machineQuery.refetch();
return machineQuery.data.fieldsSchema;
};
const readOnly = (editing: boolean, name: FieldNames) => {
if (!editing) {
return true;
}
return fieldsSchema()?.[name]?.readonly ?? false;
};
return (
@@ -66,7 +65,7 @@ export const SectionGeneral = (props: SectionGeneralProps) => {
<SidebarSectionForm
title="General"
schema={schema}
onSubmit={onSubmit}
onSubmit={props.onSubmit}
initialValues={initialValues()}
>
{({ editing, Field }) => (
@@ -80,12 +79,14 @@ export const SectionGeneral = (props: SectionGeneralProps) => {
inverted
label="Name"
required
readOnly
readOnly={readOnly(editing, "name")}
orientation="horizontal"
input={input}
tooltip={
"A unique identifier for this machine. It cannot be changed."
}
tooltip={tooltipText(
"name",
fieldsSchema()!,
"A unique identifier for this machine",
)}
/>
)}
</Field>
@@ -99,12 +100,14 @@ export const SectionGeneral = (props: SectionGeneralProps) => {
inverted
label="Platform"
required
readOnly
readOnly={readOnly(editing, "machineClass")}
orientation="horizontal"
input={input}
tooltip={
"The target platform for this machine. It cannot be changed. It is set in the installer."
}
tooltip={tooltipText(
"machineClass",
fieldsSchema()!,
"The target platform for this machine",
)}
/>
)}
</Field>
@@ -117,7 +120,8 @@ export const SectionGeneral = (props: SectionGeneralProps) => {
size="s"
label="Description"
inverted
readOnly={!editing}
readOnly={readOnly(editing, "description")}
tooltip={tooltipText("description", fieldsSchema()!)}
orientation="horizontal"
input={{
...input,

View File

@@ -1,7 +1,6 @@
import * as v from "valibot";
import { Show, splitProps } from "solid-js";
import { Machine } from "@/src/hooks/queries";
import { callApi } from "@/src/hooks/api";
import { MachineDetail } from "@/src/hooks/queries";
import { SidebarSectionForm } from "@/src/components/Sidebar/SidebarSectionForm";
import { pick } from "@/src/util";
import { UseQueryResult } from "@tanstack/solid-query";
@@ -16,7 +15,8 @@ type FormValues = v.InferInput<typeof schema>;
export interface SectionTags {
clanURI: string;
machineName: string;
machineQuery: UseQueryResult<Machine>;
onSubmit: (values: FormValues) => Promise<void>;
machineQuery: UseQueryResult<MachineDetail>;
}
export const SectionTags = (props: SectionTags) => {
@@ -27,31 +27,7 @@ export const SectionTags = (props: SectionTags) => {
return {};
}
return pick(machineQuery.data, ["tags"]) satisfies FormValues;
};
const onSubmit = async (values: FormValues) => {
console.log("submitting tags", values);
const call = callApi("set_machine", {
machine: {
name: props.machineName,
flake: {
identifier: props.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 pick(machineQuery.data.machine, ["tags"]) satisfies FormValues;
};
return (
@@ -59,7 +35,7 @@ export const SectionTags = (props: SectionTags) => {
<SidebarSectionForm
title="Tags"
schema={schema}
onSubmit={onSubmit}
onSubmit={props.onSubmit}
initialValues={initialValues()}
>
{({ editing, Field }) => (