diff --git a/package-lock.json b/package-lock.json index 33f1f875..97df7e0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@churchapps/helpers": "^1.2.22", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@hookform/resolvers": "^5.2.2", "@mui/icons-material": "^7.3.6", "@mui/lab": "^7.0.0-beta.22", "@mui/material": "^7.3.6", @@ -47,11 +48,13 @@ "react-dom": "^19.2.1", "react-ga4": "^2.1.0", "react-helmet": "^6.1.0", + "react-hook-form": "^7.71.1", "react-image-file-resizer": "^0.4.8", "react-router-dom": "^7.6.3", "react-youtube": "^10.1.0", "rrule": "^2.8.1", - "webfontloader": "^1.6.28" + "webfontloader": "^1.6.28", + "zod": "^4.3.6" }, "devDependencies": { "@playwright/test": "^1.53.0", @@ -1204,6 +1207,18 @@ "supercluster": "^8.0.1" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4714,6 +4729,12 @@ "webpack": ">=4.40.0" } }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@stripe/react-stripe-js": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.10.0.tgz", @@ -5695,6 +5716,15 @@ "zod": "4.1.12" } }, + "node_modules/@youversion/platform-core/node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@youversion/platform-react-hooks": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/@youversion/platform-react-hooks/-/platform-react-hooks-0.5.8.tgz", @@ -10805,6 +10835,22 @@ "react": "^16.3.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.71.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", + "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-i18next": { "version": "15.7.4", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", @@ -13278,9 +13324,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 313bd605..469ac828 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@churchapps/helpers": "^1.2.22", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@hookform/resolvers": "^5.2.2", "@mui/icons-material": "^7.3.6", "@mui/lab": "^7.0.0-beta.22", "@mui/material": "^7.3.6", @@ -58,11 +59,13 @@ "react-dom": "^19.2.1", "react-ga4": "^2.1.0", "react-helmet": "^6.1.0", + "react-hook-form": "^7.71.1", "react-image-file-resizer": "^0.4.8", "react-router-dom": "^7.6.3", "react-youtube": "^10.1.0", "rrule": "^2.8.1", - "webfontloader": "^1.6.28" + "webfontloader": "^1.6.28", + "zod": "^4.3.6" }, "overrides": { "react-is": "18.3.1" @@ -83,4 +86,4 @@ "eslint-plugin-unused-imports": "4.1.4", "typescript": "^5.8.3" } -} \ No newline at end of file +} diff --git a/src/components/member/directory/ProfileEdit.tsx b/src/components/member/directory/ProfileEdit.tsx index 232ab774..31f2879d 100644 --- a/src/components/member/directory/ProfileEdit.tsx +++ b/src/components/member/directory/ProfileEdit.tsx @@ -1,9 +1,91 @@ -import React, { useState, useEffect, useRef } from "react"; -import { ApiHelper, ArrayHelper, DateHelper, ImageEditor, UserHelper } from "@churchapps/apphelper"; +"use client"; + +import React, { useState, useEffect } from "react"; +import { useForm, Controller, FormProvider } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { ApiHelper, DateHelper, ImageEditor, UserHelper } from "@churchapps/apphelper"; import type { GroupInterface, PersonInterface, TaskInterface } from "@churchapps/helpers"; -import { Button, Grid, TextField, Box, Typography, Alert } from "@mui/material"; +import { Button, Grid, TextField, Box, Typography, Alert, TextFieldProps } from "@mui/material"; import { PersonHelper } from "../../../helpers"; +// Reusable form field - gets control from FormProvider context +function FormTextField({ name, ...props }: { name: string } & Omit) { + return ( + ( + + )} + /> + ); +} + +// Zod schema - email must be valid format or empty string +const nameSchema = z.object({ + first: z.string(), + middle: z.string(), + last: z.string(), +}); + +const contactInfoSchema = z.object({ + email: z.email("Invalid email format").or(z.literal("")), + address1: z.string(), + address2: z.string(), + city: z.string(), + state: z.string(), + zip: z.string(), + homePhone: z.string(), + mobilePhone: z.string(), + workPhone: z.string(), +}); + +const profileSchema = z.object({ + name: nameSchema, + contactInfo: contactInfoSchema, + birthDate: z.string(), + photo: z.string(), +}); + +type ProfileFormData = z.infer; + +// Single source of truth for empty form state +// Note: Can't use Zod .default() because zodResolver types on INPUT not OUTPUT +const PROFILE_DEFAULTS: ProfileFormData = { + name: { first: "", middle: "", last: "" }, + contactInfo: { + email: "", address1: "", address2: "", city: "", state: "", zip: "", + homePhone: "", mobilePhone: "", workPhone: "", + }, + birthDate: "", + photo: "", +}; + +// Single source of truth for field labels - used by FormTextField and dirty field display +const fieldLabels: Record = { + "name.first": "First Name", + "name.middle": "Middle Name", + "name.last": "Last Name", + "photo": "Photo", + "birthDate": "Birth Date", + "contactInfo.email": "Email", + "contactInfo.address1": "Address Line 1", + "contactInfo.address2": "Address Line 2", + "contactInfo.city": "City", + "contactInfo.state": "State", + "contactInfo.zip": "Zip", + "contactInfo.homePhone": "Home Phone", + "contactInfo.mobilePhone": "Mobile Phone", + "contactInfo.workPhone": "Work Phone", +}; + interface Props { personId: string; person: PersonInterface; @@ -19,106 +101,88 @@ interface ProfileChange { value: string; } -const fieldDefinitions = [ - { key: "name.first", label: "First Name" }, - { key: "name.middle", label: "Middle Name" }, - { key: "name.last", label: "Last Name" }, - { key: "photo", label: "Photo" }, - { key: "birthDate", label: "Birth Date" }, - { key: "contactInfo.email", label: "Email" }, - { key: "contactInfo.address1", label: "Address Line 1" }, - { key: "contactInfo.address2", label: "Address Line 2" }, - { key: "contactInfo.city", label: "City" }, - { key: "contactInfo.state", label: "State" }, - { key: "contactInfo.zip", label: "Zip" }, - { key: "contactInfo.homePhone", label: "Home Phone" }, - { key: "contactInfo.mobilePhone", label: "Mobile Phone" }, - { key: "contactInfo.workPhone", label: "Work Phone" }, -]; - export const ProfileEdit: React.FC = (props) => { - const [person, setPerson] = useState(null); - const originalPersonRef = useRef(null); - const [modifiedFields, setModifiedFields] = useState>(new Set()); + // RHF tracks dirty by comparing to defaultValues - changing back = not dirty + const methods = useForm({ + resolver: zodResolver(profileSchema), + defaultValues: PROFILE_DEFAULTS, + mode: "onBlur", + }); + const { handleSubmit, formState: { isDirty, dirtyFields, isSubmitting }, reset, setValue } = methods; + const [showPhotoEditor, setShowPhotoEditor] = useState(false); - const [submitting, setSubmitting] = useState(false); const [submitted, setSubmitted] = useState(false); - const familyMembers = props.familyMembers || []; + // Sync props to form - reset() updates values and clears dirty state useEffect(() => { if (props.person) { - setPerson({ ...props.person }); - originalPersonRef.current = JSON.parse(JSON.stringify(props.person)); + const { name, contactInfo, birthDate, photo } = props.person; + reset({ + name: { ...PROFILE_DEFAULTS.name, ...name }, + contactInfo: { ...PROFILE_DEFAULTS.contactInfo, ...contactInfo }, + birthDate: birthDate ? DateHelper.formatHtml5Date(new Date(birthDate)) : "", + photo: photo ?? "", + }); } - }, [props.person]); + }, [props.person, reset]); - const getFieldValue = (obj: PersonInterface, key: string): string => { - const parts = key.split("."); - let value: any = obj; - for (const part of parts) { - value = value?.[part]; - } - if (key === "birthDate" && value) { - return DateHelper.formatHtml5Date(new Date(value)); - } - return value || ""; + const handlePhotoUpdate = (dataUrl: string) => { + setValue("photo", dataUrl, { shouldDirty: true }); + setShowPhotoEditor(false); }; - const setFieldValue = (obj: PersonInterface, key: string, value: string): PersonInterface => { - const newObj = JSON.parse(JSON.stringify(obj)); - const parts = key.split("."); - let target: any = newObj; - for (let i = 0; i < parts.length - 1; i++) { - if (!target[parts[i]]) target[parts[i]] = {}; - target = target[parts[i]]; + // Flatten nested dirtyFields object to dot-notation paths + const flattenDirtyFields = ( + obj: Record, + prefix = "" + ): string[] => { + const paths: string[] = []; + + for (const key in obj) { + const value = obj[key]; + const path = prefix ? `${prefix}.${key}` : key; + + if (value === true) { + // Leaf node - this field is dirty + paths.push(path); + } else if (typeof value === "object" && value !== null) { + // Nested object - recurse + paths.push(...flattenDirtyFields(value as Record, path)); + } } - target[parts[parts.length - 1]] = value; - return newObj; - }; - - const handleChange = (key: string, value: string) => { - if (!person || !originalPersonRef.current) return; - - const newPerson = setFieldValue(person, key, value); - setPerson(newPerson); - const originalValue = getFieldValue(originalPersonRef.current, key); - const newModified = new Set(modifiedFields); - - if (value !== originalValue) { - newModified.add(key); - } else { - newModified.delete(key); - } - setModifiedFields(newModified); + return paths; }; - const handlePhotoUpdate = (dataUrl: string) => { - if (!person) return; - const newPerson = { ...person, photo: dataUrl }; - setPerson(newPerson); + // Get value by dot-notation path (e.g., "contactInfo.email") + const getValueByPath = (obj: Record, path: string): unknown => { + return path.split(".").reduce((current, key) => { + return current && typeof current === "object" ? (current as Record)[key] : undefined; + }, obj as unknown); + }; - const newModified = new Set(modifiedFields); - newModified.add("photo"); - setModifiedFields(newModified); - setShowPhotoEditor(false); + // Build ProfileChange array from dirty fields + const buildChangesFromDirtyFields = (data: ProfileFormData): ProfileChange[] => { + return flattenDirtyFields(dirtyFields) + .filter((path) => fieldLabels[path]) + .map((path) => ({ + field: path, + label: fieldLabels[path], + value: String(getValueByPath(data as unknown as Record, path) ?? ""), + })); }; - const buildChanges = (): ProfileChange[] => { - const changes: ProfileChange[] = []; - - modifiedFields.forEach((key) => { - const fieldDef = fieldDefinitions.find((f) => f.key === key); - if (fieldDef && person) { - changes.push({ - field: key, - label: fieldDef.label, - value: key === "photo" ? person.photo || "" : getFieldValue(person, key), - }); - } - }); + const getModifiedFieldLabels = (): string[] => { + const labels = flattenDirtyFields(dirtyFields) + .map((path) => fieldLabels[path]) + .filter(Boolean); + familyMembers.forEach(() => labels.push("New Family Member")); + return labels; + }; + const onSubmit = async (data: ProfileFormData) => { + const changes = buildChangesFromDirtyFields(data); familyMembers.forEach((name) => { changes.push({ field: "familyMember", @@ -127,15 +191,9 @@ export const ProfileEdit: React.FC = (props) => { }); }); - return changes; - }; - - const handleSubmit = async () => { - const changes = buildChanges(); if (changes.length === 0) return; - setSubmitting(true); - + // Build and submit the task const task: TaskInterface = { dateCreated: new Date(), associatedWithType: "person", @@ -150,9 +208,15 @@ export const ProfileEdit: React.FC = (props) => { }; try { - const publicSettings = await ApiHelper.get(`/settings/public/${UserHelper.currentUserChurch.church.id}`, "MembershipApi"); + const publicSettings = await ApiHelper.get( + `/settings/public/${UserHelper.currentUserChurch.church.id}`, + "MembershipApi" + ); if (publicSettings?.directoryApprovalGroupId) { - const group: GroupInterface = await ApiHelper.get(`/groups/${publicSettings?.directoryApprovalGroupId}`, "MembershipApi"); + const group: GroupInterface = await ApiHelper.get( + `/groups/${publicSettings?.directoryApprovalGroupId}`, + "MembershipApi" + ); task.assignedToType = "group"; task.assignedToId = publicSettings.directoryApprovalGroupId; task.assignedToLabel = group?.name; @@ -160,30 +224,48 @@ export const ProfileEdit: React.FC = (props) => { await ApiHelper.post("/tasks?type=directoryUpdate", [task], "DoingApi"); setSubmitted(true); - setModifiedFields(new Set()); + + // Reset form to current values (clears dirty state) + reset(data); + if (props.onFamilyMembersChange) props.onFamilyMembersChange([]); if (props.onSave) props.onSave(); } catch (error) { console.error("Error submitting profile changes:", error); - } finally { - setSubmitting(false); } }; - const getModifiedFieldLabels = (): string[] => { - const labels: string[] = []; - modifiedFields.forEach((key) => { - const fieldDef = fieldDefinitions.find((f) => f.key === key); - if (fieldDef) labels.push(fieldDef.label); - }); - familyMembers.forEach(() => labels.push("New Family Member")); - return labels; - }; - - const hasChanges = modifiedFields.size > 0 || familyMembers.length > 0; - - if (!person) return null; - + // --------------------------------------------------------------------------- + // COMPUTED VALUES + // --------------------------------------------------------------------------- + const hasChanges = isDirty || familyMembers.length > 0; + + // Get current photo value for display + // Note: We can't use watch("photo") easily here since we need original too + const currentPhoto = props.person?.photo; + + if (!props.person) return null; + + // --------------------------------------------------------------------------- + // RENDER + // --------------------------------------------------------------------------- + /** + * CONTROLLER COMPONENT PATTERN + * + * MUI TextField is a "controlled component" that needs value/onChange props. + * RHF's register() works with native inputs via refs (uncontrolled). + * + * Controller bridges this gap: + * - name: Field path in the form (supports dot notation!) + * - control: The control object from useForm + * - render: Function that receives field props and fieldState + * + * The render function receives: + * - field: { onChange, onBlur, value, name, ref } + * - fieldState: { invalid, isTouched, isDirty, error } + * + * Spread {...field} onto your input to connect it to RHF. + */ return ( {submitted && ( @@ -197,190 +279,121 @@ export const ProfileEdit: React.FC = (props) => { setShowPhotoEditor(false)} onUpdate={handlePhotoUpdate} /> )} - - {/* Photo Section */} - - - setShowPhotoEditor(true)} - sx={{ cursor: "pointer", "&:hover": { opacity: 0.8 } }} - > - Profile - - Click to change photo - + +
+ + + + setShowPhotoEditor(true)} + sx={{ cursor: "pointer", "&:hover": { opacity: 0.8 } }} + > + Profile + + Click to change photo + + - - + - {/* Name Fields */} - - - - handleChange("name.first", e.target.value)} - /> - - - handleChange("name.middle", e.target.value)} - /> - - - handleChange("name.last", e.target.value)} - /> + + + + + + + + + + + - - - {/* Contact Section */} - - Contact - - - - handleChange("contactInfo.email", e.target.value)} - /> - - - handleChange("birthDate", e.target.value)} - /> + + + Contact + + + + + + + + - - - {/* Address and Phone Section */} - - {/* Address */} - - - Address - - handleChange("contactInfo.address1", e.target.value)} - sx={{ mb: 2 }} - /> - handleChange("contactInfo.address2", e.target.value)} - sx={{ mb: 2 }} - /> - - - handleChange("contactInfo.city", e.target.value)} - /> - - - handleChange("contactInfo.state", e.target.value)} - /> - - - handleChange("contactInfo.zip", e.target.value)} - /> + + + + + Address + + + + + + + + + + + + + - - {/* Phone */} - - - Phone - - handleChange("contactInfo.mobilePhone", e.target.value)} - sx={{ mb: 2 }} - /> - handleChange("contactInfo.homePhone", e.target.value)} - sx={{ mb: 2 }} - /> - handleChange("contactInfo.workPhone", e.target.value)} - /> + + + Phone + + + + + - - - {/* Modified Fields and Submit */} - - {hasChanges && ( - - Modified: {getModifiedFieldLabels().join(", ")} - - )} - - {props.onCancel && ( - + + + {hasChanges && ( + + Modified: {getModifiedFieldLabels().join(", ")} + )} - - - + + {props.onCancel && ( + + )} + + + + +
); }; diff --git a/src/components/notes/NewConversation.tsx b/src/components/notes/NewConversation.tsx index 0a6b4026..69512eab 100644 --- a/src/components/notes/NewConversation.tsx +++ b/src/components/notes/NewConversation.tsx @@ -2,11 +2,11 @@ import { Icon, Paper, Stack, TextField } from "@mui/material"; import React from "react"; +import { useForm, Controller } from "react-hook-form"; import { ApiHelper } from "@churchapps/apphelper"; import { Locale } from "@churchapps/apphelper"; import { PersonHelper } from "@churchapps/apphelper"; -import { ConversationInterface, MessageInterface, UserContextInterface } from "@churchapps/helpers"; -import { ErrorMessages } from "@churchapps/apphelper"; +import { ConversationInterface, UserContextInterface } from "@churchapps/helpers"; import { SmallButton } from "@churchapps/apphelper"; interface Props { @@ -19,32 +19,23 @@ interface Props { conversation: ConversationInterface[] } +interface FormData { + content: string; +} + export function NewConversation({ context, conversation, ...props }: Props) { - const [message, setMessage] = React.useState({}) - const [errors, setErrors] = React.useState([]); - const [isSubmitting, setIsSubmitting] = React.useState(false); + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + defaultValues: { content: "" } + }); const hasConversations = conversation?.length !== 0; - const handleChange = (e: React.ChangeEvent) => { - setErrors([]); - const m = { ...message } as MessageInterface; - m.content = e.target.value; - setMessage(m); - } - - const validate = () => { - const result = []; - if (!message.content.trim()) result.push(Locale.label("notes.validate.content")); - setErrors(result); - return result.length === 0; - } - - async function handleSave() { - if (!validate()) return; - - setIsSubmitting(true); - + async function onSubmit(data: FormData) { try { let cId: string; @@ -64,15 +55,13 @@ export function NewConversation({ context, conversation, ...props }: Props) { cId = result[0].id; } - const m = { ...message, conversationId: cId }; - await ApiHelper.post("/messages", [m], "MessagingApi"); + const message = { content: data.content, conversationId: cId }; + await ApiHelper.post("/messages", [message], "MessagingApi"); - setMessage({ ...message, content: "" }); + reset(); props.onUpdate(); } catch (error) { console.error("Error saving message:", error); - } finally { - setIsSubmitting(false); } } @@ -80,17 +69,36 @@ export function NewConversation({ context, conversation, ...props }: Props) { return ( - {image ? user : person}
{context?.person?.name?.display}
- + value.trim() !== "" || Locale.label("notes.validate.content") + }} + render={({ field, fieldState }) => ( + + )} + />
- +