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:
@@ -11,6 +11,10 @@
|
||||
@apply outline outline-1 outline-inv-2
|
||||
}
|
||||
|
||||
&[data-loading] {
|
||||
@apply cursor-wait;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-def-2;
|
||||
|
||||
@@ -38,6 +42,9 @@
|
||||
&[data-disabled] {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
&[data-loading] {
|
||||
@apply bg-inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.options_content {
|
||||
|
||||
@@ -47,25 +47,28 @@ export const Default: Story = {
|
||||
placeholder: "Select your pet",
|
||||
},
|
||||
};
|
||||
|
||||
// <Field name="language">
|
||||
// {(field, input) => (
|
||||
// <Select
|
||||
// required
|
||||
// label={{
|
||||
// label: "Language",
|
||||
// description: "Select your preferred language",
|
||||
// }}
|
||||
// options={[
|
||||
// { value: "en", label: "English" },
|
||||
// { value: "fr", label: "Français" },
|
||||
// ]}
|
||||
// placeholder="Language"
|
||||
// onChange={(opt) => {
|
||||
// setValue(formStore, "language", opt?.value || "");
|
||||
// }}
|
||||
// name={field.name}
|
||||
// validationState={field.error ? "invalid" : "valid"}
|
||||
// />
|
||||
// )}
|
||||
// </Field>
|
||||
export const Async: Story = {
|
||||
args: {
|
||||
required: true,
|
||||
label: {
|
||||
label: "Select your pet",
|
||||
description: "Choose your favorite pet from the list",
|
||||
},
|
||||
getOptions: async () => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve([
|
||||
{ value: "dog", label: "Doggy" },
|
||||
{ value: "cat", label: "Catty" },
|
||||
{ value: "fish", label: "Fishy" },
|
||||
{ value: "bird", label: "Birdy" },
|
||||
{ value: "hamster", label: "Hammy" },
|
||||
{ value: "snake", label: "Snakey" },
|
||||
{ value: "turtle", label: "Turtly" },
|
||||
]);
|
||||
}, 3000);
|
||||
});
|
||||
},
|
||||
placeholder: "Select your pet",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,18 +2,21 @@ import { Select as KSelect } from "@kobalte/core/select";
|
||||
import Icon from "../Icon/Icon";
|
||||
import { Orienter } from "../Form/Orienter";
|
||||
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 { Typography } from "../Typography/Typography";
|
||||
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
|
||||
name: string;
|
||||
placeholder?: string | undefined;
|
||||
options: Option[];
|
||||
value: string | undefined;
|
||||
error: string;
|
||||
required?: boolean | undefined;
|
||||
@@ -25,24 +28,57 @@ export interface SelectProps {
|
||||
// Custom props
|
||||
orientation?: "horizontal" | "vertical";
|
||||
label?: Omit<LabelProps, "labelComponent" | "descriptionComponent">;
|
||||
}
|
||||
} & (
|
||||
| {
|
||||
// Sync options
|
||||
options: Option[];
|
||||
getOptions?: never;
|
||||
}
|
||||
| {
|
||||
// Async options
|
||||
getOptions: () => Promise<Option[]>;
|
||||
options?: never;
|
||||
}
|
||||
);
|
||||
|
||||
export const Select = (props: SelectProps) => {
|
||||
const [root, selectProps] = splitProps(
|
||||
props,
|
||||
["name", "placeholder", "options", "required", "disabled"],
|
||||
["name", "placeholder", "required", "disabled"],
|
||||
["placeholder", "ref", "onInput", "onChange", "onBlur"],
|
||||
);
|
||||
|
||||
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(() => {
|
||||
setValue(props.options.find((option) => props.value === option.value));
|
||||
console.log("options,", options());
|
||||
setValue(options().find((option) => props.value === option.value));
|
||||
});
|
||||
|
||||
return (
|
||||
<KSelect
|
||||
{...root}
|
||||
options={options()}
|
||||
sameWidth={true}
|
||||
gutter={0}
|
||||
multiple={false}
|
||||
@@ -70,14 +106,29 @@ export const Select = (props: SelectProps) => {
|
||||
</KSelect.Item>
|
||||
)}
|
||||
placeholder={
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="bold"
|
||||
class="flex w-full items-center"
|
||||
<Show
|
||||
when={!loading()}
|
||||
fallback={
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="bold"
|
||||
class="flex w-full items-center"
|
||||
color="secondary"
|
||||
>
|
||||
Loading...
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
{props.placeholder}
|
||||
</Typography>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="bold"
|
||||
class="flex w-full items-center"
|
||||
>
|
||||
{props.placeholder}
|
||||
</Typography>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Orienter orientation={props.orientation || "horizontal"}>
|
||||
@@ -88,7 +139,10 @@ export const Select = (props: SelectProps) => {
|
||||
validationState={props.error ? "invalid" : "valid"}
|
||||
/>
|
||||
<KSelect.HiddenSelect {...selectProps} />
|
||||
<KSelect.Trigger class={cx(styles.trigger)}>
|
||||
<KSelect.Trigger
|
||||
class={cx(styles.trigger)}
|
||||
data-loading={loading() || undefined}
|
||||
>
|
||||
<KSelect.Value<Option>>
|
||||
{(state) => (
|
||||
<Typography
|
||||
@@ -101,7 +155,11 @@ export const Select = (props: SelectProps) => {
|
||||
</Typography>
|
||||
)}
|
||||
</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" />
|
||||
</KSelect.Icon>
|
||||
</KSelect.Trigger>
|
||||
|
||||
33
pkgs/clan-app/ui/src/hooks/ApiClient.tsx
Normal file
33
pkgs/clan-app/ui/src/hooks/ApiClient.tsx
Normal 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;
|
||||
};
|
||||
@@ -49,6 +49,11 @@ export interface ApiCall<K extends OperationNames> {
|
||||
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>(
|
||||
method: K,
|
||||
args: OperationArgs<K>,
|
||||
|
||||
@@ -47,7 +47,12 @@ export const navigateToMachine = (
|
||||
};
|
||||
|
||||
export const clanURIParam = (params: Params) => {
|
||||
return decodeBase64(params.clanURI);
|
||||
try {
|
||||
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());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { useApiClient } from "./ApiClient";
|
||||
|
||||
export type ClanDetails = SuccessData<"get_clan_details">;
|
||||
export type ClanDetailsWithURI = ClanDetails & { uri: string };
|
||||
@@ -12,11 +13,12 @@ export type MachineDetails = SuccessData<"get_machine_details">;
|
||||
export type MachinesQueryResult = UseQueryResult<ListMachines>;
|
||||
export type ClanListQueryResult = UseQueryResult<ClanDetailsWithURI>[];
|
||||
|
||||
export const useMachinesQuery = (clanURI: string) =>
|
||||
useQuery<ListMachines>(() => ({
|
||||
export const useMachinesQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<ListMachines>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machines"],
|
||||
queryFn: async () => {
|
||||
const api = callApi("list_machines", {
|
||||
const api = client.fetch("list_machines", {
|
||||
flake: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
@@ -29,12 +31,14 @@ export const useMachinesQuery = (clanURI: string) =>
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export const useMachineQuery = (clanURI: string, machineName: string) =>
|
||||
useQuery<Machine>(() => ({
|
||||
export const useMachineQuery = (clanURI: string, machineName: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<Machine>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
|
||||
queryFn: async () => {
|
||||
const call = callApi("get_machine", {
|
||||
const call = client.fetch("get_machine", {
|
||||
name: machineName,
|
||||
flake: {
|
||||
identifier: clanURI,
|
||||
@@ -49,12 +53,17 @@ export const useMachineQuery = (clanURI: string, machineName: string) =>
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export const useMachineDetailsQuery = (clanURI: string, machineName: string) =>
|
||||
useQuery<MachineDetails>(() => ({
|
||||
export const useMachineDetailsQuery = (
|
||||
clanURI: string,
|
||||
machineName: string,
|
||||
) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<MachineDetails>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName],
|
||||
queryFn: async () => {
|
||||
const call = callApi("get_machine_details", {
|
||||
const call = client.fetch("get_machine_details", {
|
||||
machine: {
|
||||
name: machineName,
|
||||
flake: {
|
||||
@@ -73,12 +82,14 @@ export const useMachineDetailsQuery = (clanURI: string, machineName: string) =>
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export const useClanDetailsQuery = (clanURI: string) =>
|
||||
useQuery<ClanDetails>(() => ({
|
||||
export const useClanDetailsQuery = (clanURI: string) => {
|
||||
const client = useApiClient();
|
||||
return useQuery<ClanDetails>(() => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "details"],
|
||||
queryFn: async () => {
|
||||
const call = callApi("get_clan_details", {
|
||||
const call = client.fetch("get_clan_details", {
|
||||
flake: {
|
||||
identifier: clanURI,
|
||||
},
|
||||
@@ -97,14 +108,16 @@ export const useClanDetailsQuery = (clanURI: string) =>
|
||||
};
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export const useClanListQuery = (clanURIs: string[]): ClanListQueryResult =>
|
||||
useQueries(() => ({
|
||||
export const useClanListQuery = (clanURIs: string[]): ClanListQueryResult => {
|
||||
const client = useApiClient();
|
||||
return useQueries(() => ({
|
||||
queries: clanURIs.map((clanURI) => ({
|
||||
queryKey: ["clans", encodeBase64(clanURI), "details"],
|
||||
enabled: !!clanURI,
|
||||
queryFn: async () => {
|
||||
const call = callApi("get_clan_details", {
|
||||
const call = client.fetch("get_clan_details", {
|
||||
flake: {
|
||||
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;
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Setter,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import { createStore, SetStoreFunction, Store } from "solid-js/store";
|
||||
|
||||
export interface StepBase {
|
||||
id: string;
|
||||
@@ -13,24 +14,39 @@ export interface StepBase {
|
||||
|
||||
export type Step<ExtraFields = unknown> = StepBase & ExtraFields;
|
||||
|
||||
export interface StepOptions<Id> {
|
||||
export interface StepOptions<Id, StoreType> {
|
||||
initialStep: Id;
|
||||
initialStoreData?: StoreType;
|
||||
}
|
||||
|
||||
export function createStepper<
|
||||
T extends readonly Step<Extra>[],
|
||||
StepId extends T[number]["id"],
|
||||
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"]>(
|
||||
stepOpts.initialStep,
|
||||
);
|
||||
|
||||
const store: StoreTuple<StoreType> = createStore<StoreType>(
|
||||
stepOpts.initialStoreData ?? ({} as StoreType),
|
||||
);
|
||||
|
||||
/**
|
||||
* Hooks to manage the current step in the workflow.
|
||||
* It provides the active step and a function to set the active step.
|
||||
*/
|
||||
return {
|
||||
/**
|
||||
* Usage store = getStepStore<MyStoreType>(stepper);
|
||||
*
|
||||
* TODO: Getting type inference working is tricky. Might fix this later.
|
||||
*/
|
||||
_store: store as unknown as never,
|
||||
activeStep,
|
||||
setActiveStep,
|
||||
currentStep: () => {
|
||||
@@ -73,10 +89,12 @@ export function createStepper<
|
||||
};
|
||||
}
|
||||
|
||||
type StoreTuple<T> = [get: Store<T>, set: SetStoreFunction<T>];
|
||||
export interface StepperReturn<
|
||||
T extends readonly Step[],
|
||||
StepId = T[number]["id"],
|
||||
> {
|
||||
_store: never;
|
||||
activeStep: Accessor<StepId>;
|
||||
setActiveStep: Setter<StepId>;
|
||||
currentStep: () => T[number];
|
||||
@@ -125,3 +143,10 @@ export function StepperProvider<
|
||||
export function defineSteps<T extends readonly StepBase[]>(steps: T) {
|
||||
return steps;
|
||||
}
|
||||
|
||||
interface getStepStoreArg {
|
||||
_store: never;
|
||||
}
|
||||
export function getStepStore<StoreType>(stepper: getStepStoreArg) {
|
||||
return stepper._store as StoreTuple<StoreType>;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Routes } from "@/src/routes";
|
||||
import { Router } from "@solidjs/router";
|
||||
import { Layout } from "@/src/routes/Layout";
|
||||
import { SolidQueryDevtools } from "@tanstack/solid-query-devtools";
|
||||
import { ApiClientProvider } from "./hooks/ApiClient";
|
||||
import { callApi } from "./hooks/api";
|
||||
|
||||
export const client = new QueryClient();
|
||||
|
||||
@@ -23,10 +25,12 @@ if (import.meta.env.DEV) {
|
||||
|
||||
render(
|
||||
() => (
|
||||
<QueryClientProvider client={client}>
|
||||
{import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />}
|
||||
<Router root={Layout}>{Routes}</Router>
|
||||
</QueryClientProvider>
|
||||
<ApiClientProvider client={{ fetch: callApi }}>
|
||||
<QueryClientProvider client={client}>
|
||||
{import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />}
|
||||
<Router root={Layout}>{Routes}</Router>
|
||||
</QueryClientProvider>
|
||||
</ApiClientProvider>
|
||||
),
|
||||
root!,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
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",
|
||||
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;
|
||||
|
||||
type Story = StoryObj;
|
||||
type Story = StoryObj<typeof InstallModal>;
|
||||
|
||||
export const Init: Story = {
|
||||
description: "Welcome step for the installation workflow",
|
||||
|
||||
@@ -50,6 +50,14 @@ const steps = [
|
||||
] as const;
|
||||
|
||||
export type InstallSteps = typeof steps;
|
||||
export interface InstallStoreType {
|
||||
flash: {
|
||||
language: string;
|
||||
keymap: string;
|
||||
ssh_file: string;
|
||||
device: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const InstallModal = (props: InstallModalProps) => {
|
||||
const stepper = createStepper(
|
||||
|
||||
@@ -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 { Typography } from "@/src/components/Typography/Typography";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { Divider } from "@/src/components/Divider/Divider";
|
||||
import { StepLayout } from "../../Steps";
|
||||
|
||||
const ChoiceLocalOrRemote = () => {
|
||||
@@ -10,8 +9,8 @@ const ChoiceLocalOrRemote = () => {
|
||||
return (
|
||||
<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 gap-2 justify-between">
|
||||
<div class="flex flex-col gap-1 px-1 justify-center">
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex flex-col justify-center gap-1 px-1">
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
@@ -31,8 +30,8 @@ const ChoiceLocalOrRemote = () => {
|
||||
</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 gap-2 justify-between">
|
||||
<div class="flex flex-col gap-1 px-1 justify-center">
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex flex-col justify-center gap-1 px-1">
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
@@ -62,8 +61,8 @@ const ChoiceLocalInstaller = () => {
|
||||
body={
|
||||
<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 gap-2 justify-between">
|
||||
<div class="flex flex-col gap-1 px-1 justify-center">
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex flex-col justify-center gap-1 px-1">
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
@@ -83,8 +82,8 @@ const ChoiceLocalInstaller = () => {
|
||||
</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 gap-2 justify-between">
|
||||
<div class="flex flex-col gap-1 px-1 justify-center">
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex flex-col justify-center gap-1 px-1">
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useStepper } from "@/src/hooks/stepper";
|
||||
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||
import {
|
||||
createForm,
|
||||
getError,
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
valiForm,
|
||||
} from "@modular-forms/solid";
|
||||
import * as v from "valibot";
|
||||
import { InstallSteps } from "../install";
|
||||
import { callApi } from "@/src/hooks/api";
|
||||
import { InstallSteps, InstallStoreType } from "../install";
|
||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
||||
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 { Button } from "@/src/components/Button/Button";
|
||||
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 = () => (
|
||||
<StepLayout
|
||||
@@ -47,7 +52,7 @@ const Prose = () => (
|
||||
</Typography>
|
||||
</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">
|
||||
<Typography hierarchy="body" size="default" weight="bold">
|
||||
Let's walk through it.
|
||||
@@ -101,15 +106,23 @@ const ConfigureImage = () => {
|
||||
});
|
||||
const stepSignal = useStepper<InstallSteps>();
|
||||
|
||||
// TODO: push values to the parent form Store
|
||||
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
|
||||
|
||||
const handleSubmit: SubmitHandler<ConfigureImageForm> = (values, event) => {
|
||||
console.log("ISO creation submitted", values);
|
||||
// Here you would typically trigger the ISO creation process
|
||||
// Push values to the store
|
||||
set("flash", (s) => ({
|
||||
...s,
|
||||
language: values.language,
|
||||
keymap: values.keymap,
|
||||
ssh_file: values.ssh_key,
|
||||
}));
|
||||
|
||||
stepSignal.next();
|
||||
};
|
||||
|
||||
const client = useApiClient();
|
||||
const onSelectFile = async () => {
|
||||
const req = callApi("get_system_file", {
|
||||
const req = client.fetch("get_system_file", {
|
||||
file_request: {
|
||||
mode: "select_folder",
|
||||
title: "Select a folder for you new Clan",
|
||||
@@ -131,6 +144,9 @@ const ConfigureImage = () => {
|
||||
throw new Error("No data returned from api call");
|
||||
};
|
||||
|
||||
const currClan = useClanURI();
|
||||
const optionsQuery = useMachineFlashOptions(currClan);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<StepLayout
|
||||
@@ -168,10 +184,19 @@ const ConfigureImage = () => {
|
||||
label: "Language",
|
||||
description: "Select your preferred language",
|
||||
}}
|
||||
options={[
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "Français" },
|
||||
]}
|
||||
getOptions={async () => {
|
||||
if (!optionsQuery.data) {
|
||||
await optionsQuery.refetch();
|
||||
}
|
||||
|
||||
return (optionsQuery.data?.languages ?? []).map(
|
||||
(lang) => ({
|
||||
// TODO: Pretty label ?
|
||||
value: lang,
|
||||
label: lang,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
placeholder="Language"
|
||||
name={field.name}
|
||||
/>
|
||||
@@ -188,10 +213,19 @@ const ConfigureImage = () => {
|
||||
label: "Keymap",
|
||||
description: "Select your keyboard layout",
|
||||
}}
|
||||
options={[
|
||||
{ value: "EN_US", label: "QWERTY" },
|
||||
{ value: "DE_DE", label: "QWERTZ" },
|
||||
]}
|
||||
getOptions={async () => {
|
||||
if (!optionsQuery.data) {
|
||||
await optionsQuery.refetch();
|
||||
}
|
||||
|
||||
return (optionsQuery.data?.keymaps ?? []).map(
|
||||
(keymap) => ({
|
||||
// TODO: Pretty label ?
|
||||
value: keymap,
|
||||
label: keymap,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
placeholder="Keymap"
|
||||
name={field.name}
|
||||
/>
|
||||
@@ -226,9 +260,34 @@ const ChooseDisk = () => {
|
||||
const [formStore, { Form, Field }] = createForm<ChooseDiskForm>({
|
||||
validate: valiForm(ChooseDiskSchema),
|
||||
});
|
||||
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
|
||||
|
||||
const client = useApiClient();
|
||||
const systemStorageQuery = useSystemStorageOptions();
|
||||
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
|
||||
stepSignal.next();
|
||||
};
|
||||
@@ -249,10 +308,18 @@ const ChooseDisk = () => {
|
||||
label: "USB Stick",
|
||||
description: "Select the usb stick",
|
||||
}}
|
||||
options={[
|
||||
{ value: "1", label: "sda1" },
|
||||
{ value: "2", label: "sdb2" },
|
||||
]}
|
||||
getOptions={async () => {
|
||||
if (!systemStorageQuery.data) {
|
||||
await systemStorageQuery.refetch();
|
||||
}
|
||||
|
||||
return (systemStorageQuery.data?.blockdevices ?? []).map(
|
||||
(dev) => ({
|
||||
value: dev.path,
|
||||
label: dev.name,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
placeholder="Choose Device"
|
||||
name={field.name}
|
||||
/>
|
||||
|
||||
@@ -84,8 +84,8 @@ def flash_command(args: argparse.Namespace) -> None:
|
||||
return
|
||||
|
||||
run_machine_flash(
|
||||
machine,
|
||||
mode=opts.mode,
|
||||
machine=machine,
|
||||
mode=opts.mode, # type: ignore
|
||||
disks=opts.disks,
|
||||
system_config=opts.system_config,
|
||||
dry_run=opts.dry_run,
|
||||
|
||||
@@ -56,6 +56,13 @@ def find_toplevel(top_level_files: list[str]) -> Path | 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):
|
||||
CLAN = "clan"
|
||||
DISK = "disk"
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from clan_cli.facts.generate import generate_facts
|
||||
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.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.flake.flake import Flake
|
||||
from clan_lib.machines.machines import Machine
|
||||
from clan_lib.nix import nix_shell
|
||||
|
||||
@@ -35,17 +37,20 @@ class Disk:
|
||||
device: str
|
||||
|
||||
|
||||
installer_machine = Machine(name="flash-installer", flake=Flake(str(clan_core_flake())))
|
||||
|
||||
|
||||
# TODO: unify this with machine install
|
||||
@API.register
|
||||
def run_machine_flash(
|
||||
machine: Machine,
|
||||
*,
|
||||
mode: str,
|
||||
disks: list[Disk],
|
||||
system_config: SystemConfig,
|
||||
dry_run: bool,
|
||||
write_efi_boot_entries: bool,
|
||||
debug: bool,
|
||||
# Optional parameters
|
||||
machine: Machine = installer_machine,
|
||||
mode: Literal["format", "mount"] = "format",
|
||||
dry_run: bool = False,
|
||||
write_efi_boot_entries: bool = False,
|
||||
debug: bool = False,
|
||||
extra_args: list[str] | None = None,
|
||||
graphical: bool = False,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user