Merge pull request 'ui/clan-switching' (#4844) from ui/clan-switching into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4844
This commit is contained in:
@@ -11,6 +11,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||
import { addClanURI, resetStore } from "@/src/stores/clan";
|
||||
import { SolidQueryDevtools } from "@tanstack/solid-query-devtools";
|
||||
import { encodeBase64 } from "@/src/hooks/clan";
|
||||
import { ApiClientProvider } from "@/src/hooks/ApiClient";
|
||||
import {
|
||||
ApiCall,
|
||||
OperationArgs,
|
||||
OperationNames,
|
||||
OperationResponse,
|
||||
} from "@/src/hooks/api";
|
||||
|
||||
const defaultClanURI = "/home/brian/clans/my-clan";
|
||||
|
||||
@@ -24,10 +31,16 @@ const queryData = {
|
||||
europa: {
|
||||
name: "Europa",
|
||||
machineClass: "nixos",
|
||||
state: {
|
||||
status: "online",
|
||||
},
|
||||
},
|
||||
ganymede: {
|
||||
name: "Ganymede",
|
||||
machineClass: "nixos",
|
||||
state: {
|
||||
status: "out_of_sync",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -40,10 +53,16 @@ const queryData = {
|
||||
callisto: {
|
||||
name: "Callisto",
|
||||
machineClass: "nixos",
|
||||
state: {
|
||||
status: "not_installed",
|
||||
},
|
||||
},
|
||||
amalthea: {
|
||||
name: "Amalthea",
|
||||
machineClass: "nixos",
|
||||
state: {
|
||||
status: "offline",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -56,10 +75,16 @@ const queryData = {
|
||||
thebe: {
|
||||
name: "Thebe",
|
||||
machineClass: "nixos",
|
||||
state: {
|
||||
status: "online",
|
||||
},
|
||||
},
|
||||
sponde: {
|
||||
name: "Sponde",
|
||||
machineClass: "nixos",
|
||||
state: {
|
||||
status: "online",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -123,6 +148,18 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<RouteSectionProps>;
|
||||
|
||||
const mockFetcher = <K extends OperationNames>(
|
||||
method: K,
|
||||
args: OperationArgs<K>,
|
||||
) =>
|
||||
({
|
||||
uuid: "mock",
|
||||
result: Promise.reject<OperationResponse<K>>("not implemented"),
|
||||
cancel: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
}) satisfies ApiCall<K>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
decorators: [
|
||||
@@ -141,16 +178,28 @@ export const Default: Story = {
|
||||
["clans", encodeBase64(clanURI), "details"],
|
||||
clan.details,
|
||||
);
|
||||
|
||||
const machines = clan.machines || {};
|
||||
|
||||
queryClient.setQueryData(
|
||||
["clans", encodeBase64(clanURI), "machines"],
|
||||
clan.machines || {},
|
||||
machines,
|
||||
);
|
||||
|
||||
Object.entries(machines).forEach(([name, machine]) => {
|
||||
queryClient.setQueryData(
|
||||
["clans", encodeBase64(clanURI), "machine", name, "state"],
|
||||
machine.state,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Story />
|
||||
</QueryClientProvider>
|
||||
<ApiClientProvider client={{ fetch: mockFetcher }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Story />
|
||||
</QueryClientProvider>
|
||||
</ApiClientProvider>
|
||||
);
|
||||
},
|
||||
],
|
||||
|
||||
@@ -58,6 +58,7 @@ div.sidebar-header {
|
||||
@apply px-1;
|
||||
|
||||
.dropdown-group-label {
|
||||
@apply flex items-baseline justify-between w-full;
|
||||
}
|
||||
|
||||
.dropdown-group-items {
|
||||
|
||||
@@ -5,8 +5,13 @@ import { useNavigate } from "@solidjs/router";
|
||||
import { Typography } from "../Typography/Typography";
|
||||
import { createSignal, For, Suspense } from "solid-js";
|
||||
import { useClanListQuery } from "@/src/hooks/queries";
|
||||
import { navigateToClan, useClanURI } from "@/src/hooks/clan";
|
||||
import { clanURIs } from "@/src/stores/clan";
|
||||
import {
|
||||
navigateToClan,
|
||||
navigateToOnboarding,
|
||||
useClanURI,
|
||||
} from "@/src/hooks/clan";
|
||||
import { clanURIs, setActiveClanURI } from "@/src/stores/clan";
|
||||
import { Button } from "../Button/Button";
|
||||
|
||||
export const SidebarHeader = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -71,9 +76,19 @@ export const SidebarHeader = () => {
|
||||
family="mono"
|
||||
size="xs"
|
||||
color="tertiary"
|
||||
transform="uppercase"
|
||||
>
|
||||
YOUR CLANS
|
||||
Your Clans
|
||||
</Typography>
|
||||
<Button
|
||||
hierarchy="secondary"
|
||||
ghost
|
||||
size="xs"
|
||||
startIcon="Plus"
|
||||
onClick={() => navigateToOnboarding(navigate, true)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</DropdownMenu.GroupLabel>
|
||||
<div class="dropdown-group-items">
|
||||
<For each={allClans}>
|
||||
@@ -81,9 +96,9 @@ export const SidebarHeader = () => {
|
||||
<Suspense fallback={"Loading..."}>
|
||||
<DropdownMenu.Item
|
||||
class="dropdown-item"
|
||||
onSelect={() =>
|
||||
navigateToClan(navigate, clan.data!.uri)
|
||||
}
|
||||
onSelect={() => {
|
||||
setActiveClanURI(clan.data!.uri);
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
|
||||
@@ -36,6 +36,9 @@ export const navigateToClan = (navigate: Navigator, clanURI: string) => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
export const navigateToOnboarding = (navigate: Navigator, addClan: boolean) =>
|
||||
navigate(`/${addClan ? "?addClan=true" : ""}`);
|
||||
|
||||
export const navigateToMachine = (
|
||||
navigate: Navigator,
|
||||
clanURI: string,
|
||||
|
||||
@@ -27,10 +27,6 @@ export type ClanListQueryResult = UseQueryResult<ClanDetailsWithURI>[];
|
||||
export const useMachinesQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
|
||||
if (!clanURI) {
|
||||
throw new Error("useMachinesQuery: clanURI is undefined");
|
||||
}
|
||||
|
||||
return useQuery<ListMachines>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machines"],
|
||||
queryFn: async () => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
.modal {
|
||||
@apply w-screen max-w-2xl h-fit flex flex-col;
|
||||
|
||||
.content {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.clans {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.clan {
|
||||
@apply flex items-center justify-between;
|
||||
@apply px-4 py-5 bg-def-2;
|
||||
|
||||
.meta {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
91
pkgs/clan-app/ui/src/routes/Onboarding/ListClansModal.tsx
Normal file
91
pkgs/clan-app/ui/src/routes/Onboarding/ListClansModal.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Modal } from "../../components/Modal/Modal";
|
||||
import cx from "classnames";
|
||||
import styles from "./ListClansModal.module.css";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { navigateToClan, navigateToOnboarding } from "@/src/hooks/clan";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { For } from "solid-js";
|
||||
import { activeClanURI, clanURIs, setActiveClanURI } from "@/src/stores/clan";
|
||||
import { useClanListQuery } from "@/src/hooks/queries";
|
||||
|
||||
export interface ClansModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ListClansModal = (props: ClansModalProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const query = useClanListQuery(clanURIs());
|
||||
|
||||
// we only want clans we could interrogate successfully
|
||||
// todo how to surface the ones that failed to users?
|
||||
const clanList = () => query.filter((it) => it.isSuccess);
|
||||
|
||||
const selectClan = (uri: string) => () => {
|
||||
if (uri == activeClanURI()) {
|
||||
navigateToClan(navigate, uri);
|
||||
} else {
|
||||
setActiveClanURI(uri);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Select Clan"
|
||||
open
|
||||
onClose={props.onClose}
|
||||
class={cx(styles.modal)}
|
||||
>
|
||||
<div class={cx(styles.content)}>
|
||||
<div class={cx(styles.header)}>
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
family="mono"
|
||||
size="xs"
|
||||
color="tertiary"
|
||||
transform="uppercase"
|
||||
>
|
||||
Your Clans
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
hierarchy="secondary"
|
||||
ghost
|
||||
size="s"
|
||||
startIcon="Plus"
|
||||
onClick={() => {
|
||||
props.onClose();
|
||||
navigateToOnboarding(navigate, true);
|
||||
}}
|
||||
>
|
||||
Add Clan
|
||||
</Button>
|
||||
</div>
|
||||
<ul class={cx(styles.clans)}>
|
||||
<For each={clanList()}>
|
||||
{(clan) => (
|
||||
<li class={cx(styles.clan)}>
|
||||
<div class={cx(styles.meta)}>
|
||||
<Typography hierarchy="label" weight="bold" size="default">
|
||||
{clan.data.name}
|
||||
</Typography>
|
||||
<Typography hierarchy="body" size="s">
|
||||
{clan.data.description}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
hierarchy="secondary"
|
||||
ghost
|
||||
icon="CaretRight"
|
||||
onClick={selectClan(clan.data.uri)}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -33,6 +33,10 @@ main#welcome {
|
||||
@apply w-16;
|
||||
@apply absolute bottom-28 left-1/2 transform -translate-x-1/2;
|
||||
}
|
||||
|
||||
button.list-clans {
|
||||
@apply absolute bottom-28 right-0 transform -translate-x-1/2;
|
||||
}
|
||||
}
|
||||
|
||||
& > div.container {
|
||||
|
||||
@@ -4,9 +4,14 @@ import {
|
||||
createSignal,
|
||||
Match,
|
||||
Setter,
|
||||
Show,
|
||||
Switch,
|
||||
} from "solid-js";
|
||||
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||
import {
|
||||
RouteSectionProps,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "@solidjs/router";
|
||||
import "./Onboarding.css";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
@@ -32,6 +37,7 @@ import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { Creating } from "./Creating";
|
||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||
import { ListClansModal } from "@/src/routes/Onboarding/ListClansModal";
|
||||
|
||||
type State = "welcome" | "setup" | "creating";
|
||||
|
||||
@@ -55,14 +61,32 @@ const SetupSchema = v.object({
|
||||
|
||||
type SetupForm = v.InferInput<typeof SetupSchema>;
|
||||
|
||||
const background = (props: { state: State; form: FormStore<SetupForm> }) => (
|
||||
<div class="background">
|
||||
<div class="layer-1" />
|
||||
<div class="layer-2" />
|
||||
<div class="layer-3" />
|
||||
<Logo variant="Clan" inverted={true} />
|
||||
</div>
|
||||
);
|
||||
const background = (props: { state: State; form: FormStore<SetupForm> }) => {
|
||||
// controls whether the list clans modal is displayed
|
||||
const [showModal, setShowModal] = createSignal(false);
|
||||
|
||||
return (
|
||||
<div class="background">
|
||||
<div class="layer-1" />
|
||||
<div class="layer-2" />
|
||||
<div class="layer-3" />
|
||||
<Logo variant="Clan" inverted />
|
||||
<Button
|
||||
class="list-clans"
|
||||
hierarchy="primary"
|
||||
ghost
|
||||
size="s"
|
||||
startIcon="Grid"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
All Clans
|
||||
</Button>
|
||||
<Show when={showModal()}>
|
||||
<ListClansModal onClose={() => setShowModal(false)} />
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const welcome = (props: {
|
||||
setState: Setter<State>;
|
||||
@@ -75,9 +99,17 @@ const welcome = (props: {
|
||||
|
||||
const selectFolder = async () => {
|
||||
setLoading(true);
|
||||
const uri = await selectClanFolder();
|
||||
setLoading(false);
|
||||
navigateToClan(navigate, uri);
|
||||
|
||||
try {
|
||||
const uri = await selectClanFolder();
|
||||
setLoading(false);
|
||||
navigateToClan(navigate, uri);
|
||||
} catch (e) {
|
||||
// todo display error, currently we don't get anything to distinguish between cancel or an actual error
|
||||
} finally {
|
||||
// stop the loading state of the button
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -127,7 +159,7 @@ const welcome = (props: {
|
||||
</div>
|
||||
<Button
|
||||
hierarchy="primary"
|
||||
ghost={true}
|
||||
ghost
|
||||
loading={loading()}
|
||||
onClick={selectFolder}
|
||||
>
|
||||
@@ -139,9 +171,11 @@ const welcome = (props: {
|
||||
|
||||
export const Onboarding: Component<RouteSectionProps> = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const activeURI = activeClanURI();
|
||||
if (activeURI) {
|
||||
|
||||
if (!searchParams.addClan && activeURI) {
|
||||
// the user has already selected a clan, so we should navigate to it
|
||||
console.log("active clan detected, navigating to it", activeURI);
|
||||
navigateToClan(navigate, activeURI);
|
||||
|
||||
Reference in New Issue
Block a user