fix: improve Contacts and Segments UX and functionality (#6855)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Johannes
2025-11-25 23:49:23 -08:00
committed by GitHub
parent aab6798b29
commit f57497d8b3
24 changed files with 997 additions and 702 deletions
@@ -19,7 +19,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">{t("common.attributes")}</h2>
<div>
<dt className="text-sm font-medium text-slate-500">{t("common.email")}</dt>
<dt className="text-sm font-medium text-slate-500">email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.email ? (
<span>{attributes.email}</span>
@@ -29,7 +29,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">{t("common.language")}</dt>
<dt className="text-sm font-medium text-slate-500">language</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.language ? (
<span>{attributes.language}</span>
@@ -39,7 +39,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">{t("common.user_id")}</dt>
<dt className="text-sm font-medium text-slate-500">userId</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.userId ? (
<IdBadge id={attributes.userId} />
@@ -49,7 +49,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">ID</dt>
<dt className="text-sm font-medium text-slate-500">contactId</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{contact.id}</dd>
</div>
@@ -58,7 +58,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
.map(([key, attributeData]) => {
return (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{key.toString()}</dt>
<dt className="text-sm font-medium text-slate-500">{key}</dt>
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
</div>
);
@@ -8,6 +8,7 @@ import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { ResponseSection } from "./components/response-section";
@@ -47,6 +48,7 @@ export const SingleContactPage = async (props: {
return (
<PageContentWrapper>
<GoBackButton url={`/environments/${params.environmentId}/contacts`} />
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getContactControlBar()} />
<section className="pb-24 pt-6">
<div className="grid grid-cols-4 gap-x-8">
@@ -274,7 +274,7 @@ export const ContactsTable = ({
}}
style={cell.column.id === "select" ? getCommonPinningStyles(cell.column) : {}}
className={cn(
"border-slate-200 bg-white shadow-none group-hover:bg-slate-100",
"px-4 py-2 border-slate-200 bg-white shadow-none group-hover:bg-slate-100",
row.getIsSelected() && "bg-slate-100",
{
"border-r": !cell.column.getIsLastColumn(),
@@ -282,7 +282,7 @@ export const ContactsTable = ({
}
)}>
<div
className={cn("flex flex-1 items-center truncate", isExpanded ? "h-full" : "h-10")}>
className={cn("flex flex-1 items-center truncate", isExpanded ? "h-10" : "h-full")}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableCell>
@@ -12,14 +12,14 @@ export const CsvTable = ({ data }: CsvTableProps) => {
const columns = Object.keys(data[0]);
return (
<div className="w-full rounded-md hover:overflow-auto">
<div className="w-full overflow-x-auto rounded-md">
<div
className="grid gap-4 border-b-2 border-slate-100 bg-slate-100 p-4 text-left"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}>
className="sticky top-0 z-10 grid gap-2 border-b-2 border-slate-100 bg-slate-100 px-3 py-2 text-left"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(100px, 1fr))` }}>
{columns.map((header, index) => (
<span
key={index}
className="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold capitalize">
className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-semibold capitalize leading-tight">
{header.replace(/_/g, " ")}
</span>
))}
@@ -28,10 +28,10 @@ export const CsvTable = ({ data }: CsvTableProps) => {
{data.map((row, rowIndex) => (
<div
key={rowIndex}
className="grid gap-4 border-b border-gray-200 bg-white p-4 text-left last:border-b-0"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}>
className="grid gap-2 border-b border-gray-200 bg-white px-3 py-2 text-left leading-tight last:border-b-0"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(100px, 1fr))` }}>
{columns.map((header, colIndex) => (
<span key={colIndex} className="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
<span key={colIndex} className="overflow-hidden text-ellipsis whitespace-nowrap text-xs">
{row[header]}
</span>
))}
@@ -1,5 +1,6 @@
"use client";
import { ChevronDownIcon } from "lucide-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
@@ -50,16 +51,26 @@ export const UploadContactsAttributeCombobox = ({
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{currentKey ? (
<Button variant="ghost" size="sm" className="border border-slate-300" aria-expanded={open}>
<Button
variant="ghost"
size="sm"
className="justify-between border border-slate-300"
aria-expanded={open}>
{currentKey.label}
<ChevronDownIcon className="h-4 w-4 opacity-50" />
</Button>
) : (
<Button variant="ghost" size="sm" className="border border-slate-300" aria-expanded={open}>
<Button
variant="ghost"
size="sm"
className="justify-between border border-slate-300"
aria-expanded={open}>
{t("environments.contacts.select_attribute")}
<ChevronDownIcon className="h-4 w-4 opacity-50" />
</Button>
)}
</PopoverTrigger>
<PopoverContent className="h-full w-[200px] overflow-y-auto p-0">
<PopoverContent className="h-full w-[200px] p-0">
<Command
filter={(value, search) => {
if (value === "_create") {
@@ -92,7 +103,7 @@ export const UploadContactsAttributeCombobox = ({
}}
/>
</div>
<CommandList className="border-0">
<CommandList className="max-h-[300px] overflow-y-auto border-0">
<CommandGroup>
{keys.map((tag) => {
return (
@@ -4,6 +4,7 @@ import { parse } from "csv-parse/sync";
import { ArrowUpFromLineIcon, FileUpIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { cn } from "@/lib/cn";
@@ -44,7 +45,7 @@ export const UploadContactsCSVButton = ({
);
const [csvResponse, setCSVResponse] = useState<TContactCSVUploadResponse>([]);
const [attributeMap, setAttributeMap] = useState<Record<string, string>>({});
const [error, setErrror] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const processCSVFile = async (file: File) => {
@@ -52,25 +53,25 @@ export const UploadContactsCSVButton = ({
// Check file type
if (!file.type && !file.name.endsWith(".csv")) {
setErrror("Please upload a CSV file");
setError("Please upload a CSV file");
return;
}
if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) {
setErrror("Please upload a CSV file");
setError("Please upload a CSV file");
return;
}
// Max file size check (800KB)
const maxSizeInBytes = 800 * 1024;
if (file.size > maxSizeInBytes) {
setErrror("File size exceeds the maximum limit of 800KB");
setError("File size exceeds the maximum limit of 800KB");
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
setErrror("");
setError("");
const csv = e.target?.result as string;
try {
@@ -82,12 +83,12 @@ export const UploadContactsCSVButton = ({
const parsedRecords = ZContactCSVUploadResponse.safeParse(records);
if (!parsedRecords.success) {
console.error("Error parsing CSV:", parsedRecords.error);
setErrror(parsedRecords.error.errors[0].message);
setError(parsedRecords.error.errors[0].message);
return;
}
if (!parsedRecords.data.length) {
setErrror(
setError(
"The uploaded CSV file does not contain any valid contacts, please see the sample CSV file for the correct format."
);
return;
@@ -123,7 +124,7 @@ export const UploadContactsCSVButton = ({
const resetState = (closeModal?: boolean) => {
setCSVResponse([]);
setDuplicateContactsAction("skip");
setErrror("");
setError("");
setAttributeMap({});
setLoading(false);
@@ -138,7 +139,7 @@ export const UploadContactsCSVButton = ({
}
setLoading(true);
setErrror("");
setError("");
const values = Object.values(attributeMap);
@@ -156,9 +157,7 @@ export const UploadContactsCSVButton = ({
.filter(([_, value]) => duplicateValues.includes(value))
.map(([key, _]) => key);
setErrror(
`Duplicate mappings found for the following attributes: ${duplicateAttributeKeys.join(", ")}`
);
setError(`Duplicate mappings found for the following attributes: ${duplicateAttributeKeys.join(", ")}`);
errorContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
setLoading(false);
return;
@@ -193,7 +192,8 @@ export const UploadContactsCSVButton = ({
});
if (result?.data) {
setErrror("");
setError("");
toast.success(t("environments.contacts.upload_contacts_success"));
resetState(true);
router.refresh();
@@ -201,7 +201,7 @@ export const UploadContactsCSVButton = ({
}
if (result?.serverError) {
setErrror(result.serverError);
setError(result.serverError);
}
if (result?.validationErrors) {
@@ -210,9 +210,9 @@ export const UploadContactsCSVButton = ({
: result.validationErrors.csvData?._errors?.[0];
if (csvDataErrors) {
setErrror(csvDataErrors);
setError(csvDataErrors);
} else {
setErrror("An error occurred while uploading the contacts. Please try again later.");
setError("An error occurred while uploading the contacts. Please try again later.");
}
}
@@ -295,8 +295,18 @@ export const UploadContactsCSVButton = ({
{t("common.upload")} CSV
<PlusIcon />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick={true} className="overflow-auto">
<Dialog
open={open}
onOpenChange={(newOpen) => {
setOpen(newOpen);
if (!newOpen) {
setError("");
}
}}>
<DialogContent
disableCloseOnOutsideClick={true}
unconstrained={true}
style={{ scrollbarGutter: "stable" }}>
<DialogHeader>
<FileUpIcon />
<DialogTitle>{t("common.upload")} CSV</DialogTitle>
@@ -305,8 +315,8 @@ export const UploadContactsCSVButton = ({
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex flex-col gap-6">
<DialogBody unconstrained={false}>
<div className="flex flex-col gap-4">
{error ? (
<Alert variant="error" size="small">
{error}
@@ -340,11 +350,11 @@ export const UploadContactsCSVButton = ({
</label>
</div>
) : (
<div className="flex flex-col items-center gap-8">
<div className="flex flex-col items-center gap-4">
<h3 className="font-medium text-slate-500">
{t("environments.contacts.upload_contacts_modal_preview")}
</h3>
<div className="h-[300px] w-full overflow-auto rounded-md border border-slate-300">
<div className="max-h-[300px] w-full overflow-auto rounded-md border border-slate-300">
<CsvTable data={[...csvResponse.slice(0, 11)]} />
</div>
</div>
@@ -384,11 +394,11 @@ export const UploadContactsCSVButton = ({
</div>
) : null}
<div className="flex flex-col">
<div className="flex flex-col gap-2">
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_duplicates_title")}
</h3>
<p className="mb-2 text-slate-500">
<p className="text-slate-500">
{t("environments.contacts.upload_contacts_modal_duplicates_description")}
</p>
<StylingTabs
@@ -413,8 +423,8 @@ export const UploadContactsCSVButton = ({
tabsContainerClassName="p-1 rounded-lg"
/>
<div className="mt-1">
<p className="text-sm font-medium text-slate-500">
<div>
<p className="text-xs font-medium text-slate-500">
{duplicateContactsAction === "skip" &&
t("environments.contacts.upload_contacts_modal_duplicates_skip_description")}
{duplicateContactsAction === "update" &&
File diff suppressed because it is too large Load Diff
+16 -10
View File
@@ -291,10 +291,10 @@ export const createContactsFromCSV = async (
attributeKeyMap.set(attrKey.key, attrKey.id);
});
// Identify missing attribute keys
// Identify missing attribute keys (normalize keys to lowercase)
const csvKeys = new Set<string>();
csvData.forEach((record) => {
Object.keys(record).forEach((key) => csvKeys.add(key));
Object.keys(record).forEach((key) => csvKeys.add(key.toLowerCase()));
});
const missingKeys = Array.from(csvKeys).filter((key) => !attributeKeyMap.has(key));
@@ -328,12 +328,18 @@ export const createContactsFromCSV = async (
// Process contacts in parallel
const contactPromises = csvData.map(async (record) => {
// Normalize record keys to lowercase
const normalizedRecord: Record<string, string> = {};
Object.entries(record).forEach(([key, value]) => {
normalizedRecord[key.toLowerCase()] = value;
});
// Skip records without email
if (!record.email) {
if (!normalizedRecord.email) {
throw new ValidationError("Email is required for all contacts");
}
const existingContact = emailToContactMap.get(record.email);
const existingContact = emailToContactMap.get(normalizedRecord.email);
if (existingContact) {
// Handle duplicates based on duplicateContactsAction
@@ -344,11 +350,11 @@ export const createContactsFromCSV = async (
case "update": {
// if the record has a userId, check if it already exists
const existingUserId = existingUserIds.find(
(attr) => attr.value === record.userId && attr.contactId !== existingContact.id
(attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id
);
let recordToProcess = { ...record };
let recordToProcess = { ...normalizedRecord };
if (existingUserId) {
const { userId, ...rest } = recordToProcess;
const { userid, ...rest } = recordToProcess;
const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId"
@@ -401,11 +407,11 @@ export const createContactsFromCSV = async (
case "overwrite": {
// if the record has a userId, check if it already exists
const existingUserId = existingUserIds.find(
(attr) => attr.value === record.userId && attr.contactId !== existingContact.id
(attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id
);
let recordToProcess = { ...record };
let recordToProcess = { ...normalizedRecord };
if (existingUserId) {
const { userId, ...rest } = recordToProcess;
const { userid, ...rest } = recordToProcess;
const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId"
)?.value;
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import type { TBaseFilter, TSegment } from "@formbricks/types/segment";
import { ZSegmentFilters } from "@formbricks/types/segment";
@@ -100,13 +101,14 @@ export function CreateSegmentModal({
toast.error(errorMessage);
setIsCreatingSegment(false);
}
} catch (err: any) {
} catch (error_) {
logger.error("Error creating segment:", error_);
// parse the segment filters to check if they are valid
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
toast.error(t("environments.segments.invalid_segment_filters"));
} else {
if (parsedFilters.success) {
toast.error(t("common.something_went_wrong_please_try_again"));
} else {
toast.error(t("environments.segments.invalid_segment_filters"));
}
setIsCreatingSegment(false);
return;
@@ -115,11 +117,15 @@ export function CreateSegmentModal({
const isSaveDisabled = useMemo(() => {
// check if title is empty
if (!segment.title || segment.title.trim() === "") {
return true;
}
// check if filters are empty
if (segment.filters.length === 0) {
return true;
}
// parse the filters to check if they are valid
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
@@ -37,7 +37,6 @@ import {
} from "@formbricks/types/segment";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { isCapitalized } from "@/lib/utils/strings";
import {
convertOperatorToText,
convertOperatorToTitle,
@@ -149,8 +148,10 @@ function SegmentFilterItemContextMenu({
return (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger disabled={viewOnly}>
<MoreVertical className="h-4 w-4" />
<DropdownMenuTrigger asChild disabled={viewOnly}>
<Button variant="outline" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
@@ -185,13 +186,13 @@ function SegmentFilterItemContextMenu({
</DropdownMenu>
<Button
className="mr-4 p-0"
size="icon"
disabled={viewOnly}
onClick={() => {
if (viewOnly) return;
onDeleteFilter(filterId);
}}
variant="ghost">
variant="outline">
<Trash2 className={cn("h-4 w-4 cursor-pointer", viewOnly && "cursor-not-allowed")} />
</Button>
</div>
@@ -317,7 +318,7 @@ function AttributeSegmentFilter({
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
hideArrow>
<SelectValue>
<div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
<div className="flex items-center gap-2">
<TagIcon className="h-4 w-4 text-sm" />
<p>{attrKeyValue}</p>
</div>
@@ -357,7 +358,7 @@ function AttributeSegmentFilter({
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
<div className="relative flex flex-col gap-1">
<Input
className={cn("w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
className={cn("h-9 w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
disabled={viewOnly}
onChange={(e) => {
if (viewOnly) return;
@@ -537,7 +538,7 @@ function PersonSegmentFilter({
{!["isSet", "isNotSet"].includes(resource.qualifier.operator) && (
<div className="relative flex flex-col gap-1">
<Input
className={cn("w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
className={cn("h-8 w-auto bg-white", valueError && "border border-red-500 focus:border-red-500")}
disabled={viewOnly}
onChange={(e) => {
if (viewOnly) return;