Template
1
0

feat: add initial account creation form

This commit is contained in:
2025-08-13 00:30:02 +02:00
parent 82d7a0d9cd
commit 0b0ecbcb79
12 changed files with 449 additions and 167 deletions

View File

@@ -10,16 +10,20 @@
"preview": "vite preview"
},
"dependencies": {
"@spec/relay": "workspace:*",
"@spec/schemas": "workspace:*",
"@tanstack/react-query": "5",
"@tanstack/react-router": "1",
"@valkyr/db": "1",
"@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1",
"fast-equals": "5",
"react": "19",
"react-dom": "19"
"react-dom": "19",
"zod": "4"
},
"devDependencies": {
"@eslint/js": "9",
"@tanstack/react-router-devtools": "1",
"@types/react": "19",
"@types/react-dom": "19",
"@vitejs/plugin-react": "4",

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,36 +0,0 @@
import "./App.css";
import { useState } from "react";
import viteLogo from "/vite.svg";
import reactLogo from "./assets/react.svg";
import { Session } from "./components/Session.tsx";
function App() {
const [count, setCount] = useState(0);
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>count is {count}</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">Click on the Vite and React logos to learn more</p>
<Session />
</>
);
}
export default App;

View File

@@ -1,68 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,269 @@
import z, { type ZodObject, type ZodRawShape } from "zod";
export class Form<TSchema extends ZodRawShape, TInputs = z.infer<ZodObject<TSchema>>> {
readonly schema: ZodObject<TSchema>;
readonly inputs: Partial<TInputs> = {};
#debounce: FormDebounce<TInputs> = {
validate: {},
};
#defaults: Partial<TInputs>;
#errors: FormErrors<TInputs> = {};
#elements: Record<string, HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> = {};
#onChange?: OnChangeCallback<TInputs>;
#onProcessing?: OnProcessingCallback;
#onError?: OnErrorCallback<TInputs>;
#onSubmit?: OnSubmitCallback<TInputs>;
#onResponse?: OnResponseCallback<any, any>;
/*
|--------------------------------------------------------------------------------
| Constructor
|--------------------------------------------------------------------------------
*/
constructor(schema: TSchema, defaults: Partial<TInputs> = {}) {
this.schema = z.object(schema);
this.#defaults = defaults;
this.#bindMethods();
this.#setDefaults();
this.#setSubmit();
}
#bindMethods() {
this.register = this.register.bind(this);
this.set = this.set.bind(this);
this.get = this.get.bind(this);
this.validate = this.validate.bind(this);
this.submit = this.submit.bind(this);
}
#setDefaults() {
for (const key in this.#defaults) {
this.inputs[key] = this.#defaults[key] ?? ("" as any);
}
}
#setSubmit() {
if ((this.constructor as any).submit !== undefined) {
this.onSubmit((this.constructor as any).submit);
}
}
/*
|--------------------------------------------------------------------------------
| Accessors
|--------------------------------------------------------------------------------
*/
get isValid(): boolean {
return Object.keys(this.#getFormErrors()).length === 0;
}
get hasError() {
return Object.keys(this.errors).length !== 0;
}
get errors(): FormErrors<TInputs> {
return this.#errors;
}
set errors(value: FormErrors<TInputs>) {
this.#errors = value;
this.#onError?.(value);
}
/**
* Register a input element with the form. This registers form related methods and a
* reference to the element itself that can be utilized by the form.
*
* @param name - Name of the input field.
*/
register<TKey extends keyof TInputs>(name: TKey) {
return {
name,
ref: (element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null) => {
if (element !== null) {
this.#elements[name as string] = element;
}
},
defaultValue: this.get(name),
onChange: ({ target: { value } }: any) => {
this.set(name, value);
},
};
}
/*
|--------------------------------------------------------------------------------
| Registrars
|--------------------------------------------------------------------------------
*/
onChange(callback: OnChangeCallback<TInputs>): this {
this.#onChange = callback;
return this;
}
onProcessing(callback: OnProcessingCallback): this {
this.#onProcessing = callback;
return this;
}
onError(callback: OnErrorCallback<TInputs>): this {
this.#onError = callback;
return this;
}
onSubmit(callback: OnSubmitCallback<TInputs>): this {
this.#onSubmit = callback;
return this;
}
onResponse<E, R>(callback: OnResponseCallback<E, R>): this {
this.#onResponse = callback;
return this;
}
/*
|--------------------------------------------------------------------------------
| Data
|--------------------------------------------------------------------------------
*/
/**
* Set the value of an input field.
*
* @param name - Name of the input field.
* @param value - Value to set.
*/
set<TKey extends keyof TInputs>(name: TKey, value: TInputs[TKey]): void {
this.inputs[name] = value;
this.#onChange?.(name, value);
clearTimeout(this.#debounce.validate[name]);
this.#debounce.validate[name] = setTimeout(() => {
this.validate(name);
}, 200);
}
/**
* Get the current input values or a specific input value.
*
* @param name - Name of the input field. _(Optional)_
*/
get(): Partial<TInputs>;
get<TKey extends keyof TInputs>(name: TKey): TInputs[TKey] | undefined;
get<TKey extends keyof TInputs>(name?: TKey): Partial<TInputs> | TInputs[TKey] | undefined {
if (name === undefined) {
return { ...this.inputs };
}
return this.inputs[name];
}
/**
* Reset form back to its default values.
*/
reset() {
for (const key in this.inputs) {
const value = this.#defaults[key] ?? "";
(this.inputs as any)[key] = value;
if (this.#elements[key] !== undefined) {
(this.#elements as any)[key].value = value;
}
}
}
/*
|--------------------------------------------------------------------------------
| Submission
|--------------------------------------------------------------------------------
*/
async submit(event: any) {
event.preventDefault?.();
this.#onProcessing?.(true);
this.validate();
if (this.hasError === false) {
try {
const response = await this.#onSubmit?.(this.schema.parse(this.inputs) as TInputs);
this.#onResponse?.(undefined, response);
} catch (error) {
this.#onResponse?.(error, undefined as any);
}
}
this.#onProcessing?.(false);
this.reset();
}
validate(name?: keyof TInputs) {
if (name !== undefined) {
this.#validateInput(name);
} else {
this.#validateForm();
}
}
#validateForm(): void {
this.errors = this.#getFormErrors();
}
#validateInput(name: keyof TInputs): void {
const errors = this.#getFormErrors();
let hasChanges = false;
if (errors[name] === undefined && this.errors[name] !== undefined) {
delete this.errors[name];
hasChanges = true;
}
if (errors[name] !== undefined && this.errors[name] !== errors[name]) {
this.errors[name] = errors[name];
hasChanges = true;
}
if (hasChanges === true) {
this.#onError?.({ ...this.errors });
}
}
#getFormErrors(): FormErrors<TInputs> {
const result = this.schema.safeParse(this.inputs);
if (result.success === false) {
throw result.error.flatten;
// return result.error.details.reduce<Partial<TInputs>>(
// (error, next) => ({
// ...error,
// [next.path[0]]: next.message,
// }),
// {},
// );
}
return {};
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type OnChangeCallback<TInputs, TKey extends keyof TInputs = keyof TInputs> = (name: TKey, value: TInputs[TKey]) => void;
type OnProcessingCallback = (value: boolean) => void;
type OnErrorCallback<TInputs> = (errors: FormErrors<TInputs>) => void;
type OnSubmitCallback<TInputs> = (inputs: TInputs) => Promise<any>;
type OnResponseCallback<Error, Response> = (err: Error, res: Response) => void;
type FormDebounce<TInputs> = {
validate: {
[TKey in keyof TInputs]?: any;
};
};
type FormErrors<TInputs> = {
[TKey in keyof TInputs]?: string;
};

