Merge pull request 'ui/services: workflow init' (#5013) from search into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5013
This commit is contained in:
hsjobeki
2025-08-28 08:19:01 +00:00
12 changed files with 691 additions and 264 deletions

View File

@@ -2,9 +2,11 @@ import Icon from "../Icon/Icon";
import { Button } from "../Button/Button";
import styles from "./Search.module.css";
import { Combobox } from "@kobalte/core/combobox";
import { createMemo, createSignal, For, JSX } from "solid-js";
import { createMemo, createSignal, For, JSX, Match, Switch } from "solid-js";
import { createVirtualizer, VirtualizerOptions } from "@tanstack/solid-virtual";
import { CollectionNode } from "@kobalte/core/*";
import cx from "classnames";
import { Loader } from "../Loader/Loader";
export interface Option {
value: string;
@@ -23,6 +25,10 @@ export interface SearchMultipleProps<T> {
placeholder?: string;
virtualizerOptions?: Partial<VirtualizerOptions<Element, Element>>;
height: string; // e.g. '14.5rem'
headerClass?: string;
headerChildren?: JSX.Element;
loading?: boolean;
loadingComponent?: JSX.Element;
}
export function SearchMultiple<T extends Option>(
props: SearchMultipleProps<T>,
@@ -72,7 +78,6 @@ export function SearchMultiple<T extends Option>(
props.onChange(values);
}}
class={styles.searchContainer}
style={{ "--container-height": props.height }}
placement="bottom-start"
options={props.options}
optionValue="value"
@@ -89,69 +94,78 @@ export function SearchMultiple<T extends Option>(
triggerMode="manual"
noResetInputOnBlur={true}
>
<Combobox.Control<T> class={styles.searchHeader}>
<Combobox.Control<T>
class={cx(styles.searchHeader, props.headerClass || "bg-inv-3")}
>
{(state) => (
<div class={styles.inputContainer}>
<Icon icon="Search" color="quaternary" />
<Combobox.Input
ref={(el) => {
inputEl = el;
}}
class={styles.searchInput}
placeholder={props.placeholder}
value={inputValue()}
onChange={(e) => {
setInputValue(e.currentTarget.value);
}}
/>
<Button
type="reset"
hierarchy="primary"
size="s"
ghost
icon="CloseCircle"
onClick={() => {
state.clear();
setInputValue("");
<>
{props.headerChildren}
<div class={styles.inputContainer}>
<Icon icon="Search" color="quaternary" />
<Combobox.Input
ref={(el) => {
inputEl = el;
}}
class={styles.searchInput}
placeholder={props.placeholder}
value={inputValue()}
onChange={(e) => {
setInputValue(e.currentTarget.value);
}}
/>
<Button
type="reset"
hierarchy="primary"
size="s"
ghost
icon="CloseCircle"
onClick={() => {
state.clear();
setInputValue("");
// Dispatch an input event to notify combobox listeners
inputEl.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true }),
);
}}
/>
</div>
// Dispatch an input event to notify combobox listeners
inputEl.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true }),
);
}}
/>
</div>
</>
)}
</Combobox.Control>
<Combobox.Portal>
<Combobox.Content
class={styles.searchContent}
tabIndex={-1}
style={{ "--container-height": props.height }}
>
<Combobox.Listbox<T>
ref={(el) => {
listboxRef = el;
}}
style={{
height: "100%",
width: "100%",
overflow: "auto",
"overflow-y": "auto",
}}
scrollToItem={(key) => {
const idx = comboboxItems().findIndex(
(option) => option.rawValue.value === key,
);
virtualizer().scrollToIndex(idx);
}}
>
{(items) => {
// Update the virtualizer with the filtered items
const arr = Array.from(items());
setComboboxItems(arr);
<Combobox.Listbox<T>
ref={(el) => {
listboxRef = el;
}}
style={{
height: props.height,
width: "100%",
overflow: "auto",
"overflow-y": "auto",
}}
scrollToItem={(key) => {
const idx = comboboxItems().findIndex(
(option) => option.rawValue.value === key,
);
virtualizer().scrollToIndex(idx);
}}
class={styles.listbox}
>
{(items) => {
// Update the virtualizer with the filtered items
const arr = Array.from(items());
setComboboxItems(arr);
return (
return (
<Switch>
<Match when={props.loading}>
{props.loadingComponent ?? (
<div class="flex w-full justify-center py-2">
<Loader />
</div>
)}
</Match>
<Match when={!props.loading}>
<div
style={{
height: `${virtualizer().getTotalSize()}px`,
@@ -191,11 +205,12 @@ export function SearchMultiple<T extends Option>(
}}
</For>
</div>
);
}}
</Combobox.Listbox>
</Combobox.Content>
</Combobox.Portal>
</Match>
</Switch>
);
}}
</Combobox.Listbox>
{/* </Combobox.Content> */}
</Combobox>
);
}

