Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views. - Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts. - Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface. - Added loading and error states in ContactsListView for better user feedback during data fetching. - Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list. - Updated package.json to include @formkit/auto-animate for improved UI animations.
221 lines
6.5 KiB
TypeScript
221 lines
6.5 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState } from "react"
|
||
import { Pencil, X } from "lucide-react"
|
||
import { Input } from "@/components/ui/input"
|
||
import { FIELD_LABELS } from "@/lib/contacts/discovery-utils"
|
||
import {
|
||
CONTACTS_DISCOVERY_CHIP_CLASS,
|
||
CONTACTS_DISCOVERY_GRID_CELL_CLASS,
|
||
CONTACTS_MUTED_TEXT,
|
||
} from "@/lib/contacts-chrome-classes"
|
||
import { cn } from "@/lib/utils"
|
||
|
||
export interface ChipFieldItem {
|
||
id: string
|
||
fieldKey: string
|
||
value: string
|
||
removed?: boolean
|
||
}
|
||
|
||
const FIELD_GROUP_ORDER = [
|
||
"first_name",
|
||
"last_name",
|
||
"full_name",
|
||
"company",
|
||
"department",
|
||
"job_title",
|
||
"emails",
|
||
"phones",
|
||
"addresses",
|
||
"website",
|
||
"social_profiles",
|
||
"notes",
|
||
] as const
|
||
|
||
function fieldSortIndex(fieldKey: string): number {
|
||
const i = FIELD_GROUP_ORDER.indexOf(fieldKey as (typeof FIELD_GROUP_ORDER)[number])
|
||
return i === -1 ? 999 : i
|
||
}
|
||
|
||
function sortFieldKeys(keys: string[]): string[] {
|
||
return [...keys].sort(
|
||
(a, b) => fieldSortIndex(a) - fieldSortIndex(b) || a.localeCompare(b, "fr"),
|
||
)
|
||
}
|
||
|
||
function chipAccessibleLabel(fieldLabel: string, value: string): string {
|
||
const v = value.trim()
|
||
return v ? `${fieldLabel} : ${v}` : fieldLabel
|
||
}
|
||
|
||
function chipValueDedupeKey(fieldKey: string, value: string): string {
|
||
const v = value.trim()
|
||
if (fieldKey === "emails") return `emails:${v.toLowerCase()}`
|
||
if (fieldKey === "phones") {
|
||
const digits = v.replace(/\D/g, "")
|
||
return digits.length >= 6 ? `phones:${digits}` : `phones:${v.toLowerCase()}`
|
||
}
|
||
if (fieldKey === "addresses") return `addresses:${v.toLowerCase()}`
|
||
return `${fieldKey}:${v.toLowerCase()}`
|
||
}
|
||
|
||
export function groupChipFields(items: ChipFieldItem[], denseGrid = false) {
|
||
const map = new Map<string, ChipFieldItem[]>()
|
||
const seenValues = new Map<string, Set<string>>()
|
||
for (const item of items) {
|
||
if (item.removed) continue
|
||
const perField = seenValues.get(item.fieldKey) ?? new Set<string>()
|
||
const dedupeKey = chipValueDedupeKey(item.fieldKey, item.value)
|
||
if (perField.has(dedupeKey)) continue
|
||
perField.add(dedupeKey)
|
||
seenValues.set(item.fieldKey, perField)
|
||
const list = map.get(item.fieldKey) ?? []
|
||
list.push(item)
|
||
map.set(item.fieldKey, list)
|
||
}
|
||
|
||
const toGroup = (fieldKey: string) => ({
|
||
fieldKey,
|
||
label: FIELD_LABELS[fieldKey] ?? fieldKey,
|
||
items: map.get(fieldKey) ?? [],
|
||
})
|
||
|
||
const keys = sortFieldKeys([...map.keys()])
|
||
if (!denseGrid) {
|
||
return keys.map(toGroup)
|
||
}
|
||
|
||
const sparse: ReturnType<typeof toGroup>[] = []
|
||
const dense: ReturnType<typeof toGroup>[] = []
|
||
for (const fieldKey of keys) {
|
||
const group = toGroup(fieldKey)
|
||
if (group.items.length > 2) {
|
||
dense.push(group)
|
||
} else {
|
||
sparse.push(group)
|
||
}
|
||
}
|
||
return [...sparse, ...dense]
|
||
}
|
||
|
||
interface DiscoveryFieldChipsProps {
|
||
items: ChipFieldItem[]
|
||
/** Compact grid: 1 col < md, 2 cols md–lg, 3 cols xl+; dense fields span full row */
|
||
denseGrid?: boolean
|
||
/** Pencil edit + remove */
|
||
editable?: boolean
|
||
/** X only (e.g. reject suggestion) */
|
||
dismissible?: boolean
|
||
onRemove?: (id: string) => void
|
||
onValueChange?: (id: string, value: string) => void
|
||
className?: string
|
||
}
|
||
|
||
export function DiscoveryFieldChips({
|
||
items,
|
||
denseGrid = false,
|
||
editable = false,
|
||
dismissible = false,
|
||
onRemove,
|
||
onValueChange,
|
||
className,
|
||
}: DiscoveryFieldChipsProps) {
|
||
const [editingId, setEditingId] = useState<string | null>(null)
|
||
const [editValue, setEditValue] = useState("")
|
||
|
||
const groups = useMemo(() => groupChipFields(items, denseGrid), [items, denseGrid])
|
||
if (groups.length === 0) return null
|
||
|
||
function startEdit(item: ChipFieldItem) {
|
||
setEditingId(item.id)
|
||
setEditValue(item.value)
|
||
}
|
||
|
||
function commitEdit(id: string) {
|
||
const v = editValue.trim()
|
||
if (!v) {
|
||
onRemove?.(id)
|
||
} else {
|
||
onValueChange?.(id, v)
|
||
}
|
||
setEditingId(null)
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"mt-3",
|
||
denseGrid
|
||
? "grid grid-cols-1 gap-x-3 gap-y-2 md:grid-cols-2 xl:grid-cols-3"
|
||
: "space-y-2.5",
|
||
className,
|
||
)}
|
||
>
|
||
{groups.map((group) => (
|
||
<div
|
||
key={group.fieldKey}
|
||
className={cn(
|
||
denseGrid && CONTACTS_DISCOVERY_GRID_CELL_CLASS,
|
||
denseGrid && group.items.length > 2 && "md:col-span-2 xl:col-span-3",
|
||
)}
|
||
>
|
||
<p className={cn("mb-0.5 text-[11px] font-medium", CONTACTS_MUTED_TEXT)}>
|
||
{group.label}
|
||
</p>
|
||
<div className="flex flex-wrap gap-1">
|
||
{group.items.map((item) => {
|
||
const accessibleLabel = chipAccessibleLabel(group.label, item.value)
|
||
return editingId === item.id ? (
|
||
<Input
|
||
key={item.id}
|
||
value={editValue}
|
||
onChange={(e) => setEditValue(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") commitEdit(item.id)
|
||
if (e.key === "Escape") setEditingId(null)
|
||
}}
|
||
onBlur={() => commitEdit(item.id)}
|
||
className="h-7 min-w-40 max-w-full flex-1 rounded-full px-3 text-xs"
|
||
aria-label={`Modifier ${accessibleLabel}`}
|
||
autoFocus
|
||
/>
|
||
) : (
|
||
<span
|
||
key={item.id}
|
||
className={CONTACTS_DISCOVERY_CHIP_CLASS}
|
||
title={accessibleLabel}
|
||
>
|
||
<span className="min-w-0 truncate" aria-label={accessibleLabel}>
|
||
{item.value}
|
||
</span>
|
||
{editable && (
|
||
<button
|
||
type="button"
|
||
onClick={() => startEdit(item)}
|
||
className="rounded-full p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||
aria-label={`Modifier ${accessibleLabel}`}
|
||
>
|
||
<Pencil className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
{(editable || dismissible) && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemove?.(item.id)}
|
||
className="rounded-full p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||
aria-label={`Retirer ${accessibleLabel}`}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|