From 997d675f8c9f176c160ca7206d68d52f9116c526 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Wed, 16 Jul 2025 17:04:34 +0200 Subject: [PATCH] feat: onboarding workflow --- .../ui/src/components/Button/Button.css | 4 + .../components/Form/HostFileInput.stories.tsx | 2 +- .../ui/src/components/Form/HostFileInput.tsx | 75 ++++- .../clan-app/ui/src/components/Form/Label.css | 37 -- .../clan-app/ui/src/components/Form/Label.tsx | 37 +- .../ui/src/components/Tooltip/Tooltip.css | 9 + .../components/Tooltip/Tooltip.stories.tsx | 40 +++ .../ui/src/components/Tooltip/Tooltip.tsx | 34 ++ pkgs/clan-app/ui/src/hooks/api.ts | 2 +- pkgs/clan-app/ui/src/routes/Layout.tsx | 20 +- .../ui/src/routes/Onboarding/Onboarding.css | 5 + .../ui/src/routes/Onboarding/Onboarding.tsx | 193 +++++++++-- .../ui/src/routes/Onboarding/cube.svg | 316 ++++++++++++++++++ 13 files changed, 673 insertions(+), 101 deletions(-) create mode 100644 pkgs/clan-app/ui/src/components/Tooltip/Tooltip.css create mode 100644 pkgs/clan-app/ui/src/components/Tooltip/Tooltip.stories.tsx create mode 100644 pkgs/clan-app/ui/src/components/Tooltip/Tooltip.tsx create mode 100644 pkgs/clan-app/ui/src/routes/Onboarding/cube.svg diff --git a/pkgs/clan-app/ui/src/components/Button/Button.css b/pkgs/clan-app/ui/src/components/Button/Button.css index 5422cd191..6851e851f 100644 --- a/pkgs/clan-app/ui/src/components/Button/Button.css +++ b/pkgs/clan-app/ui/src/components/Button/Button.css @@ -138,6 +138,10 @@ transition: all 0.5s ease; } } + + & > span.typography { + @apply max-w-full overflow-hidden whitespace-nowrap text-ellipsis; + } } /* button group */ diff --git a/pkgs/clan-app/ui/src/components/Form/HostFileInput.stories.tsx b/pkgs/clan-app/ui/src/components/Form/HostFileInput.stories.tsx index bc9e6153c..55f887c09 100644 --- a/pkgs/clan-app/ui/src/components/Form/HostFileInput.stories.tsx +++ b/pkgs/clan-app/ui/src/components/Form/HostFileInput.stories.tsx @@ -58,7 +58,7 @@ export type Story = StoryObj; export const Bare: Story = { args: { onSelectFile: async () => { - return "/home/bob/clans/my-clan"; + return "/home/github/clans/my-clan/foo/bar/baz/fizz/buzz"; }, input: { placeholder: "e.g. 11/06/89", diff --git a/pkgs/clan-app/ui/src/components/Form/HostFileInput.tsx b/pkgs/clan-app/ui/src/components/Form/HostFileInput.tsx index c13d8eae6..bc9caf636 100644 --- a/pkgs/clan-app/ui/src/components/Form/HostFileInput.tsx +++ b/pkgs/clan-app/ui/src/components/Form/HostFileInput.tsx @@ -12,6 +12,8 @@ import { PolymorphicProps } from "@kobalte/core/polymorphic"; import { FieldProps } from "./Field"; import { Orienter } from "./Orienter"; import { createSignal } from "solid-js"; +import { Tooltip } from "@kobalte/core/tooltip"; +import { Typography } from "@/src/components/Typography/Typography"; export type HostFileInputProps = FieldProps & TextFieldRootProps & { @@ -20,10 +22,21 @@ export type HostFileInputProps = FieldProps & }; export const HostFileInput = (props: HostFileInputProps) => { - const [value, setValue] = createSignal(undefined); + const [value, setValue] = createSignal(props.value || ""); + + let actualInputElement: HTMLInputElement | undefined; const selectFile = async () => { - setValue(await props.onSelectFile()); + try { + console.log("selecting file", props.onSelectFile); + setValue(await props.onSelectFile()); + actualInputElement?.dispatchEvent( + new Event("input", { bubbles: true, cancelable: true }), + ); + } catch (error) { + console.log("Error selecting file", error); + // todo work out how to display the error + } }; return ( @@ -33,8 +46,6 @@ export const HostFileInput = (props: HostFileInputProps) => { ghost: props.ghost, })} {...props} - value={value()} - onChange={setValue} > ); diff --git a/pkgs/clan-app/ui/src/components/Form/Label.css b/pkgs/clan-app/ui/src/components/Form/Label.css index ea5f18330..c90a55955 100644 --- a/pkgs/clan-app/ui/src/components/Form/Label.css +++ b/pkgs/clan-app/ui/src/components/Form/Label.css @@ -22,40 +22,3 @@ div.form-label { } } } - -div.tooltip-content { - @apply z-50 px-2 py-0.5 bg-inv-4 rounded-[0.125rem] leading-none; - - max-width: min(calc(100vw - 16px), 380px); - transform-origin: var(--kb-tooltip-content-transform-origin); - animation: tooltipHide 250ms ease-in forwards; - - &[data-expanded] { - animation: tooltipShow 250ms ease-out; - } - - &.inverted { - @apply bg-def-2; - } -} - -@keyframes tooltipShow { - from { - opacity: 0; - transform: scale(0.96); - } - to { - opacity: 1; - transform: scale(1); - } -} -@keyframes tooltipHide { - from { - opacity: 1; - transform: scale(1); - } - to { - opacity: 0; - transform: scale(0.96); - } -} diff --git a/pkgs/clan-app/ui/src/components/Form/Label.tsx b/pkgs/clan-app/ui/src/components/Form/Label.tsx index 0188df271..788a494af 100644 --- a/pkgs/clan-app/ui/src/components/Form/Label.tsx +++ b/pkgs/clan-app/ui/src/components/Form/Label.tsx @@ -1,12 +1,11 @@ import { Show } from "solid-js"; import { Typography } from "@/src/components/Typography/Typography"; -import { Tooltip as KTooltip } from "@kobalte/core/tooltip"; +import { Tooltip } from "@/src/components/Tooltip/Tooltip"; import Icon from "@/src/components/Icon/Icon"; import { TextField } from "@kobalte/core/text-field"; import { Checkbox } from "@kobalte/core/checkbox"; import { Combobox } from "@kobalte/core/combobox"; import "./Label.css"; -import cx from "classnames"; export type Size = "default" | "s"; @@ -49,31 +48,27 @@ export const Label = (props: LabelProps) => { {props.label} {props.tooltip && ( - - + - - - - {props.tooltip} - - - - - - + } + > + + {props.tooltip} + + )} {props.description && ( diff --git a/pkgs/clan-app/ui/src/components/Tooltip/Tooltip.css b/pkgs/clan-app/ui/src/components/Tooltip/Tooltip.css new file mode 100644 index 000000000..65b6fec3f --- /dev/null +++ b/pkgs/clan-app/ui/src/components/Tooltip/Tooltip.css @@ -0,0 +1,9 @@ +div.tooltip-content { + @apply z-50 px-2 py-0.5 bg-inv-4 rounded-[0.125rem] leading-none; + + max-width: min(calc(100vw - 16px), 380px); + + &.inverted { + @apply bg-def-2; + } +} diff --git a/pkgs/clan-app/ui/src/components/Tooltip/Tooltip.stories.tsx b/pkgs/clan-app/ui/src/components/Tooltip/Tooltip.stories.tsx new file mode 100644 index 000000000..98fbf9a22 --- /dev/null +++ b/pkgs/clan-app/ui/src/components/Tooltip/Tooltip.stories.tsx @@ -0,0 +1,40 @@ +import { Meta, StoryObj } from "@kachurun/storybook-solid"; +import { Tooltip, TooltipProps } from "@/src/components/Tooltip/Tooltip"; +import { Typography } from "@/src/components/Typography/Typography"; +import { Button } from "@/src/components/Button/Button"; + +const meta: Meta = { + title: "Components/Tooltip", + component: Tooltip, + decorators: [ + (Story: StoryObj) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + placement: "top", + inverted: false, + trigger: , + children: ( + + Your Clan is being created + + ), + }, +}; + +export const AnimateBounce: Story = { + args: { + ...Default.args, + animation: "bounce", + }, +}; diff --git a/pkgs/clan-app/ui/src/components/Tooltip/Tooltip.tsx b/pkgs/clan-app/ui/src/components/Tooltip/Tooltip.tsx new file mode 100644 index 000000000..1b55b6e1a --- /dev/null +++ b/pkgs/clan-app/ui/src/components/Tooltip/Tooltip.tsx @@ -0,0 +1,34 @@ +import "./Tooltip.css"; +import { + Tooltip as KTooltip, + TooltipRootProps as KTooltipRootProps, +} from "@kobalte/core/tooltip"; +import cx from "classnames"; +import { JSX } from "solid-js"; + +export interface TooltipProps extends KTooltipRootProps { + inverted?: boolean; + trigger: JSX.Element; + children: JSX.Element; + animation?: "bounce"; +} + +export const Tooltip = (props: TooltipProps) => { + return ( + + {props.trigger} + + + {props.placement == "bottom" && } + {props.children} + {props.placement == "top" && } + + + + ); +}; diff --git a/pkgs/clan-app/ui/src/hooks/api.ts b/pkgs/clan-app/ui/src/hooks/api.ts index 86fefe2ce..d84c24367 100644 --- a/pkgs/clan-app/ui/src/hooks/api.ts +++ b/pkgs/clan-app/ui/src/hooks/api.ts @@ -42,7 +42,7 @@ interface BackendReturnType { * @property {Promise>} result - A promise that resolves to the return type of the backend operation. * @property {() => Promise} cancel - A function to cancel the API call, returning a promise that resolves when cancellation is completed. */ -interface ApiCall { +export interface ApiCall { uuid: string; result: Promise>; cancel: () => Promise; diff --git a/pkgs/clan-app/ui/src/routes/Layout.tsx b/pkgs/clan-app/ui/src/routes/Layout.tsx index 6028c9e68..4d18b1e7d 100644 --- a/pkgs/clan-app/ui/src/routes/Layout.tsx +++ b/pkgs/clan-app/ui/src/routes/Layout.tsx @@ -1,6 +1,18 @@ import { Component } from "solid-js"; -import { RouteSectionProps } from "@solidjs/router"; +import { RouteSectionProps, useNavigate } from "@solidjs/router"; +import { activeClanURI } from "@/src/stores/clan"; +import { navigateToClan } from "@/src/hooks/clan"; -export const Layout: Component = (props) => ( -
{props.children}
-); +export const Layout: Component = (props) => { + const navigate = useNavigate(); + + // check for an active clan uri and redirect to it on first load + const activeURI = activeClanURI(); + if (!props.location.pathname.startsWith("/clan/") && activeURI) { + navigateToClan(navigate, activeURI); + } else { + navigate("/"); + } + + return
{props.children}
; +}; diff --git a/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.css b/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.css index db1d52344..73a8af72b 100644 --- a/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.css +++ b/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.css @@ -81,5 +81,10 @@ main#welcome { } } } + + div.creating { + @apply w-[17.0625rem] h-[20.4375rem]; + background: url(./cube.svg) center / cover no-repeat; + } } } diff --git a/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.tsx b/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.tsx index 141a9daba..048980964 100644 --- a/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.tsx +++ b/pkgs/clan-app/ui/src/routes/Onboarding/Onboarding.tsx @@ -1,18 +1,30 @@ -import { Component, createSignal, Match, Setter, Show, Switch } from "solid-js"; +import { + Accessor, + Component, + createSignal, + Match, + Setter, + Show, + Switch, +} from "solid-js"; import { RouteSectionProps, useNavigate } from "@solidjs/router"; import "./Onboarding.css"; import { Typography } from "@/src/components/Typography/Typography"; import { Button } from "@/src/components/Button/Button"; +import { Tooltip } from "@/src/components/Tooltip/Tooltip"; +import { Alert } from "@/src/components/Alert/Alert"; + import { Divider } from "@/src/components/Divider/Divider"; import { Logo } from "@/src/components/Logo/Logo"; import { navigateToClan, selectClanFolder } from "@/src/hooks/clan"; -import { activeClanURI } from "@/src/stores/clan"; +import { activeClanURI, addClanURI, setActiveClanURI } from "@/src/stores/clan"; import { createForm, FormStore, getError, getErrors, getValue, + SubmitHandler, valiForm, } from "@modular-forms/solid"; import { TextInput } from "@/src/components/Form/TextInput"; @@ -20,23 +32,31 @@ import { TextArea } from "@/src/components/Form/TextArea"; import { Fieldset } from "@/src/components/Form/Fieldset"; import * as v from "valibot"; import { HostFileInput } from "@/src/components/Form/HostFileInput"; +import { callApi } from "@/src/hooks/api"; -type State = "welcome" | "setup"; +type State = "welcome" | "setup" | "creating"; const SetupSchema = v.object({ - name: v.pipe(v.string(), v.nonEmpty("Please enter a name.")), + name: v.pipe( + v.string(), + v.nonEmpty("Please enter a name."), + v.regex( + new RegExp("^[a-zA-Z0-9_\\-]+$"), + "Name must be alphanumeric and can contain underscores and dashes, without spaces.", + ), + ), description: v.pipe(v.string(), v.nonEmpty("Please describe your clan.")), - directory: v.pipe(v.string(), v.nonEmpty("Please select a directory.")), + directory: v.pipe( + // initial value is undefined, and I can't see how to handle this better in valibot, so for now when the type + // is incorrect we treat it as empty + v.string("Please select a directory."), + v.nonEmpty("Please select a directory."), + ), }); type SetupForm = v.InferInput; -interface backgroundProps { - state: State; - form: FormStore; -} - -const background = (props: backgroundProps) => ( +const background = (props: { state: State; form: FormStore }) => (
@@ -71,7 +91,11 @@ const background = (props: backgroundProps) => (
); -const welcome = (setState: Setter) => { +const welcome = (props: { + setState: Setter; + welcomeError: Accessor; + setWelcomeError: Setter; +}) => { const navigate = useNavigate(); const selectFolder = async () => { @@ -91,7 +115,23 @@ const welcome = (setState: Setter) => { Build your
own darknet -
@@ -114,6 +154,21 @@ const welcome = (setState: Setter) => { ); }; +const creating = () => ( +
+ } + > + + Your Clan is being created + + +
+); + export const Onboarding: Component = (props) => { const navigate = useNavigate(); @@ -126,13 +181,89 @@ export const Onboarding: Component = (props) => { const [state, setState] = createSignal("welcome"); + // used to display an error in the welcome screen in the event of a failed + // clan creation + const [welcomeError, setWelcomeError] = createSignal(); + + // const [setupForm, { Form, Field }] = createForm({ validate: valiForm(SetupSchema), }); - const metaError = () => { - const errors = getErrors(setupForm, ["name", "description"]); - return errors ? errors.name || errors.description : undefined; + const formError = () => { + const formErrors = getErrors(setupForm); + return ( + formErrors.name || + formErrors.description || + formErrors.directory || + undefined + ); + }; + + 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"); + }; + + const onSubmit: SubmitHandler = async ( + { name, description, directory }, + event, + ) => { + const path = `${directory}/${name}`; + + const req = callApi("create_clan", { + opts: { + dest: path, + // todo allow users to select a template + template: "minimal", + initial: { + meta: { + name: name, + description: description, + // todo it tries to 'delete' icon if it's not provided + // this logic is unexpected, and needs reviewed. + icon: null, + }, + machines: {}, + instances: {}, + services: {}, + }, + }, + }); + + setState("creating"); + + const resp = await req.result; + + if (resp.status === "error") { + setWelcomeError(resp.errors[0].message); + setState("welcome"); + return; + } + + if (resp.status === "success") { + addClanURI(path); + setActiveClanURI(path); + navigateToClan(navigate, path); + } }; return ( @@ -140,7 +271,13 @@ export const Onboarding: Component = (props) => { {background({ form: setupForm, state: state() })}
- {welcome(setState)} + + {welcome({ + setState, + welcomeError, + setWelcomeError, + })} +
@@ -155,8 +292,16 @@ export const Onboarding: Component = (props) => { Setup
-
-
+ + {formError() && ( + + )} +
{(field, input) => ( = (props) => {
-
+
{(field, input) => ( "test"} + onSelectFile={onSelectFile} {...field} + value={field.value} label="Select directory" orientation="horizontal" required={true} @@ -228,6 +371,8 @@ export const Onboarding: Component = (props) => {
+ + {creating()}
diff --git a/pkgs/clan-app/ui/src/routes/Onboarding/cube.svg b/pkgs/clan-app/ui/src/routes/Onboarding/cube.svg new file mode 100644 index 000000000..5fe3d5824 --- /dev/null +++ b/pkgs/clan-app/ui/src/routes/Onboarding/cube.svg @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +