diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 7e1334c..390ac3a 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -11,13 +11,7 @@ let app: App; export type BunBkndConfig = RuntimeBkndConfig & Omit; -export async function createApp({ - distPath, - onBuilt, - buildConfig, - beforeBuild, - ...config -}: RuntimeBkndConfig = {}) { +export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) { const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); if (!app) { diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 835b886..4f98466 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -19,9 +19,6 @@ export function serve({ port = $config.server.default_port, hostname, listener, - onBuilt, - buildConfig = {}, - beforeBuild, ...config }: NodeBkndConfig = {}) { const root = path.relative( diff --git a/app/src/ui/lib/json-schema-form/index.tsx b/app/src/ui/lib/json-schema-form/index.tsx new file mode 100644 index 0000000..56bbbb6 --- /dev/null +++ b/app/src/ui/lib/json-schema-form/index.tsx @@ -0,0 +1,350 @@ +/** + * @todo: currently just hard importing this library due to building and react issues with static assets + * man I hate bundling for react. + */ + +import { + type ComponentPropsWithoutRef, + type ForwardedRef, + type ReactNode, + type RefObject, + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState +} from "react"; + +export type JSONSchemaTypeName = + | "string" + | "number" + | "integer" + | "boolean" + | "object" + | "array" + | "null" + | string; + +export type JSONSchemaDefinition = JSONSchema | boolean; + +export interface JSONSchema { + $id?: string; + $ref?: string; + $schema?: string; + title?: string; + description?: string; + default?: any; + + // Data types + type?: JSONSchemaTypeName | JSONSchemaTypeName[]; + enum?: any[]; + const?: any; + + // Numbers + multipleOf?: number; + maximum?: number; + exclusiveMaximum?: number; + minimum?: number; + exclusiveMinimum?: number; + + // Strings + maxLength?: number; + minLength?: number; + pattern?: string; + format?: string; + + // Arrays + items?: JSONSchemaDefinition | JSONSchemaDefinition[]; + additionalItems?: JSONSchemaDefinition; + uniqueItems?: boolean; + maxItems?: number; + minItems?: number; + + // Objects + properties?: { [key: string]: JSONSchemaDefinition }; + patternProperties?: { [key: string]: JSONSchemaDefinition }; + additionalProperties?: JSONSchemaDefinition; + required?: string[]; + maxProperties?: number; + minProperties?: number; + dependencies?: { [key: string]: JSONSchemaDefinition | string[] }; + + // Combining schemas + allOf?: JSONSchemaDefinition[]; + anyOf?: JSONSchemaDefinition[]; + oneOf?: JSONSchemaDefinition[]; + not?: JSONSchemaDefinition; + if?: JSONSchemaDefinition; + then?: JSONSchemaDefinition; + else?: JSONSchemaDefinition; + + // Definitions + definitions?: { [key: string]: JSONSchemaDefinition }; + $comment?: string; + [key: string | symbol]: any; // catch-all for custom extensions +} + +export function formDataToNestedObject( + formData: FormData, + formElement: HTMLFormElement +): Record { + const result: Record = {}; + + formData.forEach((value, key) => { + const inputElement = formElement.querySelector(`[name="${key}"]`) as + | HTMLInputElement + | HTMLTextAreaElement + | HTMLSelectElement + | null; + + if (!inputElement) { + return; // Skip if the input element is not found + } + + // Skip fields with empty values + if (value === "") { + return; + } + + const keys = key + .replace(/\[([^\]]*)\]/g, ".$1") // Convert [key] to .key + .split(".") // Split by dots + .filter(Boolean); // Remove empty parts + + let current = result; + + keys.forEach((k, i) => { + if (i === keys.length - 1) { + let parsedValue: any = value; + + if (inputElement.type === "number") { + parsedValue = !Number.isNaN(Number(value)) ? Number(value) : value; + } else if (inputElement.type === "checkbox") { + parsedValue = "checked" in inputElement && inputElement.checked; + } + + // Handle array or single value + if (current[k] !== undefined) { + if (!Array.isArray(current[k])) { + current[k] = [current[k]]; + } + current[k].push(parsedValue); + } else { + current[k] = parsedValue; + } + } else { + // Ensure the key exists as an object + if (current[k] === undefined || typeof current[k] !== "object") { + current[k] = {}; + } + current = current[k]; + } + }); + }); + + return result; +} + +const cache = new Map(); + +export type ChangeSet = { name: string; value: any }; + +export type Validator = { + validate: (schema: JSONSchema | any, data: FormData) => Promise | Err[]; +}; + +export type FormRenderProps = { + errors: Err[]; + schema: JSONSchema; + submitting: boolean; + dirty: boolean; + submit: () => Promise; + reset: () => void; + resetDirty: () => void; +}; + +export type FormRef = { + submit: () => Promise; + validate: () => Promise<{ data: FormData; errors: Err[] }>; + reset: () => void; + resetDirty: () => void; + formRef: RefObject; +}; + +export type FormProps = Omit< + ComponentPropsWithoutRef<"form">, + "onSubmit" | "onChange" | "children" +> & { + schema: `http${string}` | `/${string}` | JSONSchema; + validator: Validator; + validationMode?: "submit" | "change"; + children: (props: FormRenderProps) => ReactNode; + onChange?: (formData: FormData, changed: ChangeSet) => void | Promise; + onSubmit?: (formData: FormData) => void | Promise; + onSubmitInvalid?: (errors: Err[], formData: FormData) => void | Promise; + resetOnSubmit?: boolean; + revalidateOnError?: boolean; + hiddenSubmit?: boolean; +}; + +const FormComponent = ( + { + schema: initialSchema, + validator, + validationMode = "submit", + children, + onChange, + onSubmit, + onSubmitInvalid, + resetOnSubmit, + revalidateOnError = true, + hiddenSubmit, + ...formProps + }: FormProps, + ref: ForwardedRef> +) => { + const is_schema = typeof initialSchema !== "string"; + const [schema, setSchema] = useState( + is_schema ? initialSchema : undefined + ); + const [submitting, setSubmitting] = useState(false); + const [errors, setErrors] = useState([]); + const [dirty, setDirty] = useState(false); + const formRef = useRef(null); + + function resetDirty() { + setDirty(false); + setDirty(false); + } + + useImperativeHandle(ref, () => ({ + submit: submit, + validate: validate, + reset: reset, + resetDirty, + formRef + })); + + useEffect(() => { + (async () => { + if (!is_schema) { + if (cache.has(initialSchema)) { + setSchema(cache.get(initialSchema)); + return; + } + + const res = await fetch(initialSchema); + + if (res.ok) { + const s = (await res.json()) as JSONSchema; + setSchema(s); + cache.set(initialSchema, s); + } + } + })(); + }, [initialSchema]); + + async function handleChangeEvent(e: React.FormEvent) { + const form = formRef.current; + if (!form) return; + setDirty(true); + const target = e.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null; + + if (!target || !form.contains(target)) { + return; // Ignore events from outside the form + } + + const name = target.name; + const formData = new FormData(form); + const data = formDataToNestedObject(formData, form) as FormData; + const value = formData.get(name); + + await onChange?.(data, { name, value }); + + if ((revalidateOnError && errors.length > 0) || validationMode === "change") { + await validate(); + } + } + + async function validate() { + const form = formRef.current; + if (!form || !schema) return { data: {} as FormData, errors: [] }; + + const formData = new FormData(form); + const data = formDataToNestedObject(formData, form) as FormData; + + const errors = await validator.validate(schema, data); + setErrors(errors); + return { data, errors }; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + await submit(); + return false; + } + + async function submit() { + const form = formRef.current; + if (!form || !schema) { + console.log("invalid", { form, schema }); + return; + } + + const { data, errors } = await validate(); + if (errors.length > 0) { + await onSubmitInvalid?.(errors, data); + } else { + setSubmitting(true); + try { + if (onSubmit) { + await onSubmit?.(data); + if (resetOnSubmit) { + reset(); + } + } else { + form.submit(); + } + } catch (e) { + console.error(e); + console.warn("You should wrap your submit handler in a try/catch block"); + } finally { + setSubmitting(false); + setDirty(false); + } + } + } + + function reset() { + formRef.current?.reset(); + setErrors([]); + } + + return ( +
+ {children({ + schema: schema as any, + submit, + dirty, + reset, + resetDirty, + submitting, + errors + })} + + {hiddenSubmit && ( + 0} /> + )} +
+ ); +}; + +export const Form = forwardRef(FormComponent) as < + FormData = any, + ValidatorActual = Validator, + Err = ValidatorActual extends Validator ? Awaited : never +>( + props: FormProps & { + ref?: ForwardedRef; + } +) => ReturnType; diff --git a/app/src/ui/modules/auth/AuthForm.tsx b/app/src/ui/modules/auth/AuthForm.tsx index fa864ef..a530c17 100644 --- a/app/src/ui/modules/auth/AuthForm.tsx +++ b/app/src/ui/modules/auth/AuthForm.tsx @@ -1,12 +1,12 @@ import type { ValueError } from "@sinclair/typebox/value"; import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema"; import { type TSchema, Type, Value } from "core/utils"; -import { Form, type Validator } from "json-schema-form-react"; import { transform } from "lodash-es"; import type { ComponentPropsWithoutRef } from "react"; import { twMerge } from "tailwind-merge"; import { Button } from "ui/components/buttons/Button"; import { Group, Input, Label } from "ui/components/form/Formy"; +import { Form, type Validator } from "ui/lib/json-schema-form"; import { SocialLink } from "ui/modules/auth/SocialLink"; export type LoginFormProps = Omit, "onSubmit" | "action"> & { diff --git a/app/src/ui/routes/test/index.tsx b/app/src/ui/routes/test/index.tsx index e2e8cb8..da6c0cd 100644 --- a/app/src/ui/routes/test/index.tsx +++ b/app/src/ui/routes/test/index.tsx @@ -1,4 +1,5 @@ import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test"; +import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test"; import SwaggerTest from "ui/routes/test/tests/swagger-test"; import SWRAndAPI from "ui/routes/test/tests/swr-and-api"; import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api"; @@ -43,7 +44,8 @@ const tests = { SwaggerTest, SWRAndAPI, SwrAndDataApi, - DropzoneElementTest + DropzoneElementTest, + JsonSchemaFormReactTest } as const; export default function TestRoutes() { diff --git a/app/src/ui/routes/test/tests/json-schema-form-react-test.tsx b/app/src/ui/routes/test/tests/json-schema-form-react-test.tsx new file mode 100644 index 0000000..4bfe01a --- /dev/null +++ b/app/src/ui/routes/test/tests/json-schema-form-react-test.tsx @@ -0,0 +1,54 @@ +import { Form, type Validator } from "json-schema-form-react"; +import { useState } from "react"; + +import { type TSchema, Type } from "@sinclair/typebox"; +import { Value, type ValueError } from "@sinclair/typebox/value"; + +class TypeboxValidator implements Validator { + async validate(schema: TSchema, data: any) { + return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)]; + } +} +const validator = new TypeboxValidator(); + +const schema = Type.Object({ + name: Type.String(), + age: Type.Optional(Type.Number()) +}); + +export default function JsonSchemaFormReactTest() { + const [data, setData] = useState(null); + + return ( + <> +
+ {({ errors, dirty, reset }) => ( + <> +
+ + Form {dirty ? "*" : ""} (valid: {errors.length === 0 ? "valid" : "invalid"}) + +
+
+ + +
+
+ + +
+ + )} +
+
{JSON.stringify(data, null, 2)}
+ + ); +} diff --git a/bun.lockb b/bun.lockb index 6400239..a9548bd 100755 Binary files a/bun.lockb and b/bun.lockb differ