ui/services: workflow init

This commit is contained in:
Johannes Kirschbauer
2025-08-28 10:11:15 +02:00
parent e2f64e1d40
commit 56923ae2c3
3 changed files with 413 additions and 33 deletions

View File

@@ -0,0 +1,35 @@
.content {
@apply px-3 flex flex-col gap-5 py-6;
border: 1px solid #2e4a4b;
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
linear-gradient(
180deg,
theme(colors.bg.inv.2) 0%,
theme(colors.bg.inv.3) 100%
);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.header {
@apply py-2 pl-3 pr-2 flex gap-2.5 w-full items-center;
border-radius: 8px 8px 0 0;
border-bottom: 1px solid #2e4a4b;
}
.footer {
@apply py-3 px-4 flex justify-end w-full;
border-radius: 0 0 8px 8px;
border-top: 1px solid #2e4a4b;
}
.backgroundAlt {
background: linear-gradient(
90deg,
theme(colors.bg.inv.3) 0%,
theme(colors.bg.inv.4) 100%
);
}

View File

@@ -26,23 +26,37 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
const resultData: Partial<ResultDataMap> = { const resultData: Partial<ResultDataMap> = {
list_service_modules: [ list_service_modules: [
{ {
module: { name: "Module A", input: "Input A" }, module: { name: "Borgbackup", input: "clan-core" },
info: { info: {
manifest: { manifest: {
name: "Module A", name: "Borgbackup",
description: "This is module A", description: "This is module A",
}, },
roles: { roles: {
peer: null, client: null,
server: null, server: null,
}, },
}, },
}, },
{ {
module: { name: "Module B", input: "Input B" }, module: { name: "Zerotier", input: "clan-core" },
info: { info: {
manifest: { manifest: {
name: "Module B", name: "Zerotier",
description: "This is module B",
},
roles: {
peer: null,
moon: null,
controller: null,
},
},
},
{
module: { name: "Admin", input: "clan-core" },
info: {
manifest: {
name: "Admin",
description: "This is module B", description: "This is module B",
}, },
roles: { roles: {
@@ -51,22 +65,10 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
}, },
}, },
{ {
module: { name: "Module C", input: "Input B" }, module: { name: "Garage", input: "lo-l" },
info: { info: {
manifest: { manifest: {
name: "Module B", name: "Garage",
description: "This is module B",
},
roles: {
default: null,
},
},
},
{
module: { name: "Module B", input: "Input A" },
info: {
manifest: {
name: "Module B",
description: "This is module B", description: "This is module B",
}, },
roles: { roles: {
@@ -75,6 +77,28 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
}, },
}, },
], ],
list_machines: {
jon: {
name: "jon",
tags: ["all", "nixos", "tag1"],
},
sara: {
name: "sara",
tags: ["all", "darwin", "tag2"],
},
kyra: {
name: "kyra",
tags: ["all", "darwin", "tag2"],
},
leila: {
name: "leila",
tags: ["all", "darwin", "tag2"],
},
},
list_tags: {
options: ["desktop", "server", "full", "only", "streaming", "backup"],
special: ["all", "nixos", "darwin"],
},
}; };
return { return {
@@ -138,3 +162,14 @@ type Story = StoryObj<typeof ServiceWorkflow>;
export const Default: Story = { export const Default: Story = {
args: {}, args: {},
}; };
export const SelectRoleMembers: Story = {
render: () => (
<ServiceWorkflow
initialStep="select:members"
initialStore={{
currentRole: "peer",
}}
/>
),
};

View File

@@ -4,14 +4,31 @@ import {
StepperProvider, StepperProvider,
useStepper, useStepper,
} from "@/src/hooks/stepper"; } from "@/src/hooks/stepper";
import { BackButton, NextButton } from "../Steps";
import { useClanURI } from "@/src/hooks/clan"; import { useClanURI } from "@/src/hooks/clan";
import { ServiceModules, useServiceModules } from "@/src/hooks/queries"; import {
import { createEffect, createSignal } from "solid-js"; MachinesQuery,
ServiceModules,
TagsQuery,
useMachinesQuery,
useServiceModules,
useTags,
} from "@/src/hooks/queries";
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
import { Search } from "@/src/components/Search/Search"; 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 { Tag } from "@/src/components/Tag/Tag";
import { createForm, FieldValues, setValue } from "@modular-forms/solid";
import styles from "./Service.module.css";
import { TextInput } from "@/src/components/Form/TextInput";
import { Button } from "@/src/components/Button/Button";
import cx from "classnames";
import { BackButton } from "../Steps";
import { SearchMultiple } from "@/src/components/Search/MultipleSearch";
type ModuleItem = ServiceModules[number]; type ModuleItem = ServiceModules[number];
@@ -48,20 +65,21 @@ const SelectService = () => {
return ( return (
<Search<Module> <Search<Module>
loading={serviceModulesQuery.isLoading} loading={serviceModulesQuery.isLoading}
height="13rem"
onChange={(module) => { onChange={(module) => {
if (!module) return; if (!module) return;
console.log("Module selected");
set("module", { set("module", {
name: module.raw.module.name, name: module.raw.module.name,
input: module.raw.module.input, input: module.raw.module.input,
raw: module.raw,
}); });
stepper.next(); stepper.next();
}} }}
options={moduleOptions()} options={moduleOptions()}
renderItem={(item) => { renderItem={(item) => {
return ( return (
<div class="flex items-center justify-between gap-2 rounded-md px-2 py-1 pr-4"> <div class="flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1 pr-4">
<div class="flex size-8 items-center justify-center rounded-md bg-white"> <div class="flex size-8 items-center justify-center rounded-md bg-white">
<Icon icon="Code" /> <Icon icon="Code" />
</div> </div>
@@ -90,14 +108,273 @@ const SelectService = () => {
); );
}; };
const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
createMemo<TagType[]>(() => {
const tags = tagsQuery.data;
const machines = machinesQuery.data;
if (!tags || !machines) {
return [];
}
const machineOptions = Object.keys(machines).map((m) => ({
label: m,
value: "m_" + m,
type: "machine" as const,
}));
const tagOptions = [...tags.options, ...tags.special].map((tag) => ({
type: "tag" as const,
label: tag,
value: "t_" + tag,
members: Object.entries(machines)
.filter(([_, v]) => v.tags?.includes(tag))
.map(([k]) => k),
}));
return [...machineOptions, ...tagOptions].sort((a, b) =>
a.label.localeCompare(b.label),
);
});
interface RolesForm extends FieldValues {
roles: Record<string, string[]>;
instanceName: string;
}
const ConfigureService = () => {
const stepper = useStepper<ServiceSteps>();
const [store, set] = getStepStore<ServiceStoreType>(stepper);
const [formStore, { Form, Field }] = createForm<RolesForm>({
// initialValues: props.initialValues,
initialValues: {
instanceName: "backup-instance-1",
},
});
const machinesQuery = useMachinesQuery(useClanURI());
const tagsQuery = useTags(useClanURI());
const options = useOptions(tagsQuery, machinesQuery);
const handleSubmit = (values: RolesForm) => {
console.log("Create service submitted with values:", values);
};
return (
<Form onSubmit={handleSubmit}>
<div class={cx(styles.header, styles.backgroundAlt)}>
<div class="overflow-hidden rounded-sm">
<Icon icon="Services" size={36} inverted />
</div>
<div class="flex flex-col">
<Typography hierarchy="body" size="s" weight="medium" inverted>
{store.module.name}
</Typography>
<Field name="instanceName">
{(field, input) => (
<TextInput
{...field}
value={field.value}
size="s"
inverted
required
readOnly={true}
orientation="horizontal"
input={input}
/>
)}
</Field>
</div>
<Button icon="Close" color="primary" ghost size="s" class="ml-auto" />
</div>
<div class={styles.content}>
<For each={Object.keys(store.module.raw?.info.roles || {})}>
{(role) => {
const values = store.roles?.[role] || [];
console.log("Role members:", role, values, "from", options());
return (
<TagSelect<TagType>
label={role}
renderItem={(item: TagType) => (
<Tag
inverted
icon={(tag) => (
<Icon
icon={item.type === "machine" ? "Machine" : "Tag"}
size="0.5rem"
inverted={tag.inverted}
/>
)}
>
{item.label}
</Tag>
)}
values={values}
options={options()}
onClick={() => {
set("currentRole", role);
stepper.next();
}}
/>
);
}}
</For>
</div>
<div class={cx(styles.footer, styles.backgroundAlt)}>
<Button hierarchy="secondary">Add Service</Button>
</div>
</Form>
);
};
type TagType =
| {
value: string;
label: string;
type: "machine";
}
| {
value: string;
label: string;
type: "tag";
members: string[];
};
interface RoleMembers extends FieldValues {
members: string[];
}
const ConfigureRole = () => {
const stepper = useStepper<ServiceSteps>();
const [store, set] = getStepStore<ServiceStoreType>(stepper);
const [formStore, { Form, Field }] = createForm<RoleMembers>({
initialValues: {
members: [],
},
});
const machinesQuery = useMachinesQuery(useClanURI());
const tagsQuery = useTags(useClanURI());
const options = useOptions(tagsQuery, machinesQuery);
const handleSubmit = (values: RoleMembers) => {
if (!store.currentRole) return;
const members: TagType[] = values.members.map(
(m) => options().find((o) => o.value === m)!,
);
if (!store.roles) {
set("roles", {});
}
set("roles", (r) => ({ ...r, [store.currentRole as string]: members }));
console.log("Roles form submitted ", members);
stepper.setActiveStep("view:members");
};
return (
<Form onSubmit={handleSubmit}>
<div class={cx(styles.backgroundAlt, "rounded-md")}>
<div class="flex w-full flex-col ">
<Field name="members" type="string[]">
{(field, input) => (
<SearchMultiple<TagType>
initialValues={store.roles?.[store.currentRole || ""] || []}
options={options()}
headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")}
headerChildren={
<div class="flex w-full gap-2.5">
<BackButton ghost size="xs" hierarchy="primary" />
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted
class="capitalize"
>
Select {store.currentRole}
</Typography>
</div>
}
placeholder={"Search for Machine or Tags"}
renderItem={(item, opts) => (
<div class={cx("flex w-full items-center gap-2 px-3 py-2")}>
<Combobox.ItemIndicator>
<Show
when={opts.selected}
fallback={<Icon icon="Code" />}
>
<Icon icon="Checkmark" color="primary" inverted />
</Show>
</Combobox.ItemIndicator>
<Combobox.ItemLabel class="flex items-center gap-2">
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted
>
{item.label}
</Typography>
<Show when={item.type === "tag" && item}>
{(tag) => (
<Typography
hierarchy="body"
size="xs"
weight="medium"
inverted
color="secondary"
tag="div"
>
{tag().members.length}
</Typography>
)}
</Show>
</Combobox.ItemLabel>
<Icon
class="ml-auto"
icon={item.type === "machine" ? "Machine" : "Tag"}
color="quaternary"
inverted
/>
</div>
)}
height="20rem"
virtualizerOptions={{
estimateSize: () => 38,
}}
onChange={(selection) => {
const newval = selection.map((s) => s.value);
setValue(formStore, field.name, newval);
}}
/>
)}
</Field>
</div>
<div class={cx(styles.footer, styles.backgroundAlt)}>
<Button hierarchy="secondary" type="submit">
Confirm
</Button>
</div>
</div>
</Form>
);
};
const steps = [ const steps = [
{ {
id: "select:service", id: "select:service",
content: SelectService, content: SelectService,
}, },
{
id: "view:members",
content: ConfigureService,
},
{ {
id: "select:members", id: "select:members",
content: () => <div>Configure your service here.</div>, content: ConfigureRole,
}, },
{ id: "settings", content: () => <div>Adjust settings here.</div> }, { id: "settings", content: () => <div>Adjust settings here.</div> },
] as const; ] as const;
@@ -108,17 +385,50 @@ export interface ServiceStoreType {
module: { module: {
name: string; name: string;
input: string; input: string;
raw?: ModuleItem;
}; };
roles: Record<string, TagType[]>;
currentRole?: string;
close: () => void;
} }
export const ServiceWorkflow = () => { interface ServiceWorkflowProps {
const stepper = createStepper({ steps }, { initialStep: "select:service" }); initialStep?: ServiceSteps[number]["id"];
initialStore?: Partial<ServiceStoreType>;
}
export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
const [show, setShow] = createSignal(false);
const stepper = createStepper(
{ steps },
{
initialStep: props.initialStep || "select:service",
initialStoreData: {
...props.initialStore,
close: () => setShow(false),
} satisfies Partial<ServiceStoreType>,
},
);
return ( return (
<>
<div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
<Show when={show()}>
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
<StepperProvider stepper={stepper}> <StepperProvider stepper={stepper}>
<BackButton /> <div class="w-[30rem]">{stepper.currentStep().content()}</div>
{stepper.currentStep().content()}
<NextButton onClick={() => stepper.next()} />
</StepperProvider> </StepperProvider>
</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>
</>
); );
}; };