feat(ui): integrate machine writeability
This commit is contained in:
29
pkgs/clan-app/ui/src/components/Form/index.tsx
Normal file
29
pkgs/clan-app/ui/src/components/Form/index.tsx
Normal 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(". ");
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
Reference in New Issue
Block a user