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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>(() => ({
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user