feat(contacts): enhance import modal with tag preview and CSV parsing improvements

- Added `ImportPreviewTags` component to display tags with color coding in the import modal.
- Updated `parseContactCsv` to return an object containing both parsed rows and a flag indicating the presence of a tags column.
- Modified state management in the import modal to handle tags and their colors, improving user experience during contact import.
- Adjusted the import modal to conditionally render the tags column based on the presence of tags in the preview.
This commit is contained in:
Abhishek Sonwal 2026-06-15 13:12:00 +05:30
parent 3a416832a7
commit 73062cf06d
3 changed files with 109 additions and 36 deletions

View File

@ -8,7 +8,7 @@ import {
isUniqueViolation,
normalizeKey,
} from '@/lib/contacts/dedupe';
import { parseContactCsv } from '@/lib/contacts/parse-contact-csv';
import { parseContactCsv, type ParsedContactRow } from '@/lib/contacts/parse-contact-csv';
import {
assignImportedContactTags,
resolveImportTagIds,
@ -26,6 +26,41 @@ import {
import { Button } from '@/components/ui/button';
import { Upload, FileText, Loader2, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
const DEFAULT_TAG_COLOR = '#3b82f6';
function ImportPreviewTags({
tagNames,
tagColorByKey,
}: {
tagNames: string[];
tagColorByKey: Map<string, string>;
}) {
if (tagNames.length === 0) {
return <span className="text-slate-500">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{tagNames.map((name) => {
const color = tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR;
return (
<span
key={name}
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: `${color}20`,
color,
border: `1px solid ${color}40`,
}}
>
{name}
</span>
);
})}
</div>
);
}
interface ImportModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -38,7 +73,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [parsedRows, setParsedRows] = useState<ReturnType<typeof parseContactCsv>>([]);
const [parsedRows, setParsedRows] = useState<ParsedContactRow[]>([]);
const [hasTagsColumn, setHasTagsColumn] = useState(false);
const [tagColorByKey, setTagColorByKey] = useState<Map<string, string>>(new Map());
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{
imported: number;
@ -50,6 +87,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
function reset() {
setFile(null);
setParsedRows([]);
setHasTagsColumn(false);
setTagColorByKey(new Map());
setResult(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}
@ -67,15 +106,34 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
setResult(null);
const text = await selected.text();
const rows = parseContactCsv(text);
const { rows, hasTagsColumn: csvHasTags } = parseContactCsv(text);
if (rows.length === 0) {
toast.error('No valid rows found. Ensure CSV has a "phone" column header.');
setParsedRows([]);
setHasTagsColumn(false);
setTagColorByKey(new Map());
return;
}
setParsedRows(rows);
setHasTagsColumn(csvHasTags);
if (csvHasTags && accountId) {
const { data: tags } = await supabase
.from('tags')
.select('name, color')
.eq('account_id', accountId);
const colors = new Map<string, string>();
for (const tag of tags ?? []) {
const key = tag.name.trim().toLowerCase();
if (!colors.has(key)) colors.set(key, tag.color);
}
setTagColorByKey(colors);
} else {
setTagColorByKey(new Map());
}
}
async function handleImport() {
@ -228,7 +286,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
}
const preview = parsedRows.slice(0, 5);
const hasTagsColumn = parsedRows.some((row) => row.tagNames.length > 0);
const previewHasTags = hasTagsColumn || preview.some((row) => row.tagNames.length > 0);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
@ -291,7 +349,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Name</th>
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Email</th>
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Company</th>
{hasTagsColumn && (
{previewHasTags && (
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Tags</th>
)}
</tr>
@ -303,9 +361,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
<td className="px-3 py-1.5 text-slate-300">{row.name || '-'}</td>
<td className="px-3 py-1.5 text-slate-300">{row.email || '-'}</td>
<td className="px-3 py-1.5 text-slate-300">{row.company || '-'}</td>
{hasTagsColumn && (
{previewHasTags && (
<td className="px-3 py-1.5 text-slate-300">
{row.tagNames.length > 0 ? row.tagNames.join(', ') : '-'}
<ImportPreviewTags
tagNames={row.tagNames}
tagColorByKey={tagColorByKey}
/>
</td>
)}
</tr>

View File

@ -30,7 +30,9 @@ describe("parseContactCsv", () => {
+15551234567,Alice,"VIP, Lead"
+15559876543,Bob,Customer`;
expect(parseContactCsv(csv)).toEqual([
expect(parseContactCsv(csv)).toEqual({
hasTagsColumn: true,
rows: [
{
phone: "+15551234567",
name: "Alice",
@ -45,14 +47,17 @@ describe("parseContactCsv", () => {
company: undefined,
tagNames: ["Customer"],
},
]);
],
});
});
it("returns empty tagNames when tags column is absent", () => {
const csv = `phone,name
+15551234567,Alice`;
expect(parseContactCsv(csv)).toEqual([
expect(parseContactCsv(csv)).toEqual({
hasTagsColumn: false,
rows: [
{
phone: "+15551234567",
name: "Alice",
@ -60,6 +65,7 @@ describe("parseContactCsv", () => {
company: undefined,
tagNames: [],
},
]);
],
});
});
});

View File

@ -31,16 +31,22 @@ export function parseTagCell(value: string | undefined): string[] {
return names;
}
export function parseContactCsv(text: string): ParsedContactRow[] {
export interface ParseContactCsvResult {
rows: ParsedContactRow[];
/** True when the CSV header includes a `tags` column. */
hasTagsColumn: boolean;
}
export function parseContactCsv(text: string): ParseContactCsvResult {
const lines = text.trim().split(/\r?\n/);
if (lines.length < 2) return [];
if (lines.length < 2) return { rows: [], hasTagsColumn: false };
const headers = lines[0]
.split(",")
.map((h) => h.trim().toLowerCase().replace(/["']/g, ""));
const phoneIdx = headers.indexOf("phone");
if (phoneIdx === -1) return [];
if (phoneIdx === -1) return { rows: [], hasTagsColumn: false };
const nameIdx = headers.indexOf("name");
const emailIdx = headers.indexOf("email");
@ -76,7 +82,7 @@ export function parseContactCsv(text: string): ParsedContactRow[] {
});
}
return rows;
return { rows, hasTagsColumn: tagsIdx >= 0 };
}
/** Simple CSV line parse (handles quoted fields). */