From 92e9bb2ed80fa18698719cea264ecd8a7bd555bb Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Tue, 12 Aug 2025 14:31:07 +0100 Subject: [PATCH 1/2] feat(ui): integrate list_tags api call into machine detail --- .../ui/src/components/Form/MachineTags.tsx | 26 +++++++++--------- pkgs/clan-app/ui/src/hooks/queries.ts | 15 ++++++++++- .../ui/src/routes/Machine/SectionTags.tsx | 27 ++++++++++++++++++- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/pkgs/clan-app/ui/src/components/Form/MachineTags.tsx b/pkgs/clan-app/ui/src/components/Form/MachineTags.tsx index 9d272ac34..34a510233 100644 --- a/pkgs/clan-app/ui/src/components/Form/MachineTags.tsx +++ b/pkgs/clan-app/ui/src/components/Form/MachineTags.tsx @@ -11,7 +11,7 @@ import { Label } from "@/src/components/Form/Label"; import { Orienter } from "@/src/components/Form/Orienter"; import { CollectionNode } from "@kobalte/core"; -interface MachineTag { +export interface MachineTag { value: string; disabled?: boolean; new?: boolean; @@ -24,11 +24,10 @@ export type MachineTagsProps = FieldProps & { disabled?: boolean; required?: boolean; defaultValue?: string[]; + defaultOptions?: string[]; + readonlyOptions?: string[]; }; -// tags which are applied to all machines and cannot be removed -const staticOptions = [{ value: "all", disabled: true }]; - const uniqueOptions = (options: MachineTag[]) => { const record: Record = {}; options.forEach((option) => { @@ -39,13 +38,8 @@ const uniqueOptions = (options: MachineTag[]) => { return Object.values(record); }; -const sortedOptions = (options: MachineTag[]) => { - return options.sort((a, b) => { - if (a.new && !b.new) return -1; - if (a.disabled && !b.disabled) return -1; - return a.value.localeCompare(b.value); - }); -}; +const sortedOptions = (options: MachineTag[]) => + options.sort((a, b) => a.value.localeCompare(b.value)); const sortedAndUniqueOptions = (options: MachineTag[]) => sortedOptions(uniqueOptions(options)); @@ -72,9 +66,15 @@ export const MachineTags = (props: MachineTagsProps) => { (props.defaultValue || []).map((value) => ({ value })), ); - // todo this should be the superset of tags used across the entire clan and be passed in via a prop + // convert default options string[] into MachineTag[] const [availableOptions, setAvailableOptions] = createSignal( - sortedAndUniqueOptions([...staticOptions, ...defaultValue]), + sortedAndUniqueOptions([ + ...(props.readonlyOptions || []).map((value) => ({ + value, + disabled: true, + })), + ...(props.defaultOptions || []).map((value) => ({ value })), + ]), ); const onKeyDown = (event: KeyboardEvent) => { diff --git a/pkgs/clan-app/ui/src/hooks/queries.ts b/pkgs/clan-app/ui/src/hooks/queries.ts index 28c6c74cd..1dd98c365 100644 --- a/pkgs/clan-app/ui/src/hooks/queries.ts +++ b/pkgs/clan-app/ui/src/hooks/queries.ts @@ -13,11 +13,13 @@ export type FieldSchema = { }; }; +export type Tags = SuccessData<"list_tags">; export type Machine = SuccessData<"get_machine">; export type ListMachines = SuccessData<"list_machines">; export type MachineDetails = SuccessData<"get_machine_details">; export interface MachineDetail { + tags: Tags; machine: Machine; fieldsSchema: FieldSchema; } @@ -50,7 +52,12 @@ export const useMachineQuery = (clanURI: string, machineName: string) => { return useQuery(() => ({ queryKey: ["clans", encodeBase64(clanURI), "machine", machineName], queryFn: async () => { - const [machineCall, schemaCall] = await Promise.all([ + const [tagsCall, machineCall, schemaCall] = await Promise.all([ + client.fetch("list_tags", { + flake: { + identifier: clanURI, + }, + }), client.fetch("get_machine", { name: machineName, flake: { @@ -67,6 +74,11 @@ export const useMachineQuery = (clanURI: string, machineName: string) => { }), ]); + const tags = await tagsCall.result; + if (tags.status === "error") { + throw new Error("Error fetching tags: " + tags.errors[0].message); + } + const machine = await machineCall.result; if (machine.status === "error") { throw new Error("Error fetching machine: " + machine.errors[0].message); @@ -81,6 +93,7 @@ export const useMachineQuery = (clanURI: string, machineName: string) => { } return { + tags: tags.data, machine: machine.data, fieldsSchema: writeSchema.data, }; diff --git a/pkgs/clan-app/ui/src/routes/Machine/SectionTags.tsx b/pkgs/clan-app/ui/src/routes/Machine/SectionTags.tsx index dcb20454d..2b173a9c9 100644 --- a/pkgs/clan-app/ui/src/routes/Machine/SectionTags.tsx +++ b/pkgs/clan-app/ui/src/routes/Machine/SectionTags.tsx @@ -4,7 +4,8 @@ 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 { MachineTags } from "@/src/components/Form/MachineTags"; +import { MachineTag, MachineTags } from "@/src/components/Form/MachineTags"; +import { machineNameParam } from "@/src/hooks/clan"; const schema = v.object({ tags: v.pipe(v.optional(v.array(v.string()))), @@ -30,6 +31,28 @@ export const SectionTags = (props: SectionTags) => { return pick(machineQuery.data.machine, ["tags"]) satisfies FormValues; }; + const readonlyOptions = () => { + if (!machineQuery.isSuccess) { + return []; + } + + const result: string[] = ["all"]; + + if (machineQuery.data.machine.machineClass) { + result.push(machineQuery.data.machine.machineClass); + } + + return result; + }; + + const defaultOptions = () => { + if (!machineQuery.isSuccess) { + return []; + } + + return machineQuery.data.tags?.options ?? []; + }; + return ( { readOnly={!editing} orientation="horizontal" defaultValue={field.value} + defaultOptions={defaultOptions()} + readonlyOptions={readonlyOptions()} input={input} /> )} From 5d1abbd303c36a41726ea98385032b6b69f23d33 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Tue, 12 Aug 2025 14:42:04 +0100 Subject: [PATCH 2/2] feat(ui): integrate tags info from field schema into tags section --- .../clan-app/ui/src/components/Form/index.tsx | 8 ++--- pkgs/clan-app/ui/src/hooks/queries.ts | 9 +---- .../ui/src/routes/Machine/SectionTags.tsx | 33 +++++++++---------- 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/pkgs/clan-app/ui/src/components/Form/index.tsx b/pkgs/clan-app/ui/src/components/Form/index.tsx index 288342117..479373408 100644 --- a/pkgs/clan-app/ui/src/components/Form/index.tsx +++ b/pkgs/clan-app/ui/src/components/Form/index.tsx @@ -1,9 +1,9 @@ -import { FieldSchema } from "@/src/hooks/queries"; +import { SuccessData } from "@/src/hooks/api"; import { Maybe } from "@modular-forms/solid"; -export const tooltipText = ( - name: K, - schema: FieldSchema, +export const tooltipText = ( + name: string, + schema: SuccessData<"get_machine_fields_schema">, staticValue: Maybe = undefined, ): Maybe => { const entry = schema[name]; diff --git a/pkgs/clan-app/ui/src/hooks/queries.ts b/pkgs/clan-app/ui/src/hooks/queries.ts index 1dd98c365..c6eb86f29 100644 --- a/pkgs/clan-app/ui/src/hooks/queries.ts +++ b/pkgs/clan-app/ui/src/hooks/queries.ts @@ -6,13 +6,6 @@ import { useApiClient } from "./ApiClient"; export type ClanDetails = SuccessData<"get_clan_details">; export type ClanDetailsWithURI = ClanDetails & { uri: string }; -export type FieldSchema = { - [K in keyof T]: { - readonly: boolean; - reason?: string; - }; -}; - export type Tags = SuccessData<"list_tags">; export type Machine = SuccessData<"get_machine">; export type ListMachines = SuccessData<"list_machines">; @@ -21,7 +14,7 @@ export type MachineDetails = SuccessData<"get_machine_details">; export interface MachineDetail { tags: Tags; machine: Machine; - fieldsSchema: FieldSchema; + fieldsSchema: SuccessData<"get_machine_fields_schema">; } export type MachinesQueryResult = UseQueryResult; diff --git a/pkgs/clan-app/ui/src/routes/Machine/SectionTags.tsx b/pkgs/clan-app/ui/src/routes/Machine/SectionTags.tsx index 2b173a9c9..b56591e30 100644 --- a/pkgs/clan-app/ui/src/routes/Machine/SectionTags.tsx +++ b/pkgs/clan-app/ui/src/routes/Machine/SectionTags.tsx @@ -4,8 +4,7 @@ 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 { MachineTag, MachineTags } from "@/src/components/Form/MachineTags"; -import { machineNameParam } from "@/src/hooks/clan"; +import { MachineTags } from "@/src/components/Form/MachineTags"; const schema = v.object({ tags: v.pipe(v.optional(v.array(v.string()))), @@ -31,26 +30,24 @@ export const SectionTags = (props: SectionTags) => { return pick(machineQuery.data.machine, ["tags"]) satisfies FormValues; }; - const readonlyOptions = () => { + const options = () => { if (!machineQuery.isSuccess) { - return []; + return [[], []]; } - const result: string[] = ["all"]; + // these are static values or values which have been configured in nix and + // cannot be modified in the UI + const readonlyOptions = + machineQuery.data.fieldsSchema.tags?.readonly_members || []; - if (machineQuery.data.machine.machineClass) { - result.push(machineQuery.data.machine.machineClass); - } + // filter out the read-only options from the superset of clan-wide options + const readonlySet = new Set(readonlyOptions); - return result; - }; + const defaultOptions = (machineQuery.data.tags.options || []).filter( + (tag) => !readonlySet.has(tag), + ); - const defaultOptions = () => { - if (!machineQuery.isSuccess) { - return []; - } - - return machineQuery.data.tags?.options ?? []; + return [defaultOptions, readonlyOptions]; }; return ( @@ -73,8 +70,8 @@ export const SectionTags = (props: SectionTags) => { readOnly={!editing} orientation="horizontal" defaultValue={field.value} - defaultOptions={defaultOptions()} - readonlyOptions={readonlyOptions()} + defaultOptions={options()[0]} + readonlyOptions={options()[1]} input={input} /> )}