Merge pull request 'ui/services: add more features to components' (#4988) from search into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4988
This commit is contained in:
hsjobeki
2025-08-26 16:40:51 +00:00
9 changed files with 402 additions and 64 deletions

View File

@@ -66,11 +66,11 @@
border: 1px solid #2b4647;
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.18) 100%),
linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
linear-gradient(
180deg,
var(--clr-bg-inv-3, rgba(43, 70, 71, 0.79)) 0%,
var(--clr-bg-inv-4, rgba(32, 54, 55, 0.79)) 100%
theme(colors.bg.inv.2) 0%,
theme(colors.bg.inv.3) 100%
);
box-shadow:
@@ -80,8 +80,7 @@
.searchContent {
@apply px-3;
height: var(--container-height, 14.5rem);
padding-bottom: 4rem;
height: calc(var(--container-height, 14.5rem) - 4rem);
}
@keyframes contentHide {

View File

@@ -117,6 +117,27 @@ export const Default: Story = {
},
};
export const Loading: Story = {
args: {
// Test with lots of modules
loading: true,
options: [],
renderItem: () => <span></span>,
},
render: (args: SearchProps<Module>) => {
return (
<div class="absolute bottom-1/3 w-3/4 px-3">
<Search<Module>
{...args}
onChange={(module) => {
// Go to the module configuration
}}
/>
</div>
);
},
};
type MachineOrTag =
| {
value: string;

View File

@@ -2,9 +2,10 @@ 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 } from "@tanstack/solid-virtual";
import { CollectionNode } from "@kobalte/core/*";
import { Loader } from "../Loader/Loader";
export interface Option {
value: string;
@@ -15,6 +16,8 @@ export interface SearchProps<T> {
onChange: (value: T | null) => void;
options: T[];
renderItem: (item: T) => JSX.Element;
loading?: boolean;
loadingComponent?: JSX.Element;
}
export function Search<T extends Option>(props: SearchProps<T>) {
// Controlled input value, to allow resetting the input itself
@@ -136,41 +139,55 @@ export function Search<T extends Option>(props: SearchProps<T>) {
setComboboxItems(arr);
return (
<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);
<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>
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>

View File

@@ -5,12 +5,13 @@
border-radius: 8px;
border: 1px solid #2e4a4b;
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.18) 100%),
linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
linear-gradient(
180deg,
var(--clr-bg-inv-3, rgba(46, 74, 75, 0.79)) 0%,
var(--clr-bg-inv-4, rgba(32, 54, 55, 0.79)) 100%
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);

View File

@@ -457,3 +457,28 @@ export const useMachineGenerators = (
},
}));
};
export type ServiceModulesQuery = ReturnType<typeof useServiceModules>;
export type ServiceModules = SuccessData<"list_service_modules">;
export const useServiceModules = (clanUri: string) => {
const client = useApiClient();
return useQuery(() => ({
queryKey: ["clans", encodeBase64(clanUri), "service_modules"],
queryFn: async () => {
const call = client.fetch("list_service_modules", {
flake: {
identifier: clanUri,
},
});
const result = await call.result;
if (result.status === "error") {
// todo should we create some specific error types?
console.error("Error fetching clan details:", result.errors);
throw new Error(result.errors[0].message);
}
return result.data;
},
}));
};

View File

