Merge pull request 'UI: extend components to prepare install workflows' (#4576) from install-ui into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4576
This commit is contained in:
@@ -4,6 +4,7 @@ import { Separator, SeparatorRootProps } from "@kobalte/core/separator";
|
|||||||
|
|
||||||
export interface DividerProps extends Pick<SeparatorRootProps, "orientation"> {
|
export interface DividerProps extends Pick<SeparatorRootProps, "orientation"> {
|
||||||
inverted?: boolean;
|
inverted?: boolean;
|
||||||
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Divider = (props: DividerProps) => {
|
export const Divider = (props: DividerProps) => {
|
||||||
@@ -11,7 +12,7 @@ export const Divider = (props: DividerProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Separator
|
<Separator
|
||||||
class={cx({ inverted: inverted })}
|
class={cx({ inverted: inverted }, props?.class)}
|
||||||
orientation={props.orientation}
|
orientation={props.orientation}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type HostFileInputProps = FieldProps &
|
|||||||
TextFieldRootProps & {
|
TextFieldRootProps & {
|
||||||
onSelectFile: () => Promise<string>;
|
onSelectFile: () => Promise<string>;
|
||||||
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
|
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
|
||||||
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HostFileInput = (props: HostFileInputProps) => {
|
export const HostFileInput = (props: HostFileInputProps) => {
|
||||||
@@ -79,7 +80,7 @@ export const HostFileInput = (props: HostFileInputProps) => {
|
|||||||
: styles.horizontal_button,
|
: styles.horizontal_button,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
No Selection
|
{props.placeholder || "No Selection"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
div.form-label {
|
div.form-label {
|
||||||
@apply flex flex-col gap-1 w-full;
|
@apply flex flex-col gap-1 w-full;
|
||||||
|
|
||||||
|
& > span,
|
||||||
& > label,
|
& > label,
|
||||||
& > div {
|
& > div {
|
||||||
@apply w-full;
|
@apply w-full;
|
||||||
@@ -8,12 +9,14 @@ div.form-label {
|
|||||||
@apply leading-none;
|
@apply leading-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > span,
|
||||||
& > label {
|
& > label {
|
||||||
@apply flex items-center gap-1;
|
@apply flex items-center gap-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > span[data-required]:not(span[data-readonly]),
|
||||||
& > label[data-required]:not(label[data-readonly]) {
|
& > label[data-required]:not(label[data-readonly]) {
|
||||||
span.typography::after {
|
.typography::after {
|
||||||
@apply fg-def-4 ml-1;
|
@apply fg-def-4 ml-1;
|
||||||
|
|
||||||
content: "*";
|
content: "*";
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Icon from "@/src/components/Icon/Icon";
|
|||||||
import { TextField } from "@kobalte/core/text-field";
|
import { TextField } from "@kobalte/core/text-field";
|
||||||
import { Checkbox } from "@kobalte/core/checkbox";
|
import { Checkbox } from "@kobalte/core/checkbox";
|
||||||
import { Combobox } from "@kobalte/core/combobox";
|
import { Combobox } from "@kobalte/core/combobox";
|
||||||
|
import { Select } from "@kobalte/core/select";
|
||||||
import "./Label.css";
|
import "./Label.css";
|
||||||
|
|
||||||
export type Size = "default" | "s";
|
export type Size = "default" | "s";
|
||||||
@@ -12,11 +13,14 @@ export type Size = "default" | "s";
|
|||||||
export type LabelComponent =
|
export type LabelComponent =
|
||||||
| typeof TextField.Label
|
| typeof TextField.Label
|
||||||
| typeof Checkbox.Label
|
| typeof Checkbox.Label
|
||||||
| typeof Combobox.Label;
|
| typeof Combobox.Label
|
||||||
|
| typeof Select.Label;
|
||||||
|
|
||||||
export type DescriptionComponent =
|
export type DescriptionComponent =
|
||||||
| typeof TextField.Description
|
| typeof TextField.Description
|
||||||
| typeof Checkbox.Description
|
| typeof Checkbox.Description
|
||||||
| typeof Combobox.Description;
|
| typeof Combobox.Description
|
||||||
|
| typeof Select.Description;
|
||||||
|
|
||||||
export interface LabelProps {
|
export interface LabelProps {
|
||||||
labelComponent: LabelComponent;
|
labelComponent: LabelComponent;
|
||||||
|
|||||||
@@ -4,14 +4,17 @@
|
|||||||
|
|
||||||
/* todo replace with a theme() color */
|
/* todo replace with a theme() color */
|
||||||
box-shadow: 0.1875rem 0.1875rem 0 0 rgba(145, 172, 175, 0.32);
|
box-shadow: 0.1875rem 0.1875rem 0 0 rgba(145, 172, 175, 0.32);
|
||||||
|
@apply border border-def-3 rounded-bl-md rounded-br-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal_header {
|
.modal_header {
|
||||||
@apply flex items-center justify-center;
|
@apply flex items-center justify-center;
|
||||||
@apply w-full px-2 py-1.5;
|
@apply w-full px-2 py-1.5;
|
||||||
@apply bg-def-3;
|
@apply bg-def-3;
|
||||||
@apply border border-def-2 rounded-tl-md rounded-tr-md;
|
@apply rounded-tl-md rounded-tr-md;
|
||||||
@apply border-b-def-3;
|
/* @apply border-b-def-3; */
|
||||||
|
|
||||||
|
border-bottom: solid 1px theme(colors.border.def.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal_title {
|
.modal_title {
|
||||||
@@ -19,6 +22,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal_body {
|
.modal_body {
|
||||||
@apply p-6 bg-def-1;
|
@apply rounded-md p-6 pt-4 bg-def-1;
|
||||||
@apply border border-def-2 rounded-bl-md rounded-br-md;
|
}
|
||||||
|
|
||||||
|
.header_divider {
|
||||||
|
@apply bg-def-3 h-[6px] border-def-2 border-t-[1px];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, JSX } from "solid-js";
|
import { createSignal, JSX, Show } from "solid-js";
|
||||||
import { Dialog as KDialog } from "@kobalte/core/dialog";
|
import { Dialog as KDialog } from "@kobalte/core/dialog";
|
||||||
import styles from "./Modal.module.css";
|
import styles from "./Modal.module.css";
|
||||||
import { Typography } from "../Typography/Typography";
|
import { Typography } from "../Typography/Typography";
|
||||||
@@ -16,6 +16,7 @@ export interface ModalProps {
|
|||||||
children: (ctx: ModalContext) => JSX.Element;
|
children: (ctx: ModalContext) => JSX.Element;
|
||||||
mount?: Node;
|
mount?: Node;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
metaHeader?: () => JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Modal = (props: ModalProps) => {
|
export const Modal = (props: ModalProps) => {
|
||||||
@@ -43,6 +44,14 @@ export const Modal = (props: ModalProps) => {
|
|||||||
<Icon icon="Close" size="0.75rem" />
|
<Icon icon="Close" size="0.75rem" />
|
||||||
</KDialog.CloseButton>
|
</KDialog.CloseButton>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={props.metaHeader?.()}>
|
||||||
|
{(metaHeader) => (
|
||||||
|
<>
|
||||||
|
{metaHeader()}
|
||||||
|
<div class={styles.header_divider} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<div class={styles.modal_body}>
|
<div class={styles.modal_body}>
|
||||||
{props.children({
|
{props.children({
|
||||||
close: () => {
|
close: () => {
|
||||||
|
|||||||
88
pkgs/clan-app/ui/src/components/Select/Select.module.css
Normal file
88
pkgs/clan-app/ui/src/components/Select/Select.module.css
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
.trigger {
|
||||||
|
@apply bg-def-1 flex flex-grow justify-between items-center gap-2 h-7 px-2 py-1;
|
||||||
|
@apply rounded-[4px];
|
||||||
|
|
||||||
|
&[data-expanded] {
|
||||||
|
@apply outline-def-2 outline-1 outline;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-highlighted] {
|
||||||
|
@apply outline outline-1 outline-inv-2
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-def-2;
|
||||||
|
|
||||||
|
& .icon {
|
||||||
|
@apply bg-def-1 text-fg-def-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
@apply bg-def-4;
|
||||||
|
|
||||||
|
& .icon {
|
||||||
|
@apply bg-inv-4 text-fg-inv-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@apply outline-def-2 outline-1 outline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
@apply bg-def-2 rounded-sm;
|
||||||
|
@apply flex items-center justify-center h-[14px] w-[14px] p-[2px];
|
||||||
|
&[data-disabled] {
|
||||||
|
@apply cursor-not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.options_content {
|
||||||
|
@apply bg-def-1 px-1 py-3 rounded-[4px] -mt-8 pt-10 -mx-1;
|
||||||
|
@apply outline-def-2 outline-1 outline;
|
||||||
|
|
||||||
|
transform-origin: var(--kb-popper-content-transform-origin);
|
||||||
|
&[data-expanded] {
|
||||||
|
animation: overlayShow 250ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Option elements (typically <li>) */
|
||||||
|
& [role="option"] {
|
||||||
|
@apply px-1 py-2 rounded-sm flex items-center gap-1 flex-shrink-0 ;
|
||||||
|
|
||||||
|
&[data-highlighted],
|
||||||
|
&:focus-visible {
|
||||||
|
@apply outline outline-1 outline-inv-2
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-def-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
@apply bg-def-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
& [role="listbox"] {
|
||||||
|
&:focus-visible {
|
||||||
|
@apply outline-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes overlayShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleY(0.94);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
pkgs/clan-app/ui/src/components/Select/Select.stories.tsx
Normal file
71
pkgs/clan-app/ui/src/components/Select/Select.stories.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { TagProps } from "@/src/components/Tag/Tag";
|
||||||
|
import { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
|
||||||
|
import { Select, SelectProps } from "./Select";
|
||||||
|
import { Fieldset } from "../Form/Fieldset";
|
||||||
|
|
||||||
|
// const meta: Meta<SelectProps> = {
|
||||||
|
// title: "Components/Select",
|
||||||
|
// component: Select,
|
||||||
|
// };
|
||||||
|
const meta = {
|
||||||
|
title: "Components/Form/Select",
|
||||||
|
component: Select,
|
||||||
|
decorators: [
|
||||||
|
(Story: StoryObj, context: StoryContext<SelectProps>) => {
|
||||||
|
return (
|
||||||
|
<div class={`w-[600px]`}>
|
||||||
|
<Fieldset>
|
||||||
|
<Story />
|
||||||
|
</Fieldset>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies Meta<SelectProps>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<TagProps>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
required: true,
|
||||||
|
label: {
|
||||||
|
label: "Select your pet",
|
||||||
|
description: "Choose your favorite pet from the list",
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{ 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" },
|
||||||
|
],
|
||||||
|
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>
|
||||||
118
pkgs/clan-app/ui/src/components/Select/Select.tsx
Normal file
118
pkgs/clan-app/ui/src/components/Select/Select.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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 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 SelectProps {
|
||||||
|
// Kobalte Select props, for modular forms
|
||||||
|
name: string;
|
||||||
|
placeholder?: string | undefined;
|
||||||
|
options: Option[];
|
||||||
|
value: string | undefined;
|
||||||
|
error: string;
|
||||||
|
required?: boolean | undefined;
|
||||||
|
disabled?: boolean | undefined;
|
||||||
|
ref: (element: HTMLSelectElement) => void;
|
||||||
|
onInput: JSX.EventHandler<HTMLSelectElement, InputEvent>;
|
||||||
|
onChange: JSX.EventHandler<HTMLSelectElement, Event>;
|
||||||
|
onBlur: JSX.EventHandler<HTMLSelectElement, FocusEvent>;
|
||||||
|
// Custom props
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
label?: Omit<LabelProps, "labelComponent" | "descriptionComponent">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select = (props: SelectProps) => {
|
||||||
|
const [root, selectProps] = splitProps(
|
||||||
|
props,
|
||||||
|
["name", "placeholder", "options", "required", "disabled"],
|
||||||
|
["placeholder", "ref", "onInput", "onChange", "onBlur"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [getValue, setValue] = createSignal<Option>();
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setValue(props.options.find((option) => props.value === option.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KSelect
|
||||||
|
{...root}
|
||||||
|
sameWidth={true}
|
||||||
|
gutter={0}
|
||||||
|
multiple={false}
|
||||||
|
value={getValue()}
|
||||||
|
onChange={setValue}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
optionDisabled="disabled"
|
||||||
|
validationState={props.error ? "invalid" : "valid"}
|
||||||
|
itemComponent={(props) => (
|
||||||
|
<KSelect.Item item={props.item} class="flex gap-1 p-2">
|
||||||
|
<KSelect.ItemIndicator>
|
||||||
|
<Icon icon="Checkmark" />
|
||||||
|
</KSelect.ItemIndicator>
|
||||||
|
<KSelect.ItemLabel>
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="xs"
|
||||||
|
weight="bold"
|
||||||
|
class="flex w-full items-center"
|
||||||
|
>
|
||||||
|
{props.item.rawValue.label}
|
||||||
|
</Typography>
|
||||||
|
</KSelect.ItemLabel>
|
||||||
|
</KSelect.Item>
|
||||||
|
)}
|
||||||
|
placeholder={
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="xs"
|
||||||
|
weight="bold"
|
||||||
|
class="flex w-full items-center"
|
||||||
|
>
|
||||||
|
{props.placeholder}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Orienter orientation={props.orientation || "horizontal"}>
|
||||||
|
<Label
|
||||||
|
{...props.label}
|
||||||
|
labelComponent={KSelect.Label}
|
||||||
|
descriptionComponent={KSelect.Description}
|
||||||
|
validationState={props.error ? "invalid" : "valid"}
|
||||||
|
/>
|
||||||
|
<KSelect.HiddenSelect {...selectProps} />
|
||||||
|
<KSelect.Trigger class={cx(styles.trigger)}>
|
||||||
|
<KSelect.Value<Option>>
|
||||||
|
{(state) => (
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="xs"
|
||||||
|
weight="bold"
|
||||||
|
class="flex w-full items-center"
|
||||||
|
>
|
||||||
|
{state.selectedOption().label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</KSelect.Value>
|
||||||
|
<KSelect.Icon as="button" class={styles.icon}>
|
||||||
|
<Icon icon="Expand" color="inherit" />
|
||||||
|
</KSelect.Icon>
|
||||||
|
</KSelect.Trigger>
|
||||||
|
</Orienter>
|
||||||
|
<KSelect.Portal>
|
||||||
|
<KSelect.Content class={styles.options_content}>
|
||||||
|
<KSelect.Listbox />
|
||||||
|
</KSelect.Content>
|
||||||
|
</KSelect.Portal>
|
||||||
|
{/* TODO: Display error next to the problem */}
|
||||||
|
{/* <KSelect.ErrorMessage>{props.error}</KSelect.ErrorMessage> */}
|
||||||
|
</KSelect>
|
||||||
|
);
|
||||||
|
};
|
||||||
120
pkgs/clan-app/ui/src/hooks/stepper.tsx
Normal file
120
pkgs/clan-app/ui/src/hooks/stepper.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
Accessor,
|
||||||
|
createContext,
|
||||||
|
createSignal,
|
||||||
|
JSX,
|
||||||
|
Setter,
|
||||||
|
useContext,
|
||||||
|
} from "solid-js";
|
||||||
|
|
||||||
|
export interface StepBase {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Step<ExtraFields = unknown> = StepBase & ExtraFields;
|
||||||
|
|
||||||
|
export interface StepOptions<Id> {
|
||||||
|
initialStep: Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStepper<
|
||||||
|
T extends readonly Step<Extra>[],
|
||||||
|
StepId extends T[number]["id"],
|
||||||
|
Extra = unknown,
|
||||||
|
>(s: { steps: T }, stepOpts: StepOptions<StepId>): StepperReturn<T> {
|
||||||
|
const [activeStep, setActiveStep] = createSignal<T[number]["id"]>(
|
||||||
|
stepOpts.initialStep,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hooks to manage the current step in the workflow.
|
||||||
|
* It provides the active step and a function to set the active step.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
activeStep,
|
||||||
|
setActiveStep,
|
||||||
|
currentStep: () => {
|
||||||
|
const curr = s.steps.find((step) => step.id === activeStep());
|
||||||
|
if (!curr) {
|
||||||
|
throw new Error(`Step with id ${activeStep()} not found`);
|
||||||
|
}
|
||||||
|
return curr;
|
||||||
|
},
|
||||||
|
next: () => {
|
||||||
|
const currentIndex = s.steps.findIndex(
|
||||||
|
(step) => step.id === activeStep(),
|
||||||
|
);
|
||||||
|
if (currentIndex === -1 || currentIndex === s.steps.length - 1) {
|
||||||
|
throw new Error("No next step available");
|
||||||
|
}
|
||||||
|
setActiveStep(s.steps[currentIndex + 1].id);
|
||||||
|
},
|
||||||
|
previous: () => {
|
||||||
|
const currentIndex = s.steps.findIndex(
|
||||||
|
(step) => step.id === activeStep(),
|
||||||
|
);
|
||||||
|
if (currentIndex <= 0) {
|
||||||
|
throw new Error("No previous step available");
|
||||||
|
}
|
||||||
|
setActiveStep(s.steps[currentIndex - 1].id);
|
||||||
|
},
|
||||||
|
hasPrevious: () => {
|
||||||
|
const currentIndex = s.steps.findIndex(
|
||||||
|
(step) => step.id === activeStep(),
|
||||||
|
);
|
||||||
|
return currentIndex > 0;
|
||||||
|
},
|
||||||
|
hasNext: () => {
|
||||||
|
const currentIndex = s.steps.findIndex(
|
||||||
|
(step) => step.id === activeStep(),
|
||||||
|
);
|
||||||
|
return currentIndex >= 0 && currentIndex < s.steps.length - 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepperReturn<
|
||||||
|
T extends readonly Step[],
|
||||||
|
StepId = T[number]["id"],
|
||||||
|
> {
|
||||||
|
activeStep: Accessor<StepId>;
|
||||||
|
setActiveStep: Setter<StepId>;
|
||||||
|
currentStep: () => T[number];
|
||||||
|
next: () => void;
|
||||||
|
previous: () => void;
|
||||||
|
hasPrevious: () => boolean;
|
||||||
|
hasNext: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepperContext = createContext<unknown>(); // Concrete type will be provided by the provider
|
||||||
|
|
||||||
|
// Default assignment to "never" forces users to specify the type when using the hook, otherwise the return type will be `never`.
|
||||||
|
export function useStepper<T extends readonly Step[] = never>() {
|
||||||
|
const ctx = useContext(StepperContext);
|
||||||
|
if (!ctx) throw new Error("useStepper must be used inside StepperProvider");
|
||||||
|
return ctx as T extends never ? never : StepperReturn<T, T[number]["id"]>; // type casting required due to context limitations
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderProps<T extends readonly Step[], StepId> {
|
||||||
|
stepper: StepperReturn<T, StepId>;
|
||||||
|
children: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderProps<
|
||||||
|
T extends readonly Step[],
|
||||||
|
StepId extends T[number]["id"],
|
||||||
|
> {
|
||||||
|
stepper: StepperReturn<T, StepId>;
|
||||||
|
children: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepperProvider<
|
||||||
|
T extends readonly Step[],
|
||||||
|
StepId extends T[number]["id"],
|
||||||
|
>(props: ProviderProps<T, StepId>) {
|
||||||
|
return (
|
||||||
|
<StepperContext.Provider value={props.stepper}>
|
||||||
|
{props.children}
|
||||||
|
</StepperContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
pkgs/clan-app/ui/src/workflows/Install/Install.stories.tsx
Normal file
18
pkgs/clan-app/ui/src/workflows/Install/Install.stories.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
import { InstallModal } from "./install";
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: "workflows/install",
|
||||||
|
component: InstallModal,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
machineName: "Test Machine",
|
||||||
|
initialStep: "create:iso-1",
|
||||||
|
},
|
||||||
|
};
|
||||||
81
pkgs/clan-app/ui/src/workflows/Install/install.tsx
Normal file
81
pkgs/clan-app/ui/src/workflows/Install/install.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Modal } from "@/src/components/Modal/Modal";
|
||||||
|
import {
|
||||||
|
createStepper,
|
||||||
|
StepperProvider,
|
||||||
|
useStepper,
|
||||||
|
} from "@/src/hooks/stepper";
|
||||||
|
import { createForm, FieldValues, SubmitHandler } from "@modular-forms/solid";
|
||||||
|
import { Show } from "solid-js";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
|
import { InitialStep } from "./steps/Initial";
|
||||||
|
import { createInstallerSteps } from "./steps/createInstaller";
|
||||||
|
import { installSteps } from "./steps/installSteps";
|
||||||
|
|
||||||
|
interface InstallForm extends FieldValues {
|
||||||
|
data_from_step_1: string;
|
||||||
|
data_from_step_2?: string;
|
||||||
|
data_from_step_3?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InstallStepper = () => {
|
||||||
|
const stepSignal = useStepper<InstallSteps>();
|
||||||
|
|
||||||
|
const [formStore, { Form, Field, FieldArray }] = createForm<InstallForm>();
|
||||||
|
|
||||||
|
const handleSubmit: SubmitHandler<InstallForm> = (values, event) => {
|
||||||
|
console.log("Installation started (submit)", values);
|
||||||
|
stepSignal.setActiveStep("install:progress");
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<div class="gap-6">
|
||||||
|
<Dynamic
|
||||||
|
component={stepSignal.currentStep().content}
|
||||||
|
machineName={"karl"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface InstallModalProps {
|
||||||
|
machineName: string;
|
||||||
|
initialStep?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = [InitialStep, ...createInstallerSteps, ...installSteps] as const;
|
||||||
|
|
||||||
|
export type InstallSteps = typeof steps;
|
||||||
|
|
||||||
|
export const InstallModal = (props: InstallModalProps) => {
|
||||||
|
const stepper = createStepper(
|
||||||
|
{
|
||||||
|
steps,
|
||||||
|
},
|
||||||
|
{ initialStep: "init" },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepperProvider stepper={stepper}>
|
||||||
|
<Modal
|
||||||
|
title="Install machine"
|
||||||
|
onClose={() => {
|
||||||
|
console.log("Install aborted");
|
||||||
|
}}
|
||||||
|
metaHeader={() => {
|
||||||
|
// @ts-expect-error some steps might not have a title
|
||||||
|
const HeaderComponent = stepper.currentStep()?.title;
|
||||||
|
return (
|
||||||
|
<Show when={HeaderComponent}>
|
||||||
|
{(C) => (
|
||||||
|
<Dynamic component={C()} machineName={props.machineName} />
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(ctx) => <InstallStepper />}
|
||||||
|
</Modal>
|
||||||
|
</StepperProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
64
pkgs/clan-app/ui/src/workflows/Install/steps/Initial.tsx
Normal file
64
pkgs/clan-app/ui/src/workflows/Install/steps/Initial.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { 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";
|
||||||
|
|
||||||
|
const InitialChoice = () => {
|
||||||
|
const stepSignal = useStepper<InstallSteps>();
|
||||||
|
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">
|
||||||
|
<div class="flex flex-col gap-1 px-1">
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
size="xs"
|
||||||
|
weight="bold"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Remote setup
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="xxs"
|
||||||
|
weight="normal"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
Is your machine currently online? Does it have an IP-address, can
|
||||||
|
you SSH into it? And does it support Kexec?
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
ghost
|
||||||
|
hierarchy="secondary"
|
||||||
|
icon="CaretRight"
|
||||||
|
onClick={() => stepSignal.setActiveStep("install:machine-0")}
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
<Divider orientation="horizontal" class="bg-def-3" />
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<Typography hierarchy="label" size="xs" weight="bold">
|
||||||
|
I don't have an installer, yet
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
ghost
|
||||||
|
hierarchy="secondary"
|
||||||
|
endIcon="Flash"
|
||||||
|
type="button"
|
||||||
|
onClick={() => stepSignal.setActiveStep("create:iso-0")}
|
||||||
|
>
|
||||||
|
Create USB Installer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InitialStep = {
|
||||||
|
id: "init",
|
||||||
|
content: InitialChoice,
|
||||||
|
};
|
||||||
206
pkgs/clan-app/ui/src/workflows/Install/steps/createInstaller.tsx
Normal file
206
pkgs/clan-app/ui/src/workflows/Install/steps/createInstaller.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useStepper } from "@/src/hooks/stepper";
|
||||||
|
import {
|
||||||
|
createForm,
|
||||||
|
getError,
|
||||||
|
SubmitHandler,
|
||||||
|
valiForm,
|
||||||
|
} from "@modular-forms/solid";
|
||||||
|
import * as v from "valibot";
|
||||||
|
import { InstallSteps } from "../install";
|
||||||
|
import { callApi } from "@/src/hooks/api";
|
||||||
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
|
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
||||||
|
import { Select } from "@/src/components/Select/Select";
|
||||||
|
import { BackButton, NextButton, StepFooter, StepLayout } from "../../Steps";
|
||||||
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
|
||||||
|
const CreateHeader = (props: { machineName: string }) => {
|
||||||
|
return (
|
||||||
|
<div class="px-6 py-2">
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
size="default"
|
||||||
|
family="mono"
|
||||||
|
weight="medium"
|
||||||
|
>
|
||||||
|
Create installer
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreateFlashSchema = v.object({
|
||||||
|
ssh_key: v.pipe(
|
||||||
|
v.string("Please select a key."),
|
||||||
|
v.nonEmpty("Please select a key."),
|
||||||
|
),
|
||||||
|
language: v.pipe(v.string(), v.nonEmpty("Please choose a language.")),
|
||||||
|
keymap: v.pipe(v.string(), v.nonEmpty("Please select a keyboard layout.")),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FlashFormType = v.InferInput<typeof CreateFlashSchema>;
|
||||||
|
|
||||||
|
const CreateIso = () => {
|
||||||
|
const [formStore, { Form, Field }] = createForm<FlashFormType>({
|
||||||
|
validate: valiForm(CreateFlashSchema),
|
||||||
|
});
|
||||||
|
const stepSignal = useStepper<InstallSteps>();
|
||||||
|
|
||||||
|
// TODO: push values to the parent form Store
|
||||||
|
const handleSubmit: SubmitHandler<FlashFormType> = (values, event) => {
|
||||||
|
console.log("ISO creation submitted", values);
|
||||||
|
// Here you would typically trigger the ISO creation process
|
||||||
|
stepSignal.next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectFile = async () => {
|
||||||
|
const req = callApi("get_system_file", {
|
||||||
|
file_request: {
|
||||||
|
mode: "select_folder",
|
||||||
|
title: "Select a folder for you new Clan",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resp = await req.result;
|
||||||
|
|
||||||
|
if (resp.status === "error") {
|
||||||
|
// just throw the first error, I can't imagine why there would be multiple
|
||||||
|
// errors for this call
|
||||||
|
throw new Error(resp.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.status === "success" && resp.data) {
|
||||||
|
return resp.data[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("No data returned from api call");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<StepLayout
|
||||||
|
body={
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Fieldset>
|
||||||
|
<Field name="ssh_key">
|
||||||
|
{(field, input) => (
|
||||||
|
<HostFileInput
|
||||||
|
description="Public Key for connecting to the machine"
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
{...field}
|
||||||
|
value={field.value}
|
||||||
|
label="Select directory"
|
||||||
|
orientation="horizontal"
|
||||||
|
placeholder="Select SSH Key"
|
||||||
|
required={true}
|
||||||
|
validationState={
|
||||||
|
getError(formStore, "ssh_key") ? "invalid" : "valid"
|
||||||
|
}
|
||||||
|
input={input}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Fieldset>
|
||||||
|
<Fieldset>
|
||||||
|
<Field name="language">
|
||||||
|
{(field, props) => (
|
||||||
|
<Select
|
||||||
|
{...props}
|
||||||
|
value={field.value}
|
||||||
|
error={field.error}
|
||||||
|
required
|
||||||
|
label={{
|
||||||
|
label: "Language",
|
||||||
|
description: "Select your preferred language",
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "fr", label: "Français" },
|
||||||
|
]}
|
||||||
|
placeholder="Language"
|
||||||
|
name={field.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field name="keymap">
|
||||||
|
{(field, props) => (
|
||||||
|
<Select
|
||||||
|
{...props}
|
||||||
|
value={field.value}
|
||||||
|
error={field.error}
|
||||||
|
required
|
||||||
|
label={{
|
||||||
|
label: "Keymap",
|
||||||
|
description: "Select your keyboard layout",
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: "EN_US", label: "QWERTY" },
|
||||||
|
{ value: "DE_DE", label: "QWERTZ" },
|
||||||
|
]}
|
||||||
|
placeholder="Keymap"
|
||||||
|
name={field.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Fieldset>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<BackButton />
|
||||||
|
<NextButton type="submit" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createInstallerSteps = [
|
||||||
|
{
|
||||||
|
id: "create:iso-0",
|
||||||
|
content: () => (
|
||||||
|
<StepLayout
|
||||||
|
body={
|
||||||
|
<>
|
||||||
|
<div class="flex h-36 w-full flex-col justify-center gap-3 rounded-md px-4 py-6 text-fg-inv-1 outline-2 outline-bg-def-acc-3 bg-inv-4">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
size="xs"
|
||||||
|
weight="medium"
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
Create a portable installer
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
hierarchy="headline"
|
||||||
|
size="default"
|
||||||
|
weight="bold"
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
Grab a disposable USB stick and plug it in
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Typography hierarchy="body" size="default" weight="bold">
|
||||||
|
We will erase everything on it during this process
|
||||||
|
</Typography>
|
||||||
|
<Typography hierarchy="body" size="xs">
|
||||||
|
Create a portable installer tool that can turn any machine into
|
||||||
|
a fully configured Clan machine.
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
footer={<StepFooter />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "create:iso-1",
|
||||||
|
title: CreateHeader,
|
||||||
|
content: CreateIso,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
import { NextButton } from "../../Steps";
|
||||||
|
|
||||||
|
export const InstallHeader = (props: { machineName: string }) => {
|
||||||
|
return (
|
||||||
|
<Typography hierarchy="label" size="default">
|
||||||
|
Installing: {props.machineName}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const installSteps = [
|
||||||
|
{
|
||||||
|
id: "install:machine-0",
|
||||||
|
title: InstallHeader,
|
||||||
|
content: () => (
|
||||||
|
<div>
|
||||||
|
Enter the targetHost
|
||||||
|
<NextButton />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "install:confirm",
|
||||||
|
title: InstallHeader,
|
||||||
|
content: (props: { machineName: string }) => (
|
||||||
|
<div>
|
||||||
|
Confirm the installation of {props.machineName}
|
||||||
|
<NextButton />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "install:progress",
|
||||||
|
title: InstallHeader,
|
||||||
|
content: () => (
|
||||||
|
<div>
|
||||||
|
<p>Installation in progress...</p>
|
||||||
|
<p>Please wait while we set up your machine.</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
70
pkgs/clan-app/ui/src/workflows/Steps.tsx
Normal file
70
pkgs/clan-app/ui/src/workflows/Steps.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { JSX } from "solid-js";
|
||||||
|
import { useStepper } from "../hooks/stepper";
|
||||||
|
import { Button } from "../components/Button/Button";
|
||||||
|
import { InstallSteps } from "./Install/install";
|
||||||
|
|
||||||
|
interface StepLayoutProps {
|
||||||
|
body: JSX.Element;
|
||||||
|
footer: JSX.Element;
|
||||||
|
}
|
||||||
|
export const StepLayout = (props: StepLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
{props.body}
|
||||||
|
{props.footer}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type NextButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & {};
|
||||||
|
|
||||||
|
export const NextButton = (props: NextButtonProps) => {
|
||||||
|
// TODO: Make this type generic
|
||||||
|
const stepSignal = useStepper<InstallSteps>();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
hierarchy="primary"
|
||||||
|
disabled={!stepSignal.hasNext()}
|
||||||
|
endIcon="ArrowRight"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BackButton = () => {
|
||||||
|
const stepSignal = useStepper<InstallSteps>();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
hierarchy="secondary"
|
||||||
|
disabled={!stepSignal.hasPrevious()}
|
||||||
|
startIcon="ArrowLeft"
|
||||||
|
onClick={() => {
|
||||||
|
stepSignal.previous();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a footer with Back and Next buttons.
|
||||||
|
* The Next button will trigger the next step in the stepper.
|
||||||
|
* The Back button will go to the previous step.
|
||||||
|
*
|
||||||
|
* Does not trigger submission on any form
|
||||||
|
*
|
||||||
|
* Use this for overview steps where no form submission is required.
|
||||||
|
*/
|
||||||
|
export const StepFooter = () => {
|
||||||
|
const stepper = useStepper<InstallSteps>();
|
||||||
|
return (
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<BackButton />
|
||||||
|
<NextButton type="button" onClick={() => stepper.next()} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user