View File

@@ -1,14 +1,9 @@
import "./index.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { routeTree } from "./routes.tsx";
const queryClient = new QueryClient();
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
@@ -19,8 +14,6 @@ declare module "@tanstack/react-router" {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
<RouterProvider router={router} />
</StrictMode>,
);

View File

@@ -1,9 +1,29 @@
import { createRootRoute } from "@tanstack/react-router";
import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import App from "./App.tsx";
import { CreateAccountView } from "./views/account/create.view.tsx";
const rootRoute = createRootRoute({
component: App,
component: () => (
<>
<Outlet />
<TanStackRouterDevtools />
</>
),
});
export const routeTree = rootRoute.addChildren([]);
const homeRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: function Index() {
return <h3>Welcome Home!</h3>;
},
});
const createAccountRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/accounts",
component: CreateAccountView,
});
export const routeTree = rootRoute.addChildren([homeRoute, createAccountRoute]);

View File

@@ -9,7 +9,7 @@ export const api = makeClient(
}),
},
{
account: (await import("@spec/modules/account/mod.ts")).routes,
auth: (await import("@spec/modules/auth/mod.ts")).routes,
account: (await import("@spec/schemas/account/routes.ts")).routes,
auth: (await import("@spec/schemas/auth/routes.ts")).routes,
},
);

View File

@@ -0,0 +1,36 @@
import z from "zod";
import { Controller } from "../../libraries/controller.ts";
import { Form } from "../../libraries/form.ts";
import { api } from "../../services/api.ts";
const inputs = {
givenName: z.string(),
familyName: z.string(),
email: z.string(),
};
export class CreateController extends Controller<{
form: Form<typeof inputs>;
}> {
async onInit() {
return {
form: new Form(inputs).onSubmit(async ({ givenName, familyName, email }) => {
const response = await api.account.create({
body: {
name: {
given: givenName,
family: familyName,
},
email,
},
});
if ("error" in response) {
console.log(response.error);
} else {
console.log(response.data);
}
}),
};
}
}

View File

@@ -0,0 +1,13 @@
import { makeControllerView } from "../../libraries/view.ts";
import { CreateController } from "./create.controller.ts";
export const CreateAccountView = makeControllerView(CreateController, ({ state: { form } }) => {
return (
<form onSubmit={form.submit}>
<input placeholder="Given Name" {...form.register("givenName")} />
<input placeholder="Family Name" {...form.register("familyName")} />
<input placeholder="Email" {...form.register("email")} />
<button type="submit">Create</button>
</form>
);
});