add form-modal

This commit is contained in:
Mohamadzadeh 2026-06-17 13:26:04 +03:30
parent 5a3942f886
commit e8a71242e0
6 changed files with 1026 additions and 3 deletions

392
dist/form.js vendored Normal file
View File

@ -0,0 +1,392 @@
import { jsx as n, jsxs as y } from "react/jsx-runtime";
import { Input as P, Checkbox as ee, Textarea as re, RadioGroup as te, HStack as ne, Radio as oe, Modal as le, ModalOverlay as ie, ModalContent as ce, ModalHeader as ue, ModalCloseButton as se, ModalBody as ae, VStack as de, SimpleGrid as fe, ModalFooter as pe, Button as G, FormControl as be, FormLabel as me, FormErrorMessage as ve, useDisclosure as xe } from "@chakra-ui/react";
import { createContext as Fe, useContext as ge, useMemo as x, useRef as he, useEffect as H, useState as Se, useCallback as I } from "react";
import { Controller as z, useForm as ke, useWatch as Re } from "react-hook-form";
import { Select as Me } from "chakra-react-select";
const Ce = {}, C = {
create: {
titlePrefix: "افزودن",
submitText: "ثبت"
},
edit: {
titlePrefix: "ویرایش",
submitText: "ویرایش"
}
}, U = Fe(null);
function ye({ item: e, mode: r }) {
return e ? [r, e.rowId, e.entityId, e.id].filter(Boolean).join("-") : r;
}
function F({ required: e, validate: r, rules: t }) {
const o = { ...t ?? {} };
return e && (o.required = typeof e == "string" ? e : "این فیلد الزامی است."), r && (o.validate = r), o;
}
function m() {
const e = ge(U);
if (!e)
throw new Error("Field باید داخل FormModal استفاده شود.");
return e;
}
function _(e = []) {
return e.map((r) => ({
label: r.label ?? r.name,
value: String(r.value ?? r.id)
}));
}
function g({ name: e, label: r, showWhen: t, children: o }) {
var s;
const { errors: i, control: u } = m(), c = Re({
control: u,
name: t == null ? void 0 : t.name
});
return x(() => t ? typeof t == "function" ? t({
value: c
}) : c === t.value : !0, [t, c]) ? /* @__PURE__ */ y(be, { isInvalid: !!i[e], children: [
r && /* @__PURE__ */ n(me, { fontSize: "sm", fontWeight: "bold", children: r }),
o,
/* @__PURE__ */ n(ve, { children: (s = i[e]) == null ? void 0 : s.message })
] }) : null;
}
function Ve({
name: e,
label: r,
required: t,
validate: o,
rules: i,
showWhen: u,
placeholder: c,
...l
}) {
const { register: s } = m();
return /* @__PURE__ */ n(g, { name: e, label: r, showWhen: u, children: /* @__PURE__ */ n(
P,
{
borderRadius: "xl",
placeholder: c,
...s(e, F({ required: t, validate: o, rules: i })),
...l
}
) });
}
function Ee({
name: e,
label: r,
required: t,
validate: o,
rules: i,
showWhen: u,
placeholder: c,
...l
}) {
const { register: s } = m();
return /* @__PURE__ */ n(g, { name: e, label: r, showWhen: u, children: /* @__PURE__ */ n(
re,
{
borderRadius: "xl",
placeholder: c,
...s(e, F({ required: t, validate: o, rules: i })),
...l
}
) });
}
function Be({
name: e,
label: r,
required: t,
validate: o,
rules: i,
showWhen: u,
placeholder: c = "انتخاب کنید",
options: l = [],
onChange: s,
isClearable: d = !0,
...p
}) {
const { control: f, setValue: a } = m(), k = _(l);
return /* @__PURE__ */ n(g, { name: e, label: r, showWhen: u, children: /* @__PURE__ */ n(
z,
{
name: e,
control: f,
rules: F({ required: t, validate: o, rules: i }),
render: ({ field: S }) => {
const V = k.find(
(v) => String(v.value) === String(S.value)
) ?? null;
return /* @__PURE__ */ n(
Me,
{
...p,
value: V,
options: k,
placeholder: c,
isClearable: d,
onChange: (v) => {
const R = (v == null ? void 0 : v.value) ?? "";
S.onChange(R), s == null || s({
value: R,
setValue: a
});
}
}
);
}
}
) });
}
function Ie({
name: e,
label: r,
required: t,
validate: o,
rules: i,
showWhen: u,
options: c = [],
onChange: l
}) {
const { control: s, setValue: d } = m(), p = _(c);
return /* @__PURE__ */ n(g, { name: e, label: r, showWhen: u, children: /* @__PURE__ */ n(
z,
{
name: e,
control: s,
rules: F({ required: t, validate: o, rules: i }),
render: ({ field: f }) => /* @__PURE__ */ n(
te,
{
value: f.value,
onChange: (a) => {
f.onChange(a), l == null || l({
value: a,
setValue: d
});
},
children: /* @__PURE__ */ n(ne, { pt: "8px", spacing: 3, children: p.map((a) => /* @__PURE__ */ n(oe, { value: a.value, children: a.label }, a.value)) })
}
)
}
) });
}
function Pe({
name: e,
label: r,
required: t,
validate: o,
rules: i,
showWhen: u,
children: c,
onChange: l
}) {
const { control: s, setValue: d } = m();
return /* @__PURE__ */ n(g, { name: e, label: r, showWhen: u, children: /* @__PURE__ */ n(
z,
{
name: e,
control: s,
rules: F({ required: t, validate: o, rules: i }),
render: ({ field: p }) => /* @__PURE__ */ n(
ee,
{
isChecked: !!p.value,
onChange: (f) => {
const a = f.target.checked;
p.onChange(a), l == null || l({
value: a,
setValue: d
});
},
children: c ?? r
}
)
}
) });
}
function ze({
name: e,
label: r,
required: t,
validate: o,
rules: i,
showWhen: u,
...c
}) {
const { register: l } = m();
return /* @__PURE__ */ n(g, { name: e, label: r, showWhen: u, children: /* @__PURE__ */ n(
P,
{
type: "file",
borderRadius: "xl",
...l(e, F({ required: t, validate: o, rules: i })),
...c
}
) });
}
function De({
name: e,
label: r,
required: t,
validate: o,
rules: i,
showWhen: u,
...c
}) {
const { register: l } = m();
return /* @__PURE__ */ n(g, { name: e, label: r, showWhen: u, children: /* @__PURE__ */ n(
P,
{
type: "date",
borderRadius: "xl",
...l(e, F({ required: t, validate: o, rules: i })),
...c
}
) });
}
const Ae = {
Input: Ve,
Select: Be,
Radio: Ie,
Textarea: Ee,
Check: Pe,
Filepicker: ze,
Datepicker: De,
useForm: m
};
function Ge({
isOpen: e,
close: r,
item: t = null,
entityName: o = "آیتم",
title: i,
createTitle: u,
editTitle: c,
submitText: l,
createSubmitText: s,
editSubmitText: d,
defaultValues: p = Ce,
getDefaultValues: f,
resetKey: a,
onSubmit: k,
transformValues: S,
children: V,
isLoading: v = !1,
size: R = "lg",
columns: Y = { base: 1, md: 2 }
}) {
const b = !!t, h = b ? "edit" : "create", w = x(() => i || (b ? c ?? `${C.edit.titlePrefix} ${o}` : u ?? `${C.create.titlePrefix} ${o}`), [i, c, u, o, b]), J = x(() => l || (b ? d ?? C.edit.submitText : s ?? C.create.submitText), [l, d, s, b]), M = x(() => typeof f == "function" ? f({
item: t,
mode: h,
isEdit: b
}) : p, [f, p, t, h, b]), E = he(M);
H(() => {
E.current = M;
}, [M]);
const K = x(() => a || ye({
item: t,
mode: h
}), [a, t, h]), Q = ke({
defaultValues: M
}), {
handleSubmit: X,
reset: B,
register: D,
control: L,
setValue: O,
watch: T,
formState: { isSubmitting: Z, errors: $ }
} = Q;
H(() => {
e && B(E.current);
}, [e, K, B]);
const j = () => {
B(E.current), r();
}, q = X(async (A) => {
const W = typeof S == "function" ? S({
values: A,
item: t,
mode: h,
isEdit: b
}) : A;
await k({
values: W,
item: t,
mode: h,
isEdit: b
});
}), N = x(
() => ({
register: D,
control: L,
setValue: O,
watch: T,
errors: $
}),
[D, L, O, T, $]
);
return /* @__PURE__ */ y(le, { isOpen: e, onClose: j, isCentered: !0, size: R, children: [
/* @__PURE__ */ n(ie, { backdropFilter: "blur(6px)" }),
/* @__PURE__ */ y(
ce,
{
as: "form",
onSubmit: q,
borderRadius: "2xl",
overflow: "visible",
children: [
/* @__PURE__ */ n(
ue,
{
bgGradient: "linear(to-l, blue.600, facebook.500)",
color: "white",
fontSize: "md",
fontWeight: "black",
children: w
}
),
/* @__PURE__ */ n(se, { color: "white" }),
/* @__PURE__ */ n(ae, { py: 5, children: /* @__PURE__ */ n(U.Provider, { value: N, children: /* @__PURE__ */ n(de, { spacing: 4, alignItems: "stretch", children: /* @__PURE__ */ n(fe, { columns: Y, spacing: 4, children: V }) }) }) }),
/* @__PURE__ */ y(pe, { gap: 2, bg: "gray.50", children: [
/* @__PURE__ */ n(G, { type: "button", variant: "ghost", onClick: j, children: "انصراف" }),
/* @__PURE__ */ n(
G,
{
type: "submit",
colorScheme: "blue",
borderRadius: "xl",
isLoading: Z || v,
children: J
}
)
] })
]
}
)
] });
}
function He() {
const { isOpen: e, onOpen: r, onClose: t } = xe(), [o, i] = Se(null), u = I(() => {
i(null), r();
}, [r]), c = I(
(d) => {
i(d), r();
},
[r]
), l = I(() => {
i(null), t();
}, [t]), s = !!o;
return x(
() => ({
isOpen: e,
item: o,
isEdit: s,
isCreate: !s,
openCreate: u,
openEdit: c,
close: l
}),
[e, o, s, u, c, l]
);
}
export {
Ae as Field,
Ge as FormModal,
He as useFormModal
};

