ui/services: workflow init
This commit is contained in:
35
pkgs/clan-app/ui/src/workflows/Service/Service.module.css
Normal file
35
pkgs/clan-app/ui/src/workflows/Service/Service.module.css
Normal 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%
|
||||
);
|
||||
}
|
||||
@@ -26,23 +26,37 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
||||
const resultData: Partial<ResultDataMap> = {
|
||||
list_service_modules: [
|
||||
{
|
||||
module: { name: "Module A", input: "Input A" },
|
||||
module: { name: "Borgbackup", input: "clan-core" },
|
||||
info: {
|
||||
manifest: {
|
||||
name: "Module A",
|
||||
name: "Borgbackup",
|
||||
description: "This is module A",
|
||||
},
|
||||
roles: {
|
||||
peer: null,
|
||||
client: null,
|
||||
server: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
module: { name: "Module B", input: "Input B" },
|
||||
module: { name: "Zerotier", input: "clan-core" },
|
||||
info: {
|
||||
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",
|
||||
},
|
||||
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: {
|
||||
manifest: {
|
||||
name: "Module B",
|
||||
description: "This is module B",
|
||||
},
|
||||
roles: {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
module: { name: "Module B", input: "Input A" },
|
||||
info: {
|
||||
manifest: {
|
||||
name: "Module B",
|
||||
name: "Garage",
|
||||
description: "This is module B",
|
||||
},
|
||||
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 {
|
||||
@@ -138,3 +162,14 @@ type Story = StoryObj<typeof ServiceWorkflow>;
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const SelectRoleMembers: Story = {
|
||||
render: () => (
|
||||
<ServiceWorkflow
|
||||
initialStep="select:members"
|
||||
initialStore={{
|
||||
currentRole: "peer",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -4,14 +4,31 @@ import {
|
||||
StepperProvider,
|
||||
useStepper,
|
||||
} from "@/src/hooks/stepper";
|
||||
import { BackButton, NextButton } from "../Steps";
|
||||
import { useClanURI } from "@/src/hooks/clan";
|
||||
import { ServiceModules, useServiceModules } from "@/src/hooks/queries";
|
||||
import { createEffect, createSignal } from "solid-js";
|
||||
import {
|
||||
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 Icon from "@/src/components/Icon/Icon";
|
||||
import { Combobox } from "@kobalte/core/combobox";
|
||||
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];
|
||||
|
||||
@@ -48,20 +65,21 @@ const SelectService = () => {
|
||||
return (
|
||||
<Search<Module>
|
||||
loading={serviceModulesQuery.isLoading}
|
||||
height="13rem"
|
||||
onChange={(module) => {
|
||||
if (!module) return;
|
||||
|
||||
console.log("Module selected");
|
||||
set("module", {
|
||||
name: module.raw.module.name,
|
||||
input: module.raw.module.input,
|
||||
raw: module.raw,
|
||||
});
|
||||
stepper.next();
|
||||
}}
|
||||
options={moduleOptions()}
|
||||
renderItem={(item) => {
|
||||
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">
|
||||
<Icon icon="Code" />
|
||||
</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 = [
|
||||
{
|
||||
id: "select:service",
|
||||
content: SelectService,
|
||||
},
|
||||
{
|
||||
id: "view:members",
|
||||
content: ConfigureService,
|
||||
},
|
||||
{
|
||||
id: "select:members",
|
||||
content: () => <div>Configure your service here.</div>,
|
||||
content: ConfigureRole,
|
||||
},
|
||||
{ id: "settings", content: () => <div>Adjust settings here.</div> },
|
||||
] as const;
|
||||
@@ -108,17 +385,50 @@ export interface ServiceStoreType {
|
||||
module: {
|
||||
name: string;
|
||||
input: string;
|
||||
raw?: ModuleItem;
|
||||
};
|
||||
roles: Record<string, TagType[]>;
|
||||
currentRole?: string;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const ServiceWorkflow = () => {
|
||||
const stepper = createStepper({ steps }, { initialStep: "select:service" });
|
||||
|
||||
interface ServiceWorkflowProps {
|
||||
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 (
|
||||
<StepperProvider stepper={stepper}>
|
||||
<BackButton />
|
||||
{stepper.currentStep().content()}
|
||||
<NextButton onClick={() => stepper.next()} />
|
||||
</StepperProvider>
|
||||
<>
|
||||
<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}>
|
||||
<div class="w-[30rem]">{stepper.currentStep().content()}</div>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user