ultisuite-client/components/gmail/contacts-page/use-contact-bulk-actions.ts
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

263 lines
7.9 KiB
TypeScript

"use client"
import { useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { useUpdateContact } from "@/lib/api/hooks/use-contact-mutations"
import { buildContactUpdatePayload } from "@/lib/api/adapters"
import {
findApiContactInCaches,
replaceContactInBookCaches,
resolveContactForUpdate,
} from "@/lib/api/contact-book-cache"
import { fetchContactByPath } from "@/lib/api/hooks/use-contact-queries"
import { contactApiPath } from "@/lib/contacts/contact-api-path"
import type { FullContact } from "@/lib/contacts/types"
import type { ContactBulkEditField } from "@/lib/contacts/bulk-edit-fields"
import { applyBulkFieldValue } from "@/lib/contacts/bulk-edit-fields"
import { useBlockedSendersStore } from "@/lib/stores/blocked-senders-store"
import { useNavStore } from "@/lib/stores/nav-store"
import type { ContactLabelPresence } from "./contact-label-picker-block"
function contactHasLabel(contact: FullContact, labelId: string): boolean {
return contact.labels?.includes(labelId) ?? false
}
function mergeLabelOnContact(
contact: FullContact,
labelId: string,
add: boolean,
): FullContact {
const current = contact.labels ?? []
const next = add
? current.includes(labelId)
? current
: [...current, labelId]
: current.filter((id) => id !== labelId)
return {
...contact,
labels: next.length ? next : undefined,
updatedAt: Date.now(),
}
}
export function useContactBulkActions() {
const queryClient = useQueryClient()
const updateContact = useUpdateContact()
const blockSenders = useBlockedSendersStore((s) => s.blockSenders)
const labelRows = useNavStore((s) => s.labelRows)
const addLabelRowFromSidebar = useNavStore((s) => s.addLabelRowFromSidebar)
const resolveContactEtag = useCallback(
async (contact: FullContact): Promise<FullContact | null> => {
let resolved = resolveContactForUpdate(queryClient, contact)
if (resolved.etag) return resolved
const apiPath = contactApiPath(resolved)
try {
const fetched = await fetchContactByPath(apiPath)
replaceContactInBookCaches(queryClient, apiPath, fetched)
resolved = resolveContactForUpdate(queryClient, {
...resolved,
etag: fetched.etag,
path: fetched.path ?? resolved.path,
})
} catch {
return null
}
return resolved.etag ? resolved : null
},
[queryClient],
)
const persistContact = useCallback(
async (
contact: FullContact,
opts?: {
bulkField?: ContactBulkEditField
bulkValue?: string
patchLabels?: boolean
skipInvalidation?: boolean
},
) => {
const resolved = await resolveContactEtag(contact)
if (!resolved?.etag) return false
const existing = findApiContactInCaches(queryClient, resolved)
const payload = buildContactUpdatePayload(existing, resolved, {
bulkField: opts?.bulkField,
bulkValue: opts?.bulkValue,
patchLabels: opts?.patchLabels,
})
await updateContact.mutateAsync({
path: contactApiPath(resolved),
etag: resolved.etag,
contact: payload,
skipInvalidation: opts?.skipInvalidation,
})
return true
},
[queryClient, resolveContactEtag, updateContact],
)
const getLabelPresence = useCallback(
(contacts: FullContact[], labelId: string): ContactLabelPresence => {
if (contacts.length === 0) return "none"
let n = 0
for (const contact of contacts) {
if (contactHasLabel(contact, labelId)) n++
}
if (n === 0) return "none"
if (n === contacts.length) return "all"
return "some"
},
[],
)
const runSequentialUpdates = useCallback(
async (
contacts: FullContact[],
buildNext: (contact: FullContact) => FullContact,
opts?: {
bulkField?: ContactBulkEditField
bulkValue?: string
patchLabels?: boolean
},
) => {
let skipped = 0
let failed = 0
let updated = 0
for (const contact of contacts) {
const next = buildNext(resolveContactForUpdate(queryClient, contact))
try {
const ok = await persistContact(next, {
...opts,
skipInvalidation: true,
})
if (ok) updated++
else skipped++
} catch {
failed++
}
}
await queryClient.invalidateQueries({ queryKey: ["contacts"] })
return { skipped, failed, updated }
},
[persistContact, queryClient],
)
const toggleLabelOnContacts = useCallback(
async (contacts: FullContact[], labelId: string) => {
if (contacts.length === 0) return
const presence = getLabelPresence(contacts, labelId)
const shouldAdd = presence !== "all"
const targets = contacts.filter((contact) => {
const has = contactHasLabel(contact, labelId)
return shouldAdd ? !has : has
})
const { skipped, failed, updated } = await runSequentialUpdates(
targets,
(contact) => mergeLabelOnContact(contact, labelId, shouldAdd),
{ patchLabels: true },
)
if (updated === 0 && skipped === 0 && failed === 0) return
if (failed > 0) {
toast.error(
`${failed} mise${failed > 1 ? "s" : ""} à jour échouée${failed > 1 ? "s" : ""}`,
)
}
if (skipped > 0) {
toast.warning(
`${skipped} contact${skipped > 1 ? "s" : ""} ignoré${skipped > 1 ? "s" : ""} (version inconnue)`,
)
}
},
[getLabelPresence, runSequentialUpdates],
)
const createAndApplyLabel = useCallback(
async (contacts: FullContact[], labelText: string) => {
addLabelRowFromSidebar(labelText)
const row = useNavStore
.getState()
.labelRows.find(
(r) => r.label.toLowerCase() === labelText.trim().toLowerCase(),
)
if (row) await toggleLabelOnContacts(contacts, row.id)
},
[addLabelRowFromSidebar, toggleLabelOnContacts],
)
const blockContacts = useCallback(
(contacts: FullContact[]) => {
const emails = contacts
.flatMap((c) => c.emails.map((e) => e.value))
.filter(Boolean)
if (emails.length === 0) {
toast.error("Aucune adresse e-mail à bloquer dans la sélection")
return
}
blockSenders(emails)
toast.success(
`${emails.length} adresse${emails.length > 1 ? "s" : ""} bloquée${emails.length > 1 ? "s" : ""}`,
)
},
[blockSenders],
)
const applyBulkField = useCallback(
async (contacts: FullContact[], field: ContactBulkEditField, value: string) => {
const toUpdate = contacts.map((c) => applyBulkFieldValue(c, field, value))
const { skipped, failed, updated } = await runSequentialUpdates(
toUpdate,
(contact) => contact,
{ bulkField: field, bulkValue: value },
)
if (updated > 0) {
toast.success(
`${updated} contact${updated > 1 ? "s" : ""} mis à jour`,
)
}
if (failed > 0) {
toast.error(
`${failed} mise${failed > 1 ? "s" : ""} à jour échouée${failed > 1 ? "s" : ""}`,
)
}
if (skipped > 0) {
toast.warning(
`${skipped} contact${skipped > 1 ? "s" : ""} ignoré${skipped > 1 ? "s" : ""} (version inconnue)`,
)
}
},
[runSequentialUpdates],
)
const resolveLabelVisualById = useCallback(
(labelId: string) => {
const row = labelRows.find((r) => r.id === labelId)
if (row?.icon) return { kind: "iconify" as const, icon: row.icon }
if (row?.color) return { kind: "dot" as const, colorClass: row.color }
return { kind: "dot" as const, colorClass: "bg-gray-400" }
},
[labelRows],
)
return {
getLabelPresence,
toggleLabelOnContacts,
createAndApplyLabel,
blockContacts,
applyBulkField,
resolveLabelVisualById,
isUpdating: updateContact.isPending,
}
}