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.
234 lines
7.0 KiB
TypeScript
234 lines
7.0 KiB
TypeScript
'use client'
|
|
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { apiClient, OfflineError } from '../client'
|
|
import { enqueue } from '../offline-queue'
|
|
import { fullContactToApiContact, buildContactUpdatePayload } from '../adapters'
|
|
import {
|
|
appendContactToBookCache,
|
|
replaceContactInBookCaches,
|
|
} from '../contact-book-cache'
|
|
import { invalidateContactListCache } from '../contact-list-cache'
|
|
import { contactApiPath } from '@/lib/contacts/contact-api-path'
|
|
import { mergeTwoContacts, mergeManyContacts } from '@/lib/contacts/merge-contacts'
|
|
import type { FullContact } from '@/lib/contacts/types'
|
|
import type { ApiContact } from '../types'
|
|
|
|
export function useCreateContact() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (vars: { bookId: string; contact: Partial<ApiContact> }) => {
|
|
const created = await apiClient.post<ApiContact | undefined>(
|
|
`/contacts/books/${vars.bookId}`,
|
|
vars.contact,
|
|
)
|
|
if (created?.uid) return created
|
|
return vars.contact as ApiContact
|
|
},
|
|
onSuccess: (data, vars) => {
|
|
const contact = data?.uid ? data : (vars.contact as ApiContact)
|
|
if (contact?.uid) {
|
|
appendContactToBookCache(queryClient, vars.bookId, {
|
|
...contact,
|
|
etag: contact.etag ?? data?.etag,
|
|
path: contact.path ?? data?.path,
|
|
})
|
|
}
|
|
invalidateContactListCache(vars.bookId)
|
|
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
|
|
},
|
|
onError: (err, vars) => {
|
|
if (err instanceof OfflineError) {
|
|
enqueue({
|
|
id: crypto.randomUUID(),
|
|
timestamp: Date.now(),
|
|
type: 'create_contact',
|
|
payload: { bookId: vars.bookId, ...vars.contact },
|
|
retries: 0,
|
|
})
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useUpdateContact() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (vars: {
|
|
path: string
|
|
contact: Partial<ApiContact>
|
|
etag?: string
|
|
skipInvalidation?: boolean
|
|
}) => {
|
|
const ifMatch = vars.etag ?? vars.contact.etag
|
|
const headers: Record<string, string> = {}
|
|
if (ifMatch) {
|
|
headers['If-Match'] = ifMatch
|
|
}
|
|
const { etag: _bodyEtag, ...contactBody } = vars.contact
|
|
const apiPath = vars.path.replace(/^\/+/, '')
|
|
return apiClient.put<{ etag?: string }>(`/contacts/${apiPath}`, contactBody, headers)
|
|
},
|
|
onSuccess: (data, vars) => {
|
|
const apiPath = vars.path.replace(/^\/+/, '')
|
|
const nextEtag = data?.etag ?? vars.etag
|
|
replaceContactInBookCaches(queryClient, apiPath, {
|
|
...vars.contact,
|
|
etag: nextEtag,
|
|
})
|
|
invalidateContactListCache()
|
|
if (!vars.skipInvalidation) {
|
|
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
|
}
|
|
},
|
|
onError: (err, vars) => {
|
|
if (err instanceof OfflineError) {
|
|
enqueue({
|
|
id: crypto.randomUUID(),
|
|
timestamp: Date.now(),
|
|
type: 'update_contact',
|
|
payload: { path: vars.path, ...vars.contact },
|
|
retries: 0,
|
|
})
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useDeleteContact() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (vars: { path: string; bookId?: string }) =>
|
|
apiClient.delete(`/contacts/${vars.path}`),
|
|
onMutate: async (vars) => {
|
|
await queryClient.cancelQueries({ queryKey: ['contacts'] })
|
|
const queries = queryClient.getQueriesData<ApiContact[]>({ queryKey: ['contacts'] })
|
|
const snapshots: [readonly unknown[], ApiContact[] | undefined][] = []
|
|
|
|
for (const [key, data] of queries) {
|
|
if (data) {
|
|
snapshots.push([key, data])
|
|
queryClient.setQueryData(
|
|
key,
|
|
data.filter((c) => c.path !== vars.path && c.uid !== vars.path)
|
|
)
|
|
}
|
|
}
|
|
return { snapshots }
|
|
},
|
|
onError: (err, vars, context) => {
|
|
if (context?.snapshots) {
|
|
for (const [key, data] of context.snapshots) {
|
|
queryClient.setQueryData(key, data)
|
|
}
|
|
}
|
|
if (err instanceof OfflineError) {
|
|
enqueue({
|
|
id: crypto.randomUUID(),
|
|
timestamp: Date.now(),
|
|
type: 'delete_contact',
|
|
payload: { path: vars.path, uid: vars.path },
|
|
retries: 0,
|
|
})
|
|
}
|
|
},
|
|
onSettled: () => {
|
|
invalidateContactListCache()
|
|
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useMergeDuplicates() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (vars: { bookId: string }) =>
|
|
apiClient.post(`/contacts/books/${vars.bookId}/merge-duplicates`),
|
|
onSuccess: (_data, vars) => {
|
|
invalidateContactListCache(vars.bookId)
|
|
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useMergeContactPair() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (vars: {
|
|
bookId: string
|
|
contactA: FullContact
|
|
contactB: FullContact
|
|
}) => {
|
|
const { primary, secondary, merged } = mergeTwoContacts(vars.contactA, vars.contactB)
|
|
const primaryPath = contactApiPath(primary)
|
|
const secondaryPath = contactApiPath(secondary)
|
|
|
|
const headers: Record<string, string> = {}
|
|
if (primary.etag) {
|
|
headers['If-Match'] = primary.etag
|
|
}
|
|
|
|
await apiClient.put(
|
|
`/contacts/${primaryPath}`,
|
|
fullContactToApiContact(merged),
|
|
headers,
|
|
)
|
|
|
|
if (secondaryPath !== primaryPath) {
|
|
await apiClient.delete(`/contacts/${secondaryPath}`)
|
|
}
|
|
},
|
|
onSuccess: (_data, vars) => {
|
|
invalidateContactListCache(vars.bookId)
|
|
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useMergeManyContacts() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (vars: {
|
|
bookId: string
|
|
contacts: FullContact[]
|
|
primaryId?: string
|
|
}) => {
|
|
const result = mergeManyContacts(vars.contacts, vars.primaryId)
|
|
if (!result) {
|
|
throw new Error('At least 2 contacts are required to merge')
|
|
}
|
|
const { primary, secondaries, merged } = result
|
|
if (!primary.etag) {
|
|
throw new Error('Cannot merge: unknown contact version. Reload the list.')
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
'If-Match': primary.etag,
|
|
}
|
|
|
|
await apiClient.put(
|
|
`/contacts/${contactApiPath(primary)}`,
|
|
fullContactToApiContact(merged),
|
|
headers,
|
|
)
|
|
|
|
for (const secondary of secondaries) {
|
|
const secondaryPath = contactApiPath(secondary)
|
|
if (secondaryPath !== contactApiPath(primary)) {
|
|
await apiClient.delete(`/contacts/${secondaryPath}`)
|
|
}
|
|
}
|
|
},
|
|
onSuccess: (_data, vars) => {
|
|
invalidateContactListCache(vars.bookId)
|
|
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
|
|
},
|
|
})
|
|
}
|