From e2c41d38bbfeecb2b6a1690549d203277560c9ff Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 29 Oct 2026 14:13:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EC=A3=BC=EC=86=8C=EB=A1=9D?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=C2=B7=20=EB=A9=94=EC=9D=BC=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 레일의 주소록 모듈 — 내 연락처 · 그룹 · 사내 목록(직원 · 공용 주소), 초성 찾기, 연락처 추가 · 고치기 창, 그룹 · 구성원 넣기 — 과 메일 연동(받는 사람 추천의 주소록 · 그룹, 연락처 · 그룹 · 사내의 [메일 쓰기], 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」)을 더한다. 디자인 README "8단계", 계약 3절. 리뷰로 고친 것: - 쓰기 화면을 채우는 라우터 상태에 연 직원을 싣는다 — 같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가면 앞 직원의 받는 사람이 채워졌다. - [고치기]는 창을 열 때 새로 받고 바꾼 칸만 보낸다 — 받아 둔 값으로 다른 탭 · 휴대폰의 변경을 덮었다. 검사도 바꾼 칸만. - [되돌리기]는 서버의 되돌리기 표로 — 다시 만들기로는 다른 프로그램의 칸 · uid 를 잃었다. - 읽기 화면에서 연 연락처 추가 창을 메일 레이아웃으로 옮겼다 — 메일을 바꾸면 쓴 것이 확인 없이 사라졌다. - 그룹을 이어 고르면 앞 그룹이 빠지던 것, 저장 중 창을 닫으면 거절이 묻히던 것, 구성원 넣기의 첫 쪽이 모두 구성원이면 막히던 것(excludeGroupId). - 받는 사람 추천은 초성 · 조합 중 글자도 서버에 묻는다(서버가 같은 맞추기 — 같은 표본 표를 두 쪽 테스트에 둔다). - 바꿀 때마다 불러온 쪽을 모두 다시 받지 않고 응답을 서버 차례로 그 자리에 넣는다. Co-Authored-By: Claude Opus 5.5 --- .../src/components/contacts/ContactAvatar.vue | 66 ++ .../src/components/contacts/ContactDetail.vue | 576 +++++++++ .../components/contacts/ContactDialog.spec.ts | 550 +++++++++ .../src/components/contacts/ContactDialog.vue | 761 ++++++++++++ .../components/contacts/ContactEntryRows.vue | 354 ++++++ .../contacts/ContactGroupMembersDialog.vue | 650 +++++++++++ .../contacts/ContactGroupNameDialog.vue | 213 ++++ .../components/contacts/ContactMoreMenu.vue | 130 +++ .../components/contacts/ContactsDialogs.vue | 78 ++ .../components/contacts/ContactsGroupRow.vue | 151 +++ .../src/components/contacts/ContactsList.vue | 523 +++++++++ .../components/contacts/ContactsListItem.vue | 133 +++ .../src/components/contacts/ContactsPanel.vue | 401 +++++++ .../components/contacts/ContactsSearchBox.vue | 206 ++++ .../src/components/contacts/contacts-ui.ts | 32 + .../src/components/feedback/ModalDialog.vue | 14 +- .../components/mail/MailContactDialogHost.vue | 38 + .../src/components/mail/MailToolButton.vue | 36 +- .../src/components/mail/MailboxMoreButton.vue | 18 +- .../src/components/mail/MessageMoreMenu.vue | 90 +- .../src/components/mail/MessageReader.spec.ts | 189 ++- .../src/components/mail/MessageReader.vue | 3 +- .../components/mail/RecipientField.spec.ts | 256 +++- .../src/components/mail/RecipientField.vue | 283 +++-- .../mail/RecipientSuggestionList.vue | 137 ++- frontend/src/components/shell/AppRail.spec.ts | 7 +- frontend/src/composables/useApi.spec.ts | 2 + frontend/src/composables/useContactActions.ts | 254 ++++ .../composables/useContactDuplicateCheck.ts | 82 ++ frontend/src/composables/useContacts.ts | 160 +++ .../src/composables/useContactsDialogs.ts | 53 + frontend/src/composables/useMail.ts | 5 +- .../src/composables/useMailContactDialog.ts | 42 + .../useRecipientSuggestions.spec.ts | 178 ++- .../composables/useRecipientSuggestions.ts | 71 +- .../src/pages/contacts/ContactsLayout.vue | 43 + .../src/pages/contacts/ContactsPage.spec.ts | 1034 +++++++++++++++++ frontend/src/pages/contacts/ContactsPage.vue | 81 ++ frontend/src/pages/mail/ComposePage.spec.ts | 62 + frontend/src/pages/mail/ComposePage.vue | 12 +- frontend/src/pages/mail/MailLayout.vue | 7 + frontend/src/router/routes.ts | 24 + frontend/src/shared/constants/app-modules.ts | 3 +- frontend/src/shared/constants/contacts.ts | 328 ++++++ frontend/src/shared/constants/icons.ts | 23 + frontend/src/shared/types/contacts.ts | 140 +++ frontend/src/shared/types/mail.ts | 14 +- frontend/src/shared/utils/clipboard.ts | 13 + .../src/shared/utils/compose-prefill.spec.ts | 67 ++ frontend/src/shared/utils/compose-prefill.ts | 47 + .../src/shared/utils/contacts-route.spec.ts | 86 ++ frontend/src/shared/utils/contacts-route.ts | 131 +++ frontend/src/shared/utils/contacts.spec.ts | 421 +++++++ frontend/src/shared/utils/contacts.ts | 511 ++++++++ .../src/shared/utils/mail-address.spec.ts | 48 + frontend/src/shared/utils/mail-address.ts | 55 + frontend/src/shared/utils/mail-filter.ts | 11 +- .../src/shared/utils/recipient-match.spec.ts | 62 +- frontend/src/shared/utils/recipient-match.ts | 68 +- frontend/src/stores/contacts.store.spec.ts | 464 ++++++++ frontend/src/stores/contacts.store.ts | 712 ++++++++++++ frontend/src/styles/base.css | 21 + frontend/src/styles/tokens.css | 6 + .../src/test-utils/recipient-match-samples.ts | 86 ++ frontend/src/test-utils/router.ts | 22 + 65 files changed, 11130 insertions(+), 214 deletions(-) create mode 100644 frontend/src/components/contacts/ContactAvatar.vue create mode 100644 frontend/src/components/contacts/ContactDetail.vue create mode 100644 frontend/src/components/contacts/ContactDialog.spec.ts create mode 100644 frontend/src/components/contacts/ContactDialog.vue create mode 100644 frontend/src/components/contacts/ContactEntryRows.vue create mode 100644 frontend/src/components/contacts/ContactGroupMembersDialog.vue create mode 100644 frontend/src/components/contacts/ContactGroupNameDialog.vue create mode 100644 frontend/src/components/contacts/ContactMoreMenu.vue create mode 100644 frontend/src/components/contacts/ContactsDialogs.vue create mode 100644 frontend/src/components/contacts/ContactsGroupRow.vue create mode 100644 frontend/src/components/contacts/ContactsList.vue create mode 100644 frontend/src/components/contacts/ContactsListItem.vue create mode 100644 frontend/src/components/contacts/ContactsPanel.vue create mode 100644 frontend/src/components/contacts/ContactsSearchBox.vue create mode 100644 frontend/src/components/contacts/contacts-ui.ts create mode 100644 frontend/src/components/mail/MailContactDialogHost.vue create mode 100644 frontend/src/composables/useContactActions.ts create mode 100644 frontend/src/composables/useContactDuplicateCheck.ts create mode 100644 frontend/src/composables/useContacts.ts create mode 100644 frontend/src/composables/useContactsDialogs.ts create mode 100644 frontend/src/composables/useMailContactDialog.ts create mode 100644 frontend/src/pages/contacts/ContactsLayout.vue create mode 100644 frontend/src/pages/contacts/ContactsPage.spec.ts create mode 100644 frontend/src/pages/contacts/ContactsPage.vue create mode 100644 frontend/src/shared/constants/contacts.ts create mode 100644 frontend/src/shared/types/contacts.ts create mode 100644 frontend/src/shared/utils/clipboard.ts create mode 100644 frontend/src/shared/utils/compose-prefill.spec.ts create mode 100644 frontend/src/shared/utils/compose-prefill.ts create mode 100644 frontend/src/shared/utils/contacts-route.spec.ts create mode 100644 frontend/src/shared/utils/contacts-route.ts create mode 100644 frontend/src/shared/utils/contacts.spec.ts create mode 100644 frontend/src/shared/utils/contacts.ts create mode 100644 frontend/src/stores/contacts.store.spec.ts create mode 100644 frontend/src/stores/contacts.store.ts create mode 100644 frontend/src/test-utils/recipient-match-samples.ts diff --git a/frontend/src/components/contacts/ContactAvatar.vue b/frontend/src/components/contacts/ContactAvatar.vue new file mode 100644 index 0000000..2f6201a --- /dev/null +++ b/frontend/src/components/contacts/ContactAvatar.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend/src/components/contacts/ContactDetail.vue b/frontend/src/components/contacts/ContactDetail.vue new file mode 100644 index 0000000..8e35c1e --- /dev/null +++ b/frontend/src/components/contacts/ContactDetail.vue @@ -0,0 +1,576 @@ + + + + + diff --git a/frontend/src/components/contacts/ContactDialog.spec.ts b/frontend/src/components/contacts/ContactDialog.spec.ts new file mode 100644 index 0000000..b4bffaf --- /dev/null +++ b/frontend/src/components/contacts/ContactDialog.spec.ts @@ -0,0 +1,550 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' + +import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated' +import type { Contact, ContactGroup, ContactInput } from '@/shared/types/contacts' +import type { PaginatedResult } from '@/shared/types/pagination' +import type { ContactPrefill } from '@/shared/utils/contacts' +import { useAuthStore } from '@/stores/auth.store' +import { useContactsStore } from '@/stores/contacts.store' +import { useToastStore } from '@/stores/toast.store' +import { + fail, + flushPromises, + installMockApi, + ok, + restoreApiAdapter, + type MockRequest, + type MockResult, +} from '@/test-utils/mock-api' +import { createTestRouter, routeViewProvide } from '@/test-utils/router' +import ContactDialog from './ContactDialog.vue' + +// 연락처 추가 · 고치기 창 (디자인 원본 ContactsEdit · README "8단계 › 연락처 추가 · 고치기") + +const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' } +const GROUPS: ContactGroup[] = [ + { id: 'g1', name: '거래처', memberCount: 4 }, + { id: 'g2', name: '프로젝트 알파', memberCount: 4 }, +] +const SEOYUN: Contact = { + id: 'c1', + name: '강서윤', + emails: [{ address: 'seoyun.kang@hanbit.example', label: 'work' }], + phones: [], + company: '(주)한빛상사', + department: '구매팀', + title: '과장', + note: null, + groupIds: ['g1'], +} +const JIHOON: Contact = { + id: 'c2', + name: '박지훈', + emails: [ + { address: 'jihoon.park@hanbit.example', label: 'work' }, + { address: 'jihoon.p@mail.example', label: 'personal' }, + ], + phones: [{ number: '010-3192-5540', label: 'mobile' }], + company: '(주)한빛상사', + department: '영업팀', + title: '팀장', + note: '견적 문의는 박지훈 팀장 → 강서윤 과장 차례로.', + groupIds: ['g1', 'g2'], +} + +function pageOf(items: T[]): PaginatedResult { + return { items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } } +} + +interface Server { + /** GET /contacts?q= — 같은 주소 찾기 */ + search?: (q: string) => Contact[] + save?: (request: MockRequest) => MockResult | Promise +} + +describe('ContactDialog', () => { + let pinia: Pinia + let requests: MockRequest[] + const mounted: VueWrapper[] = [] + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().startSession(USER) + }) + + afterEach(() => { + for (const wrapper of mounted.splice(0)) { + if (!wrapper.vm.$.isUnmounted) wrapper.unmount() + } + useToastStore().clear() + restoreApiAdapter() + document.body.innerHTML = '' + }) + + function installServer(server: Server = {}) { + requests = installMockApi((request) => { + if (request.url === '/contact-groups') return ok(pageOf(GROUPS)) + if (request.url === '/contacts' && request.method === 'GET') { + return ok(pageOf(server.search?.(String(request.params.q ?? '')) ?? [])) + } + if (request.url === '/contact-restorations') return ok({ kind: 'contact', contact: { ...JIHOON, id: 'c20' } }, 201) + if (request.method === 'POST' || request.method === 'PATCH') { + return server.save ? server.save(request) : ok({ ...JIHOON, ...(request.data as ContactInput), id: 'c9' }) + } + if (request.method === 'DELETE') return ok({ contact: JIHOON, restoreToken: 't-c2' }) + return ok(null) + }) + } + + async function mountDialog( + props: { contact?: Contact | null; prefill?: ContactPrefill | null } = {}, + server: Server = {}, + path = '/contacts', + ) { + installServer(server) + const router = createTestRouter() + await router.push(path) + await router.isReady() + const closes: number[] = [] + const wrapper: VueWrapper = mount(ContactDialog, { + attachTo: document.body, + props: { + open: true, + contact: props.contact ?? null, + prefill: props.prefill ?? null, + onClose: () => { + closes.push(closes.length + 1) + return wrapper.setProps({ open: false }) + }, + }, + global: { plugins: [pinia, router], provide: routeViewProvide(router) }, + }) + mounted.push(wrapper) + await flushPromises() + return { wrapper, router, closes } + } + + // 이메일 칸은 type="text" + inputmode="email" (type="email" 이면 Chrome 이 한글 도메인을 퓨니코드로 바꿀 수 있다) + const emailInputs = (wrapper: VueWrapper) => wrapper.findAll('input[inputmode="email"]') + const phoneInputs = (wrapper: VueWrapper) => wrapper.findAll('input[type="tel"]') + const saves = () => + requests.filter( + (request) => (request.method === 'POST' && request.url === '/contacts') || request.method === 'PATCH', + ) + const toasts = () => useToastStore().toasts.map((toast) => toast.message) + const isOpen = (wrapper: VueWrapper, selector: string) => wrapper.find(`${selector}[open]`).exists() + + it('새 연락처 — 이메일 · 전화번호 빈 줄 하나씩과 안내, 그룹은 받아 둔다 · 처음 초점은 이름', async () => { + const { wrapper } = await mountDialog() + + expect(wrapper.get('#contact-dialog-title').text()).toBe('연락처 추가') + expect(document.activeElement?.id).toBe('contact-name') + expect(emailInputs(wrapper).map((input) => input.attributes('aria-label'))).toEqual(['첫째 이메일']) + expect(wrapper.get('select[aria-label="첫째 이메일의 종류"]').findAll('option').map((option) => option.text())).toEqual([ + '회사', + '개인', + '기타', + ]) + expect(wrapper.get('select[aria-label="첫째 전화번호의 종류"]').findAll('option').map((option) => option.text())).toEqual([ + '휴대전화', + '회사', + '집', + '기타', + ]) + expect(wrapper.text()).toContain('5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다') + expect(wrapper.get('#contact-required').text()).toBe('이름이나 이메일 가운데 하나는 있어야 합니다.') + expect(wrapper.get('[data-contact-note-count]').text()).toBe('0 / 1,000') + expect(wrapper.findAll('[data-contact-group]').map((input) => input.attributes('data-contact-group'))).toEqual([ + 'g1', + 'g2', + ]) + expect(wrapper.find('#contact-remove').exists()).toBe(false) + expect(requests.map((request) => request.url)).toEqual(['/contact-groups']) + }) + + it('잘못은 [저장]을 누를 때만 칸 아래 빨간 글 — 첫 잘못된 칸에 초점, 알림 없음 · 고치면 그 칸의 잘못을 내린다', async () => { + const { wrapper } = await mountDialog() + + await wrapper.get('#contact-save').trigger('click') + expect(wrapper.get('#contact-required').classes()).toContain('form-message--error') + expect(document.activeElement?.id).toBe('contact-name') + + await wrapper.get('#contact-name').setValue('박지훈') + await emailInputs(wrapper)[0]?.setValue('jihoon.park@hanbit') + await phoneInputs(wrapper)[0]?.setValue('내선 12') + await wrapper.get('#contact-save').trigger('click') + + const email = emailInputs(wrapper)[0] + expect(email?.attributes('aria-invalid')).toBe('true') + expect(wrapper.get(`#${email?.attributes('aria-describedby')}`).text()).toBe('이메일 형식이 올바르지 않습니다.') + expect(document.activeElement).toBe(email?.element) + expect(phoneInputs(wrapper)[0]?.attributes('aria-invalid')).toBe('true') + expect(saves()).toEqual([]) + expect(toasts()).toEqual([]) + + await email?.setValue('jihoon.park@hanbit.example') + expect(email?.attributes('aria-invalid')).toBe('false') + }) + + it('저장 — 앞뒤 공백 · 빈 줄을 빼서 보내고 "연락처를 저장했습니다." · 창을 닫는다', async () => { + const { wrapper, closes } = await mountDialog() + + await wrapper.get('#contact-name').setValue(' 한도경 ') + await emailInputs(wrapper)[0]?.setValue(' dokyung.han@mail.example ') + await wrapper.get('select[aria-label="첫째 이메일의 종류"]').setValue('personal') + await wrapper.get('#contact-company').setValue(' ') + await wrapper.get('#contact-note').setValue('대학 동기\n') + expect(wrapper.get('[data-contact-note-count]').text()).toBe('6 / 1,000') + await wrapper.get('[data-contact-group="g2"]').setValue(true) + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + + expect(saves().map((request) => [request.method, request.url])).toEqual([['POST', '/contacts']]) + expect(saves()[0]?.data).toEqual({ + name: '한도경', + emails: [{ address: 'dokyung.han@mail.example', label: 'personal' }], + phones: [], + company: null, + department: null, + title: null, + note: '대학 동기', + groupIds: ['g2'], + }) + expect(toasts()).toEqual(['연락처를 저장했습니다.']) + expect(closes).toHaveLength(1) + }) + + it('이메일 칸은 type="text" + inputmode="email" · [더하기]는 옆 안내("5개까지 …")를 까닭으로 잇는다(aria-describedby)', async () => { + const { wrapper } = await mountDialog() + + const email = emailInputs(wrapper)[0] + expect(email?.attributes('type')).toBe('text') + const addEmail = wrapper.findAll('.entry-rows__add-button')[0] + expect(wrapper.get(`#${addEmail?.attributes('aria-describedby')}`).text()).toBe( + '5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다', + ) + }) + + it('고치기 — PATCH 로 바꾼 칸만 보낸다(이메일 · 전화번호 · 그룹은 바뀌면 통째로) · 바꾼 것이 없으면 보내지 않고 닫는다', async () => { + const { wrapper, closes } = await mountDialog({ contact: JIHOON }) + + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(saves()).toEqual([]) + expect(closes).toHaveLength(1) + + await wrapper.setProps({ open: true }) + await flushPromises() + await wrapper.get('#contact-title').setValue('부장') + await phoneInputs(wrapper)[0]?.setValue('010-3192-0000') + await wrapper.get('[data-contact-group="g2"]').setValue(false) + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(saves().map((request) => [request.method, request.url])).toEqual([['PATCH', '/contacts/c2']]) + expect(saves()[0]?.data).toEqual({ + phones: [{ number: '010-3192-0000', label: 'mobile' }], + title: '부장', + groupIds: ['g1'], + }) + expect(toasts()).toEqual(['연락처를 저장했습니다.']) + }) + + it('고치기는 바꾼 칸만 검사한다 — 손대지 않은 남의 값(21번째 주소 · "내선" 번호 · 긴 메모 · 100자 넘는 회사)은 그대로 저장된다', async () => { + const imported: Contact = { + ...JIHOON, + emails: Array.from({ length: 21 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })), + phones: [{ number: '내선 12', label: 'work' }], + company: '한'.repeat(150), + note: '가'.repeat(1200), + } + const { wrapper } = await mountDialog({ contact: imported }) + + await wrapper.get('#contact-title').setValue('부장') + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(saves()[0]?.data).toEqual({ title: '부장' }) + }) + + it('고친 칸은 그 칸 전부가 규칙에 맞아야 한다 — 전화번호를 고치면 남의 "내선" 번호도, 회사를 고치면 100자, 이메일을 고치면 20개', async () => { + const imported: Contact = { + ...JIHOON, + emails: Array.from({ length: 21 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })), + phones: [ + { number: '내선 12', label: 'work' }, + { number: '010-1111-2222', label: 'mobile' }, + ], + company: '한'.repeat(150), + } + const { wrapper } = await mountDialog({ contact: imported }) + + await phoneInputs(wrapper)[1]?.setValue('010-1111-3333') + await wrapper.get('#contact-company').setValue('한'.repeat(149)) + await wrapper.get('select[aria-label="첫째 이메일의 종류"]').setValue('personal') + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + + expect(saves()).toEqual([]) + expect(wrapper.get('[data-entry-count-error]').text()).toBe('이메일은 20개까지 넣을 수 있습니다. 몇 개를 빼 주세요.') + expect(document.activeElement).toBe(emailInputs(wrapper)[0]?.element) + expect(phoneInputs(wrapper)[0]?.attributes('aria-invalid')).toBe('true') + expect(wrapper.get('#contact-company').attributes('aria-invalid')).toBe('true') + expect(wrapper.get('#contact-company-error').text()).toBe('100자까지 쓸 수 있습니다.') + }) + + it('고치기 — 다른 프로그램이 넣은 6개 이상의 주소는 그대로 보이고 저장된다 · [이메일 더하기]는 5개에서 멈춘다', async () => { + const many: Contact = { + ...JIHOON, + emails: Array.from({ length: 6 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })), + } + const { wrapper } = await mountDialog({ contact: many }) + + expect(wrapper.get('#contact-dialog-title').text()).toBe('연락처 고치기') + expect(emailInputs(wrapper)).toHaveLength(6) + const addEmail = wrapper.findAll('.entry-rows__add-button')[0] + expect(addEmail?.attributes('aria-disabled')).toBe('true') + await addEmail?.trigger('click') + expect(emailInputs(wrapper)).toHaveLength(6) + // 전화번호는 하나 — 더할 수 있다 + const addPhone = wrapper.findAll('.entry-rows__add-button')[1] + expect(addPhone?.attributes('aria-disabled')).toBeUndefined() + await addPhone?.trigger('click') + await flushPromises() + expect(phoneInputs(wrapper)).toHaveLength(2) + expect(document.activeElement).toBe(phoneInputs(wrapper)[1]?.element) + + await phoneInputs(wrapper)[1]?.setValue('02-555-0147') + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + // 이메일은 손대지 않았다 — 전화번호만 보낸다 + expect(saves().map((request) => [request.method, request.url])).toEqual([['PATCH', '/contacts/c2']]) + expect(saves()[0]?.data).toEqual({ + phones: [ + { number: '010-3192-5540', label: 'mobile' }, + { number: '02-555-0147', label: 'mobile' }, + ], + }) + }) + + it('같은 주소 — 이메일 칸을 떠나면 알림 없이 찾아 갈색 글 + [그 연락처 보기] (자기 자신은 뺀다) · 막지 않는다', async () => { + const { wrapper } = await mountDialog({}, { search: () => [SEOYUN] }) + await useContactsStore().loadGroups() + + const email = emailInputs(wrapper)[0] + await email?.setValue('Seoyun.Kang@hanbit.example') + await email?.trigger('blur') + await flushPromises() + + const search = requests.find((request) => request.url === '/contacts') + expect(search?.params).toEqual({ q: 'Seoyun.Kang@hanbit.example', limit: 100 }) + expect(search?.config.silent).toBe(true) + const warning = wrapper.get('[data-contact-duplicate]') + expect(warning.text()).toBe( + '이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. 그 연락처 보기', + ) + expect(email?.attributes('aria-describedby')).toBe(warning.attributes('id')) + expect(email?.attributes('aria-invalid')).toBe('false') + + // 고치기 창에서 자기 자신의 주소는 경고하지 않는다 + const own = await mountDialog({ contact: SEOYUN }, { search: () => [SEOYUN] }) + const ownEmail = emailInputs(own.wrapper)[0] + await ownEmail?.trigger('blur') + await flushPromises() + expect(own.wrapper.find('[data-contact-duplicate]').exists()).toBe(false) + + // 고치는 동안은 옛 주소의 안내를 내린다 + await email?.setValue('seoyun.k@hanbit.example') + expect(wrapper.find('[data-contact-duplicate]').exists()).toBe(false) + + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(saves()).toHaveLength(1) + }) + + it('채워 연 새 연락처(읽기 화면 · 사내 목록)는 열 때 채운 주소를 바로 찾아보고, 채운 그대로면 닫을 때 묻지 않는다', async () => { + const { wrapper, closes } = await mountDialog( + { prefill: { name: '강서윤', email: 'seoyun.kang@hanbit.example' } }, + { search: () => [SEOYUN] }, + ) + + expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('강서윤') + expect(wrapper.get('[data-contact-duplicate]').text()).toContain('이미 주소록에 있는 주소입니다 — 강서윤') + await wrapper.get('#contact-cancel').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false) + expect(closes).toHaveLength(1) + }) + + it('[그 연락처 보기] — 창을 닫고 그 연락처를 고른 주소록으로 (바꾼 것이 있으면 먼저 묻는다)', async () => { + const { wrapper, router, closes } = await mountDialog({}, { search: () => [SEOYUN] }, '/mail/inbox') + await wrapper.get('#contact-name').setValue('강 과장') + const email = emailInputs(wrapper)[0] + await email?.setValue('seoyun.kang@hanbit.example') + await email?.trigger('blur') + await flushPromises() + + await wrapper.get('.entry-rows__link').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true) + expect(wrapper.get('#contact-leave-title').text()).toBe('저장하지 않은 연락처가 있습니다') + expect(wrapper.get('#contact-leave-message').text()).toBe('창을 닫으면 고친 내용이 사라집니다.') + await wrapper.get('#contact-leave-confirm').trigger('click') + await flushPromises() + + expect(closes).toHaveLength(1) + expect(router.currentRoute.value.name).toBe('contacts-contact') + expect(router.currentRoute.value.params).toEqual({ contactId: 'c1' }) + }) + + it('다른 프로그램이 넣은 제어 문자(탭 · CR LF)는 열 때 고친다 — 고친 것만으로는 [취소]에 묻지 않고, 저장하면 고친 값을 보낸다', async () => { + const imported: Contact = { ...JIHOON, name: '박지훈\t', title: '팀장\u0000', note: '첫 줄\r\n둘째\t줄' } + const { wrapper, closes } = await mountDialog({ contact: imported }) + + expect((wrapper.get('#contact-note').element as HTMLTextAreaElement).value).toBe('첫 줄\n둘째 줄') + await wrapper.get('#contact-cancel').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false) + expect(closes).toHaveLength(1) + + await wrapper.setProps({ open: true }) + await flushPromises() + await wrapper.get('#contact-department').setValue('영업\t2팀') + await wrapper.get('#contact-note').setValue('첫 줄\n셋째\t줄') + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + // 고친 칸만 고친 값으로 — 손대지 않은 이름 · 직책(남의 제어 문자)은 보내지 않는다 + expect(saves()[0]?.data).toEqual({ department: '영업 2팀', note: '첫 줄\n셋째 줄' }) + }) + + it('다른 프로그램이 넣은 1,000자 넘는 메모를 고쳤으면 [저장] 때 메모 아래에 알린다 — 보내지 않는다', async () => { + const { wrapper } = await mountDialog({ contact: { ...JIHOON, note: '가'.repeat(1200) } }) + + expect(wrapper.get('[data-contact-note-count]').text()).toBe('1,200 / 1,000') + await wrapper.get('#contact-note').setValue('가'.repeat(1100)) + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(wrapper.get('#contact-note').attributes('aria-invalid')).toBe('true') + expect(wrapper.get('#contact-note-error').text()).toBe('메모는 1,000자까지 쓸 수 있습니다.') + expect(document.activeElement?.id).toBe('contact-note') + expect(saves()).toEqual([]) + }) + + it('나가기 확인 — 바꾼 것이 있으면 [취소] · Esc 에 묻는다: [계속 고치기]면 그대로, [닫기]면 닫는다 · 다른 화면으로 가면 [나가기]', async () => { + const { wrapper, router, closes } = await mountDialog({ contact: JIHOON }, {}, '/contacts/all/c2') + + await wrapper.get('#contact-title').setValue('부장') + await wrapper.get('#contact-cancel').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true) + expect(document.activeElement?.id).toBe('contact-leave-stay') + expect(wrapper.get('#contact-leave-stay').text()).toBe('계속 고치기') + expect(wrapper.get('#contact-leave-confirm').text()).toBe('닫기') + + await wrapper.get('#contact-leave-stay').trigger('click') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false) + expect(isOpen(wrapper, '#contact-dialog')).toBe(true) + expect(closes).toEqual([]) + + const leaving = router.push('/mail/inbox') + await flushPromises() + expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true) + expect(wrapper.get('#contact-leave-message').text()).toBe('이 화면을 나가면 고친 내용이 사라집니다.') + expect(wrapper.get('#contact-leave-confirm').text()).toBe('나가기') + await wrapper.get('#contact-leave-confirm').trigger('click') + await leaving + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox') + }) + + it('서버의 형식 거절(VAL_001)은 단추 줄 위에 사전 문구 — 알림 없이 · 한도(CONTACT_002)는 사전 문구 알림, 창은 그대로', async () => { + let reply: MockResult = fail(400, 'VAL_001') + const { wrapper, closes } = await mountDialog( + { prefill: { name: null, email: 'dokyung.han@mail.example' } }, + { save: () => reply }, + ) + + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(wrapper.get('#contact-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001) + expect(toasts()).toEqual([]) + + reply = fail(409, 'CONTACT_002') + await wrapper.get('#contact-name').setValue('한도경') + expect(wrapper.find('#contact-error').exists()).toBe(false) + await wrapper.get('#contact-save').trigger('click') + await flushPromises() + expect(toasts()).toEqual([ERROR_CODE_MESSAGES.CONTACT_002]) + expect(wrapper.find('#contact-error').exists()).toBe(false) + expect(closes).toEqual([]) + }) + + it('[이 연락처 지우기] — 확인 없이 지우고 닫는다 · 알림의 [되돌리기]가 지운 연락처를 다시 만든다', async () => { + const { wrapper, closes } = await mountDialog({ contact: JIHOON }) + await useContactsStore().loadGroups() + + await wrapper.get('#contact-remove').trigger('click') + await flushPromises() + + expect(requests.some((request) => request.method === 'DELETE' && request.url === '/contacts/c2')).toBe(true) + expect(closes).toHaveLength(1) + const [toast] = useToastStore().toasts + expect(toast?.message).toBe('‘박지훈’ 연락처를 지웠습니다.') + expect(toast?.action?.label).toBe('되돌리기') + if (toast?.action && 'run' in toast.action) toast.action.run() + await flushPromises() + + // 되돌리기는 지우기 응답의 표로 — 서버가 지운 그대로(다른 프로그램의 칸까지) 다시 만든다 + expect(requests.find((request) => request.url === '/contact-restorations')).toMatchObject({ + method: 'POST', + data: { token: 't-c2' }, + }) + expect(saves()).toEqual([]) + expect(toasts()).toContain('연락처를 되돌렸습니다.') + }) + + it('저장을 보내는 사이 창을 닫았으면(취소) 창 안에 보일 거절(VAL_001)을 알림으로 알린다', async () => { + let finish: (result: MockResult) => void = () => undefined + const { wrapper, closes } = await mountDialog( + { prefill: { name: '한도경', email: 'dokyung.han@mail.example' } }, + { + save: () => + new Promise((resolve) => { + finish = resolve + }), + }, + ) + + await wrapper.get('#contact-save').trigger('click') + // 보내는 중에는 묻지 않고 닫는다 (결과는 알림으로) + await wrapper.get('#contact-cancel').trigger('click') + await flushPromises() + expect(closes).toHaveLength(1) + finish(fail(400, 'VAL_001')) + await flushPromises() + + expect(toasts()).toEqual([ERROR_CODE_MESSAGES.VAL_001]) + expect(wrapper.find('#contact-error').exists()).toBe(false) + }) + + it('보내는 사이 세션이 바뀌면(로그아웃 · 다른 계정) 늦게 온 응답으로 알리지도 닫지도 않는다', async () => { + let finish: (result: MockResult) => void = () => undefined + const late = new Promise((resolve) => { + finish = resolve + }) + const { wrapper, closes } = await mountDialog( + { prefill: { name: '한도경', email: 'dokyung.han@mail.example' } }, + { save: () => late }, + ) + + await wrapper.get('#contact-save').trigger('click') + expect(wrapper.get('#contact-save').attributes('aria-disabled')).toBe('true') + useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' }) + finish(ok({ ...JIHOON, id: 'c9' })) + await flushPromises() + + expect(toasts()).toEqual([]) + expect(closes).toEqual([]) + }) +}) diff --git a/frontend/src/components/contacts/ContactDialog.vue b/frontend/src/components/contacts/ContactDialog.vue new file mode 100644 index 0000000..5a9b085 --- /dev/null +++ b/frontend/src/components/contacts/ContactDialog.vue @@ -0,0 +1,761 @@ + + +