mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
added native reactive form to elements
This commit is contained in:
215
app/src/ui/components/form/native-form/NativeForm.tsx
Normal file
215
app/src/ui/components/form/native-form/NativeForm.tsx
Normal file
@@ -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?: <K extends keyof HTMLElementTagNameMap>(name: string) => any | null;
|
||||
reportValidity?: boolean;
|
||||
onSubmit?: (data: any, ctx: { event: FormEvent<HTMLFormElement> }) => Promise<void> | void;
|
||||
onSubmitInvalid?: (
|
||||
errors: InputError[],
|
||||
ctx: { event: FormEvent<HTMLFormElement> }
|
||||
) => Promise<void> | void;
|
||||
onError?: (errors: InputError[]) => void;
|
||||
onChange?: (
|
||||
data: any,
|
||||
ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] }
|
||||
) => Promise<void> | void;
|
||||
clean?: CleanOptions | true;
|
||||
} & Omit<ComponentPropsWithoutRef<"form">, "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<HTMLFormElement>(null);
|
||||
const [errors, setErrors] = useState<InputError[]>([]);
|
||||
|
||||
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<HTMLFormElement>) => {
|
||||
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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<form
|
||||
{...props}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
ref={formRef}
|
||||
onKeyDown={handleKeyDown as any}
|
||||
>
|
||||
{children}
|
||||
{hiddenSubmit && <input type="submit" style={{ visibility: "hidden" }} />}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
137
app/src/ui/components/form/native-form/utils.ts
Normal file
137
app/src/ui/components/form/native-form/utils.ts
Normal file
@@ -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<HTMLFormElement>): 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 extends { [key: string]: any }>(
|
||||
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;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./auth";
|
||||
export * from "./media";
|
||||
export * from "../components/form/native-form/NativeForm";
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as Admin, type BkndAdminProps } from "./Admin";
|
||||
export * from "./components/form/json-schema-form";
|
||||
|
||||
@@ -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() {
|
||||
|
||||
39
app/src/ui/routes/test/tests/html-form-test.tsx
Normal file
39
app/src/ui/routes/test/tests/html-form-test.tsx
Normal file
@@ -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<any>();
|
||||
const [errors, setErrors] = useState<any>();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-3">
|
||||
<h1>html</h1>
|
||||
|
||||
<NativeForm
|
||||
className="flex flex-col gap-3"
|
||||
validateOn="change"
|
||||
onChange={setData}
|
||||
onSubmit={(data) => console.log("submit", data)}
|
||||
onSubmitInvalid={(errors) => console.log("invalid", errors)}
|
||||
onError={setErrors}
|
||||
reportValidity
|
||||
clean
|
||||
>
|
||||
<Formy.Input type="text" name="what" minLength={2} maxLength={5} required />
|
||||
<div data-role="input-error" data-name="what" />
|
||||
<Formy.Input type="number" name="age" step={5} required />
|
||||
<Formy.Input type="checkbox" name="verified" />
|
||||
|
||||
<Formy.Input type="text" name="tag" minLength={1} required />
|
||||
<Formy.Input type="number" name="tag" />
|
||||
|
||||
<Button type="submit">submit</Button>
|
||||
</NativeForm>
|
||||
|
||||
<JsonViewer json={{ data, errors }} expand={9} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user