diff --git a/app/src/ui/components/form/native-form/NativeForm.tsx b/app/src/ui/components/form/native-form/NativeForm.tsx new file mode 100644 index 0000000..7d0e90c --- /dev/null +++ b/app/src/ui/components/form/native-form/NativeForm.tsx @@ -0,0 +1,215 @@ +import { + type ChangeEvent, + type ComponentPropsWithoutRef, + type FormEvent, + useEffect, + useRef, + useState +} from "react"; +import { useEvent } from "ui/hooks/use-event"; +import { + type CleanOptions, + type InputElement, + cleanObject, + coerce, + getFormTarget, + getTargetsByName, + setPath +} from "./utils"; + +export type NativeFormProps = { + hiddenSubmit?: boolean; + validateOn?: "change" | "submit"; + errorFieldSelector?: (name: string) => any | null; + reportValidity?: boolean; + onSubmit?: (data: any, ctx: { event: FormEvent }) => Promise | void; + onSubmitInvalid?: ( + errors: InputError[], + ctx: { event: FormEvent } + ) => Promise | void; + onError?: (errors: InputError[]) => void; + onChange?: ( + data: any, + ctx: { event: ChangeEvent; key: string; value: any; errors: InputError[] } + ) => Promise | void; + clean?: CleanOptions | true; +} & Omit, "onChange" | "onSubmit">; + +export type InputError = { + name: string; + message: string; +}; + +export function NativeForm({ + children, + validateOn = "submit", + hiddenSubmit = false, + errorFieldSelector, + reportValidity, + onSubmit, + onSubmitInvalid, + onError, + clean, + ...props +}: NativeFormProps) { + const formRef = useRef(null); + const [errors, setErrors] = useState([]); + + useEffect(() => { + if (!formRef.current || props.noValidate) return; + validate(); + }, []); + + useEffect(() => { + if (!formRef.current || props.noValidate) return; + + // find submit buttons and disable them if there are errors + const invalid = errors.length > 0; + formRef.current.querySelectorAll("[type=submit]").forEach((submit) => { + if (!submit || !("type" in submit) || submit.type !== "submit") return; + // @ts-ignore + submit.disabled = invalid; + }); + + onError?.(errors); + }, [errors]); + + const validateElement = useEvent((el: InputElement | null, opts?: { report?: boolean }) => { + if (props.noValidate || !el || !("name" in el)) return; + const errorElement = formRef.current?.querySelector( + errorFieldSelector?.(el.name) ?? `[data-role="input-error"][data-name="${el.name}"]` + ); + + if (!el.checkValidity()) { + const error = { + name: el.name, + message: el.validationMessage + }; + + setErrors((prev) => [...prev.filter((e) => e.name !== el.name), error]); + if (opts?.report) { + if (errorElement) { + errorElement.textContent = error.message; + } else if (reportValidity) { + el.reportValidity(); + } + } + + return error; + } else { + setErrors((prev) => prev.filter((e) => e.name !== el.name)); + if (errorElement) { + errorElement.textContent = ""; + } + } + + return; + }); + + const validate = useEvent((opts?: { report?: boolean }) => { + if (!formRef.current || props.noValidate) return []; + + const errors: InputError[] = []; + formRef.current.querySelectorAll("input, select, textarea").forEach((e) => { + const el = e as InputElement | null; + const error = validateElement(el, opts); + if (error) { + errors.push(error); + } + }); + + return errors; + }); + + const getFormValues = useEvent(() => { + if (!formRef.current) return {}; + + const formData = new FormData(formRef.current); + const obj: any = {}; + formData.forEach((value, key) => { + const targets = getTargetsByName(formRef.current!, key); + if (targets.length === 0) { + console.warn(`No target found for key: ${key}`); + return; + } + + const count = targets.length; + const multiple = count > 1; + targets.forEach((target, index) => { + let _key = key; + + if (multiple) { + _key = `${key}[${index}]`; + } + + setPath(obj, _key, coerce(target, target.value)); + }); + }); + + if (typeof clean === "undefined") return obj; + return cleanObject(obj, clean === true ? undefined : clean); + }); + + const handleChange = useEvent(async (e: ChangeEvent) => { + const form = formRef.current; + if (!form) return; + const target = getFormTarget(e); + if (!target) return; + + if (validateOn === "change") { + validateElement(target, { report: true }); + } + + if (props.onChange) { + await props.onChange(getFormValues(), { + event: e, + key: target.name, + value: target.value, + errors + }); + } + }); + + const handleSubmit = useEvent(async (e: FormEvent) => { + e.preventDefault(); + const form = formRef.current; + if (!form) return; + + const errors = validate({ report: true }); + if (errors.length > 0) { + onSubmitInvalid?.(errors, { event: e }); + return; + } + + if (onSubmit) { + await onSubmit(getFormValues(), { event: e }); + } else { + form.submit(); + } + }); + + const handleKeyDown = useEvent((e: KeyboardEvent) => { + if (!formRef.current) return; + + // if is enter key, submit is disabled, report errors + if (e.keyCode === 13) { + const invalid = errors.length > 0; + if (invalid && !props.noValidate && reportValidity) { + formRef.current.reportValidity(); + } + } + }); + + return ( +
+ {children} + {hiddenSubmit && } +
+ ); +} diff --git a/app/src/ui/components/form/native-form/utils.ts b/app/src/ui/components/form/native-form/utils.ts new file mode 100644 index 0000000..89bfe89 --- /dev/null +++ b/app/src/ui/components/form/native-form/utils.ts @@ -0,0 +1,137 @@ +import type { FormEvent } from "react"; + +export type InputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + +export function ignoreTarget(target: InputElement | Element | null, form?: HTMLFormElement) { + const tagName = target?.tagName.toLowerCase() ?? ""; + const tagNames = ["input", "select", "textarea"]; + + return ( + !target || + !form?.contains(target) || + !tagNames.includes(tagName) || + !("name" in target) || + target.hasAttribute("data-ignore") || + target.closest("[data-ignore]") + ); +} + +export function getFormTarget(e: FormEvent): InputElement | null { + const form = e.currentTarget; + const target = e.target as InputElement | null; + + return ignoreTarget(target, form) ? null : target; +} + +export function getTargetsByName(form: HTMLFormElement, name: string): InputElement[] { + const query = form.querySelectorAll(`[name="${name}"]`); + return Array.from(query).filter((e) => ignoreTarget(e)) as InputElement[]; +} + +export function coerce(target: InputElement | null, value?: any) { + if (!target) return value; + const required = target.required; + if (!value && !required) return undefined; + + if (target.type === "number") { + const num = Number(value); + if (Number.isNaN(num) && !required) return undefined; + + const min = "min" in target && target.min.length > 0 ? Number(target.min) : undefined; + const max = "max" in target && target.max.length > 0 ? Number(target.max) : undefined; + const step = "step" in target && target.step.length > 0 ? Number(target.step) : undefined; + + if (min && num < min) return min; + if (max && num > max) return max; + if (step && step !== 1) return Math.round(num / step) * step; + + return num; + } else if (target.type === "text") { + const maxLength = + "maxLength" in target && target.maxLength > -1 ? Number(target.maxLength) : undefined; + const pattern = "pattern" in target ? new RegExp(target.pattern) : undefined; + + if (maxLength && value.length > maxLength) return value.slice(0, maxLength); + if (pattern && !pattern.test(value)) return ""; + + return value; + } else if (target.type === "checkbox") { + if ("checked" in target) return !!target.checked; + return ["on", "1", "true", 1, true].includes(value); + } else { + return value; + } +} + +export type CleanOptions = { + empty?: any[]; + emptyInArray?: any[]; + keepEmptyArray?: boolean; +}; +export function cleanObject( + obj: Obj, + _opts?: CleanOptions +): Obj { + if (!obj) return obj; + const _empty = [null, undefined, ""]; + const opts = { + empty: _opts?.empty ?? _empty, + emptyInArray: _opts?.emptyInArray ?? _empty, + keepEmptyArray: _opts?.keepEmptyArray ?? false + }; + + return Object.entries(obj).reduce((acc, [key, value]) => { + if (value && Array.isArray(value) && value.some((v) => typeof v === "object")) { + const nested = value.map((o) => cleanObject(o, opts)); + if (nested.length > 0 || opts?.keepEmptyArray) { + acc[key] = nested; + } + } else if (value && typeof value === "object" && !Array.isArray(value)) { + const nested = cleanObject(value, opts); + if (Object.keys(nested).length > 0) { + acc[key] = nested; + } + } else if (Array.isArray(value)) { + const nested = value.filter((v) => !opts.emptyInArray.includes(v)); + if (nested.length > 0 || opts?.keepEmptyArray) { + acc[key] = nested; + } + } else if (!opts.empty.includes(value)) { + acc[key] = value; + } + return acc; + }, {} as any); +} + +export function setPath(object, _path, value) { + let path = _path; + + // Optional string-path support. + // You can remove this `if` block if you don't need it. + if (typeof path === "string") { + const isQuoted = (str) => str[0] === '"' && str.at(-1) === '"'; + path = path + .split(/[.\[\]]+/) + .filter((x) => x) + .map((x) => (!Number.isNaN(Number(x)) ? Number(x) : x)) + .map((x) => (typeof x === "string" && isQuoted(x) ? x.slice(1, -1) : x)); + } + + if (path.length === 0) { + throw new Error("The path must have at least one entry in it"); + } + + const [head, ...tail] = path; + + if (tail.length === 0) { + object[head] = value; + return object; + } + + if (!(head in object)) { + object[head] = typeof tail[0] === "number" ? [] : {}; + } + + setPath(object[head], tail, value); + return object; +} diff --git a/app/src/ui/elements/index.ts b/app/src/ui/elements/index.ts index 9072c1e..5e2128a 100644 --- a/app/src/ui/elements/index.ts +++ b/app/src/ui/elements/index.ts @@ -1,2 +1,3 @@ export * from "./auth"; export * from "./media"; +export * from "../components/form/native-form/NativeForm"; diff --git a/app/src/ui/index.ts b/app/src/ui/index.ts index 92a9c97..6631083 100644 --- a/app/src/ui/index.ts +++ b/app/src/ui/index.ts @@ -1 +1,2 @@ export { default as Admin, type BkndAdminProps } from "./Admin"; +export * from "./components/form/json-schema-form"; diff --git a/app/src/ui/routes/test/index.tsx b/app/src/ui/routes/test/index.tsx index 7699b07..a97f095 100644 --- a/app/src/ui/routes/test/index.tsx +++ b/app/src/ui/routes/test/index.tsx @@ -2,6 +2,7 @@ import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-tes import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test"; import FormyTest from "ui/routes/test/tests/formy-test"; +import HtmlFormTest from "ui/routes/test/tests/html-form-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"; @@ -50,7 +51,8 @@ const tests = { DropzoneElementTest, JsonSchemaFormReactTest, JsonSchemaForm3, - FormyTest + FormyTest, + HtmlFormTest } as const; export default function TestRoutes() { diff --git a/app/src/ui/routes/test/tests/html-form-test.tsx b/app/src/ui/routes/test/tests/html-form-test.tsx new file mode 100644 index 0000000..1527417 --- /dev/null +++ b/app/src/ui/routes/test/tests/html-form-test.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { Button } from "ui/components/buttons/Button"; +import { JsonViewer } from "ui/components/code/JsonViewer"; +import * as Formy from "ui/components/form/Formy"; +import { NativeForm } from "ui/components/form/native-form/NativeForm"; + +export default function HtmlFormTest() { + const [data, setData] = useState(); + const [errors, setErrors] = useState(); + + return ( +
+

html

+ + console.log("submit", data)} + onSubmitInvalid={(errors) => console.log("invalid", errors)} + onError={setErrors} + reportValidity + clean + > + +
+ + + + + + + + + + +
+ ); +}