Merge pull request 'ui/service: rewire to allow external selection' (#5020) from search into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5020
This commit is contained in:
@@ -2,7 +2,15 @@ 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, Match, Switch } from "solid-js";
|
||||
import {
|
||||
createEffect,
|
||||
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";
|
||||
@@ -11,13 +19,16 @@ import { Loader } from "../Loader/Loader";
|
||||
export interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ItemRenderOptions {
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface SearchMultipleProps<T> {
|
||||
values: T[]; // controlled values
|
||||
onChange: (values: T[]) => void;
|
||||
options: T[];
|
||||
renderItem: (item: T, opts: ItemRenderOptions) => JSX.Element;
|
||||
@@ -29,12 +40,13 @@ export interface SearchMultipleProps<T> {
|
||||
headerChildren?: JSX.Element;
|
||||
loading?: boolean;
|
||||
loadingComponent?: JSX.Element;
|
||||
divider?: boolean;
|
||||
}
|
||||
export function SearchMultiple<T extends Option>(
|
||||
props: SearchMultipleProps<T>,
|
||||
) {
|
||||
// Controlled input value, to allow resetting the input itself
|
||||
const [values, setValues] = createSignal<T[]>(props.initialValues || []);
|
||||
// const [values, setValues] = createSignal<T[]>(props.initialValues || []);
|
||||
const [inputValue, setInputValue] = createSignal<string>("");
|
||||
|
||||
let inputEl: HTMLInputElement;
|
||||
@@ -60,21 +72,23 @@ export function SearchMultiple<T extends Option>(
|
||||
return item?.rawValue?.value || `item-${index}`;
|
||||
},
|
||||
estimateSize: () => 42,
|
||||
gap: 6,
|
||||
gap: 0,
|
||||
overscan: 5,
|
||||
...props.virtualizerOptions,
|
||||
});
|
||||
|
||||
return newVirtualizer;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
console.log("multi values:", props.values);
|
||||
});
|
||||
return (
|
||||
<Combobox<T>
|
||||
multiple
|
||||
value={values()}
|
||||
value={props.values}
|
||||
onChange={(values) => {
|
||||
setValues(() => values);
|
||||
// setInputValue(value ? value.label : "");
|
||||
// setValues(() => values);
|
||||
console.log("onChange", values);
|
||||
props.onChange(values);
|
||||
}}
|
||||
class={styles.searchContainer}
|
||||
@@ -83,6 +97,7 @@ export function SearchMultiple<T extends Option>(
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
optionLabel="label"
|
||||
optionDisabled={"disabled"}
|
||||
sameWidth={true}
|
||||
open={true}
|
||||
gutter={7}
|
||||
@@ -183,11 +198,16 @@ export function SearchMultiple<T extends Option>(
|
||||
return null;
|
||||
}
|
||||
const isSelected = () =>
|
||||
values().some((v) => v.value === item.rawValue.value);
|
||||
props.values.some(
|
||||
(v) => v.value === item.rawValue.value,
|
||||
);
|
||||
return (
|
||||
<Combobox.Item
|
||||
item={item}
|
||||
class={styles.searchItem}
|
||||
class={cx(
|
||||
styles.searchItem,
|
||||
props.divider && styles.hasDivider,
|
||||
)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
@@ -199,6 +219,7 @@ export function SearchMultiple<T extends Option>(
|
||||
>
|
||||
{props.renderItem(item.rawValue, {
|
||||
selected: isSelected(),
|
||||
disabled: item.disabled,
|
||||
})}
|
||||
</Combobox.Item>
|
||||
);
|
||||
|
||||
@@ -43,20 +43,32 @@
|
||||
|
||||
.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 {
|
||||
&.hasDivider {
|
||||
box-shadow: 0 1px 0 0 theme(colors.border.inv.2);
|
||||
}
|
||||
|
||||
/* Next element is hovered */
|
||||
&:has(+ &:hover) {
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
&:not([aria-disabled="true"])[data-highlighted],
|
||||
&:not([aria-disabled="true"]):focus,
|
||||
&:not([aria-disabled="true"]):focus-visible,
|
||||
&:not([aria-disabled="true"]):hover {
|
||||
@apply bg-inv-acc-2 rounded-md;
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
&:active {
|
||||
&:not([aria-disabled="true"]):active {
|
||||
@apply bg-inv-acc-3 rounded-md;
|
||||
box-shadow: unset;
|
||||
}
|
||||
|
||||
&[aria-disabled="true"] {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
|
||||
@@ -55,8 +55,8 @@ function generateModules(count: number): Module[] {
|
||||
modules.push({
|
||||
value: `lolcat/module-${i + 1}`,
|
||||
label: `Module ${i + 1}`,
|
||||
description: `${greek[i % greek.length]}#${i + 1}`,
|
||||
input: "lolcat",
|
||||
description: `${greek[i % greek.length]}#${i + 1} this is a very long description to test text wrapping in the search component`,
|
||||
input: "lolcat-flake-part-from-nixpkgs-via-nix-via-clan-flake",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export const Default: Story = {
|
||||
renderItem: (item: Module) => {
|
||||
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">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-white">
|
||||
<Icon icon="Code" />
|
||||
</div>
|
||||
<div class="flex w-full flex-col">
|
||||
@@ -95,8 +95,12 @@ export const Default: Story = {
|
||||
inverted
|
||||
class="flex justify-between"
|
||||
>
|
||||
<span>{item.description}</span>
|
||||
<span>by {item.input}</span>
|
||||
<span class="inline-block max-w-72 truncate align-middle">
|
||||
{item.description}
|
||||
</span>
|
||||
<span class="inline-block max-w-20 truncate align-middle">
|
||||
by {item.input}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +109,7 @@ export const Default: Story = {
|
||||
},
|
||||
render: (args: SearchProps<Module>) => {
|
||||
return (
|
||||
<div class="absolute bottom-1/3 w-3/4 px-3">
|
||||
<div class="fixed bottom-10 left-1/2 mb-2 w-[30rem] -translate-x-1/2">
|
||||
<Search<Module>
|
||||
{...args}
|
||||
onChange={(module) => {
|
||||
@@ -145,11 +149,13 @@ type MachineOrTag =
|
||||
value: string;
|
||||
label: string;
|
||||
type: "machine";
|
||||
disabled?: boolean;
|
||||
}
|
||||
| {
|
||||
members: string[];
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
type: "tag";
|
||||
};
|
||||
|
||||
@@ -193,7 +199,13 @@ export const Multiple: Story = {
|
||||
</Show>
|
||||
</Combobox.ItemIndicator>
|
||||
<Combobox.ItemLabel class="flex items-center gap-2">
|
||||
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="s"
|
||||
weight="medium"
|
||||
inverted
|
||||
color={opts.disabled ? "quaternary" : "primary"}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Show when={item.type === "tag" && item}>
|
||||
@@ -226,6 +238,7 @@ export const Multiple: Story = {
|
||||
<div class="absolute bottom-1/3 w-3/4 px-3">
|
||||
<SearchMultiple<MachineOrTag>
|
||||
{...args}
|
||||
divider
|
||||
height="20rem"
|
||||
virtualizerOptions={{
|
||||
estimateSize: () => 38,
|
||||
|
||||
@@ -11,17 +11,20 @@ import cx from "classnames";
|
||||
export interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchProps<T> {
|
||||
onChange: (value: T | null) => void;
|
||||
options: T[];
|
||||
renderItem: (item: T) => JSX.Element;
|
||||
renderItem: (item: T, opts: { disabled: boolean }) => JSX.Element;
|
||||
loading?: boolean;
|
||||
loadingComponent?: JSX.Element;
|
||||
headerClass?: string;
|
||||
height: string; // e.g. '14.5rem'
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
export function Search<T extends Option>(props: SearchProps<T>) {
|
||||
// Controlled input value, to allow resetting the input itself
|
||||
const [value, setValue] = createSignal<T | null>(null);
|
||||
@@ -65,13 +68,14 @@ export function Search<T extends Option>(props: SearchProps<T>) {
|
||||
setInputValue(value ? value.label : "");
|
||||
props.onChange(value);
|
||||
}}
|
||||
class={styles.searchContainer}
|
||||
class={cx(styles.searchContainer, props.divider && styles.hasDivider)}
|
||||
placement="bottom-start"
|
||||
options={props.options}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
optionLabel="label"
|
||||
placeholder="Search a service"
|
||||
optionDisabled={"disabled"}
|
||||
sameWidth={true}
|
||||
open={true}
|
||||
gutter={7}
|
||||
@@ -181,7 +185,9 @@ export function Search<T extends Option>(props: SearchProps<T>) {
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{props.renderItem(item.rawValue)}
|
||||
{props.renderItem(item.rawValue, {
|
||||
disabled: item.disabled,
|
||||
})}
|
||||
</Combobox.Item>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -2,8 +2,7 @@ import styles from "./Sidebar.module.css";
|
||||
import { SidebarHeader } from "@/src/components/Sidebar/SidebarHeader";
|
||||
import { SidebarBody } from "@/src/components/Sidebar/SidebarBody";
|
||||
import cx from "classnames";
|
||||
import { Show, splitProps } from "solid-js";
|
||||
import { useMachineName } from "@/src/hooks/clan";
|
||||
import { splitProps } from "solid-js";
|
||||
|
||||
export interface LinkProps {
|
||||
path: string;
|
||||
|
||||
@@ -67,6 +67,12 @@
|
||||
line-height: normal;
|
||||
letter-spacing: 0.008125rem;
|
||||
}
|
||||
|
||||
&.size-xxs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
/* letter-spacing: 0.008125rem; */
|
||||
}
|
||||
}
|
||||
|
||||
&.family-mono {
|
||||
@@ -89,6 +95,11 @@
|
||||
line-height: normal;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
&.size-xxs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
/* letter-spacing: 0.008125rem; */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -504,3 +504,28 @@ export const useServiceModules = (clanUri: string) => {
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export type ServiceInstancesQuery = ReturnType<typeof useServiceInstances>;
|
||||
export type ServiceInstances = SuccessData<"list_service_instances">;
|
||||
export const useServiceInstances = (clanUri: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanUri), "service_instances"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("list_service_instances", {
|
||||
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;
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
15
pkgs/clan-app/ui/src/hooks/useClickOutside.tsx
Normal file
15
pkgs/clan-app/ui/src/hooks/useClickOutside.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { onCleanup } from "solid-js";
|
||||
|
||||
export function useClickOutside(
|
||||
el: () => HTMLElement | undefined,
|
||||
handler: (e: MouseEvent) => void,
|
||||
) {
|
||||
const listener = (e: MouseEvent) => {
|
||||
const element = el();
|
||||
if (element && !element.contains(e.target as Node)) {
|
||||
handler(e);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", listener);
|
||||
onCleanup(() => document.removeEventListener("mousedown", listener));
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { SolidQueryDevtools } from "@tanstack/solid-query-devtools";
|
||||
import { ApiClientProvider } from "./hooks/ApiClient";
|
||||
import { callApi } from "./hooks/api";
|
||||
import { DefaultQueryClient } from "@/src/hooks/queries";
|
||||
import { Toaster } from "solid-toast";
|
||||
|
||||
const root = document.getElementById("app");
|
||||
|
||||
@@ -25,6 +26,8 @@ if (import.meta.env.DEV) {
|
||||
render(
|
||||
() => (
|
||||
<ApiClientProvider client={{ fetch: callApi }}>
|
||||
{/* Temporary solution */}
|
||||
<Toaster toastOptions={{}} />
|
||||
<QueryClientProvider client={DefaultQueryClient}>
|
||||
{import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />}
|
||||
<Router root={Layout}>{Routes}</Router>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useClanURI,
|
||||
useMachineName,
|
||||
} from "@/src/hooks/clan";
|
||||
import { CubeScene } from "@/src/scene/cubes";
|
||||
import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes";
|
||||
import {
|
||||
ClanDetails,
|
||||
MachinesQueryResult,
|
||||
@@ -38,10 +38,11 @@ import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||
import { UseQueryResult } from "@tanstack/solid-query";
|
||||
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
||||
import {
|
||||
InventoryInstance,
|
||||
ServiceWorkflow,
|
||||
SubmitServiceHandler,
|
||||
} from "@/src/workflows/Service/Service";
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import toast from "solid-toast";
|
||||
|
||||
interface ClanContextProps {
|
||||
clanURI: string;
|
||||
@@ -208,7 +209,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
|
||||
const onAddService = async (): Promise<{ id: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setShowService(true);
|
||||
setShowService((v) => !v);
|
||||
console.log("setting current promise");
|
||||
setCurrentPromise({ resolve, reject });
|
||||
});
|
||||
@@ -287,8 +288,16 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
);
|
||||
|
||||
const client = useApiClient();
|
||||
const handleSubmitService = async (instance: InventoryInstance) => {
|
||||
console.log("Create Instance", instance);
|
||||
const handleSubmitService: SubmitServiceHandler = async (
|
||||
instance,
|
||||
action,
|
||||
) => {
|
||||
console.log(action, "Instance", instance);
|
||||
|
||||
if (action !== "create") {
|
||||
toast.error("Only creating new services is supported");
|
||||
return;
|
||||
}
|
||||
const call = client.fetch("create_service_instance", {
|
||||
flake: {
|
||||
identifier: ctx.clanURI,
|
||||
@@ -299,13 +308,26 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
const result = await call.result;
|
||||
|
||||
if (result.status === "error") {
|
||||
toast.error("Error creating service instance");
|
||||
console.error("Error creating service instance", result.errors);
|
||||
}
|
||||
toast.success("Created");
|
||||
//
|
||||
currentPromise()?.resolve({ id: "0" });
|
||||
setShowService(false);
|
||||
};
|
||||
|
||||
createEffect(
|
||||
on(worldMode, (mode) => {
|
||||
if (mode === "service") {
|
||||
setShowService(true);
|
||||
} else {
|
||||
// todo: request close instead of force close
|
||||
setShowService(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={loadingError()}>
|
||||
@@ -338,7 +360,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
</div>
|
||||
|
||||
<CubeScene
|
||||
onAddService={onAddService}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={onMachineSelect}
|
||||
isLoading={ctx.isLoading()}
|
||||
@@ -349,6 +370,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
handleSubmit={handleSubmitService}
|
||||
onClose={() => {
|
||||
setShowService(false);
|
||||
setWorldMode("default");
|
||||
currentPromise()?.resolve({ id: "0" });
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { callApi } from "@/src/hooks/api";
|
||||
import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineStatus";
|
||||
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
|
||||
|
||||
import cx from "classnames";
|
||||
import styles from "./Machine.module.css";
|
||||
|
||||
export const Machine = (props: RouteSectionProps) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SceneData } from "../stores/clan";
|
||||
import { MachinesQueryResult } from "../hooks/queries";
|
||||
import { ObjectRegistry } from "./ObjectRegistry";
|
||||
import { renderLoop } from "./RenderLoop";
|
||||
import { highlightGroups } from "./highlightStore";
|
||||
|
||||
function keyFromPos(pos: [number, number]): string {
|
||||
return `${pos[0]},${pos[1]}`;
|
||||
@@ -79,6 +80,7 @@ export class MachineManager {
|
||||
new THREE.Vector2(data.position[0], data.position[1]),
|
||||
id,
|
||||
selectedIds,
|
||||
highlightGroups,
|
||||
);
|
||||
this.machines.set(id, repr);
|
||||
scene.add(repr.group);
|
||||
|
||||
@@ -13,6 +13,7 @@ const CUBE_COLOR = 0xe2eff0;
|
||||
const CUBE_EMISSIVE = 0x303030;
|
||||
|
||||
const CUBE_SELECTED_COLOR = 0x4b6767;
|
||||
const HIGHLIGHT_COLOR = 0x00ee66;
|
||||
|
||||
const BASE_COLOR = 0xdbeaeb;
|
||||
const BASE_EMISSIVE = 0x0c0c0c;
|
||||
@@ -36,6 +37,7 @@ export class MachineRepr {
|
||||
position: THREE.Vector2,
|
||||
id: string,
|
||||
selectedSignal: Accessor<Set<string>>,
|
||||
highlightGroups: Record<string, Set<string>>, // Reactive store
|
||||
) {
|
||||
this.id = id;
|
||||
this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
|
||||
@@ -89,23 +91,38 @@ export class MachineRepr {
|
||||
|
||||
this.disposeRoot = createRoot((disposeEffects) => {
|
||||
createEffect(
|
||||
on(selectedSignal, (selectedIds) => {
|
||||
const isSelected = selectedIds.has(this.id);
|
||||
// Update cube
|
||||
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
||||
);
|
||||
on(
|
||||
[selectedSignal, () => Object.entries(highlightGroups)],
|
||||
([selectedIds, groups]) => {
|
||||
const isSelected = selectedIds.has(this.id);
|
||||
const highlightedGroups = groups
|
||||
.filter(([, ids]) => ids.has(this.id))
|
||||
.map(([name]) => name);
|
||||
|
||||
// Update base
|
||||
(this.baseMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||
isSelected ? BASE_SELECTED_COLOR : BASE_COLOR,
|
||||
);
|
||||
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
||||
isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
|
||||
);
|
||||
// console.log("MachineRepr effect", id, highlightedGroups);
|
||||
// Update cube
|
||||
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
||||
);
|
||||
|
||||
renderLoop.requestRender();
|
||||
}),
|
||||
// Update base
|
||||
(this.baseMesh.material as THREE.MeshPhongMaterial).color.set(
|
||||
isSelected ? BASE_SELECTED_COLOR : BASE_COLOR,
|
||||
);
|
||||
|
||||
// TOOD: Find a different way to show both selected & highlighted
|
||||
// I.e. via outline or pulsing
|
||||
// selected > highlighted > normal
|
||||
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
||||
highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000,
|
||||
);
|
||||
// (this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
||||
// isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE,
|
||||
// );
|
||||
|
||||
renderLoop.requestRender();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return disposeEffects;
|
||||
|
||||
@@ -38,10 +38,38 @@ function garbageCollectGroup(group: THREE.Group) {
|
||||
group.clear(); // Clear the group
|
||||
}
|
||||
|
||||
// Can be imported by others via wrappers below
|
||||
// Global signal for last clicked machine
|
||||
const [lastClickedMachine, setLastClickedMachine] = createSignal<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Exported so others could also emit the signal if needed
|
||||
// And for testing purposes
|
||||
export function emitMachineClick(id: string | null) {
|
||||
setLastClickedMachine(id);
|
||||
if (id) {
|
||||
// Clear after a short delay to allow re-clicking the same machine
|
||||
setTimeout(() => {
|
||||
setLastClickedMachine(null);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hook for components to subscribe */
|
||||
export function useMachineClick() {
|
||||
return lastClickedMachine;
|
||||
}
|
||||
|
||||
/*Gloabl signal*/
|
||||
const [worldMode, setWorldMode] = createSignal<
|
||||
"default" | "select" | "service" | "create"
|
||||
>("default");
|
||||
export { worldMode, setWorldMode };
|
||||
|
||||
export function CubeScene(props: {
|
||||
cubesQuery: MachinesQueryResult;
|
||||
onCreate: () => Promise<{ id: string }>;
|
||||
onAddService: () => Promise<{ id: string }>;
|
||||
selectedIds: Accessor<Set<string>>;
|
||||
onSelect: (v: Set<string>) => void;
|
||||
sceneStore: Accessor<SceneData>;
|
||||
@@ -74,8 +102,6 @@ export function CubeScene(props: {
|
||||
"grid",
|
||||
);
|
||||
|
||||
const [worldMode, setWorldMode] = createSignal<"view" | "create">("view");
|
||||
|
||||
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
|
||||
|
||||
const [cameraInfo, setCameraInfo] = createSignal({
|
||||
@@ -222,6 +248,7 @@ export function CubeScene(props: {
|
||||
controls = new MapControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled
|
||||
controls.mouseButtons.RIGHT = null;
|
||||
// controls.rotateSpeed = -0.8;
|
||||
// controls.enableRotate = false;
|
||||
controls.minZoom = 1.2;
|
||||
controls.maxZoom = 3.5;
|
||||
@@ -370,7 +397,7 @@ export function CubeScene(props: {
|
||||
);
|
||||
|
||||
// Click handler:
|
||||
// - Select/deselects a cube in "view" mode
|
||||
// - Select/deselects a cube in mode
|
||||
// - Creates a new cube in "create" mode
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (worldMode() === "create") {
|
||||
@@ -391,7 +418,7 @@ export function CubeScene(props: {
|
||||
.finally(() => {
|
||||
if (initBase) initBase.visible = false;
|
||||
|
||||
setWorldMode("view");
|
||||
setWorldMode("default");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -409,8 +436,13 @@ export function CubeScene(props: {
|
||||
if (intersects.length > 0) {
|
||||
console.log("Clicked on cube:", intersects);
|
||||
const id = intersects[0].object.userData.id;
|
||||
toggleSelection(id);
|
||||
|
||||
if (worldMode() === "select") toggleSelection(id);
|
||||
|
||||
emitMachineClick(id); // notify subscribers
|
||||
} else {
|
||||
emitMachineClick(null);
|
||||
|
||||
props.onSelect(new Set<string>()); // Clear selection if clicked outside cubes
|
||||
}
|
||||
};
|
||||
@@ -560,14 +592,15 @@ export function CubeScene(props: {
|
||||
description="Select machine"
|
||||
name="Select"
|
||||
icon="Cursor"
|
||||
onClick={() => setWorldMode("view")}
|
||||
selected={worldMode() === "view"}
|
||||
onClick={() =>
|
||||
setWorldMode((v) => (v === "select" ? "default" : "select"))
|
||||
}
|
||||
selected={worldMode() === "select"}
|
||||
/>
|
||||
<ToolbarButton
|
||||
description="Create new machine"
|
||||
name="new-machine"
|
||||
icon="NewMachine"
|
||||
disabled={positionMode() === "circle"}
|
||||
onClick={onAddClick}
|
||||
selected={worldMode() === "create"}
|
||||
/>
|
||||
@@ -576,7 +609,10 @@ export function CubeScene(props: {
|
||||
description="Add new Service"
|
||||
name="modules"
|
||||
icon="Services"
|
||||
onClick={props.onAddService}
|
||||
selected={worldMode() === "service"}
|
||||
onClick={() => {
|
||||
setWorldMode((v) => (v === "service" ? "default" : "service"));
|
||||
}}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon="Reload"
|
||||
|
||||
42
pkgs/clan-app/ui/src/scene/highlightStore.tsx
Normal file
42
pkgs/clan-app/ui/src/scene/highlightStore.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
// highlightStore.ts
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
|
||||
// groups: { [groupName: string]: Set<nodeId> }
|
||||
const [highlightGroups, setHighlightGroups] = createStore<
|
||||
Record<string, Set<string>>
|
||||
>({});
|
||||
|
||||
// Add highlight
|
||||
export function highlight(group: string, nodeId: string) {
|
||||
setHighlightGroups(group, (prev = new Set()) => {
|
||||
const next = new Set(prev);
|
||||
next.add(nodeId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Remove highlight
|
||||
export function unhighlight(group: string, nodeId: string) {
|
||||
setHighlightGroups(group, (prev = new Set()) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(nodeId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Clear group
|
||||
export function clearHighlight(group: string) {
|
||||
setHighlightGroups(group, () => new Set());
|
||||
}
|
||||
|
||||
export function clearAllHighlights() {
|
||||
setHighlightGroups(
|
||||
produce((s) => {
|
||||
for (const key of Object.keys(s)) {
|
||||
Reflect.deleteProperty(s, key);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export { highlightGroups, setHighlightGroups };
|
||||
@@ -10,32 +10,50 @@ import {
|
||||
ServiceModules,
|
||||
TagsQuery,
|
||||
useMachinesQuery,
|
||||
useServiceInstances,
|
||||
useServiceModules,
|
||||
useTags,
|
||||
} from "@/src/hooks/queries";
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
JSX,
|
||||
Show,
|
||||
on,
|
||||
onMount,
|
||||
} 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 { TagSelect } from "@/src/components/Search/TagSelect";
|
||||
import { Tag } from "@/src/components/Tag/Tag";
|
||||
import { createForm, FieldValues, setValue } from "@modular-forms/solid";
|
||||
import { createForm, FieldValues } 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";
|
||||
import { useMachineClick } from "@/src/scene/cubes";
|
||||
import {
|
||||
clearAllHighlights,
|
||||
highlightGroups,
|
||||
setHighlightGroups,
|
||||
} from "@/src/scene/highlightStore";
|
||||
import { useClickOutside } from "@/src/hooks/useClickOutside";
|
||||
|
||||
type ModuleItem = ServiceModules[number];
|
||||
|
||||
interface Module {
|
||||
value: string;
|
||||
input: string;
|
||||
input?: string;
|
||||
label: string;
|
||||
description: string;
|
||||
raw: ModuleItem;
|
||||
instances: string[];
|
||||
}
|
||||
|
||||
const SelectService = () => {
|
||||
@@ -43,17 +61,27 @@ const SelectService = () => {
|
||||
const stepper = useStepper<ServiceSteps>();
|
||||
|
||||
const serviceModulesQuery = useServiceModules(clanURI);
|
||||
const serviceInstancesQuery = useServiceInstances(clanURI);
|
||||
const machinesQuery = useMachinesQuery(clanURI);
|
||||
|
||||
const [moduleOptions, setModuleOptions] = createSignal<Module[]>([]);
|
||||
createEffect(() => {
|
||||
if (serviceModulesQuery.data) {
|
||||
if (serviceModulesQuery.data && serviceInstancesQuery.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",
|
||||
input: m.module.input,
|
||||
raw: m,
|
||||
// TODO: include the instances that use this module
|
||||
instances: Object.entries(serviceInstancesQuery.data)
|
||||
.filter(
|
||||
([name, i]) =>
|
||||
i.module?.name === m.module.name &&
|
||||
(!i.module?.input || i.module?.input === m.module.input),
|
||||
)
|
||||
.map(([name, _]) => name),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -72,17 +100,77 @@ const SelectService = () => {
|
||||
input: module.raw.module.input,
|
||||
raw: module.raw,
|
||||
});
|
||||
// TODO: Ideally we need to ask
|
||||
// - create new
|
||||
// - update existing (and select which one)
|
||||
|
||||
// For now:
|
||||
// Create a new instance, if there are no instances yet
|
||||
// Update the first instance, if there is one
|
||||
if (module.instances.length === 0) {
|
||||
set("action", "create");
|
||||
} else {
|
||||
if (!serviceInstancesQuery.data) return;
|
||||
if (!machinesQuery.data) return;
|
||||
set("action", "update");
|
||||
|
||||
const instanceName = module.instances[0];
|
||||
const instance = serviceInstancesQuery.data[instanceName];
|
||||
console.log("Editing existing instance", module);
|
||||
|
||||
for (const role of Object.keys(instance.roles || {})) {
|
||||
const tags = Object.keys(instance.roles?.[role].tags || {});
|
||||
const machines = Object.keys(instance.roles?.[role].machines || {});
|
||||
|
||||
const machineTags = machines.map((m) => ({
|
||||
value: "m_" + m,
|
||||
label: m,
|
||||
type: "machine" as const,
|
||||
}));
|
||||
const tagsTags = tags.map((t) => {
|
||||
return {
|
||||
value: "t_" + t,
|
||||
label: t,
|
||||
type: "tag" as const,
|
||||
members: Object.entries(machinesQuery.data || {})
|
||||
.filter(([_, m]) => m.tags?.includes(t))
|
||||
.map(([k]) => k),
|
||||
};
|
||||
});
|
||||
console.log("Members for role", role, [
|
||||
...machineTags,
|
||||
...tagsTags,
|
||||
]);
|
||||
if (!store.roles) {
|
||||
set("roles", {});
|
||||
}
|
||||
const roleMembers = [...machineTags, ...tagsTags].sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
set("roles", role, roleMembers);
|
||||
console.log("set", store.roles);
|
||||
}
|
||||
// Initialize the roles with the existing members
|
||||
}
|
||||
|
||||
stepper.next();
|
||||
}}
|
||||
options={moduleOptions()}
|
||||
renderItem={(item) => {
|
||||
renderItem={(item, opts) => {
|
||||
return (
|
||||
<div class="flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1 pr-4">
|
||||
<div class="flex size-8 items-center justify-center rounded-md bg-white">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-white">
|
||||
<Icon icon="Code" />
|
||||
</div>
|
||||
<div class="flex w-full flex-col">
|
||||
<Combobox.ItemLabel class="flex">
|
||||
<Combobox.ItemLabel class="flex gap-1.5">
|
||||
<Show when={item.instances.length > 0}>
|
||||
<div class="flex items-center rounded bg-[#76FFA4] px-1 py-0.5">
|
||||
<Typography hierarchy="label" weight="bold" size="xxs">
|
||||
Added
|
||||
</Typography>
|
||||
</div>
|
||||
</Show>
|
||||
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
||||
{item.label}
|
||||
</Typography>
|
||||
@@ -95,8 +183,12 @@ const SelectService = () => {
|
||||
inverted
|
||||
class="flex justify-between"
|
||||
>
|
||||
<span>{item.description}</span>
|
||||
<span>by {item.input}</span>
|
||||
<span class="inline-block max-w-32 truncate align-middle">
|
||||
{item.description}
|
||||
</span>
|
||||
<span class="inline-block max-w-8 truncate align-middle">
|
||||
by {item.input}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,7 +236,8 @@ const ConfigureService = () => {
|
||||
|
||||
const [formStore, { Form, Field }] = createForm<RolesForm>({
|
||||
initialValues: {
|
||||
instanceName: "backup-instance-1",
|
||||
// Default to the module name, until we support multiple instances
|
||||
instanceName: store.module.name,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -168,14 +261,17 @@ const ConfigureService = () => {
|
||||
]),
|
||||
);
|
||||
|
||||
store.handleSubmit({
|
||||
name: values.instanceName,
|
||||
module: {
|
||||
name: store.module.name,
|
||||
input: store.module.input,
|
||||
store.handleSubmit(
|
||||
{
|
||||
name: values.instanceName,
|
||||
module: {
|
||||
name: store.module.name,
|
||||
input: store.module.input,
|
||||
},
|
||||
roles,
|
||||
},
|
||||
roles,
|
||||
});
|
||||
store.action,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -245,8 +341,11 @@ const ConfigureService = () => {
|
||||
</For>
|
||||
</div>
|
||||
<div class={cx(styles.footer, styles.backgroundAlt)}>
|
||||
<BackButton ghost hierarchy="primary" class="mr-auto" />
|
||||
|
||||
<Button hierarchy="secondary" type="submit">
|
||||
Add Service
|
||||
<Show when={store.action === "create"}>Add Service</Show>
|
||||
<Show when={store.action === "update"}>Save Changes</Show>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
@@ -266,116 +365,145 @@ type TagType =
|
||||
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 [members, setMembers] = createSignal<TagType[]>(
|
||||
store.roles?.[store.currentRole || ""] || [],
|
||||
);
|
||||
|
||||
const lastClickedMachine = useMachineClick();
|
||||
|
||||
createEffect(() => {
|
||||
console.log("Current role", store.currentRole, members());
|
||||
clearAllHighlights();
|
||||
setHighlightGroups({
|
||||
[store.currentRole as string]: new Set(
|
||||
members().flatMap((m) => {
|
||||
if (m.type === "machine") return m.label;
|
||||
|
||||
return m.members;
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
console.log("now", highlightGroups);
|
||||
});
|
||||
onMount(() => setHighlightGroups(() => ({})));
|
||||
|
||||
createEffect(
|
||||
on(lastClickedMachine, (machine) => {
|
||||
// const machine = lastClickedMachine();
|
||||
const currentMembers = members();
|
||||
console.log("Clicked machine", machine, currentMembers);
|
||||
if (!machine) return;
|
||||
const machineTagName = "m_" + machine;
|
||||
|
||||
const existing = currentMembers.find((m) => m.value === machineTagName);
|
||||
if (existing) {
|
||||
// Remove
|
||||
setMembers(currentMembers.filter((m) => m.value !== machineTagName));
|
||||
} else {
|
||||
// Add
|
||||
setMembers([
|
||||
...currentMembers,
|
||||
{ value: machineTagName, label: machine, type: "machine" },
|
||||
]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const machinesQuery = useMachinesQuery(useClanURI());
|
||||
const tagsQuery = useTags(useClanURI());
|
||||
|
||||
const options = useOptions(tagsQuery, machinesQuery);
|
||||
|
||||
const handleSubmit = (values: RoleMembers) => {
|
||||
const handleSubmit = () => {
|
||||
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 }));
|
||||
set("roles", (r) => ({ ...r, [store.currentRole as string]: members() }));
|
||||
stepper.setActiveStep("view:members");
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<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">
|
||||
<SearchMultiple<TagType>
|
||||
values={members()}
|
||||
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"
|
||||
// onClick={() => clearAllHighlights()}
|
||||
/>
|
||||
<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="s"
|
||||
size="xs"
|
||||
weight="medium"
|
||||
inverted
|
||||
color="secondary"
|
||||
tag="div"
|
||||
>
|
||||
{item.label}
|
||||
{tag().members.length}
|
||||
</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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Combobox.ItemLabel>
|
||||
<Icon
|
||||
class="ml-auto"
|
||||
icon={item.type === "machine" ? "Machine" : "Tag"}
|
||||
color="quaternary"
|
||||
inverted
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
height="20rem"
|
||||
virtualizerOptions={{
|
||||
estimateSize: () => 38,
|
||||
}}
|
||||
onChange={(selection) => {
|
||||
setMembers(selection);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class={cx(styles.footer, styles.backgroundAlt)}>
|
||||
<Button hierarchy="secondary" type="submit">
|
||||
@@ -383,7 +511,7 @@ const ConfigureRole = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -410,7 +538,7 @@ export interface InventoryInstance {
|
||||
name: string;
|
||||
module: {
|
||||
name: string;
|
||||
input: string;
|
||||
input?: string;
|
||||
};
|
||||
roles: Record<string, RoleType>;
|
||||
}
|
||||
@@ -429,14 +557,21 @@ export interface ServiceStoreType {
|
||||
roles: Record<string, TagType[]>;
|
||||
currentRole?: string;
|
||||
close: () => void;
|
||||
handleSubmit: (values: InventoryInstance) => void;
|
||||
handleSubmit: SubmitServiceHandler;
|
||||
action: "create" | "update";
|
||||
}
|
||||
|
||||
export type SubmitServiceHandler = (
|
||||
values: InventoryInstance,
|
||||
action: "create" | "update",
|
||||
) => void | Promise<void>;
|
||||
|
||||
interface ServiceWorkflowProps {
|
||||
initialStep?: ServiceSteps[number]["id"];
|
||||
initialStore?: Partial<ServiceStoreType>;
|
||||
onClose?: () => void;
|
||||
handleSubmit: (values: InventoryInstance) => void;
|
||||
handleSubmit: SubmitServiceHandler;
|
||||
rootProps?: JSX.HTMLAttributes<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
|
||||
@@ -451,10 +586,25 @@ export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
|
||||
} satisfies Partial<ServiceStoreType>,
|
||||
},
|
||||
);
|
||||
createEffect(() => {
|
||||
if (stepper.currentStep().id !== "select:members") {
|
||||
clearAllHighlights();
|
||||
}
|
||||
});
|
||||
|
||||
let ref: HTMLDivElement;
|
||||
useClickOutside(
|
||||
() => ref,
|
||||
() => {
|
||||
if (stepper.currentStep().id === "select:service") props.onClose?.();
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={(e) => (ref = e)}
|
||||
id="add-service"
|
||||
class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"
|
||||
{...props.rootProps}
|
||||
>
|
||||
<StepperProvider stepper={stepper}>
|
||||
<div class="w-[30rem]">{stepper.currentStep().content()}</div>
|
||||
|
||||
Reference in New Issue
Block a user