ultisuite-client/components/gmail/contacts-page/discovery-field-chips.tsx
R3D347HR4Y 07d57f13a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Add Contact Avatar Features and Improve UI Components
- 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.
2026-06-06 20:26:51 +02:00

221 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 mdlg, 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>
)
}