@@ -0,0 +1,140 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { ServiceWorkflow } from "./Service";
import {
createMemoryHistory,
MemoryRouter,
RouteDefinition,
} from "@solidjs/router";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient";
import {
ApiCall,
OperationNames,
OperationResponse,
SuccessQuery,
} from "@/src/hooks/api";
type ResultDataMap = {
[K in OperationNames]: SuccessQuery<K>["data"];
};
const mockFetcher: Fetcher = <K extends OperationNames>(
name: K,
_args: unknown,
): ApiCall<K> => {
// TODO: Make this configurable for every story
const resultData: Partial<ResultDataMap> = {
list_service_modules: [
{
module: { name: "Module A", input: "Input A" },
info: {
manifest: {
name: "Module A",
description: "This is module A",
},
roles: {
peer: null,
server: null,
},
},
},
{
module: { name: "Module B", input: "Input B" },
info: {
manifest: {
name: "Module B",
description: "This is module B",
},
roles: {
default: null,
},
},
},
{
module: { name: "Module C", input: "Input B" },
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",
description: "This is module B",
},
roles: {
default: null,
},
},
},
],
};
return {
uuid: "mock",
cancel: () => Promise.resolve(),
result: new Promise((resolve) => {
setTimeout(() => {
resolve({
op_key: "1",
status: "success",
data: resultData[name],
} as OperationResponse<K>);
}, 1500);
}),
};
};
const meta: Meta<typeof ServiceWorkflow> = {
title: "workflows/service",
component: ServiceWorkflow,
decorators: [
(Story: StoryObj, context: StoryContext) => {
const Routes: RouteDefinition[] = [
{
path: "/clans/:clanURI",
component: () => (
<div>
<Story />
</div>
),
},
];
const history = createMemoryHistory();
history.set({ value: "/clans/dGVzdA==", replace: true });
const queryClient = new QueryClient();
return (
<ApiClientProvider client={{ fetch: mockFetcher }}>
<QueryClientProvider client={queryClient}>
<MemoryRouter
root={(props) => {
console.debug("Rendering MemoryRouter root with props:", props);
return props.children;
}}
history={history}
>
{Routes}
</MemoryRouter>
</QueryClientProvider>
</ApiClientProvider>
);
},
],
};
export default meta;
type Story = StoryObj<typeof ServiceWorkflow>;
export const Default: Story = {
args: {},
};

View File