View File

@ -10,7 +10,8 @@
"./pagination": "./dist/pagination.js", "./pagination": "./dist/pagination.js",
"./layout": "./dist/layout.js", "./layout": "./dist/layout.js",
"./core": "./dist/core.js", "./core": "./dist/core.js",
"./table": "./dist/table.js" "./table": "./dist/table.js",
"./form": "./dist/form.js"
}, },
"files": [ "files": [
"dist" "dist"
@ -29,7 +30,9 @@
"keycloak-js": "^26.0.0", "keycloak-js": "^26.0.0",
"react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0",
"react-icons": "^5.0.0" "react-icons": "^5.0.0",
"react-hook-form": "^7.0.0",
"chakra-react-select": "^5.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@chakra-ui/react": { "@chakra-ui/react": {
@ -64,6 +67,12 @@
}, },
"react-icons": { "react-icons": {
"optional": true "optional": true
},
"react-hook-form": {
"optional": true
},
"chakra-react-select": {
"optional": true
} }
}, },
"devDependencies": { "devDependencies": {
@ -79,7 +88,9 @@
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"react-icons": "^5.0.0", "react-icons": "^5.0.0",
"vite": "5.4.19" "vite": "5.4.19",
"react-hook-form": "^7.0.0",
"chakra-react-select": "^5.0.0"
}, },
"dependencies": {} "dependencies": {}
} }

