inlined JSON schema form library and updated related references to address bundling and React static asset issues

This commit is contained in:
dswbx
2025-01-11 08:09:52 +01:00
parent 3bf92a8c65
commit c06ba061f0
7 changed files with 409 additions and 12 deletions

View File

@@ -11,13 +11,7 @@ let app: App;
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
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) {

View File

@@ -19,9 +19,6 @@ export function serve({
port = $config.server.default_port,
hostname,
listener,
onBuilt,
buildConfig = {},
beforeBuild,
...config
}: NodeBkndConfig = {}) {
const root = path.relative(

View File

@@ -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<string, any> {
const result: Record<string, any> = {};
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<string, JSONSchema>();
export type ChangeSet = { name: string; value: any };
export type Validator<Err = unknown, FormData = any> = {
validate: (schema: JSONSchema | any, data: FormData) => Promise<Err[]> | Err[];
};
export type FormRenderProps<Err> = {
errors: Err[];
schema: JSONSchema;
submitting: boolean;
dirty: boolean;
submit: () => Promise<void>;
reset: () => void;
resetDirty: () => void;
};
export type FormRef<FormData, Err> = {
submit: () => Promise<void>;
validate: () => Promise<{ data: FormData; errors: Err[] }>;
reset: () => void;
resetDirty: () => void;
formRef: RefObject<HTMLFormElement | null>;
};
export type FormProps<FormData, ValFn, Err> = Omit<
ComponentPropsWithoutRef<"form">,
"onSubmit" | "onChange" | "children"
> & {
schema: `http${string}` | `/${string}` | JSONSchema;
validator: Validator<Err, FormData>;
validationMode?: "submit" | "change";
children: (props: FormRenderProps<Err>) => ReactNode;
onChange?: (formData: FormData, changed: ChangeSet) => void | Promise<void>;
onSubmit?: (formData: FormData) => void | Promise<void>;
onSubmitInvalid?: (errors: Err[], formData: FormData) => void | Promise<void>;
resetOnSubmit?: boolean;
revalidateOnError?: boolean;
hiddenSubmit?: boolean;
};
const FormComponent = <FormData, ValFn, Err>(
{
schema: initialSchema,
validator,
validationMode = "submit",
children,
onChange,
onSubmit,
onSubmitInvalid,
resetOnSubmit,
revalidateOnError = true,
hiddenSubmit,
...formProps
}: FormProps<FormData, ValFn, Err>,
ref: ForwardedRef<FormRef<FormData, Err>>
) => {
const is_schema = typeof initialSchema !== "string";
const [schema, setSchema] = useState<JSONSchema | undefined>(
is_schema ? initialSchema : undefined
);
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState<any[]>([]);
const [dirty, setDirty] = useState(false);
const formRef = useRef<HTMLFormElement | null>(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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<form {...formProps} onSubmit={handleSubmit} ref={formRef} onChange={handleChangeEvent}>
{children({
schema: schema as any,
submit,
dirty,
reset,
resetDirty,
submitting,
errors
})}
{hiddenSubmit && (
<input type="submit" style={{ visibility: "hidden" }} disabled={errors.length > 0} />
)}
</form>
);
};
export const Form = forwardRef(FormComponent) as <
FormData = any,
ValidatorActual = Validator,
Err = ValidatorActual extends Validator<infer E, FormData> ? Awaited<E> : never
>(
props: FormProps<FormData, ValidatorActual, Err> & {
ref?: ForwardedRef<HTMLFormElement>;
}
) => ReturnType<typeof FormComponent>;

View File

@@ -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<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {

View File

@@ -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() {

View File

@@ -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<ValueError> {
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 (
<>
<Form
schema={schema}
onChange={setData}
onSubmit={setData}
validator={validator}
validationMode="change"
>
{({ errors, dirty, reset }) => (
<>
<div>
<b>
Form {dirty ? "*" : ""} (valid: {errors.length === 0 ? "valid" : "invalid"})
</b>
</div>
<div>
<input type="text" name="name" />
<input type="number" name="age" />
</div>
<div>
<button type="submit">submit</button>
<button type="button" onClick={reset}>
reset
</button>
</div>
</>
)}
</Form>
<pre>{JSON.stringify(data, null, 2)}</pre>
</>
);
}