@@ -0,0 +1,124 @@
import {
createStepper,
getStepStore,
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 { 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";
type ModuleItem = ServiceModules[number];
interface Module {
value: string;
input: string;
label: string;
description: string;
raw: ModuleItem;
}
const SelectService = () => {
const clanURI = useClanURI();
const stepper = useStepper<ServiceSteps>();
const serviceModulesQuery = useServiceModules(clanURI);
const [moduleOptions, setModuleOptions] = createSignal<Module[]>([]);
createEffect(() => {
if (serviceModulesQuery.data) {
setModuleOptions(
serviceModulesQuery.data.map((m) => ({
value: `${m.module.name}:${m.module.input}`,
label: m.module.name,
description: m.info.manifest.description,
input: m.module.input || "clan-core",
raw: m,
})),
);
}
});
const [store, set] = getStepStore<ServiceStoreType>(stepper);
return (
<Search<Module>
loading={serviceModulesQuery.isLoading}
onChange={(module) => {
if (!module) return;
console.log("Module selected");
set("module", {
name: module.raw.module.name,
input: module.raw.module.input,
});
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 size-8 items-center justify-center rounded-md bg-white">
<Icon icon="Code" />
</div>
<div class="flex w-full flex-col">
<Combobox.ItemLabel class="flex">
<Typography hierarchy="body" size="s" weight="medium" inverted>
{item.label}
</Typography>
</Combobox.ItemLabel>
<Typography
hierarchy="body"
size="xxs"
weight="normal"
color="quaternary"
inverted
class="flex justify-between"
>
<span>{item.description}</span>
<span>by {item.input}</span>
</Typography>
</div>
</div>
);
}}
/>
);
};
const steps = [
{
id: "select:service",
content: SelectService,
},
{
id: "select:members",
content: () => <div>Configure your service here.</div>,
},
{ id: "settings", content: () => <div>Adjust settings here.</div> },
] as const;
export type ServiceSteps = typeof steps;
export interface ServiceStoreType {
module: {
name: string;
input: string;
};
}
export const ServiceWorkflow = () => {
const stepper = createStepper({ steps }, { initialStep: "select:service" });
return (
<StepperProvider stepper={stepper}>
<BackButton />
{stepper.currentStep().content()}
<NextButton onClick={() => stepper.next()} />
</StepperProvider>
);
};

View File

@@ -8,6 +8,7 @@ from clan_lib.errors import ClanError
from clan_lib.flake.flake import Flake
from clan_lib.nix_models.clan import (
InventoryInstance,
InventoryInstanceModule,
InventoryInstanceModuleType,
InventoryInstanceRolesType,
)
@@ -151,27 +152,32 @@ class ModuleInfo(TypedDict):
roles: dict[str, None]
class ModuleList(TypedDict):
modules: dict[str, dict[str, ModuleInfo]]
class Module(TypedDict):
module: InventoryInstanceModule
info: ModuleInfo
@API.register
def list_service_modules(flake: Flake) -> ModuleList:
def list_service_modules(flake: Flake) -> list[Module]:
"""Show information about a module"""
modules = flake.select("clanInternals.inventoryClass.modulesPerSource")
res: dict[str, dict[str, ModuleInfo]] = {}
res: list[Module] = []
for input_name, module_set in modules.items():
res[input_name] = {}
for module_name, module_info in module_set.items():
# breakpoint()
res[input_name][module_name] = ModuleInfo(
manifest=ModuleManifest.from_dict(module_info.get("manifest")),
roles=module_info.get("roles", {}),
res.append(
Module(
module={"name": module_name, "input": input_name},
info=ModuleInfo(
manifest=ModuleManifest.from_dict(
module_info.get("manifest"),
),
roles=module_info.get("roles", {}),
),
)
)
return ModuleList(modules=res)
return res
@API.register
@@ -188,19 +194,21 @@ def get_service_module(
input_name, module_name = check_service_module_ref(flake, module_ref)
avilable_modules = list_service_modules(flake)
module_set = avilable_modules.get("modules", {}).get(input_name)
module_set: list[Module] = [
m for m in avilable_modules if m["module"].get("input", None) == input_name
]
if module_set is None:
if not module_set:
msg = f"Module set for input '{input_name}' not found"
raise ClanError(msg)
module = module_set.get(module_name)
module = next((m for m in module_set if m["module"]["name"] == module_name), None)
if module is None:
msg = f"Module '{module_name}' not found in input '{input_name}'"
raise ClanError(msg)
return module
return module["info"]
def check_service_module_ref(
@@ -219,18 +227,21 @@ def check_service_module_ref(
msg = "Setting module_ref.input is currently required"
raise ClanError(msg)
module_set = avilable_modules.get("modules", {}).get(input_ref)
module_set = [
m for m in avilable_modules if m["module"].get("input", None) == input_ref
]
if module_set is None:
inputs = {m["module"].get("input") for m in avilable_modules}
msg = f"module set for input '{input_ref}' not found"
msg += f"\nAvilable input_refs: {avilable_modules.get('modules', {}).keys()}"
msg += f"\nAvilable input_refs: {inputs}"
raise ClanError(msg)
module_name = module_ref.get("name")
if not module_name:
msg = "Module name is required in module_ref"
raise ClanError(msg)
module = module_set.get(module_name)
module = next((m for m in module_set if m["module"]["name"] == module_name), None)
if module is None:
msg = f"module with name '{module_name}' not found"
raise ClanError(msg)

View File

@@ -215,9 +215,9 @@ def test_clan_create_api(
inventory = store.read()
modules = list_service_modules(clan_dir_flake)
assert (
modules["modules"]["clan-core"]["admin"]["manifest"].name == "clan-core/admin"
)
admin_module = next(m for m in modules if m["module"]["name"] == "admin")
assert admin_module["info"]["manifest"].name == "clan-core/admin"
set_value_by_path(inventory, "instances", inventory_conf.instances)
store.write(