Merge pull request 'ui/clan: wire up service create' (#5016) from search into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5016
This commit is contained in:
hsjobeki
2025-08-28 12:17:03 +00:00
6 changed files with 136 additions and 43 deletions

View File

@@ -55,7 +55,6 @@ export function TagSelect<T extends { value: unknown }>(
> >
<Combobox.Control<T> aria-label="Fruits"> <Combobox.Control<T> aria-label="Fruits">
{(state) => { {(state) => {
console.log("combobox state selected", state.selectedOptions());
return ( return (
<Combobox.Trigger <Combobox.Trigger
tabIndex={1} tabIndex={1}

View File

@@ -36,6 +36,11 @@ import { createForm, FieldValues, reset } from "@modular-forms/solid";
import { Sidebar } from "@/src/components/Sidebar/Sidebar"; import { Sidebar } from "@/src/components/Sidebar/Sidebar";
import { UseQueryResult } from "@tanstack/solid-query"; import { UseQueryResult } from "@tanstack/solid-query";
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal"; import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
import {
InventoryInstance,
ServiceWorkflow,
} from "@/src/workflows/Service/Service";
import { useApiClient } from "@/src/hooks/ApiClient";
interface ClanContextProps { interface ClanContextProps {
clanURI: string; clanURI: string;
@@ -179,7 +184,10 @@ const ClanSceneController = (props: RouteSectionProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [dialogHandlers, setDialogHandlers] = createSignal<{ const [showService, setShowService] = createSignal(false);
const [showModal, setShowModal] = createSignal(false);
const [currentPromise, setCurrentPromise] = createSignal<{
resolve: ({ id }: { id: string }) => void; resolve: ({ id }: { id: string }) => void;
reject: (err: unknown) => void; reject: (err: unknown) => void;
} | null>(null); } | null>(null);
@@ -187,7 +195,15 @@ const ClanSceneController = (props: RouteSectionProps) => {
const onCreate = async (): Promise<{ id: string }> => { const onCreate = async (): Promise<{ id: string }> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
setShowModal(true); setShowModal(true);
setDialogHandlers({ resolve, reject }); setCurrentPromise({ resolve, reject });
});
};
const onAddService = async (): Promise<{ id: string }> => {
return new Promise((resolve, reject) => {
setShowService(true);
console.log("setting current promise");
setCurrentPromise({ resolve, reject });
}); });
}; };
@@ -217,8 +233,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
return { id: values.name }; return { id: values.name };
}; };
const [showModal, setShowModal] = createSignal(false);
const [loadingError, setLoadingError] = createSignal< const [loadingError, setLoadingError] = createSignal<
{ title: string; description: string } | undefined { title: string; description: string } | undefined
>(); >();
@@ -265,6 +279,26 @@ const ClanSceneController = (props: RouteSectionProps) => {
}), }),
); );
const client = useApiClient();
const handleSubmitService = async (instance: InventoryInstance) => {
console.log("Create Instance", instance);
const call = client.fetch("create_service_instance", {
flake: {
identifier: ctx.clanURI,
},
module_ref: instance.module,
roles: instance.roles,
});
const result = await call.result;
if (result.status === "error") {
console.error("Error creating service instance", result.errors);
}
//
currentPromise()?.resolve({ id: "0" });
setShowService(false);
};
return ( return (
<> <>
<Show when={loadingError()}> <Show when={loadingError()}>
@@ -274,15 +308,15 @@ const ClanSceneController = (props: RouteSectionProps) => {
<MockCreateMachine <MockCreateMachine
onClose={() => { onClose={() => {
setShowModal(false); setShowModal(false);
dialogHandlers()?.reject(new Error("User cancelled")); currentPromise()?.reject(new Error("User cancelled"));
}} }}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
const result = await sendCreate(values); const result = await sendCreate(values);
dialogHandlers()?.resolve(result); currentPromise()?.resolve(result);
setShowModal(false); setShowModal(false);
} catch (err) { } catch (err) {
dialogHandlers()?.reject(err); currentPromise()?.reject(err);
setShowModal(false); setShowModal(false);
} }
}} }}
@@ -297,10 +331,22 @@ const ClanSceneController = (props: RouteSectionProps) => {
</div> </div>
<CubeScene <CubeScene
onAddService={onAddService}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelect={onMachineSelect} onSelect={onMachineSelect}
isLoading={ctx.isLoading()} isLoading={ctx.isLoading()}
cubesQuery={ctx.machinesQuery} cubesQuery={ctx.machinesQuery}
toolbarPopup={
<Show when={showService()}>
<ServiceWorkflow
handleSubmit={handleSubmitService}
onClose={() => {
setShowService(false);
currentPromise()?.resolve({ id: "0" });
}}
/>
</Show>
}
onCreate={onCreate} onCreate={onCreate}
clanURI={ctx.clanURI} clanURI={ctx.clanURI}
sceneStore={() => store.sceneData?.[ctx.clanURI]} sceneStore={() => store.sceneData?.[ctx.clanURI]}

View File

@@ -4,6 +4,8 @@
cursor: pointer; cursor: pointer;
} }
/* <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
<Show when={show()}> */
.toolbar-container { .toolbar-container {
@apply absolute bottom-10 z-10 w-full; @apply absolute bottom-10 z-10 w-full;
@apply flex justify-center items-center; @apply flex justify-center items-center;

View File

@@ -1,4 +1,11 @@
import { createSignal, createEffect, onCleanup, onMount, on } from "solid-js"; import {
createSignal,
createEffect,
onCleanup,
onMount,
on,
JSX,
} from "solid-js";
import "./cubes.css"; import "./cubes.css";
import * as THREE from "three"; import * as THREE from "three";
@@ -34,12 +41,14 @@ function garbageCollectGroup(group: THREE.Group) {
export function CubeScene(props: { export function CubeScene(props: {
cubesQuery: MachinesQueryResult; cubesQuery: MachinesQueryResult;
onCreate: () => Promise<{ id: string }>; onCreate: () => Promise<{ id: string }>;
onAddService: () => Promise<{ id: string }>;
selectedIds: Accessor<Set<string>>; selectedIds: Accessor<Set<string>>;
onSelect: (v: Set<string>) => void; onSelect: (v: Set<string>) => void;
sceneStore: Accessor<SceneData>; sceneStore: Accessor<SceneData>;
setMachinePos: (machineId: string, pos: [number, number] | null) => void; setMachinePos: (machineId: string, pos: [number, number] | null) => void;
isLoading: boolean; isLoading: boolean;
clanURI: string; clanURI: string;
toolbarPopup?: JSX.Element;
}) { }) {
let container: HTMLDivElement; let container: HTMLDivElement;
let scene: THREE.Scene; let scene: THREE.Scene;
@@ -213,7 +222,7 @@ export function CubeScene(props: {
controls = new MapControls(camera, renderer.domElement); controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled
controls.mouseButtons.RIGHT = null; controls.mouseButtons.RIGHT = null;
controls.enableRotate = false; // controls.enableRotate = false;
controls.minZoom = 1.2; controls.minZoom = 1.2;
controls.maxZoom = 3.5; controls.maxZoom = 3.5;
controls.addEventListener("change", () => { controls.addEventListener("change", () => {
@@ -543,6 +552,9 @@ export function CubeScene(props: {
<> <>
<div class="cubes-scene-container" ref={(el) => (container = el)} /> <div class="cubes-scene-container" ref={(el) => (container = el)} />
<div class="toolbar-container"> <div class="toolbar-container">
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
{props.toolbarPopup}
</div>
<Toolbar> <Toolbar>
<ToolbarButton <ToolbarButton
description="Select machine" description="Select machine"
@@ -563,7 +575,8 @@ export function CubeScene(props: {
<ToolbarButton <ToolbarButton
description="Add new Service" description="Add new Service"
name="modules" name="modules"
icon="Modules" icon="Services"
onClick={props.onAddService}
/> />
<ToolbarButton <ToolbarButton
icon="Reload" icon="Reload"

View File

@@ -166,6 +166,9 @@ export const Default: Story = {
export const SelectRoleMembers: Story = { export const SelectRoleMembers: Story = {
render: () => ( render: () => (
<ServiceWorkflow <ServiceWorkflow
handleSubmit={(instance) => {
console.log("Submitted instance:", instance);
}}
initialStep="select:members" initialStep="select:members"
initialStore={{ initialStore={{
currentRole: "peer", currentRole: "peer",

View File

@@ -18,8 +18,6 @@ import { Search } from "@/src/components/Search/Search";
import Icon from "@/src/components/Icon/Icon"; import Icon from "@/src/components/Icon/Icon";
import { Combobox } from "@kobalte/core/combobox"; import { Combobox } from "@kobalte/core/combobox";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { Toolbar } from "@/src/components/Toolbar/Toolbar";
import { ToolbarButton } from "@/src/components/Toolbar/ToolbarButton";
import { TagSelect } from "@/src/components/Search/TagSelect"; import { TagSelect } from "@/src/components/Search/TagSelect";
import { Tag } from "@/src/components/Tag/Tag"; import { Tag } from "@/src/components/Tag/Tag";
import { createForm, FieldValues, setValue } from "@modular-forms/solid"; import { createForm, FieldValues, setValue } from "@modular-forms/solid";
@@ -145,7 +143,6 @@ const ConfigureService = () => {
const [store, set] = getStepStore<ServiceStoreType>(stepper); const [store, set] = getStepStore<ServiceStoreType>(stepper);
const [formStore, { Form, Field }] = createForm<RolesForm>({ const [formStore, { Form, Field }] = createForm<RolesForm>({
// initialValues: props.initialValues,
initialValues: { initialValues: {
instanceName: "backup-instance-1", instanceName: "backup-instance-1",
}, },
@@ -157,7 +154,28 @@ const ConfigureService = () => {
const options = useOptions(tagsQuery, machinesQuery); const options = useOptions(tagsQuery, machinesQuery);
const handleSubmit = (values: RolesForm) => { const handleSubmit = (values: RolesForm) => {
console.log("Create service submitted with values:", values); const roles: Record<string, RoleType> = Object.fromEntries(
Object.entries(store.roles).map(([key, value]) => [
key,
{
machines: Object.fromEntries(
value.filter((v) => v.type === "machine").map((v) => [v.label, {}]),
),
tags: Object.fromEntries(
value.filter((v) => v.type === "tag").map((v) => [v.label, {}]),
),
},
]),
);
store.handleSubmit({
name: values.instanceName,
module: {
name: store.module.name,
input: store.module.input,
},
roles,
});
}; };
return ( return (
@@ -185,13 +203,19 @@ const ConfigureService = () => {
)} )}
</Field> </Field>
</div> </div>
<Button icon="Close" color="primary" ghost size="s" class="ml-auto" /> <Button
icon="Close"
color="primary"
ghost
size="s"
class="ml-auto"
onClick={store.close}
/>
</div> </div>
<div class={styles.content}> <div class={styles.content}>
<For each={Object.keys(store.module.raw?.info.roles || {})}> <For each={Object.keys(store.module.raw?.info.roles || {})}>
{(role) => { {(role) => {
const values = store.roles?.[role] || []; const values = store.roles?.[role] || [];
console.log("Role members:", role, values, "from", options());
return ( return (
<TagSelect<TagType> <TagSelect<TagType>
label={role} label={role}
@@ -221,7 +245,9 @@ const ConfigureService = () => {
</For> </For>
</div> </div>
<div class={cx(styles.footer, styles.backgroundAlt)}> <div class={cx(styles.footer, styles.backgroundAlt)}>
<Button hierarchy="secondary">Add Service</Button> <Button hierarchy="secondary" type="submit">
Add Service
</Button>
</div> </div>
</Form> </Form>
); );
@@ -269,8 +295,6 @@ const ConfigureRole = () => {
set("roles", {}); set("roles", {});
} }
set("roles", (r) => ({ ...r, [store.currentRole as string]: members })); set("roles", (r) => ({ ...r, [store.currentRole as string]: members }));
console.log("Roles form submitted ", members);
stepper.setActiveStep("view:members"); stepper.setActiveStep("view:members");
}; };
@@ -381,6 +405,21 @@ const steps = [
export type ServiceSteps = typeof steps; export type ServiceSteps = typeof steps;
// TODO: Ideally we would impot this from a backend model package
export interface InventoryInstance {
name: string;
module: {
name: string;
input: string;
};
roles: Record<string, RoleType>;
}
interface RoleType {
machines: Record<string, { settings?: unknown }>;
tags: Record<string, unknown>;
}
export interface ServiceStoreType { export interface ServiceStoreType {
module: { module: {
name: string; name: string;
@@ -390,45 +429,36 @@ export interface ServiceStoreType {
roles: Record<string, TagType[]>; roles: Record<string, TagType[]>;
currentRole?: string; currentRole?: string;
close: () => void; close: () => void;
handleSubmit: (values: InventoryInstance) => void;
} }
interface ServiceWorkflowProps { interface ServiceWorkflowProps {
initialStep?: ServiceSteps[number]["id"]; initialStep?: ServiceSteps[number]["id"];
initialStore?: Partial<ServiceStoreType>; initialStore?: Partial<ServiceStoreType>;
onClose?: () => void;
handleSubmit: (values: InventoryInstance) => void;
} }
export const ServiceWorkflow = (props: ServiceWorkflowProps) => { export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
const [show, setShow] = createSignal(false);
const stepper = createStepper( const stepper = createStepper(
{ steps }, { steps },
{ {
initialStep: props.initialStep || "select:service", initialStep: props.initialStep || "select:service",
initialStoreData: { initialStoreData: {
...props.initialStore, ...props.initialStore,
close: () => setShow(false), close: () => props.onClose?.(),
handleSubmit: props.handleSubmit,
} satisfies Partial<ServiceStoreType>, } satisfies Partial<ServiceStoreType>,
}, },
); );
return ( return (
<> <div
<div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center"> id="add-service"
<Show when={show()}> class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"> >
<StepperProvider stepper={stepper}> <StepperProvider stepper={stepper}>
<div class="w-[30rem]">{stepper.currentStep().content()}</div> <div class="w-[30rem]">{stepper.currentStep().content()}</div>
</StepperProvider> </StepperProvider>
</div> </div>
</Show>
<div class="flex justify-center space-x-4">
<Toolbar>
<ToolbarButton
onClick={() => setShow(!show())}
description="Add new Service"
name="modules"
icon="Modules"
/>
</Toolbar>
</div>
</div>
</>
); );
}; };