feat: add initial account creation form
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
269
apps/react/src/libraries/form.ts
Normal file
269
apps/react/src/libraries/form.ts
Normal 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;
|
||||
};
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
36
apps/react/src/views/account/create.controller.ts
Normal file
36
apps/react/src/views/account/create.controller.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
13
apps/react/src/views/account/create.view.tsx
Normal file
13
apps/react/src/views/account/create.view.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user