generated from ttipo/project-base
feat(frontend): 주소록 화면 · 메일 연동
레일의 주소록 모듈 — 내 연락처 · 그룹 · 사내 목록(직원 · 공용 주소), 초성 찾기, 연락처 추가 · 고치기 창, 그룹 · 구성원 넣기 — 과 메일 연동(받는 사람 추천의 주소록 · 그룹, 연락처 · 그룹 · 사내의 [메일 쓰기], 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」)을 더한다. 디자인 README "8단계", 계약 3절. 리뷰로 고친 것: - 쓰기 화면을 채우는 라우터 상태에 연 직원을 싣는다 — 같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가면 앞 직원의 받는 사람이 채워졌다. - [고치기]는 창을 열 때 새로 받고 바꾼 칸만 보낸다 — 받아 둔 값으로 다른 탭 · 휴대폰의 변경을 덮었다. 검사도 바꾼 칸만. - [되돌리기]는 서버의 되돌리기 표로 — 다시 만들기로는 다른 프로그램의 칸 · uid 를 잃었다. - 읽기 화면에서 연 연락처 추가 창을 메일 레이아웃으로 옮겼다 — 메일을 바꾸면 쓴 것이 확인 없이 사라졌다. - 그룹을 이어 고르면 앞 그룹이 빠지던 것, 저장 중 창을 닫으면 거절이 묻히던 것, 구성원 넣기의 첫 쪽이 모두 구성원이면 막히던 것(excludeGroupId). - 받는 사람 추천은 초성 · 조합 중 글자도 서버에 묻는다(서버가 같은 맞추기 — 같은 표본 표를 두 쪽 테스트에 둔다). - 바꿀 때마다 불러온 쪽을 모두 다시 받지 않고 응답을 서버 차례로 그 자리에 넣는다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,8 @@ describe('에러 코드 사전', () => {
|
||||
// 웹메일 6단계
|
||||
'MAIL_012', // 그사이 다른 곳에서 필터가 바뀜 — 필터 화면이 알림 없이 목록 위 줄 · 고치기 창 안에 사전 문구를 직접 꺼내 보이고 [다시 불러오기]를 둔다
|
||||
'MAIL_013', // 필터가 엔진 한도를 넘음 — 필터 고치기 창이 알림 없이 창 안에 보인다
|
||||
// 웹메일 8단계 (주소록)
|
||||
'CONTACT_001', // 같은 이름의 그룹 — 새 그룹 · 이름 바꾸기 창이 알림 없이 칸 아래에 보인다
|
||||
]
|
||||
|
||||
for (const code of usedByClient) {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { toApiError } from './useApi'
|
||||
import { useContacts } from './useContacts'
|
||||
import { useToast } from './useToast'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { CONTACTS_TOAST_TEXT } from '@/shared/constants/contacts'
|
||||
import type {
|
||||
Contact,
|
||||
ContactGroup,
|
||||
ContactGroupDeleted,
|
||||
ContactGroupMembersAdded,
|
||||
ContactGroupMembersPayload,
|
||||
ContactInput,
|
||||
ContactPatch,
|
||||
} from '@/shared/types/contacts'
|
||||
import type { Address } from '@/shared/types/mail'
|
||||
import { copyText } from '@/shared/utils/clipboard'
|
||||
import { composePrefillState } from '@/shared/utils/compose-prefill'
|
||||
import { contactDisplayName, groupRecipientsOf } from '@/shared/utils/contacts'
|
||||
import { contactsListLocation, contactsRouteStateOf } from '@/shared/utils/contacts-route'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useContactsStore } from '@/stores/contacts.store'
|
||||
|
||||
// 주소록에서 하는 일과 그 알림 (디자인 README "8단계 › 상태 · 알림" · 디자인 원본 ContactsEdit "알림")
|
||||
// - 저장 "연락처를 저장했습니다." · 지우기는 확인 없이 하고 "‘박지훈’ 연락처를 지웠습니다." + [되돌리기](10초 — 띄운 세션에서만 움직인다, useToast).
|
||||
// 되돌리기는 지우기 응답의 표로(POST /contact-restorations — 서버가 지운 그대로 다시 만든다, 새 id) → "연락처를 되돌렸습니다".
|
||||
// 표가 없으면(서버가 기억하지 못함 — null) [되돌리기] 없이 알린다. 되돌리기의 실패(표가 지남 · 같은 이름 · 한도 · 장애)는 인터셉터가 알린다
|
||||
// - 그룹 지우기 "‘거래처’ 그룹을 지웠습니다 — 연락처 4명은 그대로 있습니다." + [되돌리기](같은 표 — 그때의 이름 · 지금도 있는 구성원으로)
|
||||
// - [고치기]는 그 연락처를 새로 받아 창을 연다(loadForEdit — 창을 연 사이 다른 곳에서 바꾼 칸을 덮지 않게, 저장은 바꾼 칸만)
|
||||
// - 구성원 넣기 "2명을 ‘거래처’ 그룹에 넣었습니다."
|
||||
// - 이미 지워진 것(다른 곳에서 지움 — 404 RES_001)을 지우면 알리지 않고 지운 것으로 본다(메일함 지우기와 같다)
|
||||
// - 칸 안에 보일 거절(연락처 VAL_001 · 그룹 이름 CONTACT_001 · VAL_001)은 알림을 끄고 그대로 던진다 — 창이 칸 아래에 보인다.
|
||||
// 한도(CONTACT_002) · 엔진 장애(MAIL_001) 등은 인터셉터가 사전 문구로 알린다
|
||||
// - 보던 연락처 · 그룹을 지우면 그 목록으로 주소를 바꾸고(기록을 쌓지 않는다), 누른 단추가 사라져 초점을 잃었으면 목록 칸으로 옮긴다
|
||||
// - [메일 쓰기]는 받는 사람을 채운 새 쓰기 화면 — 주소는 URL 이 아니라 라우터 상태로 (compose-prefill.ts)
|
||||
|
||||
/** 연락처 창이 칸 아래에 보이는 거절 */
|
||||
export const CONTACT_SAVE_INLINE_CODES: readonly ErrorCode[] = ['VAL_001']
|
||||
/** 그룹 이름 창이 칸 아래에 보이는 거절 — 같은 이름 · 형식 */
|
||||
export const CONTACT_GROUP_NAME_INLINE_CODES: readonly ErrorCode[] = ['CONTACT_001', 'VAL_001']
|
||||
/** 목록 칸 (ContactsList 의 data-contacts-list) — 지운 뒤 초점을 잃으면 여기로 */
|
||||
const CONTACTS_LIST_SELECTOR = '[data-contacts-list]'
|
||||
|
||||
function focusIsLost(): boolean {
|
||||
const active = document.activeElement
|
||||
return active === null || active === document.body || !active.isConnected
|
||||
}
|
||||
|
||||
export function useContactActions() {
|
||||
const store = useContactsStore()
|
||||
const contactsApi = useContacts()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
/** 누른 단추가 사라져 초점을 잃었으면 목록 칸으로 — 다른 곳으로 옮긴 초점은 빼앗지 않는다 */
|
||||
async function recoverFocus(): Promise<void> {
|
||||
await nextTick()
|
||||
if (focusIsLost()) document.querySelector<HTMLElement>(CONTACTS_LIST_SELECTOR)?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
/** 지금 주소록 화면이 그 연락처 · 그 그룹을 보고 있으면 그 목록(고른 것 없이)으로 바꿔 끼운다 */
|
||||
async function leaveRemoved(target: { contactId?: string; groupId?: string }): Promise<void> {
|
||||
const state = contactsRouteStateOf(router.currentRoute.value)
|
||||
if (!state) return
|
||||
if (target.groupId && state.source.kind === 'group' && state.source.groupId === target.groupId) {
|
||||
await router.replace(contactsListLocation({ kind: 'all' }))
|
||||
} else if (target.contactId && state.selectedId === target.contactId && state.source.kind !== 'directory') {
|
||||
await router.replace(contactsListLocation(state.source, state.q))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 연락처 ─────────────────────────────────────────
|
||||
|
||||
/** 새 연락처 저장 — 세션이 바뀌었으면 null (알리지 않는다). 실패는 그대로 던진다 (VAL_001 은 알림을 끈다 — 창이 보인다) */
|
||||
async function createContact(input: ContactInput): Promise<Contact | null> {
|
||||
const saved = await store.createContact(input, { silent: CONTACT_SAVE_INLINE_CODES })
|
||||
if (saved) toast.success(CONTACTS_TOAST_TEXT.saved)
|
||||
return saved
|
||||
}
|
||||
|
||||
/** 고친 연락처 저장 — 바꾼 칸만(patch). 세션이 바뀌었으면 null. 실패는 그대로 던진다 */
|
||||
async function updateContact(contact: Contact, patch: ContactPatch): Promise<Contact | null> {
|
||||
const saved = await store.updateContact(contact.id, patch, { silent: CONTACT_SAVE_INLINE_CODES })
|
||||
if (saved) toast.success(CONTACTS_TOAST_TEXT.saved)
|
||||
return saved
|
||||
}
|
||||
|
||||
/**
|
||||
* [고치기] — 창에 채울 그 연락처를 새로 받는다(받는 동안 store.editLoadingId). 받으면 목록 · 상세도 그 값으로 고친다.
|
||||
* 못 받으면 null — 창을 열지 않는다: 없는 연락처(RES_001 — 알림 없이)는 목록에서 빼 상세가 "찾을 수 없음" + [목록으로]를 그리고,
|
||||
* 그 밖의 실패는 인터셉터가 알린다. 기다리는 사이 세션이 바뀌었어도 null
|
||||
*/
|
||||
async function loadForEdit(contact: Contact): Promise<Contact | null> {
|
||||
if (store.editLoadingId === contact.id) return null
|
||||
const session = authStore.generation
|
||||
store.editLoadingId = contact.id
|
||||
try {
|
||||
const fresh = await contactsApi.getContact(contact.id, { silent: ['RES_001'] })
|
||||
if (authStore.generation !== session) return null
|
||||
store.replaceContact(fresh)
|
||||
return fresh
|
||||
} catch (caught) {
|
||||
if (authStore.generation === session && toApiError(caught).code === 'RES_001') store.forgetContact(contact.id)
|
||||
return null
|
||||
} finally {
|
||||
if (store.editLoadingId === contact.id) store.editLoadingId = null
|
||||
}
|
||||
}
|
||||
|
||||
/** [되돌리기] — 표로 다시 만든다 (memberIds: 그룹을 지울 때의 구성원). 실패는 인터셉터가 알렸다 */
|
||||
async function restore(token: string, memberIds: readonly string[] = []): Promise<void> {
|
||||
try {
|
||||
const restored = await store.restore(token, memberIds)
|
||||
if (!restored) return
|
||||
toast.success(restored.kind === 'contact' ? CONTACTS_TOAST_TEXT.restored : CONTACTS_TOAST_TEXT.groupRestored)
|
||||
} catch {
|
||||
// 표가 지남 · 이미 씀(RES_001), 같은 이름의 그룹(CONTACT_001), 한도(CONTACT_002), 장애 — 인터셉터가 사전 문구로 알렸다
|
||||
}
|
||||
}
|
||||
|
||||
/** 표가 있으면 [되돌리기] 단추 (10초 — 띄운 세션에서만), 없으면 단추 없이 */
|
||||
function undoAction(token: string | null, memberIds: readonly string[] = []) {
|
||||
return token ? { label: CONTACTS_TOAST_TEXT.undo, run: () => void restore(token, memberIds) } : undefined
|
||||
}
|
||||
|
||||
/** 지우기 — 확인 없이 하고 [되돌리기]를 준다. 지웠으면(이미 없었던 것 포함) true */
|
||||
async function removeContact(contact: Contact): Promise<boolean> {
|
||||
let deleted: Awaited<ReturnType<typeof store.deleteContact>>
|
||||
try {
|
||||
deleted = await store.deleteContact(contact.id, { silent: ['RES_001'] })
|
||||
} catch (caught) {
|
||||
if (toApiError(caught).code !== 'RES_001') return false
|
||||
store.forgetContact(contact.id)
|
||||
await leaveRemoved({ contactId: contact.id })
|
||||
await recoverFocus()
|
||||
return true
|
||||
}
|
||||
if (!deleted) return false
|
||||
toast.success(CONTACTS_TOAST_TEXT.removed(contactDisplayName(deleted.contact)), undoAction(deleted.restoreToken))
|
||||
await leaveRemoved({ contactId: contact.id })
|
||||
await recoverFocus()
|
||||
return true
|
||||
}
|
||||
|
||||
// ── 그룹 ──────────────────────────────────────────
|
||||
|
||||
/** 새 그룹 — 같은 이름(CONTACT_001) · 형식(VAL_001)은 알림을 끄고 던진다 */
|
||||
async function createGroup(name: string): Promise<ContactGroup | null> {
|
||||
const created = await store.createGroup(name, { silent: CONTACT_GROUP_NAME_INLINE_CODES })
|
||||
if (created) toast.success(CONTACTS_TOAST_TEXT.groupCreated(created.name))
|
||||
return created
|
||||
}
|
||||
|
||||
async function renameGroup(group: ContactGroup, name: string): Promise<ContactGroup | null> {
|
||||
const renamed = await store.renameGroup(group.id, name, { silent: CONTACT_GROUP_NAME_INLINE_CODES })
|
||||
if (renamed) toast.success(CONTACTS_TOAST_TEXT.groupRenamed)
|
||||
return renamed
|
||||
}
|
||||
|
||||
/** 그룹 지우기 — 확인 없이 하고 [되돌리기]를 준다. 연락처는 그대로 있다 */
|
||||
async function removeGroup(group: ContactGroup): Promise<boolean> {
|
||||
let deleted: ContactGroupDeleted | null
|
||||
try {
|
||||
deleted = await store.deleteGroup(group.id, { silent: ['RES_001'] })
|
||||
} catch (caught) {
|
||||
if (toApiError(caught).code !== 'RES_001') return false
|
||||
store.forgetGroup(group.id)
|
||||
await leaveRemoved({ groupId: group.id })
|
||||
await recoverFocus()
|
||||
return true
|
||||
}
|
||||
if (!deleted) return false
|
||||
const count = deleted.memberIds.length > 0 ? formatCount(deleted.memberIds.length) : null
|
||||
toast.success(
|
||||
CONTACTS_TOAST_TEXT.groupRemoved(deleted.name, count),
|
||||
undoAction(deleted.restoreToken, deleted.memberIds),
|
||||
)
|
||||
await leaveRemoved({ groupId: group.id })
|
||||
await recoverFocus()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 구성원 넣기 — "2명을 ‘거래처’ 그룹에 넣었습니다." 수는 응답의 addedCount(고른 수와 다를 수 있다 — 서버가 이미 구성원 ·
|
||||
* 그룹에 넣을 수 없는 카드를 건너뛴다). 실패는 인터셉터가 알리고 그대로 던진다
|
||||
*/
|
||||
async function addMembers(group: ContactGroup, payload: ContactGroupMembersPayload): Promise<ContactGroupMembersAdded | null> {
|
||||
const added = await store.addMembers(group.id, payload)
|
||||
if (!added) return null
|
||||
toast.success(
|
||||
added.addedCount > 0
|
||||
? CONTACTS_TOAST_TEXT.membersAdded(formatCount(added.addedCount), added.group.name)
|
||||
: CONTACTS_TOAST_TEXT.membersNone(added.group.name),
|
||||
)
|
||||
return added
|
||||
}
|
||||
|
||||
// ── 메일 쓰기 · 주소 복사 ──────────────────────────────
|
||||
|
||||
/** 받는 사람을 채운 새 쓰기 화면 — 쓰다 만 메일은 쓰기 화면의 임시 저장 규칙대로 남는다. 상태에 연 직원을 싣는다(compose-prefill.ts) */
|
||||
async function composeTo(addresses: readonly Address[]): Promise<void> {
|
||||
await router.push({ name: 'mail-compose', state: composePrefillState(addresses, authStore.user?.email ?? '') })
|
||||
}
|
||||
|
||||
/**
|
||||
* [그룹에 메일 쓰기] — 구성원의 대표 주소로 (이메일이 없는 구성원은 뺀다). 받는 동안은 store.composingGroupId(단추의 바쁜 표시 — 거듭 누르면 무시).
|
||||
* 받지 못하면 인터셉터가 알린다. 받는 사이 다른 화면 · 목록으로 갔거나 세션이 바뀌었으면 쓰기 화면으로 끌고 가지 않는다
|
||||
*/
|
||||
async function composeToGroup(group: ContactGroup): Promise<void> {
|
||||
if (store.composingGroupId !== null) return
|
||||
const session = authStore.generation
|
||||
const startedAt = router.currentRoute.value.fullPath
|
||||
store.composingGroupId = group.id
|
||||
let members: Contact[]
|
||||
try {
|
||||
members = await contactsApi.listGroupMembers(group.id)
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
if (store.composingGroupId === group.id) store.composingGroupId = null
|
||||
}
|
||||
if (authStore.generation !== session || router.currentRoute.value.fullPath !== startedAt) return
|
||||
const { addresses } = groupRecipientsOf(members)
|
||||
if (addresses.length === 0) {
|
||||
toast.error(CONTACTS_TOAST_TEXT.groupNoAddress(group.name))
|
||||
return
|
||||
}
|
||||
await composeTo(addresses)
|
||||
}
|
||||
|
||||
/** 주소 복사 — 결과를 알린다 (브라우저가 막으면 실패 알림) */
|
||||
async function copyAddress(address: string): Promise<void> {
|
||||
if (await copyText(address)) toast.success(CONTACTS_TOAST_TEXT.copied)
|
||||
else toast.error(CONTACTS_TOAST_TEXT.copyFailed)
|
||||
}
|
||||
|
||||
return {
|
||||
createContact,
|
||||
updateContact,
|
||||
loadForEdit,
|
||||
removeContact,
|
||||
createGroup,
|
||||
renameGroup,
|
||||
removeGroup,
|
||||
addMembers,
|
||||
composeTo,
|
||||
composeToGroup,
|
||||
copyAddress,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { computed, ref, type ComputedRef } from 'vue'
|
||||
|
||||
import { withoutKey, type ContactEntryWarning } from '@/components/contacts/contacts-ui'
|
||||
import { CONTACT_DIALOG_TEXT } from '@/shared/constants/contacts'
|
||||
import { duplicateWho, type ContactEmailRow } from '@/shared/utils/contacts'
|
||||
import { isValidEmail, normalizeEmail } from '@/shared/utils/mail-address'
|
||||
import { useContactsStore } from '@/stores/contacts.store'
|
||||
|
||||
// 연락처 창의 같은 주소 안내 (디자인 원본 ContactsEdit · README "8단계 › 연락처 추가 · 고치기") — 막지 않는 갈색 글 + [그 연락처 보기]
|
||||
// - 이메일 칸을 떠날 때(check) 내 주소록에 같은 주소(다른 연락처 — 대소문자 무시)가 있는지 알림 없이 찾는다(GET /contacts?q=<주소>)
|
||||
// - 칸을 고치면(forget) 그 줄의 안내를 내리고 기다리던 찾기를 버린다 — 늦게 온 결과가 옛 주소의 안내를 다시 띄우지 않게
|
||||
// - 창을 다시 열면(reset) 모두 버린다. 찾지 못했으면(실패 · 세션이 바뀜) 안내 없이 둔다
|
||||
// 안내는 늦게 뜨므로 그리는 쪽(ContactEntryRows)이 늘 있는 알림 영역 안에 그린다
|
||||
|
||||
export interface ContactDuplicateCheckOptions {
|
||||
/** 지금 창의 이메일 줄 */
|
||||
rows: () => readonly ContactEmailRow[]
|
||||
/** 고치는 연락처 — 자기 자신의 주소는 안내하지 않는다 */
|
||||
exceptId: () => string | null
|
||||
}
|
||||
|
||||
export interface ContactDuplicateCheck {
|
||||
/** 이메일 줄마다 안내 — 없으면 null (줄 차례) */
|
||||
warnings: ComputedRef<(ContactEntryWarning | null)[]>
|
||||
check: (rowKey: number) => Promise<void>
|
||||
forget: (rowKey: number) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export function useContactDuplicateCheck(options: ContactDuplicateCheckOptions): ContactDuplicateCheck {
|
||||
const contactsStore = useContactsStore()
|
||||
/** 안내 — 이메일 줄 키마다 */
|
||||
const byKey = ref<Record<number, ContactEntryWarning>>({})
|
||||
/** 줄마다의 찾기 차례 — 칸을 고치면 오른다 */
|
||||
const seqs = new Map<number, number>()
|
||||
/** 창을 연 차례 — 다시 열면 오른다 */
|
||||
let epoch = 0
|
||||
|
||||
function bump(rowKey: number): number {
|
||||
const seq = (seqs.get(rowKey) ?? 0) + 1
|
||||
seqs.set(rowKey, seq)
|
||||
return seq
|
||||
}
|
||||
|
||||
function forget(rowKey: number) {
|
||||
bump(rowKey)
|
||||
byKey.value = withoutKey(byKey.value, rowKey)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
epoch += 1
|
||||
seqs.clear()
|
||||
byKey.value = {}
|
||||
}
|
||||
|
||||
async function check(rowKey: number): Promise<void> {
|
||||
const address = options.rows().find((row) => row.key === rowKey)?.address.trim() ?? ''
|
||||
const seq = bump(rowKey)
|
||||
if (!isValidEmail(address)) return
|
||||
const opened = epoch
|
||||
const found = await contactsStore.findContactsWithEmail(address, options.exceptId())
|
||||
if (opened !== epoch || seqs.get(rowKey) !== seq || found === undefined) return
|
||||
const current = options.rows().find((row) => row.key === rowKey)
|
||||
if (!current || normalizeEmail(current.address) !== normalizeEmail(address)) return
|
||||
const other = found[0]
|
||||
const rest = withoutKey(byKey.value, rowKey)
|
||||
byKey.value = other
|
||||
? {
|
||||
...rest,
|
||||
[rowKey]: {
|
||||
text: CONTACT_DIALOG_TEXT.duplicate(duplicateWho(other, contactsStore.groups)),
|
||||
contactId: other.id,
|
||||
linkLabel: CONTACT_DIALOG_TEXT.duplicateView,
|
||||
},
|
||||
}
|
||||
: rest
|
||||
}
|
||||
|
||||
const warnings = computed(() => options.rows().map((row) => byKey.value[row.key] ?? null))
|
||||
|
||||
return { warnings, check, forget, reset }
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
|
||||
import { useApi } from './useApi'
|
||||
import { CONTACT_GROUP_MEMBER_PAGES_MAX, CONTACTS_PAGE_SIZE } from '@/shared/constants/contacts'
|
||||
import type {
|
||||
Contact,
|
||||
ContactDeleted,
|
||||
ContactGroup,
|
||||
ContactGroupCreatePayload,
|
||||
ContactGroupDeleted,
|
||||
ContactGroupListQuery,
|
||||
ContactGroupMembersAdded,
|
||||
ContactGroupMembersPayload,
|
||||
ContactGroupRenamePayload,
|
||||
ContactInput,
|
||||
ContactListQuery,
|
||||
ContactPatch,
|
||||
ContactRestored,
|
||||
DirectoryEntry,
|
||||
DirectoryListQuery,
|
||||
} from '@/shared/types/contacts'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
|
||||
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
|
||||
export type ContactsRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
|
||||
|
||||
/** 경로에 넣는 id 는 항상 인코딩한다 */
|
||||
const segment = (value: string) => encodeURIComponent(value)
|
||||
|
||||
// 주소록 API Composable — docs/mail/api-contract.md 3절 "주소록 — 8단계" 의 엔드포인트 (모두 로그인 필요)
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
|
||||
// 컴포넌트는 이것을 직접 부르기보다 스토어(contacts.store) · 하는 일(useContactActions)을 거친다
|
||||
export function useContacts() {
|
||||
const api = useApi()
|
||||
|
||||
/** 연락처 — 이름 차례, total 포함. groupId 면 그 그룹의 구성원, q 면 찾은 것 (cursor 는 받은 그 q · groupId 로만) */
|
||||
function listContacts(query: ContactListQuery, options?: ContactsRequestOptions): Promise<PaginatedResult<Contact>> {
|
||||
return api.get<PaginatedResult<Contact>, PaginatedResult<Contact>>('/contacts', { ...options, params: query })
|
||||
}
|
||||
|
||||
function getContact(id: string, options?: ContactsRequestOptions): Promise<Contact> {
|
||||
return api.get<Contact, Contact>(`/contacts/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/** 만들기 — 연락처 3,000개 · 그룹 구성원 500명을 넘으면 409 CONTACT_002, 없는 groupId 는 404 RES_001 */
|
||||
function createContact(input: ContactInput, options?: ContactsRequestOptions): Promise<Contact> {
|
||||
return api.post<Contact, Contact>('/contacts', input, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 고치기 — 연 때와 달라진 칸만 보낸다(보내지 않은 칸 · 엔진 카드의 다른 칸은 서버가 그대로 둔다 — 창을 연 사이 다른 곳에서 바꾼 칸을 덮지 않게).
|
||||
* emails · phones · groupIds 는 보내면 통째로 바뀐다
|
||||
*/
|
||||
function updateContact(id: string, patch: ContactPatch, options?: ContactsRequestOptions): Promise<Contact> {
|
||||
return api.patch<Contact, Contact>(`/contacts/${segment(id)}`, patch, options)
|
||||
}
|
||||
|
||||
/** 지우기 — 영구 삭제라 지운 연락처와 [되돌리기] 표를 돌려준다 (표가 null 이면 되돌릴 수 없다) */
|
||||
function deleteContact(id: string, options?: ContactsRequestOptions): Promise<ContactDeleted> {
|
||||
return api.delete<ContactDeleted, ContactDeleted>(`/contacts/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 되돌리기 — 지우기 응답의 표로 지운 그대로(다른 프로그램의 칸 · 그룹 · 구성원까지) 다시 만든다. id 는 새로 받는다.
|
||||
* 표가 없음 · 지남 · 이미 씀은 404 RES_001, 같은 이름의 그룹 409 CONTACT_001, 한도 409 CONTACT_002 (계약 3절 "되돌리기")
|
||||
*/
|
||||
function restoreDeleted(token: string, options?: ContactsRequestOptions): Promise<ContactRestored> {
|
||||
return api.post<ContactRestored, ContactRestored>('/contact-restorations', { token }, options)
|
||||
}
|
||||
|
||||
function listContactGroups(
|
||||
query: ContactGroupListQuery,
|
||||
options?: ContactsRequestOptions,
|
||||
): Promise<PaginatedResult<ContactGroup>> {
|
||||
return api.get<PaginatedResult<ContactGroup>, PaginatedResult<ContactGroup>>('/contact-groups', {
|
||||
...options,
|
||||
params: query,
|
||||
})
|
||||
}
|
||||
|
||||
/** 새 그룹 — 같은 이름이면 409 CONTACT_001, 그룹 100개면 409 CONTACT_002 */
|
||||
function createContactGroup(payload: ContactGroupCreatePayload, options?: ContactsRequestOptions): Promise<ContactGroup> {
|
||||
return api.post<ContactGroup, ContactGroup>('/contact-groups', payload, options)
|
||||
}
|
||||
|
||||
function renameContactGroup(
|
||||
id: string,
|
||||
payload: ContactGroupRenamePayload,
|
||||
options?: ContactsRequestOptions,
|
||||
): Promise<ContactGroup> {
|
||||
return api.patch<ContactGroup, ContactGroup>(`/contact-groups/${segment(id)}`, payload, options)
|
||||
}
|
||||
|
||||
/** 그룹 지우기 — 연락처는 그대로다. 응답의 표로 [되돌리기]가 다시 만든다 */
|
||||
function deleteContactGroup(id: string, options?: ContactsRequestOptions): Promise<ContactGroupDeleted> {
|
||||
return api.delete<ContactGroupDeleted, ContactGroupDeleted>(`/contact-groups/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/** 구성원 넣기 — 내 연락처(contactIds) · 사내 목록(directoryIds — 내 주소록에도 만든다) 합쳐 1~100개 */
|
||||
function addContactGroupMembers(
|
||||
id: string,
|
||||
payload: ContactGroupMembersPayload,
|
||||
options?: ContactsRequestOptions,
|
||||
): Promise<ContactGroupMembersAdded> {
|
||||
return api.post<ContactGroupMembersAdded, ContactGroupMembersAdded>(
|
||||
`/contact-groups/${segment(id)}/members`,
|
||||
payload,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
/** 사내 목록 — 이름 차례, total 포함. excludeGroupId 면 그 그룹 구성원의 주소를 뺀다. 사내 목록을 읽지 못하면 503 MAIL_001 */
|
||||
function listDirectoryEntries(
|
||||
query: DirectoryListQuery,
|
||||
options?: ContactsRequestOptions,
|
||||
): Promise<PaginatedResult<DirectoryEntry>> {
|
||||
return api.get<PaginatedResult<DirectoryEntry>, PaginatedResult<DirectoryEntry>>('/directory-entries', {
|
||||
...options,
|
||||
params: query,
|
||||
})
|
||||
}
|
||||
|
||||
/** 사내 항목 하나 — 주소로 연 사내 항목 (없거나 빼는 계정이면 404 RES_001) */
|
||||
function getDirectoryEntry(id: string, options?: ContactsRequestOptions): Promise<DirectoryEntry> {
|
||||
return api.get<DirectoryEntry, DirectoryEntry>(`/directory-entries/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹의 구성원 전부 — 100명씩 다음 쪽까지 받는다 (그룹 하나에 500명까지 — 계약 3절 "메일과 함께").
|
||||
* 받는 사람 추천의 그룹 줄 · [그룹에 메일 쓰기]가 쓴다
|
||||
*/
|
||||
async function listGroupMembers(groupId: string, options?: ContactsRequestOptions): Promise<Contact[]> {
|
||||
const members: Contact[] = []
|
||||
let cursor: string | undefined
|
||||
for (let page = 0; page < CONTACT_GROUP_MEMBER_PAGES_MAX; page += 1) {
|
||||
const result = await listContacts({ groupId, limit: CONTACTS_PAGE_SIZE, ...(cursor ? { cursor } : {}) }, options)
|
||||
members.push(...result.items)
|
||||
if (!result.pageInfo.hasNext || !result.pageInfo.nextCursor) break
|
||||
cursor = result.pageInfo.nextCursor
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
return {
|
||||
listContacts,
|
||||
getContact,
|
||||
createContact,
|
||||
updateContact,
|
||||
deleteContact,
|
||||
restoreDeleted,
|
||||
listContactGroups,
|
||||
createContactGroup,
|
||||
renameContactGroup,
|
||||
deleteContactGroup,
|
||||
addContactGroupMembers,
|
||||
listDirectoryEntries,
|
||||
getDirectoryEntry,
|
||||
listGroupMembers,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { computed, inject, shallowRef, type ComputedRef, type InjectionKey } from 'vue'
|
||||
|
||||
import type { Contact, ContactGroup } from '@/shared/types/contacts'
|
||||
import type { ContactPrefill } from '@/shared/utils/contacts'
|
||||
|
||||
// 주소록 화면의 창 — 패널 · 목록 · 상세가 열고, 레이아웃의 창 묶음(ContactsDialogs)이 그린다 (메일함 패널의 MailboxDialogs 와 같은 결)
|
||||
// 레이아웃(ContactsLayout)이 만들어 provide 하고, 아래 화면은 useContactsDialogs() 로 받는다.
|
||||
// 창은 레이아웃에 있어 주소록 안에서 목록 · 고른 연락처가 바뀌어도 닫히지 않는다 — 주소록을 떠날 때만 나가기 확인이 묻는다
|
||||
|
||||
export type ContactsDialogRequest =
|
||||
/** 연락처 추가(contact 가 null — prefill 이 있으면 채워서) · 고치기 */
|
||||
| { kind: 'contact'; contact: Contact | null; prefill: ContactPrefill | null }
|
||||
/** 새 그룹(group 이 null) · 이름 바꾸기 */
|
||||
| { kind: 'group-name'; group: ContactGroup | null }
|
||||
/** 구성원 넣기 */
|
||||
| { kind: 'members'; group: ContactGroup }
|
||||
|
||||
export interface ContactsDialogsHandle {
|
||||
/** 지금 연 창 — 없으면 null (읽기만) */
|
||||
request: ComputedRef<ContactsDialogRequest | null>
|
||||
openContact: (contact: Contact | null, prefill?: ContactPrefill | null) => void
|
||||
openGroupName: (group: ContactGroup | null) => void
|
||||
openMembers: (group: ContactGroup) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const CONTACTS_DIALOGS: InjectionKey<ContactsDialogsHandle> = Symbol('contacts-dialogs')
|
||||
|
||||
export function createContactsDialogs(): ContactsDialogsHandle {
|
||||
const request = shallowRef<ContactsDialogRequest | null>(null)
|
||||
return {
|
||||
request: computed(() => request.value),
|
||||
openContact: (contact, prefill = null) => {
|
||||
request.value = { kind: 'contact', contact, prefill }
|
||||
},
|
||||
openGroupName: (group) => {
|
||||
request.value = { kind: 'group-name', group }
|
||||
},
|
||||
openMembers: (group) => {
|
||||
request.value = { kind: 'members', group }
|
||||
},
|
||||
close: () => {
|
||||
request.value = null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 주소록 레이아웃 아래의 화면에서 부른다 */
|
||||
export function useContactsDialogs(): ContactsDialogsHandle {
|
||||
const handle = inject(CONTACTS_DIALOGS, null)
|
||||
if (!handle) throw new Error('주소록 창은 주소록 레이아웃(ContactsLayout) 안에서만 열 수 있습니다.')
|
||||
return handle
|
||||
}
|
||||
@@ -299,7 +299,10 @@ export function useMail() {
|
||||
|
||||
// ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ──────────────
|
||||
|
||||
/** 사내 → 최근 주고받은 주소 순. q 가 있으면 이름 · 주소에 그 글자가 든 것만 */
|
||||
/**
|
||||
* 주소록(8단계 — 주소마다 한 줄, 대표 주소가 먼저) → 사내 → 최근 주고받은 주소 순. q 가 있으면 이름 · 주소에 그 글자가 든 것만.
|
||||
* 그룹은 여기에 없다 — 쓰기 화면이 그룹 목록을 따로 받는다 (useRecipientSuggestions)
|
||||
*/
|
||||
function listRecipientSuggestions(
|
||||
query: RecipientSuggestionQuery,
|
||||
options?: MailRequestOptions,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { computed, inject, shallowRef, type ComputedRef, type InjectionKey } from 'vue'
|
||||
|
||||
import type { ContactPrefill } from '@/shared/utils/contacts'
|
||||
|
||||
// 메일 화면의 연락처 추가 창 — 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」가 열고, 메일 레이아웃(MailLayout)의 MailContactDialogHost 가 그린다
|
||||
// (계약 3절 "메일과 함께" · 디자인 README "8단계 › 메일과 함께").
|
||||
// 창을 읽기 화면 안(v-if="message")에 두면 메일을 바꿀 때(뒤로 · 이전/다음 · 바탕화면 알림 · 다시 받는 동안) 확인 없이 사라진다 —
|
||||
// 그래서 메일 레이아웃에 둔다: 메일 화면 안에서는 메일이 바뀌어도 창이 남고(채운 값은 연 때의 보낸 사람이라 메일과 상관없다),
|
||||
// 메일 화면을 떠날 때만 창의 나가기 확인이 묻는다(주소록 화면의 창 묶음과 같은 결 — 2026-10-29 리뷰 M3)
|
||||
|
||||
export interface MailContactDialogRequest {
|
||||
prefill: ContactPrefill
|
||||
/** 창을 닫은 뒤 초점을 돌려줄 곳 — 연 ⋯ 단추가 아직 있으면 그것 */
|
||||
returnFocus: (() => void) | null
|
||||
}
|
||||
|
||||
export interface MailContactDialogHandle {
|
||||
/** 지금 연 창 — 없으면 null (읽기만) */
|
||||
request: ComputedRef<MailContactDialogRequest | null>
|
||||
open: (prefill: ContactPrefill, returnFocus?: () => void) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const MAIL_CONTACT_DIALOG: InjectionKey<MailContactDialogHandle> = Symbol('mail-contact-dialog')
|
||||
|
||||
export function createMailContactDialog(): MailContactDialogHandle {
|
||||
const request = shallowRef<MailContactDialogRequest | null>(null)
|
||||
return {
|
||||
request: computed(() => request.value),
|
||||
open: (prefill, returnFocus) => {
|
||||
request.value = { prefill, returnFocus: returnFocus ?? null }
|
||||
},
|
||||
close: () => {
|
||||
request.value = null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 메일 레이아웃 아래에서 부른다 — 레이아웃 밖(테스트 등)이면 null (창을 열지 않는다) */
|
||||
export function useMailContactDialog(): MailContactDialogHandle | null {
|
||||
return inject(MAIL_CONTACT_DIALOG, null)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
RECIPIENT_SEARCH_DELAY_MS,
|
||||
useRecipientSuggestions,
|
||||
} from '@/composables/useRecipientSuggestions'
|
||||
import type { Contact, ContactGroup } from '@/shared/types/contacts'
|
||||
import type { RecipientSuggestion } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
@@ -46,15 +47,20 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
|
||||
})
|
||||
|
||||
function setup(complete: boolean, searchResult: RecipientSuggestion[] = []) {
|
||||
requests = installMockApi((request) =>
|
||||
request.params.q === undefined ? page([DOYUN, JUNHO], !complete) : page(searchResult, false),
|
||||
)
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/contact-groups') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
return request.params.q === undefined ? page([DOYUN, JUNHO], !complete) : page(searchResult, false)
|
||||
})
|
||||
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
|
||||
if (!handle) throw new Error('scope')
|
||||
return handle
|
||||
}
|
||||
|
||||
const searches = () => requests.filter((request) => request.params.q !== undefined).map((request) => request.params.q)
|
||||
const suggestionRequests = () => requests.filter((request) => request.url === '/recipient-suggestions')
|
||||
const searches = () =>
|
||||
suggestionRequests()
|
||||
.filter((request) => request.params.q !== undefined)
|
||||
.map((request) => request.params.q)
|
||||
|
||||
it('쓰기 화면을 열 때 q 없이 100개를 한 번 받아 두고 화면에서 거른다', async () => {
|
||||
const handle = setup(true)
|
||||
@@ -62,7 +68,7 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => request.params)).toEqual([{ limit: 100 }])
|
||||
expect(suggestionRequests().map((request) => request.params)).toEqual([{ limit: 100 }])
|
||||
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
|
||||
|
||||
excluded = new Set([DOYUN.email])
|
||||
@@ -80,7 +86,7 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
|
||||
expect(searches()).toEqual([])
|
||||
})
|
||||
|
||||
it('모자라면 250ms 기다린 뒤 마지막 글자로 묻고, 받은 것을 더한다 — 초성은 묻지 않는다', async () => {
|
||||
it('모자라고 더 있으면(hasNext) 250ms 기다린 뒤 마지막 글자로 묻고, 받은 것을 더한다 · 같은 글자는 다시 묻지 않는다', async () => {
|
||||
const handle = setup(false, [VENDOR])
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
@@ -104,9 +110,33 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
|
||||
expect(searches()).toEqual(['vendor'])
|
||||
})
|
||||
|
||||
it('미리 받기가 실패하면 치는 대로 q 로 묻고(자모만 친 글자는 묻지 않는다), 다음 preload(칸에 초점)가 다시 받는다', async () => {
|
||||
it('초성 · 조합 중 글자도 서버에 묻는다 — 8단계부터 서버의 q 가 같은 맞추기다 (주소록으로 찬 100줄 밖의 사내 · 최근 주소)', async () => {
|
||||
const SEOHA: RecipientSuggestion = { name: '도서하', email: 'seoha.do@arasystem.it.kr', source: 'directory' }
|
||||
const handle = setup(false, [SEOHA])
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
|
||||
handle.search('ㄷㅅ')
|
||||
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
|
||||
await flushPromises()
|
||||
handle.search('도ㅅ')
|
||||
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
|
||||
await flushPromises()
|
||||
// 조합 중(윤을 치는 중)도 — 그사이 또 치면 마지막 글자만
|
||||
handle.search('도서')
|
||||
clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
|
||||
handle.search('도서ㅎ')
|
||||
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(searches()).toEqual(['ㄷㅅ', '도ㅅ', '도서ㅎ'])
|
||||
expect(handle.matchesFor('ㄷㅅ').map((match) => match.suggestion)).toEqual([SEOHA])
|
||||
})
|
||||
|
||||
it('미리 받기가 실패하면 치는 대로 q 로 묻고(초성도), 다음 preload(칸에 초점)가 다시 받는다', async () => {
|
||||
let failPreload = true
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/contact-groups') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
if (request.params.q !== undefined) return page([VENDOR], false)
|
||||
if (failPreload) return fail(503, 'MAIL_001')
|
||||
return page([DOYUN, JUNHO], false)
|
||||
@@ -119,11 +149,11 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
|
||||
handle.search('ㄷㅇ')
|
||||
clock.advance(1_000)
|
||||
await flushPromises()
|
||||
expect(searches()).toEqual([])
|
||||
expect(searches()).toEqual(['ㄷㅇ'])
|
||||
handle.search('vendor')
|
||||
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
|
||||
await flushPromises()
|
||||
expect(searches()).toEqual(['vendor'])
|
||||
expect(searches()).toEqual(['ㄷㅇ', 'vendor'])
|
||||
expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
|
||||
|
||||
failPreload = false
|
||||
@@ -131,7 +161,7 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
|
||||
await flushPromises()
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.params.q === undefined)).toHaveLength(2)
|
||||
expect(suggestionRequests().filter((request) => request.params.q === undefined)).toHaveLength(2)
|
||||
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
|
||||
})
|
||||
|
||||
@@ -141,5 +171,133 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
|
||||
handle?.preload()
|
||||
await flushPromises()
|
||||
expect(handle?.matchesFor('도윤')).toEqual([])
|
||||
expect(handle?.optionsFor('도윤')).toEqual([])
|
||||
})
|
||||
|
||||
describe('주소록 · 그룹 (8단계 — 계약 3절 "메일과 함께")', () => {
|
||||
const VENDORS: ContactGroup = { id: 'g1', name: '거래처', memberCount: 3 }
|
||||
const JIHOON: RecipientSuggestion = {
|
||||
name: '박지훈',
|
||||
email: 'jihoon.park@hanbit.example',
|
||||
source: 'contact',
|
||||
company: '(주)한빛상사',
|
||||
emailLabel: 'work',
|
||||
}
|
||||
|
||||
function member(id: string, name: string | null, emails: string[]): Contact {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
emails: emails.map((address) => ({ address, label: 'work' as const })),
|
||||
phones: [],
|
||||
company: null,
|
||||
department: null,
|
||||
title: null,
|
||||
note: null,
|
||||
groupIds: ['g1'],
|
||||
}
|
||||
}
|
||||
|
||||
function setupWithGroups(members: (cursor: unknown) => ReturnType<typeof ok> | ReturnType<typeof fail>) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/contact-groups') {
|
||||
return ok({ items: [VENDORS], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
}
|
||||
if (request.url === '/contacts') return members(request.params.cursor)
|
||||
return page([JIHOON, DOYUN], false)
|
||||
})
|
||||
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
|
||||
if (!handle) throw new Error('scope')
|
||||
return handle
|
||||
}
|
||||
|
||||
it('열 때 그룹 목록을 한 번 받아 두고(알림 없이) 그룹 이름으로 찾는다 — 묶음은 주소록 → 그룹 → 사내', async () => {
|
||||
const handle = setupWithGroups(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
|
||||
handle.preload()
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
|
||||
const groupRequests = requests.filter((request) => request.url === '/contact-groups')
|
||||
expect(groupRequests.map((request) => request.params)).toEqual([{ limit: 100 }])
|
||||
expect(groupRequests[0]?.config.silent).toBe(true)
|
||||
expect(handle.optionsFor('ㄱㄹ').map((option) => option.kind)).toEqual(['group'])
|
||||
// 주소 추천만 보는 matchesFor 에는 그룹이 없다 (서버에 더 물을지는 주소 추천의 수로 정한다)
|
||||
expect(handle.matchesFor('ㄱㄹ')).toEqual([])
|
||||
})
|
||||
|
||||
it('주소록 → 그룹 → 사내 차례로 섞어 보인다', async () => {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/contact-groups') {
|
||||
return ok({
|
||||
items: [{ id: 'g2', name: '박팀', memberCount: 2 }],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 1 },
|
||||
})
|
||||
}
|
||||
return page([JUNHO, JIHOON], false)
|
||||
})
|
||||
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
|
||||
handle?.preload()
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
handle
|
||||
?.optionsFor('박')
|
||||
.map((option) => (option.kind === 'group' ? `그룹 ${option.match.group.name}` : option.match.suggestion.email)),
|
||||
).toEqual(['jihoon.park@hanbit.example', '그룹 박팀', JUNHO.email])
|
||||
})
|
||||
|
||||
it('그룹을 고르면 구성원을 100명씩 다음 쪽까지 받아 대표 주소를 돌려준다 — 이메일이 없는 구성원은 센다', async () => {
|
||||
const handle = setupWithGroups((cursor) =>
|
||||
cursor === undefined
|
||||
? ok({
|
||||
items: [member('c1', '박지훈', ['jihoon.park@hanbit.example', 'jihoon.p@mail.example']), member('c2', '메모만', [])],
|
||||
pageInfo: { hasNext: true, nextCursor: 'page-2', total: 3 },
|
||||
})
|
||||
: ok({
|
||||
items: [member('c3', null, ['ops@vendor.example'])],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 3 },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await handle.expandGroup(VENDORS)
|
||||
|
||||
const memberRequests = requests.filter((request) => request.url === '/contacts')
|
||||
expect(memberRequests.map((request) => request.params)).toEqual([
|
||||
{ groupId: 'g1', limit: 100 },
|
||||
{ groupId: 'g1', limit: 100, cursor: 'page-2' },
|
||||
])
|
||||
expect(result).toEqual({
|
||||
addresses: [
|
||||
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
|
||||
{ name: null, email: 'ops@vendor.example' },
|
||||
],
|
||||
withoutEmail: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('구성원을 받지 못하면 null (알림은 인터셉터) · 기다리는 사이 세션이 바뀌면 결과를 버린다', async () => {
|
||||
const failing = setupWithGroups(() => fail(503, 'MAIL_001'))
|
||||
expect(await failing.expandGroup(VENDORS)).toBeNull()
|
||||
|
||||
let finish: (result: ReturnType<typeof ok>) => void = () => undefined
|
||||
const late = new Promise<ReturnType<typeof ok>>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
requests = installMockApi((request) =>
|
||||
request.url === '/contacts' ? late : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }),
|
||||
)
|
||||
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
|
||||
const pending = handle?.expandGroup(VENDORS)
|
||||
useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' })
|
||||
finish(ok({ items: [member('c1', '박지훈', ['jihoon.park@hanbit.example'])], pageInfo: { hasNext: false, nextCursor: null, total: 1 } }))
|
||||
expect(await pending).toBeNull()
|
||||
})
|
||||
|
||||
it('이미 넣은 주소인지는 지금 칸 값으로 본다 (대소문자 무시)', () => {
|
||||
const handle = setup(true)
|
||||
excluded = new Set(['jihoon.park@hanbit.example'])
|
||||
expect(handle.isAdded('Jihoon.Park@hanbit.example')).toBe(true)
|
||||
expect(handle.isAdded('ops@vendor.example')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import { onScopeDispose, ref, shallowRef, type InjectionKey } from 'vue'
|
||||
|
||||
import { systemClock, type Clock, type ClockTimer } from './useClock'
|
||||
import { useContacts } from './useContacts'
|
||||
import { useMail } from './useMail'
|
||||
import { CONTACTS_PAGE_SIZE } from '@/shared/constants/contacts'
|
||||
import type { ContactGroup } from '@/shared/types/contacts'
|
||||
import type { RecipientSuggestion } from '@/shared/types/mail'
|
||||
import { groupRecipientsOf, type GroupRecipients } from '@/shared/utils/contacts'
|
||||
import { normalizeEmail } from '@/shared/utils/mail-address'
|
||||
import {
|
||||
RECIPIENT_SUGGESTION_LIMIT,
|
||||
arrangeRecipientOptions,
|
||||
filterRecipientSuggestions,
|
||||
hasHangulJamo,
|
||||
matchContactGroups,
|
||||
type RecipientMatch,
|
||||
type RecipientOption,
|
||||
} from '@/shared/utils/recipient-match'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 받는 사람 추천 (계약 2절 "받는 사람 추천" · 디자인 README "2단계 › 받는 사람 자동 완성")
|
||||
// - 쓰기 화면을 열 때 q 없이 limit=100 으로 한 번 받아 두고, 치는 대로 화면에서 거른다(초성 포함 — recipient-match.ts)
|
||||
// - 맞는 것이 8개보다 모자라고 받아 둔 목록이 전부가 아닐 때(hasNext)만 250ms 기다린 뒤 q 로 서버에 다시 묻는다.
|
||||
// 받은 것은 받아 둔 목록에 더한다. 서버는 초성을 모르므로 초성 · 홀로 쓴 자모가 든 글자는 묻지 않는다
|
||||
// - 맞는 것이 8개보다 모자라고 받아 둔 목록이 전부가 아닐 때(hasNext)만 250ms 기다린 뒤 q 로 서버에 다시 묻는다(늦은 응답은 버린다).
|
||||
// 받은 것은 받아 둔 목록에 더한다. 8단계부터 서버의 q 도 이 화면과 같은 맞추기라(초성 · 조합 중 글자 — 계약 2절 "받는 사람 추천") 그런 글자도 묻는다 —
|
||||
// 주소록이 크면 받아 둔 100줄이 주소록으로 차서 사내 · 최근 주소가 그 안에 없다
|
||||
// - 추천은 보조 정보다 — 못 받아도 알리지 않는다 (silent). 주소는 그대로 칠 수 있다.
|
||||
// 미리 받기가 실패하면 치는 대로 q 로 묻고(초성 · 자모만 친 글자는 여전히 묻지 않는다), 다음에 칸에 초점이 올 때 다시 받아 본다
|
||||
// 미리 받기가 실패하면 치는 대로 q 로 묻고, 다음에 칸에 초점이 올 때 다시 받아 본다
|
||||
// - 8단계(계약 3절 "메일과 함께"): 서버 추천에 내 주소록(source contact)이 먼저 온다. 그룹은 서버 추천에 없어 열 때 그룹 목록을 한 번 받아 두고
|
||||
// 이름으로 찾는다(주소록 → 그룹 → 사내 → 최근 — optionsFor). 그룹을 고르면 구성원을 받아(100명씩 다음 쪽까지 — expandGroup)
|
||||
// 대표 주소를 넣는다 — 그룹 목록 · 구성원을 못 받아도 주소 추천은 그대로다. 구성원 받기의 실패는 고른 일이라 인터셉터가 알린다
|
||||
// 쓰기 화면이 만들어 provide 하고(RECIPIENT_SUGGESTIONS), 받는 사람 칸(RecipientField)이 inject 해 쓴다.
|
||||
|
||||
/** 처음 받아 두는 개수 — 계약의 최대 limit */
|
||||
@@ -35,18 +46,31 @@ export interface RecipientSuggestionsOptions {
|
||||
export interface RecipientSuggestionsHandle {
|
||||
/** 쓰기 화면을 열 때 · 받는 사람 칸에 초점이 올 때 — 받는 중이거나 받았으면 아무것도 하지 않는다 (실패했으면 다시 받는다) */
|
||||
preload: () => void
|
||||
/** 친 글자에 맞는 추천 — 최대 8개, 반응형으로 읽는다 (받아 둔 목록 · 이미 넣은 주소가 바뀌면 다시 계산된다) */
|
||||
/** 친 글자에 맞는 주소 추천 — 최대 8개, 반응형으로 읽는다 (받아 둔 목록 · 이미 넣은 주소가 바뀌면 다시 계산된다) */
|
||||
matchesFor: (query: string) => RecipientMatch[]
|
||||
/** 추천 목록의 줄 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 모두 합쳐 최대 8개 (반응형) */
|
||||
optionsFor: (query: string) => RecipientOption[]
|
||||
/** 맞는 것이 모자라면 서버에 묻는다 (기다렸다가 — 그사이 또 치면 마지막 글자만) */
|
||||
search: (query: string) => void
|
||||
/** 그룹의 구성원 → 넣을 대표 주소 · 이메일이 없는 구성원 수. 못 받았거나 그사이 세션이 바뀌었으면 null (실패는 인터셉터가 알렸다) */
|
||||
expandGroup: (group: ContactGroup) => Promise<GroupRecipients | null>
|
||||
/** 받는 사람 · 참조 · 숨은참조 가운데 이미 넣은 주소인가 — 지금 칸 값으로 본다 */
|
||||
isAdded: (email: string) => boolean
|
||||
}
|
||||
|
||||
export const RECIPIENT_SUGGESTIONS: InjectionKey<RecipientSuggestionsHandle> = Symbol('recipient-suggestions')
|
||||
|
||||
export function useRecipientSuggestions(options: RecipientSuggestionsOptions): RecipientSuggestionsHandle {
|
||||
const { listRecipientSuggestions } = useMail()
|
||||
const { listContactGroups, listGroupMembers } = useContacts()
|
||||
const authStore = useAuthStore()
|
||||
const clock = options.clock ?? systemClock
|
||||
|
||||
/** 받아 둔 그룹 — 이름으로 찾는다 (그룹은 100개까지 — 한 쪽) */
|
||||
const groups = shallowRef<readonly ContactGroup[]>([])
|
||||
/** 그룹 받기 — 실패했으면 다음 preload 가 다시 받는다 */
|
||||
let groupsState: 'idle' | 'loading' | 'done' | 'failed' = 'idle'
|
||||
|
||||
/** 받아 둔 추천 — 처음 받은 것 + 검색으로 더 받은 것 (주소가 겹치지 않게) */
|
||||
const pool = shallowRef<readonly RecipientSuggestion[]>([])
|
||||
/** 처음 받은 목록이 전부다 — 서버에 더 물을 것이 없다 */
|
||||
@@ -69,7 +93,22 @@ export function useRecipientSuggestions(options: RecipientSuggestionsOptions): R
|
||||
if (added.length > 0) pool.value = [...pool.value, ...added]
|
||||
}
|
||||
|
||||
function preloadGroups() {
|
||||
if (groupsState === 'loading' || groupsState === 'done') return
|
||||
groupsState = 'loading'
|
||||
void listContactGroups({ limit: CONTACTS_PAGE_SIZE }, { silent: true })
|
||||
.then((page) => {
|
||||
groupsState = 'done'
|
||||
groups.value = Array.isArray(page?.items) ? page.items : []
|
||||
})
|
||||
.catch(() => {
|
||||
// 그룹 줄 없이 주소 추천만 — 다음에 칸에 초점이 오면 다시 받아 본다
|
||||
groupsState = 'failed'
|
||||
})
|
||||
}
|
||||
|
||||
function preload() {
|
||||
preloadGroups()
|
||||
if (preloadState === 'loading' || preloadState === 'done') return
|
||||
preloadState = 'loading'
|
||||
void listRecipientSuggestions({ limit: RECIPIENT_SUGGESTION_PRELOAD }, { silent: true })
|
||||
@@ -88,6 +127,24 @@ export function useRecipientSuggestions(options: RecipientSuggestionsOptions): R
|
||||
return filterRecipientSuggestions(query, pool.value, options.excluded())
|
||||
}
|
||||
|
||||
function optionsFor(query: string): RecipientOption[] {
|
||||
return arrangeRecipientOptions(matchesFor(query), matchContactGroups(query, groups.value))
|
||||
}
|
||||
|
||||
function isAdded(email: string): boolean {
|
||||
return options.excluded().has(normalizeEmail(email))
|
||||
}
|
||||
|
||||
async function expandGroup(group: ContactGroup): Promise<GroupRecipients | null> {
|
||||
const session = authStore.generation
|
||||
try {
|
||||
const members = await listGroupMembers(group.id)
|
||||
return authStore.generation === session ? groupRecipientsOf(members) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSearch() {
|
||||
if (searchTimer !== null) clock.clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
@@ -116,12 +173,12 @@ export function useRecipientSuggestions(options: RecipientSuggestionsOptions): R
|
||||
cancelSearch()
|
||||
const text = query.trim()
|
||||
if (text === '' || text.length > SEARCH_QUERY_MAX_LENGTH || complete.value) return
|
||||
if (hasHangulJamo(text) || asked.has(text)) return
|
||||
if (asked.has(text)) return
|
||||
if (matchesFor(text).length >= RECIPIENT_SUGGESTION_LIMIT) return
|
||||
searchTimer = clock.setTimeout(() => void ask(text), RECIPIENT_SEARCH_DELAY_MS)
|
||||
}
|
||||
|
||||
onScopeDispose(cancelSearch)
|
||||
|
||||
return { preload, matchesFor, search }
|
||||
return { preload, matchesFor, optionsFor, search, expandGroup, isAdded }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user