Merge pull request 'ui/install: hook up stepper store and api' (#4626) from install-ui into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4626
This commit is contained in:
hsjobeki
2025-08-07 14:21:21 +00:00
16 changed files with 485 additions and 104 deletions

View File

@@ -11,6 +11,10 @@
@apply outline outline-1 outline-inv-2 @apply outline outline-1 outline-inv-2
} }
&[data-loading] {
@apply cursor-wait;
}
&:hover { &:hover {
@apply bg-def-2; @apply bg-def-2;
@@ -38,6 +42,9 @@
&[data-disabled] { &[data-disabled] {
@apply cursor-not-allowed; @apply cursor-not-allowed;
} }
&[data-loading] {
@apply bg-inherit;
}
} }
.options_content { .options_content {

View File

@@ -47,25 +47,28 @@ export const Default: Story = {
placeholder: "Select your pet", placeholder: "Select your pet",
}, },
}; };
export const Async: Story = {
// <Field name="language"> args: {
// {(field, input) => ( required: true,
// <Select label: {
// required label: "Select your pet",
// label={{ description: "Choose your favorite pet from the list",
// label: "Language", },
// description: "Select your preferred language", getOptions: async () => {
// }} return new Promise((resolve) => {
// options={[ setTimeout(() => {
// { value: "en", label: "English" }, resolve([
// { value: "fr", label: "Français" }, { value: "dog", label: "Doggy" },
// ]} { value: "cat", label: "Catty" },
// placeholder="Language" { value: "fish", label: "Fishy" },
// onChange={(opt) => { { value: "bird", label: "Birdy" },
// setValue(formStore, "language", opt?.value || ""); { value: "hamster", label: "Hammy" },
// }} { value: "snake", label: "Snakey" },
// name={field.name} { value: "turtle", label: "Turtly" },
// validationState={field.error ? "invalid" : "valid"} ]);
// /> }, 3000);
// )} });
// </Field> },
placeholder: "Select your pet",
},
};

View File