574
src/form/FormModal.jsx Normal file
View File

@ -0,0 +1,574 @@
import {
Button,
Checkbox,
FormControl,
FormErrorMessage,
FormLabel,
HStack,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Radio,
RadioGroup,
Select,
SimpleGrid,
Textarea,
VStack,
} from "@chakra-ui/react";
import { createContext, useContext, useEffect, useMemo, useRef } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { Select as ChakraReactSelect } from "chakra-react-select";
const EMPTY_DEFAULT_VALUES = {};
const defaultActionLabels = {
create: {
titlePrefix: "افزودن",
submitText: "ثبت",
},
edit: {
titlePrefix: "ویرایش",
submitText: "ویرایش",
},
};
const FormModalContext = createContext(null);
// ----- utils -----
function getDefaultResetKey({ item, mode }) {
if (!item) return mode;
return [mode, item.rowId, item.entityId, item.id].filter(Boolean).join("-");
}
function getRules({ required, validate, rules }) {
const finalRules = { ...(rules ?? {}) };
if (required) {
finalRules.required =
typeof required === "string" ? required : "این فیلد الزامی است.";
}
if (validate) {
finalRules.validate = validate;
}
return finalRules;
}
function useFormModalContext() {
const context = useContext(FormModalContext);
if (!context) {
throw new Error("Field باید داخل FormModal استفاده شود.");
}
return context;
}
function normalizeOptions(options = []) {
return options.map((option) => ({
label: option.label ?? option.name,
value: String(option.value ?? option.id),
}));
}
// ----- field wrapper -----
function FieldWrapper({ name, label, showWhen, children }) {
const { errors, control } = useFormModalContext();
const watchedValue = useWatch({
control,
name: showWhen?.name,
});
const isVisible = useMemo(() => {
if (!showWhen) return true;
if (typeof showWhen === "function") {
return showWhen({
value: watchedValue,
});
}
return watchedValue === showWhen.value;
}, [showWhen, watchedValue]);
if (!isVisible) return null;
return (
<FormControl isInvalid={Boolean(errors[name])}>
{label && (
<FormLabel fontSize="sm" fontWeight="bold">
{label}
</FormLabel>
)}
{children}
<FormErrorMessage>{errors[name]?.message}</FormErrorMessage>
</FormControl>
);
}
// ----- fields -----
function InputField({
name,
label,
required,
validate,
rules,
showWhen,
placeholder,
...props
}) {
const { register } = useFormModalContext();
return (
<FieldWrapper name={name} label={label} showWhen={showWhen}>
<Input
borderRadius="xl"
placeholder={placeholder}
{...register(name, getRules({ required, validate, rules }))}
{...props}
/>
</FieldWrapper>
);
}
function TextareaField({
name,
label,
required,
validate,
rules,
showWhen,
placeholder,
...props
}) {
const { register } = useFormModalContext();
return (
<FieldWrapper name={name} label={label} showWhen={showWhen}>
<Textarea
borderRadius="xl"
placeholder={placeholder}
{...register(name, getRules({ required, validate, rules }))}
{...props}
/>
</FieldWrapper>
);
}
function SelectField({
name,
label,
required,
validate,
rules,
showWhen,
placeholder = "انتخاب کنید",
options = [],
onChange,
isClearable = true,
...props
}) {
const { control, setValue } = useFormModalContext();
const normalizedOptions = normalizeOptions(options);
return (
<FieldWrapper name={name} label={label} showWhen={showWhen}>
<Controller
name={name}
control={control}
rules={getRules({ required, validate, rules })}
render={({ field }) => {
const selectedOption =
normalizedOptions.find(
(option) => String(option.value) === String(field.value),
) ?? null;
return (
<ChakraReactSelect
{...props}
value={selectedOption}
options={normalizedOptions}
placeholder={placeholder}
isClearable={isClearable}
onChange={(selected) => {
const value = selected?.value ?? "";
field.onChange(value);
onChange?.({
value,
setValue,
});
}}
/>
);
}}
/>
</FieldWrapper>
);
}
function RadioField({
name,
label,
required,
validate,
rules,
showWhen,
options = [],
onChange,
}) {
const { control, setValue } = useFormModalContext();
const normalizedOptions = normalizeOptions(options);
return (
<FieldWrapper name={name} label={label} showWhen={showWhen}>
<Controller
name={name}
control={control}
rules={getRules({ required, validate, rules })}
render={({ field }) => (
<RadioGroup
value={field.value}
onChange={(value) => {
field.onChange(value);
onChange?.({
value,
setValue,
});
}}
>
<HStack pt="8px" spacing={3}>
{normalizedOptions.map((option) => (
<Radio key={option.value} value={option.value}>
{option.label}
</Radio>
))}
</HStack>
</RadioGroup>
)}
/>
</FieldWrapper>
);
}
function CheckField({
name,
label,
required,
validate,
rules,
showWhen,
children,
onChange,
}) {
const { control, setValue } = useFormModalContext();
return (
<FieldWrapper name={name} label={label} showWhen={showWhen}>
<Controller
name={name}
control={control}
rules={getRules({ required, validate, rules })}
render={({ field }) => (
<Checkbox
isChecked={Boolean(field.value)}
onChange={(event) => {
const checked = event.target.checked;
field.onChange(checked);
onChange?.({
value: checked,
setValue,
});
}}
>
{children ?? label}
</Checkbox>
)}
/>
</FieldWrapper>
);
}
function FilepickerField({
name,
label,
required,
validate,
rules,
showWhen,
...props
}) {
const { register } = useFormModalContext();
return (
<FieldWrapper name={name} label={label} showWhen={showWhen}>
<Input
type="file"
borderRadius="xl"
{...register(name, getRules({ required, validate, rules }))}
{...props}
/>
</FieldWrapper>
);
}
function DatepickerField({
name,
label,
required,
validate,
rules,
showWhen,
...props
}) {
const { register } = useFormModalContext();
return (
<FieldWrapper name={name} label={label} showWhen={showWhen}>
<Input
type="date"
borderRadius="xl"
{...register(name, getRules({ required, validate, rules }))}
{...props}
/>
</FieldWrapper>
);
}
export const Field = {
Input: InputField,
Select: SelectField,
Radio: RadioField,
Textarea: TextareaField,
Check: CheckField,
Filepicker: FilepickerField,
Datepicker: DatepickerField,
useForm: useFormModalContext,
};
// ----- form modal -----
export function FormModal({
isOpen,
close,
item = null,
entityName = "آیتم",
title,
createTitle,
editTitle,
submitText,
createSubmitText,
editSubmitText,
defaultValues = EMPTY_DEFAULT_VALUES,
getDefaultValues,
resetKey,
onSubmit,
transformValues,
children,
isLoading = false,
size = "lg",
columns = { base: 1, md: 2 },
}) {
const isEdit = Boolean(item);
const mode = isEdit ? "edit" : "create";
// ----- title -----
const resolvedTitle = useMemo(() => {
if (title) return title;
if (isEdit) {
return (
editTitle ?? `${defaultActionLabels.edit.titlePrefix} ${entityName}`
);
}
return (
createTitle ?? `${defaultActionLabels.create.titlePrefix} ${entityName}`
);
}, [title, editTitle, createTitle, entityName, isEdit]);
// ----- submit text -----
const resolvedSubmitText = useMemo(() => {
if (submitText) return submitText;
if (isEdit) {
return editSubmitText ?? defaultActionLabels.edit.submitText;
}
return createSubmitText ?? defaultActionLabels.create.submitText;
}, [submitText, editSubmitText, createSubmitText, isEdit]);
// ----- default values -----
const resolvedDefaultValues = useMemo(() => {
if (typeof getDefaultValues === "function") {
return getDefaultValues({
item,
mode,
isEdit,
});
}
return defaultValues;
}, [getDefaultValues, defaultValues, item, mode, isEdit]);
const latestDefaultValuesRef = useRef(resolvedDefaultValues);
useEffect(() => {
latestDefaultValuesRef.current = resolvedDefaultValues;
}, [resolvedDefaultValues]);
// ----- reset key -----
const resolvedResetKey = useMemo(() => {
if (resetKey) return resetKey;
return getDefaultResetKey({
item,
mode,
});
}, [resetKey, item, mode]);
// ----- form -----
const methods = useForm({
defaultValues: resolvedDefaultValues,
});
const {
handleSubmit,
reset,
register,
control,
setValue,
watch,
formState: { isSubmitting, errors },
} = methods;
// ----- reset on open -----
useEffect(() => {
if (!isOpen) return;
reset(latestDefaultValuesRef.current);
}, [isOpen, resolvedResetKey, reset]);
// ----- close -----
const handleClose = () => {
reset(latestDefaultValuesRef.current);
close();
};
// ----- submit -----
const handleFormSubmit = handleSubmit(async (values) => {
const finalValues =
typeof transformValues === "function"
? transformValues({
values,
item,
mode,
isEdit,
})
: values;
await onSubmit({
values: finalValues,
item,
mode,
isEdit,
});
});
// ----- context -----
const contextValue = useMemo(
() => ({
register,
control,
setValue,
watch,
errors,
}),
[register, control, setValue, watch, errors],
);
// ----- jsx -----
return (
<Modal isOpen={isOpen} onClose={handleClose} isCentered size={size}>
<ModalOverlay backdropFilter="blur(6px)" />
<ModalContent
as="form"
onSubmit={handleFormSubmit}
borderRadius="2xl"
overflow="visible"
>
<ModalHeader
bgGradient="linear(to-l, blue.600, facebook.500)"
color="white"
fontSize="md"
fontWeight="black"
>
{resolvedTitle}
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody py={5}>
<FormModalContext.Provider value={contextValue}>
<VStack spacing={4} alignItems="stretch">
<SimpleGrid columns={columns} spacing={4}>
{children}
</SimpleGrid>
</VStack>
</FormModalContext.Provider>
</ModalBody>
<ModalFooter gap={2} bg="gray.50">
<Button type="button" variant="ghost" onClick={handleClose}>
انصراف
</Button>
<Button
type="submit"
colorScheme="blue"
borderRadius="xl"
isLoading={isSubmitting || isLoading}
>
{resolvedSubmitText}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

2
src/form/index.js Normal file
View File

@ -0,0 +1,2 @@
export { FormModal, Field } from "./FormModal.jsx";
export { useFormModal } from "./useFormModal.jsx";

40
src/form/useFormModal.jsx Normal file
View File

@ -0,0 +1,40 @@
import { useCallback, useMemo, useState } from "react";
import { useDisclosure } from "@chakra-ui/react";
export function useFormModal() {
const { isOpen, onOpen, onClose } = useDisclosure();
const [item, setItem] = useState(null);
const openCreate = useCallback(() => {
setItem(null);
onOpen();
}, [onOpen]);
const openEdit = useCallback(
(selectedItem) => {
setItem(selectedItem);
onOpen();
},
[onOpen],
);
const close = useCallback(() => {
setItem(null);
onClose();
}, [onClose]);
const isEdit = Boolean(item);
return useMemo(
() => ({
isOpen,
item,
isEdit,
isCreate: !isEdit,
openCreate,
openEdit,
close,
}),
[isOpen, item, isEdit, openCreate, openEdit, close],
);
}

View File

@ -19,6 +19,9 @@ const externalPackages = [
"@tanstack/react-table", "@tanstack/react-table",
"react-icons", "react-icons",
"react-hook-form",
"chakra-react-select",
]; ];
export default defineConfig({ export default defineConfig({
@ -33,6 +36,7 @@ export default defineConfig({
layout: path.resolve(process.cwd(), "src/layout/index.js"), layout: path.resolve(process.cwd(), "src/layout/index.js"),
core: path.resolve(process.cwd(), "src/core/index.js"), core: path.resolve(process.cwd(), "src/core/index.js"),
table: path.resolve(process.cwd(), "src/table/index.js"), table: path.resolve(process.cwd(), "src/table/index.js"),
form: path.resolve(process.cwd(), "src/form/index.js"),
}, },
formats: ["es"], formats: ["es"],
}, },