mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Merge pull request #71 from bknd-io/feat/elements-native-form
feat/elements-native-form
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 "./auth";
|
||||||
export * from "./media";
|
export * from "./media";
|
||||||
|
export * from "../components/form/native-form/NativeForm";
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { default as Admin, type BkndAdminProps } from "./Admin";
|
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 JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test";
|
||||||
|
|
||||||
import FormyTest from "ui/routes/test/tests/formy-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 SwaggerTest from "ui/routes/test/tests/swagger-test";
|
||||||
import SWRAndAPI from "ui/routes/test/tests/swr-and-api";
|
import SWRAndAPI from "ui/routes/test/tests/swr-and-api";
|
||||||
import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api";
|
import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api";
|
||||||
@@ -50,7 +51,8 @@ const tests = {
|
|||||||
DropzoneElementTest,
|
DropzoneElementTest,
|
||||||
JsonSchemaFormReactTest,
|
JsonSchemaFormReactTest,
|
||||||
JsonSchemaForm3,
|
JsonSchemaForm3,
|
||||||
FormyTest
|
FormyTest,
|
||||||
|
HtmlFormTest
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function TestRoutes() {
|
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