View File

@@ -29,7 +29,7 @@
}
.searchHeader {
@apply bg-inv-3 flex gap-2 items-center p-2 rounded-md z-50;
@apply flex gap-2 items-center p-2 rounded-t-md z-50;
@apply px-3 pt-3 pb-2;
}
@@ -42,18 +42,21 @@
}
.searchItem {
@apply flex flex-col justify-center overflow-hidden;
box-shadow: 0 1px 0 0 theme(colors.border.inv.2);
&[data-highlighted],
&:focus,
&:focus-visible,
&:hover {
@apply bg-inv-acc-2;
@apply bg-inv-acc-2 rounded-md;
box-shadow: unset;
}
&:active {
@apply bg-inv-acc-3;
@apply bg-inv-acc-3 rounded-md;
box-shadow: unset;
}
@apply flex flex-col justify-center;
}
.searchContainer {
@@ -61,8 +64,6 @@
@apply rounded-lg;
height: var(--container-height, 14.5rem);
border: 1px solid #2b4647;
background:
@@ -78,9 +79,8 @@
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.searchContent {
@apply px-3;
height: calc(var(--container-height, 14.5rem) - 3.5rem);
.listbox {
@apply px-3 pt-3.5;
}
@keyframes contentHide {

View File

@@ -9,7 +9,7 @@ import {
SearchMultiple,
SearchMultipleProps,
} from "./MultipleSearch";
import { JSX, Show } from "solid-js";
import { Show } from "solid-js";
const meta = {
title: "Components/Search",
@@ -72,6 +72,7 @@ export interface Module {
export const Default: Story = {
args: {
height: "14.5rem",
// Test with lots of modules
options: generateModules(1000),
renderItem: (item: Module) => {
@@ -119,6 +120,7 @@ export const Default: Story = {
export const Loading: Story = {
args: {
height: "14.5rem",
// Test with lots of modules
loading: true,
options: [],
@@ -151,19 +153,6 @@ type MachineOrTag =
type: "tag";
};
interface WrapIfProps {
condition: boolean;
wrapper: (children: JSX.Element) => JSX.Element;
children: JSX.Element;
}
const WrapIf = (props: WrapIfProps) => {
if (props.condition) {
return props.wrapper(props.children);
} else {
return props.children;
}
};
const machinesAndTags: MachineOrTag[] = [
{ value: "machine-1", label: "Machine 1", type: "machine" },
{ value: "machine-2", label: "Machine 2", type: "machine" },

View File

@@ -6,6 +6,7 @@ import { createMemo, createSignal, For, JSX, Match, Switch } from "solid-js";
import { createVirtualizer } from "@tanstack/solid-virtual";
import { CollectionNode } from "@kobalte/core/*";
import { Loader } from "../Loader/Loader";
import cx from "classnames";
export interface Option {
value: string;
@@ -18,6 +19,8 @@ export interface SearchProps<T> {
renderItem: (item: T) => JSX.Element;
loading?: boolean;
loadingComponent?: JSX.Element;
headerClass?: string;
height: string; // e.g. '14.5rem'
}
export function Search<T extends Option>(props: SearchProps<T>) {
// Controlled input value, to allow resetting the input itself
@@ -80,7 +83,9 @@ export function Search<T extends Option>(props: SearchProps<T>) {
triggerMode="manual"
noResetInputOnBlur={true}
>
<Combobox.Control<T> class={styles.searchHeader}>
<Combobox.Control<T>
class={cx(styles.searchHeader, props.headerClass || "bg-inv-3")}
>
{(state) => (
<div class={styles.inputContainer}>
<Icon icon="Search" color="quaternary" />
@@ -114,85 +119,79 @@ export function Search<T extends Option>(props: SearchProps<T>) {
</div>
)}
</Combobox.Control>
<Combobox.Portal>
<Combobox.Content class={styles.searchContent} tabIndex={-1}>
<Combobox.Listbox<T>
ref={(el) => {
listboxRef = el;
}}
style={{
height: "100%",
width: "100%",
overflow: "auto",
"overflow-y": "auto",
}}
scrollToItem={(key) => {
const idx = comboboxItems().findIndex(
(option) => option.rawValue.value === key,
);
virtualizer().scrollToIndex(idx);
}}
>
{(items) => {
// Update the virtualizer with the filtered items
const arr = Array.from(items());
setComboboxItems(arr);
<Combobox.Listbox<T>
ref={(el) => {
listboxRef = el;
}}
style={{
height: props.height,
width: "100%",
overflow: "auto",
"overflow-y": "auto",
}}
class={styles.listbox}
scrollToItem={(key) => {
const idx = comboboxItems().findIndex(
(option) => option.rawValue.value === key,
);
virtualizer().scrollToIndex(idx);
}}
>
{(items) => {
// Update the virtualizer with the filtered items
const arr = Array.from(items());
setComboboxItems(arr);
return (
<Switch>
<Match when={props.loading}>
{props.loadingComponent ?? (
<div class="flex w-full justify-center py-2">
<Loader />
</div>
)}
</Match>
<Match when={!props.loading}>
<div
style={{
height: `${virtualizer().getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
<For each={virtualizer().getVirtualItems()}>
{(virtualRow) => {
const item: CollectionNode<T> | undefined =
items().getItem(virtualRow.key as string);
return (
<Switch>
<Match when={props.loading}>
{props.loadingComponent ?? (
<div class="flex w-full justify-center py-2">
<Loader />
</div>
)}
</Match>
<Match when={!props.loading}>
<div
style={{
height: `${virtualizer().getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
<For each={virtualizer().getVirtualItems()}>
{(virtualRow) => {
const item: CollectionNode<T> | undefined =
items().getItem(virtualRow.key as string);
if (!item) {
console.warn(
"Item not found for key:",
virtualRow.key,
);
return null;
}
return (
<Combobox.Item
item={item}
class={styles.searchItem}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{props.renderItem(item.rawValue)}
</Combobox.Item>
);
}}
</For>
</div>
</Match>
</Switch>
);
}}
</Combobox.Listbox>
</Combobox.Content>
</Combobox.Portal>
if (!item) {
console.warn("Item not found for key:", virtualRow.key);
return null;
}
return (
<Combobox.Item
item={item}
class={styles.searchItem}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{props.renderItem(item.rawValue)}
</Combobox.Item>
);
}}
</For>
</div>
</Match>
</Switch>
);
}}
</Combobox.Listbox>
</Combobox>
);
}

View File

@@ -1,22 +1,3 @@
.dummybg {
padding: 1rem;
width: 20rem;
min-height: 10rem;
border-radius: 8px;
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);
}
.trigger {
@apply rounded-md bg-inv-4 w-full min-h-11;

View File

@@ -3,6 +3,7 @@ import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { TagSelect, TagSelectProps } from "./TagSelect";
import { Tag } from "../Tag/Tag";
import Icon from "../Icon/Icon";
import { createSignal } from "solid-js";
const meta = {
title: "Components/Custom/SelectStepper",
@@ -11,28 +12,51 @@ const meta = {
export default meta;
type Story = StoryObj<TagSelectProps<string>>;
interface Item {
value: string;
label: string;
}
const Item = (item: string) => (
type Story = StoryObj<TagSelectProps<Item>>;
const Item = (item: Item) => (
<Tag
inverted
icon={(tag) => (
<Icon icon={"Machine"} size="0.5rem" inverted={tag.inverted} />
)}
>
{item}
{item.label}
</Tag>
);
export const Default: Story = {
args: {
renderItem: Item,
values: ["foo", "bar"],
options: ["foo", "bar", "baz", "qux", "quux"],
onChange: (values: string[]) => {
console.log("Selected values:", values);
},
onClick: () => {
console.log("Combobox clicked");
},
label: "Peer",
options: [
{ value: "foo", label: "Foo" },
{ value: "bar", label: "Bar" },
{ value: "baz", label: "Baz" },
{ value: "qux", label: "Qux" },
{ value: "quux", label: "Quux" },
{ value: "corge", label: "Corge" },
{ value: "grault", label: "Grault" },
],
} satisfies Partial<TagSelectProps<Item>>,
render: (args: TagSelectProps<Item>) => {
const [state, setState] = createSignal<Item[]>([]);
return (
<TagSelect<Item>
{...args}
values={state()}
onClick={() => {
console.log("Clicked, current values:");
setState(() => [
{ value: "baz", label: "Baz" },
{ value: "qux", label: "Qux" },
]);
}}
/>
);
},
};

View File

@@ -5,43 +5,58 @@ import styles from "./TagSelect.module.css";
import { Combobox } from "@kobalte/core/combobox";
import { Button } from "../Button/Button";
// Base props common to both modes
export interface TagSelectProps<T> {
// Define any props needed for the SelectStepper component
onClick: () => void;
label: string;
values: T[];
options: T[];
onChange: (values: T[]) => void;
onClick: () => void;
renderItem: (item: T) => JSX.Element;
}
export function TagSelect<T>(props: TagSelectProps<T>) {
/**
* Shallowly interactive field for selecting multiple tags / machines.
* It does only handle click and focus interactions
* Displays the selected items as tags
*/
export function TagSelect<T extends { value: unknown }>(
props: TagSelectProps<T>,
) {
const optionValue = "value";
return (
<div class={styles.dummybg}>
<div class="flex flex-col gap-1.5">
<div class="flex w-full items-center gap-2 px-1.5">
<Typography
hierarchy="body"
weight="medium"
class="flex gap-2 uppercase"
size="s"
inverted
color="secondary"
>
Servers
</Typography>
<Icon icon="Info" color="tertiary" inverted />
<Button icon="Settings" hierarchy="primary" ghost class="ml-auto" />
</div>
<Combobox<T>
multiple
value={props.values}
onChange={props.onChange}
options={props.options}
allowsEmptyCollection
class="w-full"
<div class="flex flex-col gap-1.5">
<div class="flex w-full items-center gap-2 px-1.5 py-0">
<Typography
hierarchy="label"
weight="medium"
class="flex gap-2 uppercase"
size="xs"
inverted
color="secondary"
>
<Combobox.Control<T> aria-label="Fruits">
{(state) => (
{props.label}
</Typography>
<Icon icon="Info" color="tertiary" inverted size={11} />
<Button
icon="Settings"
hierarchy="primary"
ghost
class="ml-auto"
size="xs"
/>
</div>
<Combobox<T>
multiple
optionValue={optionValue}
value={props.values}
options={props.options}
allowsEmptyCollection
class="w-full"
>
<Combobox.Control<T> aria-label="Fruits">
{(state) => {
console.log("combobox state selected", state.selectedOptions());
return (
<Combobox.Trigger
tabIndex={1}
class={styles.trigger}
@@ -62,10 +77,10 @@ export function TagSelect<T>(props: TagSelectProps<T>) {
<For each={state.selectedOptions()}>{props.renderItem}</For>
</div>
</Combobox.Trigger>
)}
</Combobox.Control>
</Combobox>
</div>
);
}}
</Combobox.Control>
</Combobox>
</div>
);
}

View File

@@ -42,6 +42,7 @@ export const DefaultQueryClient = new QueryClient({
},
});
export type MachinesQuery = ReturnType<typeof useMachinesQuery>;
export const useMachinesQuery = (clanURI: string) => {
const client = useApiClient();
@@ -117,6 +118,27 @@ export const useMachineQuery = (clanURI: string, machineName: string) => {
}));
};
export type TagsQuery = ReturnType<typeof useTags>;
export const useTags = (clanURI: string) => {
const client = useApiClient();
return useQuery(() => ({
queryKey: ["clans", encodeBase64(clanURI), "tags"],
queryFn: async () => {
const apiCall = client.fetch("list_tags", {
flake: {
identifier: clanURI,
},
});
const result = await apiCall.result;
if (result.status === "error") {
throw new Error("Error fetching tags: " + result.errors[0].message);
}
return result.data;
},
}));
};
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
const client = useApiClient();
return useQuery<MachineState>(() => ({

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> = {
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",
}}
/>
),
};

View File

@@ -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>
</>
);
};

View File

@@ -35,7 +35,8 @@ export const NextButton = (props: NextButtonProps) => {
);
};
export const BackButton = () => {
type BackButtonProps = ButtonProps & {};
export const BackButton = (props: BackButtonProps) => {
const stepSignal = useStepper<InstallSteps>();
return (
<Button
@@ -45,6 +46,7 @@ export const BackButton = () => {
onClick={() => {
stepSignal.previous();
}}
{...props}
></Button>
);
};