ui/services: refactor services
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
|
||||
import { Params, Navigator, useParams } from "@solidjs/router";
|
||||
import { Params, Navigator, useParams, useSearchParams } from "@solidjs/router";
|
||||
|
||||
export const encodeBase64 = (value: string) => window.btoa(value);
|
||||
export const decodeBase64 = (value: string) => window.atob(value);
|
||||
@@ -32,20 +32,43 @@ export const buildMachinePath = (clanURI: string, name: string) =>
|
||||
|
||||
export const buildServicePath = (props: {
|
||||
clanURI: string;
|
||||
machineName?: string;
|
||||
id: string;
|
||||
module: {
|
||||
input?: string | null | undefined;
|
||||
name: string;
|
||||
input?: string | null | undefined;
|
||||
};
|
||||
}) => {
|
||||
const { clanURI, machineName, id, module } = props;
|
||||
const { clanURI, id, module } = props;
|
||||
|
||||
const moduleName = encodeBase64(module.name);
|
||||
const idEncoded = encodeBase64(id);
|
||||
|
||||
const result =
|
||||
(machineName
|
||||
? buildMachinePath(clanURI, machineName)
|
||||
: buildClanPath(clanURI)) +
|
||||
`/services/${module.input ?? "clan"}/${module.name}`;
|
||||
return id == module.name ? result : result + "/" + id;
|
||||
buildClanPath(clanURI) +
|
||||
`/services/${moduleName}/${idEncoded}` +
|
||||
(module.input ? `?input=${module.input}` : "");
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const useServiceParams = () => {
|
||||
const params = useParams<{
|
||||
name?: string;
|
||||
id?: string;
|
||||
}>();
|
||||
|
||||
const [search] = useSearchParams<{ input?: string }>();
|
||||
|
||||
if (!params.name || !params.id) {
|
||||
console.error("Service params not found", params, window.location.pathname);
|
||||
throw new Error("Service params not found");
|
||||
}
|
||||
|
||||
return {
|
||||
name: decodeBase64(params.name),
|
||||
id: decodeBase64(params.id),
|
||||
input: search.input,
|
||||
};
|
||||
};
|
||||
|
||||
export const navigateToClan = (navigate: Navigator, clanURI: string) => {
|
||||
|
||||
@@ -50,7 +50,7 @@ export const useMachinesQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
|
||||
return useQuery<ListMachines>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machines"],
|
||||
queryKey: [...clanKey(clanURI), "machines"],
|
||||
queryFn: async () => {
|
||||
const api = client.fetch("list_machines", {
|
||||
flake: {
|
||||
@@ -67,10 +67,16 @@ export const useMachinesQuery = (clanURI: string) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const machineKey = (clanUri: string, machineName: string) => [
|
||||
...clanKey(clanUri),
|
||||
"machine",
|
||||
encodeBase64(machineName),
|
||||
];
|
||||
|
||||
export const useMachineQuery = (clanURI: string, machineName: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineDetail>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
|
||||
queryKey: [machineKey(clanURI, machineName)],
|
||||
queryFn: async () => {
|
||||
const [tagsCall, machineCall, schemaCall] = [
|
||||
client.fetch("list_tags", {
|
||||
@@ -125,7 +131,7 @@ export type TagsQuery = ReturnType<typeof useTags>;
|
||||
export const useTags = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "tags"],
|
||||
queryKey: [...clanKey(clanURI), "tags"],
|
||||
queryFn: async () => {
|
||||
const apiCall = client.fetch("list_tags", {
|
||||
flake: {
|
||||
@@ -145,8 +151,9 @@ export const useTags = (clanURI: string) => {
|
||||
export const useMachineStateQuery = (clanURI: string, machineName: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineState>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"],
|
||||
queryKey: [...machineKey(clanURI, machineName), "state"],
|
||||
staleTime: 60_000, // 1 minute stale time
|
||||
enabled: false,
|
||||
queryFn: async () => {
|
||||
const apiCall = client.fetch("get_machine_state", {
|
||||
machine: {
|
||||
@@ -173,7 +180,7 @@ export const useServiceModulesQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
|
||||
return useQuery<ListServiceModules>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "service_modules"],
|
||||
queryKey: [...clanKey(clanURI), "service_modules"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("list_service_modules", {
|
||||
flake: {
|
||||
@@ -197,7 +204,7 @@ export const useServiceInstancesQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
|
||||
return useQuery<ListServiceInstances>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "service_instances"],
|
||||
queryKey: [...clanKey(clanURI), "service_instances"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("list_service_instances", {
|
||||
flake: {
|
||||
@@ -223,7 +230,7 @@ export const useMachineDetailsQuery = (
|
||||
) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineDetails>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName],
|
||||
queryKey: [machineKey(clanURI, machineName), "details"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("get_machine_details", {
|
||||
machine: {
|
||||
@@ -253,7 +260,7 @@ export const ClanDetailsPersister = experimental_createQueryPersister({
|
||||
export const useClanDetailsQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<ClanDetails>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "details"],
|
||||
queryKey: [...clanKey(clanURI), "details"],
|
||||
persister: ClanDetailsPersister.persisterFn,
|
||||
queryFn: async () => {
|
||||
const args = {
|
||||
@@ -304,7 +311,8 @@ export const useClanListQuery = (
|
||||
|
||||
return useQueries(() => ({
|
||||
queries: clanURIs.map((clanURI) => {
|
||||
const queryKey = ["clans", encodeBase64(clanURI), "details"];
|
||||
// @BMG: Is duplicating query key intentional?
|
||||
const queryKey = [...clanKey(clanURI), "details"];
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
@@ -373,7 +381,7 @@ export type MachineFlashOptionsQuery = UseQueryResult<MachineFlashOptions>;
|
||||
export const useMachineFlashOptions = (): MachineFlashOptionsQuery => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineFlashOptions>(() => ({
|
||||
queryKey: ["clans", "machine_flash_options"],
|
||||
queryKey: ["flash_options"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("get_machine_flash_options", {});
|
||||
const result = await call.result;
|
||||
@@ -537,7 +545,7 @@ export type ServiceModules = SuccessData<"list_service_modules">;
|
||||
export const useServiceModules = (clanUri: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanUri), "service_modules"],
|
||||
queryKey: [...clanKey(clanUri), "service_modules"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("list_service_modules", {
|
||||
flake: {
|
||||
@@ -557,12 +565,14 @@ export const useServiceModules = (clanUri: string) => {
|
||||
}));
|
||||
};
|
||||
|
||||
export const clanKey = (clanUri: string) => ["clans", encodeBase64(clanUri)];
|
||||
|
||||
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"],
|
||||
queryKey: [...clanKey(clanUri), "service_instances"],
|
||||
queryFn: async () => {
|
||||
const call = client.fetch("list_service_instances", {
|
||||
flake: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||
import { RouteSectionProps, useLocation, useNavigate } from "@solidjs/router";
|
||||
import {
|
||||
Component,
|
||||
createContext,
|
||||
@@ -35,34 +35,15 @@ import styles from "./Clan.module.css";
|
||||
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||
import { UseQueryResult } from "@tanstack/solid-query";
|
||||
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
||||
import {
|
||||
ServiceWorkflow,
|
||||
SubmitServiceHandler,
|
||||
} from "@/src/workflows/Service/Service";
|
||||
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import toast from "solid-toast";
|
||||
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||
import { SelectService } from "@/src/workflows/Service/SelectServiceFlyout";
|
||||
import { SubmitServiceHandler } from "@/src/workflows/Service/models";
|
||||
|
||||
export type WorldMode = "default" | "select" | "service" | "create" | "move";
|
||||
|
||||
interface ClanContextProps {
|
||||
clanURI: string;
|
||||
machinesQuery: MachinesQueryResult;
|
||||
activeClanQuery: UseQueryResult<ClanDetails>;
|
||||
otherClanQueries: UseQueryResult<ClanDetails>[];
|
||||
allClansQueries: UseQueryResult<ClanDetails>[];
|
||||
serviceInstancesQuery: UseQueryResult<ListServiceInstances>;
|
||||
|
||||
isLoading(): boolean;
|
||||
isError(): boolean;
|
||||
|
||||
showAddMachine(): boolean;
|
||||
setShowAddMachine(value: boolean): void;
|
||||
|
||||
worldMode(): WorldMode;
|
||||
setWorldMode(mode: WorldMode): void;
|
||||
}
|
||||
|
||||
function createClanContext(
|
||||
clanURI: string,
|
||||
machinesQuery: MachinesQueryResult,
|
||||
@@ -73,6 +54,9 @@ function createClanContext(
|
||||
const [worldMode, setWorldMode] = createSignal<WorldMode>("select");
|
||||
const [showAddMachine, setShowAddMachine] = createSignal(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const allClansQueries = [activeClanQuery, ...otherClanQueries];
|
||||
const allQueries = [machinesQuery, ...allClansQueries, serviceInstancesQuery];
|
||||
|
||||
@@ -87,12 +71,18 @@ function createClanContext(
|
||||
isError: () => activeClanQuery.isError,
|
||||
showAddMachine,
|
||||
setShowAddMachine,
|
||||
navigateToRoot: () => {
|
||||
if (location.pathname === buildClanPath(clanURI)) return;
|
||||
navigate(buildClanPath(clanURI), { replace: true });
|
||||
},
|
||||
setWorldMode,
|
||||
worldMode,
|
||||
};
|
||||
}
|
||||
|
||||
const ClanContext = createContext<ClanContextProps>();
|
||||
const ClanContext = createContext<
|
||||
ReturnType<typeof createClanContext> | undefined
|
||||
>();
|
||||
|
||||
export const useClanContext = () => {
|
||||
const ctx = useContext(ClanContext);
|
||||
@@ -208,34 +198,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
}),
|
||||
);
|
||||
|
||||
const client = useApiClient();
|
||||
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,
|
||||
},
|
||||
module_ref: instance.module,
|
||||
roles: instance.roles,
|
||||
});
|
||||
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");
|
||||
ctx.setWorldMode("select");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={loadingError()}>
|
||||
@@ -271,13 +233,19 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
||||
cubesQuery={ctx.machinesQuery}
|
||||
toolbarPopup={
|
||||
<Show when={ctx.worldMode() === "service"}>
|
||||
<ServiceWorkflow
|
||||
handleSubmit={handleSubmitService}
|
||||
onClose={() => {
|
||||
ctx.setWorldMode("select");
|
||||
currentPromise()?.resolve({ id: "0" });
|
||||
}}
|
||||
/>
|
||||
<Show
|
||||
when={location.pathname.includes("/services/")}
|
||||
fallback={
|
||||
<SelectService
|
||||
// handleSubmit={handleSubmitService}
|
||||
onClose={() => {
|
||||
ctx.setWorldMode("select");
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
}
|
||||
onCreate={onCreate}
|
||||
|
||||
@@ -1,22 +1,54 @@
|
||||
import { RouteSectionProps } from "@solidjs/router";
|
||||
import { maybeUseIdParam, useInputParam, useNameParam } from "@/src/hooks/clan";
|
||||
import { createEffect } from "solid-js";
|
||||
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||
import { useClanContext } from "@/src/routes/Clan/Clan";
|
||||
import { ServiceWorkflow } from "@/src/workflows/Service/Service";
|
||||
import { SubmitServiceHandler } from "@/src/workflows/Service/models";
|
||||
import { buildClanPath } from "@/src/hooks/clan";
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import { useQueryClient } from "@tanstack/solid-query";
|
||||
import { clanKey } from "@/src/hooks/queries";
|
||||
|
||||
export const Service = (props: RouteSectionProps) => {
|
||||
const ctx = useClanContext();
|
||||
|
||||
console.log("service route");
|
||||
const navigate = useNavigate();
|
||||
|
||||
createEffect(() => {
|
||||
const input = useInputParam();
|
||||
const name = useNameParam();
|
||||
const id = maybeUseIdParam();
|
||||
const client = useApiClient();
|
||||
|
||||
ctx.setWorldMode("service");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
console.log("service", input, name, id);
|
||||
});
|
||||
const handleSubmit: SubmitServiceHandler = async (instance, action) => {
|
||||
console.log("Service submitted", instance, action);
|
||||
|
||||
return <>h1</>;
|
||||
if (action !== "create") {
|
||||
console.warn("Updating service instances is not supported yet");
|
||||
return;
|
||||
}
|
||||
|
||||
const call = client.fetch("create_service_instance", {
|
||||
flake: {
|
||||
identifier: ctx.clanURI,
|
||||
},
|
||||
module_ref: instance.module,
|
||||
roles: instance.roles,
|
||||
});
|
||||
const result = await call.result;
|
||||
|
||||
if (result.status === "error") {
|
||||
console.error("Error creating service instance", result.errors);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: clanKey(ctx.clanURI),
|
||||
});
|
||||
|
||||
ctx.setWorldMode("select");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
console.log("Service closed, navigating back");
|
||||
navigate(buildClanPath(ctx.clanURI), { replace: true });
|
||||
ctx.setWorldMode("select");
|
||||
};
|
||||
|
||||
return <ServiceWorkflow handleSubmit={handleSubmit} onClose={handleClose} />;
|
||||
};
|
||||
|
||||
@@ -35,14 +35,10 @@ export const Routes: RouteDefinition[] = [
|
||||
{
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
path: "/services/:input/:name/:id?",
|
||||
component: Service,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/services/:input/:name/:id?",
|
||||
path: "/services/:name/:id",
|
||||
component: Service,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -807,6 +807,7 @@ export function CubeScene(props: {
|
||||
icon="Services"
|
||||
selected={ctx.worldMode() === "service"}
|
||||
onClick={() => {
|
||||
ctx.navigateToRoot();
|
||||
ctx.setWorldMode("service");
|
||||
}}
|
||||
/>
|
||||
|
||||
120
pkgs/clan-app/ui/src/workflows/Service/SelectServiceFlyout.tsx
Normal file
120
pkgs/clan-app/ui/src/workflows/Service/SelectServiceFlyout.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Search } from "@/src/components/Search/Search";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { buildServicePath, useClanURI } from "@/src/hooks/clan";
|
||||
import { useServiceInstances, useServiceModules } from "@/src/hooks/queries";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { createEffect, createSignal, Show } from "solid-js";
|
||||
import { Module } from "./models";
|
||||
import Icon from "@/src/components/Icon/Icon";
|
||||
import { Combobox } from "@kobalte/core/combobox";
|
||||
import { useClickOutside } from "@/src/hooks/useClickOutside";
|
||||
|
||||
interface FlyoutProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
export const SelectService = (props: FlyoutProps) => {
|
||||
const clanURI = useClanURI();
|
||||
|
||||
const serviceModulesQuery = useServiceModules(clanURI);
|
||||
const serviceInstancesQuery = useServiceInstances(clanURI);
|
||||
|
||||
const [moduleOptions, setModuleOptions] = createSignal<Module[]>([]);
|
||||
|
||||
createEffect(() => {
|
||||
if (serviceModulesQuery.data && serviceInstancesQuery.data) {
|
||||
setModuleOptions(
|
||||
serviceModulesQuery.data.modules.map((currService) => ({
|
||||
value: `${currService.usage_ref.name}:${currService.usage_ref.input}`,
|
||||
label: currService.usage_ref.name,
|
||||
raw: currService,
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const handleChange = (module: Module | null) => {
|
||||
if (!module) return;
|
||||
|
||||
const serviceURL = buildServicePath({
|
||||
clanURI,
|
||||
id: module.raw.instance_refs[0] || module.raw.usage_ref.name,
|
||||
module: {
|
||||
name: module.raw.usage_ref.name,
|
||||
input: module.raw.usage_ref.input,
|
||||
},
|
||||
});
|
||||
navigate(serviceURL);
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
|
||||
let ref: HTMLDivElement;
|
||||
|
||||
useClickOutside(
|
||||
() => ref,
|
||||
() => {
|
||||
props.onClose();
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={(e) => (ref = e)}
|
||||
class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"
|
||||
>
|
||||
<div class="w-[30rem]">
|
||||
<Search<Module>
|
||||
loading={
|
||||
serviceModulesQuery.isLoading || serviceInstancesQuery.isLoading
|
||||
}
|
||||
height="13rem"
|
||||
onChange={handleChange}
|
||||
options={moduleOptions()}
|
||||
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 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 gap-1.5">
|
||||
<Show when={item.raw.instance_refs.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>
|
||||
</Combobox.ItemLabel>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xxs"
|
||||
weight="normal"
|
||||
color="quaternary"
|
||||
inverted
|
||||
class="flex justify-between"
|
||||
>
|
||||
<span class="inline-block max-w-80 truncate align-middle">
|
||||
{item.raw.info.manifest.description}
|
||||
</span>
|
||||
<span class="inline-block max-w-32 truncate align-middle">
|
||||
<Show when={!item.raw.native} fallback="by clan-core">
|
||||
by {item.raw.usage_ref.input}
|
||||
</Show>
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,10 +4,9 @@ import {
|
||||
StepperProvider,
|
||||
useStepper,
|
||||
} from "@/src/hooks/stepper";
|
||||
import { useClanURI } from "@/src/hooks/clan";
|
||||
import { useClanURI, useServiceParams } from "@/src/hooks/clan";
|
||||
import {
|
||||
MachinesQuery,
|
||||
ServiceModules,
|
||||
TagsQuery,
|
||||
useMachinesQuery,
|
||||
useServiceInstances,
|
||||
@@ -18,18 +17,16 @@ import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
JSX,
|
||||
Show,
|
||||
on,
|
||||
onMount,
|
||||
For,
|
||||
onCleanup,
|
||||
} 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 } from "@modular-forms/solid";
|
||||
import styles from "./Service.module.css";
|
||||
import { TextInput } from "@/src/components/Form/TextInput";
|
||||
@@ -40,152 +37,16 @@ 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["modules"][number];
|
||||
|
||||
interface Module {
|
||||
value: string;
|
||||
label: string;
|
||||
raw: ModuleItem;
|
||||
}
|
||||
|
||||
const SelectService = () => {
|
||||
const clanURI = useClanURI();
|
||||
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 && serviceInstancesQuery.data) {
|
||||
setModuleOptions(
|
||||
serviceModulesQuery.data.modules.map((currService) => ({
|
||||
value: `${currService.usage_ref.name}:${currService.usage_ref.input}`,
|
||||
label: currService.usage_ref.name,
|
||||
raw: currService,
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
const [store, set] = getStepStore<ServiceStoreType>(stepper);
|
||||
|
||||
return (
|
||||
<Search<Module>
|
||||
loading={serviceModulesQuery.isLoading}
|
||||
height="13rem"
|
||||
onChange={(module) => {
|
||||
if (!module) return;
|
||||
|
||||
set("module", {
|
||||
name: module.raw.usage_ref.name,
|
||||
input: module.raw.usage_ref.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.raw.instance_refs.length === 0) {
|
||||
set("action", "create");
|
||||
} else {
|
||||
if (!serviceInstancesQuery.data) return;
|
||||
if (!machinesQuery.data) return;
|
||||
set("action", "update");
|
||||
|
||||
const instanceName = module.raw.instance_refs[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.data.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, 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 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 gap-1.5">
|
||||
<Show when={item.raw.instance_refs.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>
|
||||
</Combobox.ItemLabel>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xxs"
|
||||
weight="normal"
|
||||
color="quaternary"
|
||||
inverted
|
||||
class="flex justify-between"
|
||||
>
|
||||
<span class="inline-block max-w-80 truncate align-middle">
|
||||
{item.raw.info.manifest.description}
|
||||
</span>
|
||||
<span class="inline-block max-w-32 truncate align-middle">
|
||||
<Show when={!item.raw.native} fallback="by clan-core">
|
||||
by {item.raw.usage_ref.input}
|
||||
</Show>
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
import {
|
||||
getRoleMembers,
|
||||
RoleType,
|
||||
ServiceStoreType,
|
||||
SubmitServiceHandler,
|
||||
} from "./models";
|
||||
import { TagSelect } from "@/src/components/Search/TagSelect";
|
||||
import { Tag } from "@/src/components/Tag/Tag";
|
||||
|
||||
const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
|
||||
createMemo<TagType[]>(() => {
|
||||
@@ -215,22 +76,81 @@ const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
|
||||
);
|
||||
});
|
||||
|
||||
const sanitizeModuleInput = (
|
||||
input: string | undefined,
|
||||
core_input_name: string,
|
||||
) => {
|
||||
if (!input) return null;
|
||||
|
||||
if (input === core_input_name) return null;
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
interface RolesForm extends FieldValues {
|
||||
roles: Record<string, string[]>;
|
||||
instanceName: string;
|
||||
}
|
||||
const ConfigureService = () => {
|
||||
const stepper = useStepper<ServiceSteps>();
|
||||
const clanURI = useClanURI();
|
||||
const machinesQuery = useMachinesQuery(clanURI);
|
||||
const serviceModulesQuery = useServiceModules(clanURI);
|
||||
const serviceInstancesQuery = useServiceInstances(clanURI);
|
||||
const routerProps = useServiceParams();
|
||||
|
||||
const [store, set] = getStepStore<ServiceStoreType>(stepper);
|
||||
|
||||
const [formStore, { Form, Field }] = createForm<RolesForm>({
|
||||
initialValues: {
|
||||
// Default to the module name, until we support multiple instances
|
||||
instanceName: store.module.name,
|
||||
instanceName: routerProps.id,
|
||||
},
|
||||
});
|
||||
|
||||
const machinesQuery = useMachinesQuery(useClanURI());
|
||||
const selectedModule = createMemo(() => {
|
||||
if (!serviceModulesQuery.data) return undefined;
|
||||
return serviceModulesQuery.data.modules.find(
|
||||
(m) =>
|
||||
m.usage_ref.name === routerProps.name &&
|
||||
// left side is string | null
|
||||
// right side is string | undefined
|
||||
m.usage_ref.input ===
|
||||
sanitizeModuleInput(
|
||||
routerProps.input,
|
||||
serviceModulesQuery.data.core_input_name,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [serviceInstancesQuery.data, machinesQuery.data] as const,
|
||||
([instances, machines]) => {
|
||||
console.log("Effect RUNNING");
|
||||
// Wait for all queries to be ready
|
||||
if (!instances || !machines) return;
|
||||
|
||||
const instance = instances[routerProps.id || routerProps.name];
|
||||
|
||||
console.log("Data ready, instance", instance ?? "NEW");
|
||||
|
||||
set("roles", {});
|
||||
if (!instance) {
|
||||
set("action", "create");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const role of Object.keys(instance.roles || {})) {
|
||||
// Get Role members
|
||||
const roleMembers = getRoleMembers(instance, machines, role);
|
||||
set("roles", role, roleMembers);
|
||||
}
|
||||
set("action", "update");
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const tagsQuery = useTags(useClanURI());
|
||||
|
||||
const options = useOptions(tagsQuery, machinesQuery);
|
||||
@@ -249,13 +169,15 @@ const ConfigureService = () => {
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
store.handleSubmit(
|
||||
{
|
||||
name: values.instanceName,
|
||||
module: {
|
||||
name: store.module.name,
|
||||
input: store.module.input,
|
||||
name: routerProps.name,
|
||||
input: sanitizeModuleInput(
|
||||
routerProps.input,
|
||||
serviceModulesQuery.data?.core_input_name || "clan-core",
|
||||
),
|
||||
},
|
||||
roles,
|
||||
},
|
||||
@@ -271,7 +193,7 @@ const ConfigureService = () => {
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
||||
{store.module.name}
|
||||
{routerProps.name}
|
||||
</Typography>
|
||||
<Field name="instanceName">
|
||||
{(field, input) => (
|
||||
@@ -294,54 +216,71 @@ const ConfigureService = () => {
|
||||
ghost
|
||||
size="s"
|
||||
class="ml-auto"
|
||||
onClick={store.close}
|
||||
onClick={() => store.close()}
|
||||
/>
|
||||
</div>
|
||||
<div class={styles.content}>
|
||||
<For each={Object.keys(store.module.raw?.info.roles || {})}>
|
||||
{(role) => {
|
||||
const values = store.roles?.[role] || [];
|
||||
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>
|
||||
<Show
|
||||
when={serviceModulesQuery.data && store.roles}
|
||||
fallback={<div>Loading...</div>}
|
||||
>
|
||||
<For each={Object.keys(selectedModule()?.info.roles || {})}>
|
||||
{(role) => {
|
||||
const values = store.roles?.[role] || [];
|
||||
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>
|
||||
</Show>
|
||||
</div>
|
||||
<div class={cx(styles.footer, styles.backgroundAlt)}>
|
||||
<BackButton ghost hierarchy="primary" class="mr-auto" />
|
||||
|
||||
<Button hierarchy="secondary" type="submit">
|
||||
<Show when={store.action === "create"}>Add Service</Show>
|
||||
<Show when={store.action === "update"}>Save Changes</Show>
|
||||
<Button
|
||||
hierarchy="secondary"
|
||||
type="submit"
|
||||
loading={!serviceInstancesQuery.data}
|
||||
>
|
||||
<Show when={serviceInstancesQuery.data}>
|
||||
{(d) => (
|
||||
<>
|
||||
<Show
|
||||
when={Object.keys(d()).includes(routerProps.id)}
|
||||
fallback={"Add Service"}
|
||||
>
|
||||
Save Changes
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
type TagType =
|
||||
export type TagType =
|
||||
| {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -362,24 +301,29 @@ const ConfigureRole = () => {
|
||||
store.roles?.[store.currentRole || ""] || [],
|
||||
);
|
||||
|
||||
const clanUri = useClanURI();
|
||||
const machinesQuery = useMachinesQuery(clanUri);
|
||||
|
||||
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;
|
||||
createEffect(
|
||||
on(members, (m) => {
|
||||
clearAllHighlights();
|
||||
setHighlightGroups({
|
||||
[store.currentRole as string]: new Set(
|
||||
m.flatMap((m) => {
|
||||
if (m.type === "machine") return m.label;
|
||||
|
||||
return m.members;
|
||||
}),
|
||||
),
|
||||
});
|
||||
return m.members;
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
console.log("now", highlightGroups);
|
||||
onMount(() => {
|
||||
setHighlightGroups(() => ({}));
|
||||
});
|
||||
onMount(() => setHighlightGroups(() => ({})));
|
||||
|
||||
createEffect(
|
||||
on(lastClickedMachine, (machine) => {
|
||||
@@ -403,7 +347,6 @@ const ConfigureRole = () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const machinesQuery = useMachinesQuery(useClanURI());
|
||||
const tagsQuery = useTags(useClanURI());
|
||||
|
||||
const options = useOptions(tagsQuery, machinesQuery);
|
||||
@@ -428,12 +371,7 @@ const ConfigureRole = () => {
|
||||
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()}
|
||||
/>
|
||||
<BackButton ghost size="xs" hierarchy="primary" />
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="s"
|
||||
@@ -505,10 +443,6 @@ const ConfigureRole = () => {
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: "select:service",
|
||||
content: SelectService,
|
||||
},
|
||||
{
|
||||
id: "view:members",
|
||||
content: ConfigureService,
|
||||
@@ -522,79 +456,38 @@ const steps = [
|
||||
|
||||
export type ServiceSteps = typeof steps;
|
||||
|
||||
// TODO: Ideally we would impot this from a backend model package
|
||||
export interface InventoryInstance {
|
||||
name: string;
|
||||
module: {
|
||||
name: string;
|
||||
input?: string | null;
|
||||
};
|
||||
roles: Record<string, RoleType>;
|
||||
}
|
||||
|
||||
interface RoleType {
|
||||
machines: Record<string, { settings?: unknown }>;
|
||||
tags: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ServiceStoreType {
|
||||
module: {
|
||||
name: string;
|
||||
input?: string | null;
|
||||
raw?: ModuleItem;
|
||||
};
|
||||
roles: Record<string, TagType[]>;
|
||||
currentRole?: string;
|
||||
close: () => 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;
|
||||
onClose: () => void;
|
||||
handleSubmit: SubmitServiceHandler;
|
||||
rootProps?: JSX.HTMLAttributes<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
|
||||
const stepper = createStepper(
|
||||
{ steps },
|
||||
{
|
||||
initialStep: props.initialStep || "select:service",
|
||||
initialStep: props.initialStep || "view:members",
|
||||
initialStoreData: {
|
||||
...props.initialStore,
|
||||
close: () => props.onClose?.(),
|
||||
close: props.onClose,
|
||||
handleSubmit: props.handleSubmit,
|
||||
} satisfies Partial<ServiceStoreType>,
|
||||
},
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
if (stepper.currentStep().id !== "select:members") {
|
||||
clearAllHighlights();
|
||||
}
|
||||
});
|
||||
|
||||
let ref: HTMLDivElement;
|
||||
useClickOutside(
|
||||
() => ref,
|
||||
() => {
|
||||
if (stepper.currentStep().id === "select:service") props.onClose?.();
|
||||
},
|
||||
);
|
||||
onCleanup(() => {
|
||||
console.log("cleanup");
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(e) => (ref = e)}
|
||||
id="add-service"
|
||||
class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"
|
||||
{...props.rootProps}
|
||||
>
|
||||
<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>
|
||||
|
||||
81
pkgs/clan-app/ui/src/workflows/Service/models.ts
Normal file
81
pkgs/clan-app/ui/src/workflows/Service/models.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
MachinesQuery,
|
||||
ServiceInstancesQuery,
|
||||
ServiceModules,
|
||||
} from "@/src/hooks/queries";
|
||||
import { TagType } from "./Service";
|
||||
|
||||
export interface ServiceStoreType {
|
||||
roles: Record<string, TagType[]>;
|
||||
currentRole?: string;
|
||||
close: () => void;
|
||||
handleSubmit: SubmitServiceHandler;
|
||||
action: "create" | "update";
|
||||
}
|
||||
|
||||
// TODO: Ideally we would impot this from a backend model package
|
||||
export interface InventoryInstance {
|
||||
name: string;
|
||||
module: {
|
||||
name: string;
|
||||
input?: string | null;
|
||||
};
|
||||
roles: Record<string, RoleType>;
|
||||
}
|
||||
|
||||
export interface RoleType {
|
||||
machines: Record<string, { settings?: unknown }>;
|
||||
tags: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type SubmitServiceHandler = (
|
||||
values: InventoryInstance,
|
||||
action: "create" | "update",
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type ModuleItem = ServiceModules["modules"][number];
|
||||
|
||||
export interface Module {
|
||||
value: string;
|
||||
label: string;
|
||||
raw: ModuleItem;
|
||||
}
|
||||
|
||||
type ValueOf<T> = T[keyof T];
|
||||
|
||||
/**
|
||||
* Collect all members (machines and tags) for a given role in a service instance
|
||||
*
|
||||
* TODO: Make this native feature of the API
|
||||
*
|
||||
*/
|
||||
export function getRoleMembers(
|
||||
instance: ValueOf<NonNullable<ServiceInstancesQuery["data"]>>,
|
||||
all_machines: NonNullable<MachinesQuery["data"]>,
|
||||
role: string,
|
||||
) {
|
||||
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(all_machines)
|
||||
.filter(([_, m]) => m.data.tags?.includes(t))
|
||||
.map(([k]) => k),
|
||||
};
|
||||
});
|
||||
console.log("Members for role", role, [...machineTags, ...tagsTags]);
|
||||
|
||||
const roleMembers = [...machineTags, ...tagsTags].sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
return roleMembers;
|
||||
}
|
||||
Reference in New Issue
Block a user