@@ -2,18 +2,21 @@ import { Select as KSelect } from "@kobalte/core/select";
import Icon from "../Icon/Icon"; import Icon from "../Icon/Icon";
import { Orienter } from "../Form/Orienter"; import { Orienter } from "../Form/Orienter";
import { Label, LabelProps } from "../Form/Label"; import { Label, LabelProps } from "../Form/Label";
import { createEffect, createSignal, JSX, splitProps } from "solid-js"; import { createEffect, createSignal, JSX, Show, splitProps } from "solid-js";
import styles from "./Select.module.css"; import styles from "./Select.module.css";
import { Typography } from "../Typography/Typography"; import { Typography } from "../Typography/Typography";
import cx from "classnames"; import cx from "classnames";
export interface Option { value: string; label: string; disabled?: boolean } export interface Option {
value: string;
label: string;
disabled?: boolean;
}
export interface SelectProps { export type SelectProps = {
// Kobalte Select props, for modular forms // Kobalte Select props, for modular forms
name: string; name: string;
placeholder?: string | undefined; placeholder?: string | undefined;
options: Option[];
value: string | undefined; value: string | undefined;
error: string; error: string;
required?: boolean | undefined; required?: boolean | undefined;
@@ -25,24 +28,57 @@ export interface SelectProps {
// Custom props // Custom props
orientation?: "horizontal" | "vertical"; orientation?: "horizontal" | "vertical";
label?: Omit<LabelProps, "labelComponent" | "descriptionComponent">; label?: Omit<LabelProps, "labelComponent" | "descriptionComponent">;
} } & (
| {
// Sync options
options: Option[];
getOptions?: never;
}
| {
// Async options
getOptions: () => Promise<Option[]>;
options?: never;
}
);
export const Select = (props: SelectProps) => { export const Select = (props: SelectProps) => {
const [root, selectProps] = splitProps( const [root, selectProps] = splitProps(
props, props,
["name", "placeholder", "options", "required", "disabled"], ["name", "placeholder", "required", "disabled"],
["placeholder", "ref", "onInput", "onChange", "onBlur"], ["placeholder", "ref", "onInput", "onChange", "onBlur"],
); );
const [getValue, setValue] = createSignal<Option>(); const [getValue, setValue] = createSignal<Option>();
const [resolvedOptions, setResolvedOptions] = createSignal<Option[]>([]);
// Internal loading state for async options
const [loading, setLoading] = createSignal(false);
createEffect(async () => {
if (props.getOptions) {
setLoading(true);
try {
const options = await props.getOptions();
setResolvedOptions(options);
} finally {
setLoading(false);
}
} else if (props.options) {
setResolvedOptions(props.options);
}
});
const options = () => props.options ?? resolvedOptions();
createEffect(() => { createEffect(() => {
setValue(props.options.find((option) => props.value === option.value)); console.log("options,", options());
setValue(options().find((option) => props.value === option.value));
}); });
return ( return (
<KSelect <KSelect
{...root} {...root}
options={options()}
sameWidth={true} sameWidth={true}
gutter={0} gutter={0}
multiple={false} multiple={false}
@@ -70,6 +106,20 @@ export const Select = (props: SelectProps) => {
</KSelect.Item> </KSelect.Item>
)} )}
placeholder={ placeholder={
<Show
when={!loading()}
fallback={
<Typography
hierarchy="body"
size="xs"
weight="bold"
class="flex w-full items-center"
color="secondary"
>
Loading...
</Typography>
}
>
<Typography <Typography
hierarchy="body" hierarchy="body"
size="xs" size="xs"
@@ -78,6 +128,7 @@ export const Select = (props: SelectProps) => {
> >
{props.placeholder} {props.placeholder}
</Typography> </Typography>
</Show>
} }
> >
<Orienter orientation={props.orientation || "horizontal"}> <Orienter orientation={props.orientation || "horizontal"}>
@@ -88,7 +139,10 @@ export const Select = (props: SelectProps) => {
validationState={props.error ? "invalid" : "valid"} validationState={props.error ? "invalid" : "valid"}
/> />
<KSelect.HiddenSelect {...selectProps} /> <KSelect.HiddenSelect {...selectProps} />
<KSelect.Trigger class={cx(styles.trigger)}> <KSelect.Trigger
class={cx(styles.trigger)}
data-loading={loading() || undefined}
>
<KSelect.Value<Option>> <KSelect.Value<Option>>
{(state) => ( {(state) => (
<Typography <Typography
@@ -101,7 +155,11 @@ export const Select = (props: SelectProps) => {
</Typography> </Typography>
)} )}
</KSelect.Value> </KSelect.Value>
<KSelect.Icon as="button" class={styles.icon}> <KSelect.Icon
as="button"
class={styles.icon}
data-loading={loading() || undefined}
>
<Icon icon="Expand" color="inherit" /> <Icon icon="Expand" color="inherit" />
</KSelect.Icon> </KSelect.Icon>
</KSelect.Trigger> </KSelect.Trigger>

View File

@@ -0,0 +1,33 @@
import { createContext, JSX, useContext } from "solid-js";
import { ApiCall, OperationArgs, OperationNames } from "./api";
export interface ApiClient {
fetch: Fetcher;
}
export type Fetcher = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
) => ApiCall<K>;
const ApiClientContext = createContext<ApiClient>();
interface ApiClientProviderProps {
client: ApiClient;
children: JSX.Element;
}
export const ApiClientProvider = (props: ApiClientProviderProps) => {
return (
<ApiClientContext.Provider value={props.client}>
{props.children}
</ApiClientContext.Provider>
);
};
export const useApiClient = () => {
const client = useContext(ApiClientContext);
if (!client) {
throw new Error("useApiClient must be used within an ApiClientProvider");
}
return client;
};

View File

@@ -49,6 +49,11 @@ export interface ApiCall<K extends OperationNames> {
cancel: () => Promise<void>; cancel: () => Promise<void>;
} }
/**
* Do not use this function directly, use `useApiClient` function instead.
* This allows mocking the result in tests.
* Or switch to different client implementations.
*/
export const callApi = <K extends OperationNames>( export const callApi = <K extends OperationNames>(
method: K, method: K,
args: OperationArgs<K>, args: OperationArgs<K>,

View File

@@ -47,7 +47,12 @@ export const navigateToMachine = (
}; };
export const clanURIParam = (params: Params) => { export const clanURIParam = (params: Params) => {
try {
return decodeBase64(params.clanURI); return decodeBase64(params.clanURI);
} catch (e) {
console.error("Failed to decode clan URI:", params.clanURI, e);
throw new Error("Invalid clan URI");
}
}; };
export const useClanURI = () => clanURIParam(useParams()); export const useClanURI = () => clanURIParam(useParams());

View File

@@ -1,6 +1,7 @@
import { useQueries, useQuery, UseQueryResult } from "@tanstack/solid-query"; import { useQueries, useQuery, UseQueryResult } from "@tanstack/solid-query";
import { callApi, SuccessData } from "../hooks/api"; import { SuccessData } from "../hooks/api";
import { encodeBase64 } from "@/src/hooks/clan"; import { encodeBase64 } from "@/src/hooks/clan";
import { useApiClient } from "./ApiClient";
export type ClanDetails = SuccessData<"get_clan_details">; export type ClanDetails = SuccessData<"get_clan_details">;
export type ClanDetailsWithURI = ClanDetails & { uri: string }; export type ClanDetailsWithURI = ClanDetails & { uri: string };
@@ -12,11 +13,12 @@ export type MachineDetails = SuccessData<"get_machine_details">;
export type MachinesQueryResult = UseQueryResult<ListMachines>; export type MachinesQueryResult = UseQueryResult<ListMachines>;
export type ClanListQueryResult = UseQueryResult<ClanDetailsWithURI>[]; export type ClanListQueryResult = UseQueryResult<ClanDetailsWithURI>[];
export const useMachinesQuery = (clanURI: string) => export const useMachinesQuery = (clanURI: string) => {
useQuery<ListMachines>(() => ({ const client = useApiClient();
return useQuery<ListMachines>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machines"], queryKey: ["clans", encodeBase64(clanURI), "machines"],
queryFn: async () => { queryFn: async () => {
const api = callApi("list_machines", { const api = client.fetch("list_machines", {
flake: { flake: {
identifier: clanURI, identifier: clanURI,
}, },
@@ -29,12 +31,14 @@ export const useMachinesQuery = (clanURI: string) =>
return result.data; return result.data;
}, },
})); }));
};
export const useMachineQuery = (clanURI: string, machineName: string) => export const useMachineQuery = (clanURI: string, machineName: string) => {
useQuery<Machine>(() => ({ const client = useApiClient();
return useQuery<Machine>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName], queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
queryFn: async () => { queryFn: async () => {
const call = callApi("get_machine", { const call = client.fetch("get_machine", {
name: machineName, name: machineName,
flake: { flake: {
identifier: clanURI, identifier: clanURI,
@@ -49,12 +53,17 @@ export const useMachineQuery = (clanURI: string, machineName: string) =>
return result.data; return result.data;
}, },
})); }));
};
export const useMachineDetailsQuery = (clanURI: string, machineName: string) => export const useMachineDetailsQuery = (
useQuery<MachineDetails>(() => ({ clanURI: string,
machineName: string,
) => {
const client = useApiClient();
return useQuery<MachineDetails>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName], queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName],
queryFn: async () => { queryFn: async () => {
const call = callApi("get_machine_details", { const call = client.fetch("get_machine_details", {
machine: { machine: {
name: machineName, name: machineName,
flake: { flake: {
@@ -73,12 +82,14 @@ export const useMachineDetailsQuery = (clanURI: string, machineName: string) =>
return result.data; return result.data;
}, },
})); }));
};
export const useClanDetailsQuery = (clanURI: string) => export const useClanDetailsQuery = (clanURI: string) => {
useQuery<ClanDetails>(() => ({ const client = useApiClient();
return useQuery<ClanDetails>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "details"], queryKey: ["clans", encodeBase64(clanURI), "details"],
queryFn: async () => { queryFn: async () => {
const call = callApi("get_clan_details", { const call = client.fetch("get_clan_details", {
flake: { flake: {
identifier: clanURI, identifier: clanURI,
}, },
@@ -97,14 +108,16 @@ export const useClanDetailsQuery = (clanURI: string) =>
}; };
}, },
})); }));
};
export const useClanListQuery = (clanURIs: string[]): ClanListQueryResult => export const useClanListQuery = (clanURIs: string[]): ClanListQueryResult => {
useQueries(() => ({ const client = useApiClient();
return useQueries(() => ({
queries: clanURIs.map((clanURI) => ({ queries: clanURIs.map((clanURI) => ({
queryKey: ["clans", encodeBase64(clanURI), "details"], queryKey: ["clans", encodeBase64(clanURI), "details"],
enabled: !!clanURI, enabled: !!clanURI,
queryFn: async () => { queryFn: async () => {
const call = callApi("get_clan_details", { const call = client.fetch("get_clan_details", {
flake: { flake: {
identifier: clanURI, identifier: clanURI,
}, },
@@ -124,3 +137,54 @@ export const useClanListQuery = (clanURIs: string[]): ClanListQueryResult =>
}, },
})), })),
})); }));
};
export type MachineFlashOptions = SuccessData<"get_machine_flash_options">;
export type MachineFlashOptionsQuery = UseQueryResult<MachineFlashOptions>;
export const useMachineFlashOptions = (
clanURI: string,
): MachineFlashOptionsQuery => {
const client = useApiClient();
return useQuery<MachineFlashOptions>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine_flash_options"],
queryFn: async () => {
const call = client.fetch("get_machine_flash_options", {
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;
},
}));
};
export type SystemStorageOptions = SuccessData<"list_system_storage_devices">;
export type SystemStorageOptionsQuery = UseQueryResult<SystemStorageOptions>;
export const useSystemStorageOptions = (): SystemStorageOptionsQuery => {
const client = useApiClient();
return useQuery<SystemStorageOptions>(() => ({
queryKey: ["system", "storage_devices"],
queryFn: async () => {
const call = client.fetch("list_system_storage_devices", {});
const result = await call.result;
if (result.status === "error") {
// todo should we create some specific error types?
console.error("Error fetching clan details:", result.errors);
throw new Error(result.errors[0].message);
}
return result.data;
},
}));
};

View File

@@ -6,6 +6,7 @@ import {
Setter, Setter,
useContext, useContext,
} from "solid-js"; } from "solid-js";
import { createStore, SetStoreFunction, Store } from "solid-js/store";
export interface StepBase { export interface StepBase {
id: string; id: string;
@@ -13,24 +14,39 @@ export interface StepBase {
export type Step<ExtraFields = unknown> = StepBase & ExtraFields; export type Step<ExtraFields = unknown> = StepBase & ExtraFields;
export interface StepOptions<Id> { export interface StepOptions<Id, StoreType> {
initialStep: Id; initialStep: Id;
initialStoreData?: StoreType;
} }
export function createStepper< export function createStepper<
T extends readonly Step<Extra>[], T extends readonly Step<Extra>[],
StepId extends T[number]["id"], StepId extends T[number]["id"],
Extra = unknown, Extra = unknown,
>(s: { steps: T }, stepOpts: StepOptions<StepId>): StepperReturn<T> { StoreType extends Record<string, unknown> = Record<string, unknown>,
>(
s: { steps: T },
stepOpts: StepOptions<StepId, StoreType>,
): StepperReturn<T, T[number]["id"]> {
const [activeStep, setActiveStep] = createSignal<T[number]["id"]>( const [activeStep, setActiveStep] = createSignal<T[number]["id"]>(
stepOpts.initialStep, stepOpts.initialStep,
); );
const store: StoreTuple<StoreType> = createStore<StoreType>(
stepOpts.initialStoreData ?? ({} as StoreType),
);
/** /**
* Hooks to manage the current step in the workflow. * Hooks to manage the current step in the workflow.
* It provides the active step and a function to set the active step. * It provides the active step and a function to set the active step.
*/ */
return { return {
/**
* Usage store = getStepStore<MyStoreType>(stepper);
*
* TODO: Getting type inference working is tricky. Might fix this later.
*/
_store: store as unknown as never,
activeStep, activeStep,
setActiveStep, setActiveStep,
currentStep: () => { currentStep: () => {
@@ -73,10 +89,12 @@ export function createStepper<
}; };
} }
type StoreTuple<T> = [get: Store<T>, set: SetStoreFunction<T>];
export interface StepperReturn< export interface StepperReturn<
T extends readonly Step[], T extends readonly Step[],
StepId = T[number]["id"], StepId = T[number]["id"],
> { > {
_store: never;
activeStep: Accessor<StepId>; activeStep: Accessor<StepId>;
setActiveStep: Setter<StepId>; setActiveStep: Setter<StepId>;
currentStep: () => T[number]; currentStep: () => T[number];
@@ -125,3 +143,10 @@ export function StepperProvider<
export function defineSteps<T extends readonly StepBase[]>(steps: T) { export function defineSteps<T extends readonly StepBase[]>(steps: T) {
return steps; return steps;
} }
interface getStepStoreArg {
_store: never;
}
export function getStepStore<StoreType>(stepper: getStepStoreArg) {
return stepper._store as StoreTuple<StoreType>;
}

View File

@@ -7,6 +7,8 @@ import { Routes } from "@/src/routes";
import { Router } from "@solidjs/router"; import { Router } from "@solidjs/router";
import { Layout } from "@/src/routes/Layout"; import { Layout } from "@/src/routes/Layout";
import { SolidQueryDevtools } from "@tanstack/solid-query-devtools"; import { SolidQueryDevtools } from "@tanstack/solid-query-devtools";
import { ApiClientProvider } from "./hooks/ApiClient";
import { callApi } from "./hooks/api";
export const client = new QueryClient(); export const client = new QueryClient();
@@ -23,10 +25,12 @@ if (import.meta.env.DEV) {
render( render(
() => ( () => (
<ApiClientProvider client={{ fetch: callApi }}>
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
{import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />} {import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />}
<Router root={Layout}>{Routes}</Router> <Router root={Layout}>{Routes}</Router>
</QueryClientProvider> </QueryClientProvider>
</ApiClientProvider>
), ),
root!, root!,
); );

View File

@@ -1,14 +1,105 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid"; import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { InstallModal } from "./install"; import { InstallModal } from "./install";
import {
createMemoryHistory,
MemoryRouter,
RouteDefinition,
} from "@solidjs/router";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient";
import {
ApiCall,
OperationNames,
OperationResponse,
SuccessQuery,
} from "@/src/hooks/api";
const meta: Meta = { type ResultDataMap = {
[K in OperationNames]: SuccessQuery<K>["data"];
};
export const mockFetcher: Fetcher = <K extends OperationNames>(
name: K,
_args: unknown,
): ApiCall<K> => {
// TODO: Make this configurable for every story
const resultData: Partial<ResultDataMap> = {
get_machine_flash_options: {
keymaps: ["DE_de", "US_en"],
languages: ["en", "de"],
},
get_system_file: ["id_rsa.pub"],
list_system_storage_devices: {
blockdevices: [
{
name: "sda_bla_bla",
path: "/dev/sda",
},
{
name: "sdb_foo_foo",
path: "/dev/sdb",
},
] as SuccessQuery<"list_system_storage_devices">["data"]["blockdevices"],
},
};
return {
uuid: "mock",
cancel: () => Promise.resolve(),
result: new Promise((resolve) => {
setTimeout(() => {
resolve({
op_key: "1",
status: "success",
data: resultData[name],
} as OperationResponse<K>);
}, 1500);
}),
};
};
const meta: Meta<typeof InstallModal> = {
title: "workflows/install", title: "workflows/install",
component: InstallModal, component: InstallModal,
decorators: [
(Story: StoryObj, context: StoryContext) => {
const Routes: RouteDefinition[] = [
{
path: "/clans/:clanURI",
component: () => (
<div class="w-[600px]">
<Story />
</div>
),
},
];
const history = createMemoryHistory();
history.set({ value: "/clans/dGVzdA==", replace: true });
const queryClient = new QueryClient();
return (
<ApiClientProvider client={{ fetch: mockFetcher }}>
<QueryClientProvider client={queryClient}>
<MemoryRouter
root={(props) => {
console.debug("Rendering MemoryRouter root with props:", props);
return props.children;
}}
history={history}
>
{Routes}
</MemoryRouter>
</QueryClientProvider>
</ApiClientProvider>
);
},
],
}; };
export default meta; export default meta;
type Story = StoryObj; type Story = StoryObj<typeof InstallModal>;
export const Init: Story = { export const Init: Story = {
description: "Welcome step for the installation workflow", description: "Welcome step for the installation workflow",

View File

@@ -50,6 +50,14 @@ const steps = [
] as const; ] as const;
export type InstallSteps = typeof steps; export type InstallSteps = typeof steps;
export interface InstallStoreType {
flash: {
language: string;
keymap: string;
ssh_file: string;
device: string;
};
}
export const InstallModal = (props: InstallModalProps) => { export const InstallModal = (props: InstallModalProps) => {
const stepper = createStepper( const stepper = createStepper(

View File

@@ -1,8 +1,7 @@
import { defineSteps, Step, StepBase, useStepper } from "@/src/hooks/stepper"; import { defineSteps, useStepper } from "@/src/hooks/stepper";
import { InstallSteps } from "../install"; import { InstallSteps } from "../install";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button"; import { Button } from "@/src/components/Button/Button";
import { Divider } from "@/src/components/Divider/Divider";
import { StepLayout } from "../../Steps"; import { StepLayout } from "../../Steps";
const ChoiceLocalOrRemote = () => { const ChoiceLocalOrRemote = () => {
@@ -10,8 +9,8 @@ const ChoiceLocalOrRemote = () => {
return ( return (
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2"> <div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2">
<div class="flex gap-2 justify-between"> <div class="flex justify-between gap-2">
<div class="flex flex-col gap-1 px-1 justify-center"> <div class="flex flex-col justify-center gap-1 px-1">
<Typography <Typography
hierarchy="label" hierarchy="label"
size="xs" size="xs"
@@ -31,8 +30,8 @@ const ChoiceLocalOrRemote = () => {
</div> </div>
</div> </div>
<div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2"> <div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2">
<div class="flex gap-2 justify-between"> <div class="flex justify-between gap-2">
<div class="flex flex-col gap-1 px-1 justify-center"> <div class="flex flex-col justify-center gap-1 px-1">
<Typography <Typography
hierarchy="label" hierarchy="label"
size="xs" size="xs"
@@ -62,8 +61,8 @@ const ChoiceLocalInstaller = () => {
body={ body={
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2"> <div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2">
<div class="flex gap-2 justify-between"> <div class="flex justify-between gap-2">
<div class="flex flex-col gap-1 px-1 justify-center"> <div class="flex flex-col justify-center gap-1 px-1">
<Typography <Typography
hierarchy="label" hierarchy="label"
size="xs" size="xs"
@@ -83,8 +82,8 @@ const ChoiceLocalInstaller = () => {
</div> </div>
</div> </div>
<div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2"> <div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2">
<div class="flex gap-2 justify-between"> <div class="flex justify-between gap-2">
<div class="flex flex-col gap-1 px-1 justify-center"> <div class="flex flex-col justify-center gap-1 px-1">
<Typography <Typography
hierarchy="label" hierarchy="label"
size="xs" size="xs"

View File

@@ -1,4 +1,4 @@
import { useStepper } from "@/src/hooks/stepper"; import { getStepStore, useStepper } from "@/src/hooks/stepper";
import { import {
createForm, createForm,
getError, getError,
@@ -6,8 +6,7 @@ import {
valiForm, valiForm,
} from "@modular-forms/solid"; } from "@modular-forms/solid";
import * as v from "valibot"; import * as v from "valibot";
import { InstallSteps } from "../install"; import { InstallSteps, InstallStoreType } from "../install";
import { callApi } from "@/src/hooks/api";
import { Fieldset } from "@/src/components/Form/Fieldset"; import { Fieldset } from "@/src/components/Form/Fieldset";
import { HostFileInput } from "@/src/components/Form/HostFileInput"; import { HostFileInput } from "@/src/components/Form/HostFileInput";
import { Select } from "@/src/components/Select/Select"; import { Select } from "@/src/components/Select/Select";
@@ -17,6 +16,12 @@ import { Alert } from "@/src/components/Alert/Alert";
import { LoadingBar } from "@/src/components/LoadingBar/LoadingBar"; import { LoadingBar } from "@/src/components/LoadingBar/LoadingBar";
import { Button } from "@/src/components/Button/Button"; import { Button } from "@/src/components/Button/Button";
import Icon from "@/src/components/Icon/Icon"; import Icon from "@/src/components/Icon/Icon";
import {
useMachineFlashOptions,
useSystemStorageOptions,
} from "@/src/hooks/queries";
import { useClanURI } from "@/src/hooks/clan";
import { useApiClient } from "@/src/hooks/ApiClient";
const Prose = () => ( const Prose = () => (
<StepLayout <StepLayout
@@ -47,7 +52,7 @@ const Prose = () => (
</Typography> </Typography>
</div> </div>
</div> </div>
<div class="flex flex-col px-4 gap-4"> <div class="flex flex-col gap-4 px-4">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<Typography hierarchy="body" size="default" weight="bold"> <Typography hierarchy="body" size="default" weight="bold">
Let's walk through it. Let's walk through it.
@@ -101,15 +106,23 @@ const ConfigureImage = () => {
}); });
const stepSignal = useStepper<InstallSteps>(); const stepSignal = useStepper<InstallSteps>();
// TODO: push values to the parent form Store const [store, set] = getStepStore<InstallStoreType>(stepSignal);
const handleSubmit: SubmitHandler<ConfigureImageForm> = (values, event) => { const handleSubmit: SubmitHandler<ConfigureImageForm> = (values, event) => {
console.log("ISO creation submitted", values); // Push values to the store
// Here you would typically trigger the ISO creation process set("flash", (s) => ({
...s,
language: values.language,
keymap: values.keymap,
ssh_file: values.ssh_key,
}));
stepSignal.next(); stepSignal.next();
}; };
const client = useApiClient();
const onSelectFile = async () => { const onSelectFile = async () => {
const req = callApi("get_system_file", { const req = client.fetch("get_system_file", {
file_request: { file_request: {
mode: "select_folder", mode: "select_folder",
title: "Select a folder for you new Clan", title: "Select a folder for you new Clan",
@@ -131,6 +144,9 @@ const ConfigureImage = () => {
throw new Error("No data returned from api call"); throw new Error("No data returned from api call");
}; };
const currClan = useClanURI();
const optionsQuery = useMachineFlashOptions(currClan);
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<StepLayout <StepLayout
@@ -168,10 +184,19 @@ const ConfigureImage = () => {
label: "Language", label: "Language",
description: "Select your preferred language", description: "Select your preferred language",
}} }}
options={[ getOptions={async () => {
{ value: "en", label: "English" }, if (!optionsQuery.data) {
{ value: "fr", label: "Français" }, await optionsQuery.refetch();
]} }
return (optionsQuery.data?.languages ?? []).map(
(lang) => ({
// TODO: Pretty label ?
value: lang,
label: lang,
}),
);
}}
placeholder="Language" placeholder="Language"
name={field.name} name={field.name}
/> />
@@ -188,10 +213,19 @@ const ConfigureImage = () => {
label: "Keymap", label: "Keymap",
description: "Select your keyboard layout", description: "Select your keyboard layout",
}} }}
options={[ getOptions={async () => {
{ value: "EN_US", label: "QWERTY" }, if (!optionsQuery.data) {
{ value: "DE_DE", label: "QWERTZ" }, await optionsQuery.refetch();
]} }
return (optionsQuery.data?.keymaps ?? []).map(
(keymap) => ({
// TODO: Pretty label ?
value: keymap,
label: keymap,
}),
);
}}
placeholder="Keymap" placeholder="Keymap"
name={field.name} name={field.name}
/> />
@@ -226,9 +260,34 @@ const ChooseDisk = () => {
const [formStore, { Form, Field }] = createForm<ChooseDiskForm>({ const [formStore, { Form, Field }] = createForm<ChooseDiskForm>({
validate: valiForm(ChooseDiskSchema), validate: valiForm(ChooseDiskSchema),
}); });
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
const client = useApiClient();
const systemStorageQuery = useSystemStorageOptions();
const handleSubmit: SubmitHandler<ChooseDiskForm> = (values, event) => { const handleSubmit: SubmitHandler<ChooseDiskForm> = (values, event) => {
console.log("Disk selected", values); // Just for completeness, set the disk in the store
console.log("Flashing", store.flash);
set("flash", (s) => ({
...s,
device: values.disk,
}));
const call = client.fetch("run_machine_flash", {
system_config: {
keymap: store.flash.keymap,
language: store.flash.language,
ssh_keys_path: [store.flash.ssh_file],
},
disks: [
{
name: "main",
device: values.disk,
},
],
});
// TOOD: Pass the "call" Promise to the "progress step"
console.log("Flashing", store.flash);
// Here you would typically trigger the disk selection process // Here you would typically trigger the disk selection process
stepSignal.next(); stepSignal.next();
}; };
@@ -249,10 +308,18 @@ const ChooseDisk = () => {
label: "USB Stick", label: "USB Stick",
description: "Select the usb stick", description: "Select the usb stick",
}} }}
options={[ getOptions={async () => {
{ value: "1", label: "sda1" }, if (!systemStorageQuery.data) {
{ value: "2", label: "sdb2" }, await systemStorageQuery.refetch();
]} }
return (systemStorageQuery.data?.blockdevices ?? []).map(
(dev) => ({
value: dev.path,
label: dev.name,
}),
);
}}
placeholder="Choose Device" placeholder="Choose Device"
name={field.name} name={field.name}
/> />

View File

@@ -84,8 +84,8 @@ def flash_command(args: argparse.Namespace) -> None:
return return
run_machine_flash( run_machine_flash(
machine, machine=machine,
mode=opts.mode, mode=opts.mode, # type: ignore
disks=opts.disks, disks=opts.disks,
system_config=opts.system_config, system_config=opts.system_config,
dry_run=opts.dry_run, dry_run=opts.dry_run,

View File

@@ -56,6 +56,13 @@ def find_toplevel(top_level_files: list[str]) -> Path | None:
return None return None
def clan_core_flake() -> Path:
"""
Returns the path to the clan core flake.
"""
return module_root().parent.parent.parent
class TemplateType(Enum): class TemplateType(Enum):
CLAN = "clan" CLAN = "clan"
DISK = "disk" DISK = "disk"

View File

@@ -4,7 +4,7 @@ import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any from typing import Any, Literal
from clan_cli.facts.generate import generate_facts from clan_cli.facts.generate import generate_facts
from clan_cli.vars.generate import generate_vars from clan_cli.vars.generate import generate_vars
@@ -12,7 +12,9 @@ from clan_cli.vars.upload import populate_secret_vars
from clan_lib.api import API from clan_lib.api import API
from clan_lib.cmd import Log, RunOpts, cmd_with_root, run from clan_lib.cmd import Log, RunOpts, cmd_with_root, run
from clan_lib.dirs import clan_core_flake
from clan_lib.errors import ClanError from clan_lib.errors import ClanError
from clan_lib.flake.flake import Flake
from clan_lib.machines.machines import Machine from clan_lib.machines.machines import Machine
from clan_lib.nix import nix_shell from clan_lib.nix import nix_shell
@@ -35,17 +37,20 @@ class Disk:
device: str device: str
installer_machine = Machine(name="flash-installer", flake=Flake(str(clan_core_flake())))
# TODO: unify this with machine install # TODO: unify this with machine install
@API.register @API.register
def run_machine_flash( def run_machine_flash(
machine: Machine,
*,
mode: str,
disks: list[Disk], disks: list[Disk],
system_config: SystemConfig, system_config: SystemConfig,
dry_run: bool, # Optional parameters
write_efi_boot_entries: bool, machine: Machine = installer_machine,
debug: bool, mode: Literal["format", "mount"] = "format",
dry_run: bool = False,
write_efi_boot_entries: bool = False,
debug: bool = False,
extra_args: list[str] | None = None, extra_args: list[str] | None = None,
graphical: bool = False, graphical: bool = False,
) -> None: ) -> None: