From 38765edd0c4433281139ad8acacc684412bb8f5a Mon Sep 17 00:00:00 2001 From: Johannes Date: Thu, 5 Feb 2026 11:55:59 -0300 Subject: [PATCH] improve the UX of the Edit Contact Attributes UI, refactor large component --- .../components/attribute-field-row.tsx | 169 ++++++++++++++ .../edit-contact-attributes-modal.tsx | 216 ++++++------------ 2 files changed, 240 insertions(+), 145 deletions(-) create mode 100644 apps/web/modules/ee/contacts/components/attribute-field-row.tsx diff --git a/apps/web/modules/ee/contacts/components/attribute-field-row.tsx b/apps/web/modules/ee/contacts/components/attribute-field-row.tsx new file mode 100644 index 0000000000..3f98e44e28 --- /dev/null +++ b/apps/web/modules/ee/contacts/components/attribute-field-row.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { CalendarIcon, HashIcon, TagIcon, TrashIcon } from "lucide-react"; +import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; +import { Button } from "@/modules/ui/components/button"; +import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; +import { Input } from "@/modules/ui/components/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/ui/components/select"; + +type KeyOption = { + icon: typeof CalendarIcon | typeof HashIcon | typeof TagIcon; + label: string; + value: string; +}; + +interface AttributeFieldRowProps { + index: number; + fieldId: string; + form: any; + attributeKeys: TContactAttributeKey[]; + watchedAttributes: { key: string; value: string }[]; + allKeyOptions: KeyOption[]; + getAvailableOptions: (index: number) => KeyOption[]; + savedAttributeKeys: Set; + onRemove: (index: number) => void; + t: (key: string) => string; +} + +export const AttributeFieldRow = ({ + index, + fieldId, + form, + attributeKeys, + watchedAttributes, + allKeyOptions, + getAvailableOptions, + savedAttributeKeys, + onRemove, + t, +}: AttributeFieldRowProps) => { + const availableOptions = getAvailableOptions(index); + + return ( +
+ { + const selectedOption = allKeyOptions.find((opt) => opt.value === keyField.value); + const Icon = selectedOption?.icon ?? TagIcon; + + return ( + + {t("environments.contacts.attribute_key")} + + + + + + ); + }} + /> + + { + const selectedKey = attributeKeys.find((ak) => ak.key === watchedAttributes[index]?.key); + const dataType = selectedKey?.dataType || "string"; + + const renderValueInput = () => { + if (dataType === "date") { + return ( + { + const dateValue = e.target.value ? new Date(e.target.value).toISOString() : ""; + valueField.onChange(dateValue); + }} + placeholder={t("environments.contacts.attribute_value_placeholder")} + className="w-full" + /> + ); + } + + if (dataType === "number") { + return ( + + ); + } + + return ( + + ); + }; + + return ( + + {t("environments.contacts.attribute_value")} + +
+ {renderValueInput()} +
+ +
+
+
+ +
+ ); + }} + /> +
+ ); +}; diff --git a/apps/web/modules/ee/contacts/components/edit-contact-attributes-modal.tsx b/apps/web/modules/ee/contacts/components/edit-contact-attributes-modal.tsx index 20984b83b3..350f648e7f 100644 --- a/apps/web/modules/ee/contacts/components/edit-contact-attributes-modal.tsx +++ b/apps/web/modules/ee/contacts/components/edit-contact-attributes-modal.tsx @@ -1,7 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { CalendarIcon, HashIcon, PlusIcon, TagIcon, TrashIcon } from "lucide-react"; +import { CalendarIcon, HashIcon, PlusIcon, TagIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useRef } from "react"; import { useFieldArray, useForm } from "react-hook-form"; @@ -19,24 +19,10 @@ import { DialogHeader, DialogTitle, } from "@/modules/ui/components/dialog"; -import { - FormControl, - FormError, - FormField, - FormItem, - FormLabel, - FormProvider, -} from "@/modules/ui/components/form"; -import { Input } from "@/modules/ui/components/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/modules/ui/components/select"; +import { FormError, FormProvider } from "@/modules/ui/components/form"; import { updateContactAttributesAction } from "../actions"; import { TEditContactAttributesForm, createEditContactAttributesSchema } from "../types/contact"; +import { AttributeFieldRow } from "./attribute-field-row"; interface AttributeWithMetadata { key: string; @@ -92,6 +78,29 @@ export const EditContactAttributesModal = ({ // Watch form values to get currently selected keys const watchedAttributes = form.watch("attributes"); + // Track which attributes were already saved (should be disabled) + const savedAttributeKeys = useMemo( + () => new Set(currentAttributes.map((attr) => attr.key)), + [currentAttributes] + ); + + // Separate system and custom attributes by index + const { systemFieldIndices, customFieldIndices } = useMemo(() => { + const system: number[] = []; + const custom: number[] = []; + + watchedAttributes.forEach((attr, index) => { + const attrKey = attributeKeys.find((ak) => ak.key === attr.key); + if (attrKey?.type === "default") { + system.push(index); + } else { + custom.push(index); + } + }); + + return { systemFieldIndices: system, customFieldIndices: custom }; + }, [watchedAttributes, attributeKeys]); + // Icon mapping for attribute data types const dataTypeIcons = { date: CalendarIcon, @@ -227,137 +236,54 @@ export const EditContactAttributesModal = ({
-
- {fields.map((field, index) => ( -
- { - const availableOptions = getAvailableOptions(index); - const selectedOption = allKeyOptions.find((opt) => opt.value === keyField.value); - const Icon = selectedOption?.icon ?? TagIcon; - - return ( - - {t("environments.contacts.attribute_key")} - - - - - - ); - }} + {/* System Attributes Section */} + {systemFieldIndices.length > 0 && ( +
+

+ {t("environments.contacts.system_attributes")} +

+ {systemFieldIndices.map((index) => ( + + ))} +
+ )} - { - // Get the data type for this attribute key - const selectedKey = attributeKeys.find( - (ak) => ak.key === watchedAttributes[index]?.key - ); - const dataType = selectedKey?.dataType || "string"; - - // Render input based on data type - const renderValueInput = () => { - if (dataType === "date") { - return ( - { - // NOSONAR - standard date input onchange, no need to take this out of the component - const dateValue = e.target.value - ? new Date(e.target.value).toISOString() - : ""; - valueField.onChange(dateValue); - }} - placeholder={t("environments.contacts.attribute_value_placeholder")} - className="w-full" - /> - ); - } - - if (dataType === "number") { - return ( - - ); - } - - return ( - - ); - }; - - return ( - - {t("environments.contacts.attribute_value")} - -
- {renderValueInput()} -
- -
-
-
- -
- ); - }} + {/* Custom Attributes Section */} + {customFieldIndices.length > 0 && ( +
+ {systemFieldIndices.length > 0 &&
} +

+ {t("environments.contacts.custom_attributes")} +

+ {customFieldIndices.map((index) => ( + -
- ))} -
+ ))} +
+ )} {/* Only show Add Attribute button if there are remaining attributes to add */} {watchedAttributes.length < attributeKeys.length && (