Merge pull request 'feat(ui): introduce a top-level Clan context' (#4860) from feat/handle-clan-switch-errors into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4860
This commit is contained in:
brianmcgee
2025-08-21 13:57:13 +00:00
7 changed files with 160 additions and 122 deletions

View File

@@ -5,15 +5,20 @@ import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button"; import { Button } from "@/src/components/Button/Button";
import { navigateToClan, navigateToOnboarding } from "@/src/hooks/clan"; import { navigateToClan, navigateToOnboarding } from "@/src/hooks/clan";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { For } from "solid-js"; import { For, Show } from "solid-js";
import { activeClanURI, clanURIs, setActiveClanURI } from "@/src/stores/clan"; import { activeClanURI, clanURIs, setActiveClanURI } from "@/src/stores/clan";
import { useClanListQuery } from "@/src/hooks/queries"; import { useClanListQuery } from "@/src/hooks/queries";
import { Alert } from "@/src/components/Alert/Alert";
export interface ClansModalProps { export interface ListClansModalProps {
onClose: () => void; onClose: () => void;
error?: {
title: string;
description: string;
};
} }
export const ListClansModal = (props: ClansModalProps) => { export const ListClansModal = (props: ListClansModalProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const query = useClanListQuery(clanURIs()); const query = useClanListQuery(clanURIs());
@@ -38,6 +43,14 @@ export const ListClansModal = (props: ClansModalProps) => {
class={cx(styles.modal)} class={cx(styles.modal)}
> >
<div class={cx(styles.content)}> <div class={cx(styles.content)}>
<Show when={props.error}>
<Alert
type="error"
title={props.error?.title || ""}
description={props.error?.description}
/>
</Show>
<div class={cx(styles.header)}> <div class={cx(styles.header)}>
<Typography <Typography
hierarchy="label" hierarchy="label"

View File

@@ -3,11 +3,12 @@ import { A } from "@solidjs/router";
import { Accordion } from "@kobalte/core/accordion"; import { Accordion } from "@kobalte/core/accordion";
import Icon from "../Icon/Icon"; import Icon from "../Icon/Icon";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { For } from "solid-js"; import { For, useContext } from "solid-js";
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus"; import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
import { buildMachinePath, useClanURI } from "@/src/hooks/clan"; import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
import { useMachinesQuery, useMachineStateQuery } from "@/src/hooks/queries"; import { useMachineStateQuery } from "@/src/hooks/queries";
import { SidebarProps } from "./Sidebar"; import { SidebarProps } from "./Sidebar";
import { ClanContext } from "@/src/routes/Clan/Clan";
interface MachineProps { interface MachineProps {
clanURI: string; clanURI: string;
@@ -56,7 +57,11 @@ const MachineRoute = (props: MachineProps) => {
export const SidebarBody = (props: SidebarProps) => { export const SidebarBody = (props: SidebarProps) => {
const clanURI = useClanURI(); const clanURI = useClanURI();
const machineList = useMachinesQuery(clanURI);
const ctx = useContext(ClanContext);
if (!ctx) {
throw new Error("ClanContext not found");
}
const sectionLabels = (props.staticSections || []).map( const sectionLabels = (props.staticSections || []).map(
(section) => section.title, (section) => section.title,
@@ -96,7 +101,7 @@ export const SidebarBody = (props: SidebarProps) => {
</Accordion.Header> </Accordion.Header>
<Accordion.Content class="content"> <Accordion.Content class="content">
<nav> <nav>
<For each={Object.entries(machineList.data || {})}> <For each={Object.entries(ctx.machinesQuery.data || {})}>
{([id, machine]) => ( {([id, machine]) => (
<MachineRoute <MachineRoute
clanURI={clanURI} clanURI={clanURI}

View File

@@ -3,15 +3,15 @@ import Icon from "@/src/components/Icon/Icon";
import { DropdownMenu } from "@kobalte/core/dropdown-menu"; import { DropdownMenu } from "@kobalte/core/dropdown-menu";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { Typography } from "../Typography/Typography"; import { Typography } from "../Typography/Typography";
import { createSignal, For, Suspense } from "solid-js"; import { createSignal, For, Suspense, useContext } from "solid-js";
import { useClanListQuery } from "@/src/hooks/queries";
import { import {
navigateToClan, navigateToClan,
navigateToOnboarding, navigateToOnboarding,
useClanURI, useClanURI,
} from "@/src/hooks/clan"; } from "@/src/hooks/clan";
import { clanURIs, setActiveClanURI } from "@/src/stores/clan"; import { setActiveClanURI } from "@/src/stores/clan";
import { Button } from "../Button/Button"; import { Button } from "../Button/Button";
import { ClanContext } from "@/src/routes/Clan/Clan";
export const SidebarHeader = () => { export const SidebarHeader = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -19,10 +19,19 @@ export const SidebarHeader = () => {
const [open, setOpen] = createSignal(false); const [open, setOpen] = createSignal(false);
// get information about the current active clan // get information about the current active clan
const clanURI = useClanURI(); const ctx = useContext(ClanContext);
const allClans = useClanListQuery(clanURIs());
const activeClan = () => allClans.find(({ data }) => data?.uri === clanURI); if (!ctx) {
throw new Error("SidebarContext not found");
}
const clanURI = useClanURI();
const clanChar = () =>
ctx?.activeClanQuery?.data?.name.charAt(0).toUpperCase();
const clanName = () => ctx?.activeClanQuery?.data?.name;
const clans = () => ctx.otherClanQueries.filter((clan) => !clan.isError);
return ( return (
<div class="sidebar-header"> <div class="sidebar-header">
@@ -37,7 +46,7 @@ export const SidebarHeader = () => {
weight="bold" weight="bold"
inverted={true} inverted={true}
> >
{activeClan()?.data?.name.charAt(0).toUpperCase()} {clanChar()}
</Typography> </Typography>
</div> </div>
<Typography <Typography
@@ -46,7 +55,7 @@ export const SidebarHeader = () => {
weight="bold" weight="bold"
inverted={!open()} inverted={!open()}
> >
{activeClan()?.data?.name} {clanName()}
</Typography> </Typography>
</div> </div>
<DropdownMenu.Icon> <DropdownMenu.Icon>
@@ -91,7 +100,7 @@ export const SidebarHeader = () => {
</Button> </Button>
</DropdownMenu.GroupLabel> </DropdownMenu.GroupLabel>
<div class="dropdown-group-items"> <div class="dropdown-group-items">
<For each={allClans}> <For each={clans()}>
{(clan) => ( {(clan) => (
<Suspense fallback={"Loading..."}> <Suspense fallback={"Loading..."}>
<DropdownMenu.Item <DropdownMenu.Item

View File

@@ -157,7 +157,7 @@ export const useMachineDetailsQuery = (
export const useClanDetailsQuery = (clanURI: string) => { export const useClanDetailsQuery = (clanURI: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<ClanDetails>(() => ({ return useQuery<ClanDetailsWithURI>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "details"], queryKey: ["clans", encodeBase64(clanURI), "details"],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("get_clan_details", { const call = client.fetch("get_clan_details", {

View File

@@ -1,13 +1,14 @@
import { RouteSectionProps } from "@solidjs/router"; import { RouteSectionProps, useNavigate } from "@solidjs/router";
import { import {
Component, Component,
JSX, createContext,
Show,
createEffect, createEffect,
createMemo, createMemo,
createSignal, createSignal,
on, on,
onMount, onMount,
Show,
useContext,
} from "solid-js"; } from "solid-js";
import { import {
buildMachinePath, buildMachinePath,
@@ -16,13 +17,14 @@ import {
} from "@/src/hooks/clan"; } from "@/src/hooks/clan";
import { CubeScene } from "@/src/scene/cubes"; import { CubeScene } from "@/src/scene/cubes";
import { import {
ClanListQueryResult, ClanDetailsWithURI,
MachinesQueryResult, MachinesQueryResult,
useClanDetailsQuery,
useClanListQuery, useClanListQuery,
useMachinesQuery, useMachinesQuery,
} from "@/src/hooks/queries"; } from "@/src/hooks/queries";
import { callApi } from "@/src/hooks/api"; import { callApi } from "@/src/hooks/api";
import { store, setStore, clanURIs } from "@/src/stores/clan"; import { clanURIs, setStore, store } from "@/src/stores/clan";
import { produce } from "solid-js/store"; import { produce } from "solid-js/store";
import { Button } from "@/src/components/Button/Button"; import { Button } from "@/src/components/Button/Button";
import { Splash } from "@/src/scene/splash"; import { Splash } from "@/src/scene/splash";
@@ -32,15 +34,36 @@ import { Modal } from "@/src/components/Modal/Modal";
import { TextInput } from "@/src/components/Form/TextInput"; import { TextInput } from "@/src/components/Form/TextInput";
import { createForm, FieldValues, reset } from "@modular-forms/solid"; import { createForm, FieldValues, reset } from "@modular-forms/solid";
import { Sidebar } from "@/src/components/Sidebar/Sidebar"; import { Sidebar } from "@/src/components/Sidebar/Sidebar";
import { useNavigate } from "@solidjs/router"; import { UseQueryResult } from "@tanstack/solid-query";
export const ClanContext = createContext<{
machinesQuery: MachinesQueryResult;
activeClanQuery: UseQueryResult<ClanDetailsWithURI>;
otherClanQueries: UseQueryResult<ClanDetailsWithURI>[];
}>();
export const Clan: Component<RouteSectionProps> = (props) => { export const Clan: Component<RouteSectionProps> = (props) => {
const clanURI = useClanURI();
const activeClanQuery = useClanDetailsQuery(clanURI);
const otherClanQueries = useClanListQuery(
clanURIs().filter((uri) => uri !== clanURI),
);
const machinesQuery = useMachinesQuery(clanURI);
return ( return (
<> <ClanContext.Provider
value={{
machinesQuery,
activeClanQuery,
otherClanQueries,
}}
>
<Sidebar class={cx(styles.sidebar)} /> <Sidebar class={cx(styles.sidebar)} />
{props.children} {props.children}
<ClanSceneController {...props} /> <ClanSceneController {...props} />
</> </ClanContext.Provider>
); );
}; };
@@ -98,7 +121,10 @@ const ClanSceneController = (props: RouteSectionProps) => {
const clanURI = useClanURI(); const clanURI = useClanURI();
const navigate = useNavigate(); const navigate = useNavigate();
const machinesQuery = useMachinesQuery(clanURI); const ctx = useContext(ClanContext);
if (!ctx) {
throw new Error("ClanContext not found");
}
const [dialogHandlers, setDialogHandlers] = createSignal<{ const [dialogHandlers, setDialogHandlers] = createSignal<{
resolve: ({ id }: { id: string }) => void; resolve: ({ id }: { id: string }) => void;
@@ -133,7 +159,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
} }
// trigger a refetch of the machines query // trigger a refetch of the machines query
machinesQuery.refetch(); ctx.machinesQuery.refetch();
return { id: values.name }; return { id: values.name };
}; };
@@ -177,102 +203,87 @@ const ClanSceneController = (props: RouteSectionProps) => {
}), }),
); );
// a combination of the individual clan details query status and the machines query status
// the cube scene needs the machines query, the sidebar needs the clans query and machines query results
// so we wait on both before removing the loader to avoid any loading artefacts
const isLoading = (): boolean => {
// check if the active clan query is still loading
if (ctx.activeClanQuery.isLoading) {
return true;
}
// check the machines query first
if (ctx.machinesQuery.isLoading) {
return true;
}
// otherwise iterate the clans query and return early if we find a queries that is still loading
for (const query of ctx.otherClanQueries) {
if (query.isLoading) {
return true;
}
}
return false;
};
return ( return (
<SceneDataProvider clanURI={clanURI}> <>
{({ clansQuery, machinesQuery }) => { <Show when={showModal()}>
// a combination of the individual clan details query status and the machines query status <MockCreateMachine
// the cube scene needs the machines query, the sidebar needs the clans query and machines query results onClose={() => {
// so we wait on both before removing the loader to avoid any loading artefacts setShowModal(false);
const isLoading = (): boolean => { dialogHandlers()?.reject(new Error("User cancelled"));
// check the machines query first }}
if (machinesQuery.isLoading) { onSubmit={async (values) => {
return true; try {
} const result = await sendCreate(values);
dialogHandlers()?.resolve(result);
// otherwise iterate the clans query and return early if we find a queries that is still loading setShowModal(false);
for (const query of clansQuery) { } catch (err) {
if (query.isLoading) { dialogHandlers()?.reject(err);
return true; setShowModal(false);
} }
} }}
/>
</Show>
<div
class={cx({
[styles.fadeOut]: !ctx.machinesQuery.isLoading && loadingCooldown(),
})}
>
<Splash />
</div>
return false; <CubeScene
}; selectedIds={selectedIds}
onSelect={onMachineSelect}
return ( isLoading={isLoading()}
<> cubesQuery={ctx.machinesQuery}
<Show when={showModal()}> onCreate={onCreate}
<MockCreateMachine sceneStore={() => {
onClose={() => { const clanURI = useClanURI();
setShowModal(false); return store.sceneData?.[clanURI];
dialogHandlers()?.reject(new Error("User cancelled")); }}
}} setMachinePos={(machineId: string, pos: [number, number]) => {
onSubmit={async (values) => { console.log("calling setStore", machineId, pos);
try { setStore(
const result = await sendCreate(values); produce((s) => {
dialogHandlers()?.resolve(result); if (!s.sceneData) {
setShowModal(false); s.sceneData = {};
} catch (err) { }
dialogHandlers()?.reject(err); if (!s.sceneData[clanURI]) {
setShowModal(false); s.sceneData[clanURI] = {};
} }
}} if (!s.sceneData[clanURI][machineId]) {
/> s.sceneData[clanURI][machineId] = { position: pos };
</Show> } else {
<div s.sceneData[clanURI][machineId].position = pos;
class={cx({ }
[styles.fadeOut]: !machinesQuery.isLoading && loadingCooldown(), }),
})} );
> }}
<Splash /> />
</div> </>
<CubeScene
selectedIds={selectedIds}
onSelect={onMachineSelect}
isLoading={isLoading()}
cubesQuery={machinesQuery}
onCreate={onCreate}
sceneStore={() => {
const clanURI = useClanURI();
return store.sceneData?.[clanURI];
}}
setMachinePos={(machineId: string, pos: [number, number]) => {
console.log("calling setStore", machineId, pos);
setStore(
produce((s) => {
if (!s.sceneData) {
s.sceneData = {};
}
if (!s.sceneData[clanURI]) {
s.sceneData[clanURI] = {};
}
if (!s.sceneData[clanURI][machineId]) {
s.sceneData[clanURI][machineId] = { position: pos };
} else {
s.sceneData[clanURI][machineId].position = pos;
}
}),
);
}}
/>
</>
);
}}
</SceneDataProvider>
); );
}; };
const SceneDataProvider = (props: {
clanURI: string;
children: (sceneData: {
clansQuery: ClanListQueryResult;
machinesQuery: MachinesQueryResult;
}) => JSX.Element;
}) => {
const clansQuery = useClanListQuery(clanURIs());
const machinesQuery = useMachinesQuery(props.clanURI);
// This component can be used to provide scene data or context if needed
return props.children({ clansQuery, machinesQuery });
};

View File

@@ -37,7 +37,7 @@ import { HostFileInput } from "@/src/components/Form/HostFileInput";
import { callApi } from "@/src/hooks/api"; import { callApi } from "@/src/hooks/api";
import { Creating } from "./Creating"; import { Creating } from "./Creating";
import { useApiClient } from "@/src/hooks/ApiClient"; import { useApiClient } from "@/src/hooks/ApiClient";
import { ListClansModal } from "@/src/routes/Onboarding/ListClansModal"; import { ListClansModal } from "@/src/components/ListClansModal/ListClansModal";
type State = "welcome" | "setup" | "creating"; type State = "welcome" | "setup" | "creating";