From 192807f943bf0f215e0552e94b2fadd72252bdd1 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 27 Oct 2026 23:56:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EC=83=88=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=ED=99=95=EC=9D=B8=C2=B7=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=C2=B7=EC=97=AC=EB=9F=AC=20=ED=86=B5=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EA=B3=BC=20=EC=B2=98=EB=A6=AC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면이 보이는 동안 1분마다 바뀐 것을 묻고 목록·메일함 숫자·탭 제목 "(N)" 을 맞춘다 — 늦게 온 응답은 목록 변경 세대로 버린다 - 상단 검색 칸 → /mail/search (범위 전환, 결과 없음 안내에 엔진 한계) - 표·분할 보기에서 여러 통 고르기와 도구 줄, 이동 메뉴, 휴지통 이동 + 되돌리기, 휴지통에서만 확인 뒤 영구 삭제 - 읽기 화면의 삭제도 DELETE 대신 휴지통 이동 · 확인 뒤 영구 삭제 — 다른 창이 휴지통에 넣은 메일을 확인 없이 지우지 않게 - 되돌리기 알림은 띄운 세션에서만 움직인다 — 메일 id 는 계정마다 겹칠 수 있다 - 메일 스토어를 기능별 setup 모듈로 나눴다 (공개 API 그대로) Co-Authored-By: Claude Opus 5.5 --- .../src/components/feedback/ModalDialog.vue | 13 +- .../src/components/mail/ListReadNav.spec.ts | 27 +- frontend/src/components/mail/ListReadNav.vue | 25 +- .../src/components/mail/MailBulkButton.vue | 128 ++++ .../components/mail/MailBulkMoveButton.vue | 106 ++++ frontend/src/components/mail/MailCheckbox.vue | 105 ++++ .../src/components/mail/MailListFrame.spec.ts | 143 +++++ .../src/components/mail/MailListFrame.vue | 247 ++++++++ .../src/components/mail/MailListHeader.vue | 4 +- .../src/components/mail/MailNewNotice.vue | 58 ++ .../src/components/mail/MailPurgeDialog.vue | 56 ++ .../src/components/mail/MailSearchBox.spec.ts | 167 ++++++ .../src/components/mail/MailSearchBox.vue | 157 +++++ .../src/components/mail/MailSearchEmpty.vue | 63 ++ .../src/components/mail/MailSearchHeader.vue | 125 ++++ .../src/components/mail/MailSearchScope.vue | 78 +++ .../mail/MailSelectionToolbar.spec.ts | 379 ++++++++++++ .../components/mail/MailSelectionToolbar.vue | 274 +++++++++ .../components/mail/MessageDeleteButton.vue | 86 +++ .../src/components/mail/MessageList.spec.ts | 97 ++- frontend/src/components/mail/MessageList.vue | 155 +---- .../src/components/mail/MessageListItem.vue | 152 ++++- .../components/mail/MessageListSkeleton.vue | 7 +- .../src/components/mail/MessageReader.spec.ts | 191 +++++- .../src/components/mail/MessageReader.vue | 78 +-- .../src/components/mail/MessageTable.spec.ts | 128 +++- frontend/src/components/mail/MessageTable.vue | 501 +++------------- .../src/components/mail/MessageTableHead.vue | 93 +++ .../src/components/mail/MessageTableRow.vue | 242 ++++++++ frontend/src/components/shell/AppShell.vue | 10 +- frontend/src/components/shell/AppTopBar.vue | 39 +- .../src/composables/useBulkMailActions.ts | 95 +++ .../src/composables/useDraftTrash.spec.ts | 98 +++ frontend/src/composables/useDraftTrash.ts | 18 +- .../src/composables/useListArrivalNotice.ts | 114 ++++ .../src/composables/useListPositionMemory.ts | 43 ++ frontend/src/composables/useMail.ts | 47 +- frontend/src/composables/useMailListRows.ts | 43 ++ .../src/composables/useMailMoveFeedback.ts | 43 ++ frontend/src/composables/useMailNavigation.ts | 46 +- .../composables/useMailSearchNavigation.ts | 110 ++++ .../composables/useMailStatePolling.spec.ts | 172 ++++++ .../src/composables/useMailStatePolling.ts | 115 ++++ .../src/composables/useMailTabTitle.spec.ts | 94 +++ frontend/src/composables/useMailTabTitle.ts | 29 + frontend/src/composables/useMessageRemoval.ts | 67 +++ frontend/src/composables/useToast.ts | 18 +- frontend/src/pages/mail/MailLayout.vue | 20 +- .../src/pages/mail/MailboxPage.search.spec.ts | 208 +++++++ frontend/src/pages/mail/MailboxPage.vue | 54 +- frontend/src/router/index.ts | 15 +- frontend/src/shared/constants/icons.ts | 13 + frontend/src/shared/constants/mail.ts | 79 +++ frontend/src/shared/types/mail.ts | 39 +- .../src/shared/utils/document-title.spec.ts | 17 + frontend/src/shared/utils/document-title.ts | 10 + frontend/src/shared/utils/korean.spec.ts | 24 +- frontend/src/shared/utils/korean.ts | 32 +- frontend/src/shared/utils/mail-bulk.spec.ts | 67 +++ frontend/src/shared/utils/mail-bulk.ts | 90 +++ .../src/shared/utils/mail-list-merge.spec.ts | 110 ++++ frontend/src/shared/utils/mail-list-merge.ts | 49 ++ .../src/shared/utils/mail-list-row.spec.ts | 71 +++ frontend/src/shared/utils/mail-list-row.ts | 77 +++ .../src/shared/utils/mail-list-source.spec.ts | 95 +++ frontend/src/shared/utils/mail-list-source.ts | 109 ++++ .../src/shared/utils/mail-selection.spec.ts | 46 ++ frontend/src/shared/utils/mail-selection.ts | 52 ++ frontend/src/stores/mail-view.store.spec.ts | 15 +- frontend/src/stores/mail-view.store.ts | 9 +- frontend/src/stores/mail.store.list.spec.ts | 566 ++++++++++++++++++ frontend/src/stores/mail.store.spec.ts | 40 +- frontend/src/stores/mail.store.ts | 495 +++------------ frontend/src/stores/mail/bulk.ts | 216 +++++++ frontend/src/stores/mail/list.ts | 363 +++++++++++ frontend/src/stores/mail/mailboxes.ts | 178 ++++++ frontend/src/stores/mail/message.ts | 135 +++++ frontend/src/stores/mail/selection.ts | 81 +++ frontend/src/stores/mail/types.ts | 32 + frontend/src/stores/toast.store.spec.ts | 40 ++ frontend/src/stores/toast.store.ts | 14 +- frontend/src/styles/tokens.css | 24 +- frontend/src/test-utils/router.ts | 7 + 83 files changed, 7421 insertions(+), 1157 deletions(-) create mode 100644 frontend/src/components/mail/MailBulkButton.vue create mode 100644 frontend/src/components/mail/MailBulkMoveButton.vue create mode 100644 frontend/src/components/mail/MailCheckbox.vue create mode 100644 frontend/src/components/mail/MailListFrame.spec.ts create mode 100644 frontend/src/components/mail/MailListFrame.vue create mode 100644 frontend/src/components/mail/MailNewNotice.vue create mode 100644 frontend/src/components/mail/MailPurgeDialog.vue create mode 100644 frontend/src/components/mail/MailSearchBox.spec.ts create mode 100644 frontend/src/components/mail/MailSearchBox.vue create mode 100644 frontend/src/components/mail/MailSearchEmpty.vue create mode 100644 frontend/src/components/mail/MailSearchHeader.vue create mode 100644 frontend/src/components/mail/MailSearchScope.vue create mode 100644 frontend/src/components/mail/MailSelectionToolbar.spec.ts create mode 100644 frontend/src/components/mail/MailSelectionToolbar.vue create mode 100644 frontend/src/components/mail/MessageDeleteButton.vue create mode 100644 frontend/src/components/mail/MessageTableHead.vue create mode 100644 frontend/src/components/mail/MessageTableRow.vue create mode 100644 frontend/src/composables/useBulkMailActions.ts create mode 100644 frontend/src/composables/useDraftTrash.spec.ts create mode 100644 frontend/src/composables/useListArrivalNotice.ts create mode 100644 frontend/src/composables/useListPositionMemory.ts create mode 100644 frontend/src/composables/useMailListRows.ts create mode 100644 frontend/src/composables/useMailMoveFeedback.ts create mode 100644 frontend/src/composables/useMailSearchNavigation.ts create mode 100644 frontend/src/composables/useMailStatePolling.spec.ts create mode 100644 frontend/src/composables/useMailStatePolling.ts create mode 100644 frontend/src/composables/useMailTabTitle.spec.ts create mode 100644 frontend/src/composables/useMailTabTitle.ts create mode 100644 frontend/src/composables/useMessageRemoval.ts create mode 100644 frontend/src/pages/mail/MailboxPage.search.spec.ts create mode 100644 frontend/src/shared/utils/document-title.spec.ts create mode 100644 frontend/src/shared/utils/document-title.ts create mode 100644 frontend/src/shared/utils/mail-bulk.spec.ts create mode 100644 frontend/src/shared/utils/mail-bulk.ts create mode 100644 frontend/src/shared/utils/mail-list-merge.spec.ts create mode 100644 frontend/src/shared/utils/mail-list-merge.ts create mode 100644 frontend/src/shared/utils/mail-list-row.spec.ts create mode 100644 frontend/src/shared/utils/mail-list-row.ts create mode 100644 frontend/src/shared/utils/mail-list-source.spec.ts create mode 100644 frontend/src/shared/utils/mail-list-source.ts create mode 100644 frontend/src/shared/utils/mail-selection.spec.ts create mode 100644 frontend/src/shared/utils/mail-selection.ts create mode 100644 frontend/src/stores/mail.store.list.spec.ts create mode 100644 frontend/src/stores/mail/bulk.ts create mode 100644 frontend/src/stores/mail/list.ts create mode 100644 frontend/src/stores/mail/mailboxes.ts create mode 100644 frontend/src/stores/mail/message.ts create mode 100644 frontend/src/stores/mail/selection.ts create mode 100644 frontend/src/stores/mail/types.ts diff --git a/frontend/src/components/feedback/ModalDialog.vue b/frontend/src/components/feedback/ModalDialog.vue index 608d72d..7cf14e1 100644 --- a/frontend/src/components/feedback/ModalDialog.vue +++ b/frontend/src/components/feedback/ModalDialog.vue @@ -1,10 +1,10 @@ diff --git a/frontend/src/components/mail/MailBulkButton.vue b/frontend/src/components/mail/MailBulkButton.vue new file mode 100644 index 0000000..57eb246 --- /dev/null +++ b/frontend/src/components/mail/MailBulkButton.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/frontend/src/components/mail/MailBulkMoveButton.vue b/frontend/src/components/mail/MailBulkMoveButton.vue new file mode 100644 index 0000000..e8bde88 --- /dev/null +++ b/frontend/src/components/mail/MailBulkMoveButton.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/frontend/src/components/mail/MailCheckbox.vue b/frontend/src/components/mail/MailCheckbox.vue new file mode 100644 index 0000000..27a6235 --- /dev/null +++ b/frontend/src/components/mail/MailCheckbox.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/frontend/src/components/mail/MailListFrame.spec.ts b/frontend/src/components/mail/MailListFrame.spec.ts new file mode 100644 index 0000000..10a96e6 --- /dev/null +++ b/frontend/src/components/mail/MailListFrame.spec.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' + +import type { Mailbox, MessageSummary } from '@/shared/types/mail' +import { useAuthStore } from '@/stores/auth.store' +import { useMailStore } from '@/stores/mail.store' +import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api' +import { createTestRouter } from '@/test-utils/router' +import MessageList from './MessageList.vue' + +// 목록 틀 — 새 메일 알림 줄 · Esc (디자인 README "3단계 › 새 메일" · "여러 통 처리") + +const INBOX: Mailbox = { + id: 'inbox', + name: '받은편지함', + role: 'inbox', + parentId: null, + sortOrder: 0, + totalCount: 3, + unreadCount: 0, +} + +function summary(id: string, partial: Partial = {}): MessageSummary { + return { + id, + threadId: `t-${id}`, + mailboxIds: ['inbox'], + from: { name: '이도윤', email: 'doyun.lee@arasystem.test' }, + to: [], + subject: `제목 ${id}`, + preview: '', + receivedAt: '2026-10-27T01:00:00Z', + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 100, + ...partial, + } +} + +describe('MailListFrame (목록 칸의 틀)', () => { + let pinia: Pinia + let fresh: MessageSummary[] + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' }) + fresh = [] + installMockApi(() => ok({ items: fresh, pageInfo: { hasNext: false, nextCursor: null, total: fresh.length } })) + }) + + afterEach(() => { + restoreApiAdapter() + document.body.innerHTML = '' + }) + + async function mountList(messages: MessageSummary[]) { + const store = useMailStore() + store.mailboxes = [INBOX] + store.currentMailboxId = 'inbox' + store.messages = messages + store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length } + store.listStatus = 'ready' + const router = createTestRouter() + await router.push('/mail/inbox') + await router.isReady() + const wrapper = mount(MessageList, { global: { plugins: [pinia, router] }, attachTo: document.body }) + return { wrapper, store, section: wrapper.get('section.message-list').element as HTMLElement } + } + + it('목록을 내려 본 채 새 메일이 위에 생기면 머리 아래에 "새 메일 N통 · 맨 위로" — 누르면 맨 위로 가고 사라진다', async () => { + const old = [summary('m1'), summary('m2'), summary('m3')] + const { wrapper, store, section } = await mountList(old) + section.scrollTop = 240 + fresh = [summary('new1', { isRead: false }), summary('new2', { isRead: false }), ...old] + + await store.syncVisibleList() + await flushPromises() + + const notice = wrapper.get('.new-mail-notice') + expect(notice.text()).toBe('새 메일 2통 · 맨 위로') + expect(wrapper.findAll('.message-row')).toHaveLength(5) + expect(section.scrollTop).toBe(240) + + await notice.trigger('click') + expect(section.scrollTop).toBe(0) + expect(wrapper.find('.new-mail-notice').exists()).toBe(false) + expect(document.activeElement).toBe(wrapper.findAll('.message-row__main')[0]?.element) + }) + + it('맨 위를 보고 있으면 알림 줄 없이 새 메일이 그냥 위에 생긴다', async () => { + const old = [summary('m1')] + const { wrapper, store } = await mountList(old) + fresh = [summary('new1', { isRead: false }), ...old] + + await store.syncVisibleList() + await flushPromises() + + expect(wrapper.find('.new-mail-notice').exists()).toBe(false) + expect(wrapper.findAll('.message-row')[0]?.classes()).toContain('is-unread') + }) + + it('알림 줄은 스스로 맨 위로 올라가도 사라지고, 다른 메일함으로 바꿔도 사라진다', async () => { + const old = [summary('m1')] + const { wrapper, store, section } = await mountList(old) + section.scrollTop = 300 + fresh = [summary('new1'), ...old] + await store.syncVisibleList() + await flushPromises() + expect(wrapper.find('.new-mail-notice').exists()).toBe(true) + + section.scrollTop = 0 + section.dispatchEvent(new Event('scroll')) + await flushPromises() + expect(wrapper.find('.new-mail-notice').exists()).toBe(false) + + section.scrollTop = 300 + fresh = [summary('new2'), summary('new1'), ...old] + await store.syncVisibleList() + await flushPromises() + expect(wrapper.get('.new-mail-notice').text()).toBe('새 메일 1통 · 맨 위로') + + await store.openMailbox('sent') + await flushPromises() + expect(wrapper.find('.new-mail-notice').exists()).toBe(false) + }) + + it('Esc 는 고른 것을 푼다 — 도구 줄에 있던 초점은 목록 칸으로 옮긴다', async () => { + const { wrapper, store, section } = await mountList([summary('m1'), summary('m2')]) + store.toggleCheck('m1') + await flushPromises() + const clear = wrapper.get('[data-tool="markRead"]') + ;(clear.element as HTMLElement).focus() + + await clear.trigger('keydown', { key: 'Escape' }) + await flushPromises() + + expect(store.checkedCount).toBe(0) + expect(document.activeElement).toBe(section) + }) +}) diff --git a/frontend/src/components/mail/MailListFrame.vue b/frontend/src/components/mail/MailListFrame.vue new file mode 100644 index 0000000..1e28821 --- /dev/null +++ b/frontend/src/components/mail/MailListFrame.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/frontend/src/components/mail/MailListHeader.vue b/frontend/src/components/mail/MailListHeader.vue index 19d8fb7..9d33e61 100644 --- a/frontend/src/components/mail/MailListHeader.vue +++ b/frontend/src/components/mail/MailListHeader.vue @@ -1,6 +1,7 @@ + + + + diff --git a/frontend/src/components/mail/MailPurgeDialog.vue b/frontend/src/components/mail/MailPurgeDialog.vue new file mode 100644 index 0000000..044c1ca --- /dev/null +++ b/frontend/src/components/mail/MailPurgeDialog.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/components/mail/MailSearchBox.spec.ts b/frontend/src/components/mail/MailSearchBox.spec.ts new file mode 100644 index 0000000..e8d32ae --- /dev/null +++ b/frontend/src/components/mail/MailSearchBox.spec.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' + +import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated' +import type { Mailbox } from '@/shared/types/mail' +import { useAuthStore } from '@/stores/auth.store' +import { useMailStore } from '@/stores/mail.store' +import { useToastStore } from '@/stores/toast.store' +import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api' +import { createTestRouter } from '@/test-utils/router' +import MailSearchBox from './MailSearchBox.vue' + +// 상단 검색 칸 (디자인 README "3단계 › 검색" · 계약 2절 "검색 › 클라이언트") + +function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox { + return { id, name, role, parentId: null, sortOrder: 0, totalCount: 1, unreadCount: 0 } +} + +const MAILBOXES = [mailbox('inbox', 'inbox', '받은편지함'), mailbox('trash', 'trash', '휴지통')] +const Q = encodeURIComponent('회의') + +describe('MailSearchBox (메일 검색)', () => { + let pinia: Pinia + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' }) + useMailStore().mailboxes = MAILBOXES + installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })) + }) + + afterEach(() => { + vi.restoreAllMocks() + useToastStore().clear() + restoreApiAdapter() + }) + + async function mountAt(path: string) { + const router = createTestRouter() + await router.push(path) + await router.isReady() + const wrapper = mount(MailSearchBox, { global: { plugins: [pinia, router] } }) + return { wrapper, router, input: wrapper.get('input') } + } + + it('이름 · 안내는 "메일 검색" — 검색 중이 아니면 ✕ 가 없다', async () => { + const { wrapper, input } = await mountAt('/mail/inbox') + + expect(input.attributes('aria-label')).toBe('메일 검색') + expect(input.attributes('placeholder')).toBe('메일 검색') + expect(wrapper.get('form').attributes('role')).toBe('search') + expect(wrapper.find('button').exists()).toBe(false) + expect(wrapper.get('form').classes()).not.toContain('is-active') + }) + + it('치는 동안에는 찾지 않고, Enter 로 보던 메일함에서 찾는다 (기록을 쌓는다 — 뒤로 가기로 메일함)', async () => { + const { router, input } = await mountAt('/mail/inbox/m7') + const push = vi.spyOn(router, 'push') + + await input.setValue(' 회의 ') + await flushPromises() + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m7') + + await input.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`) + expect(push).toHaveBeenCalled() + expect(useMailStore().searchOrigin).toEqual({ mailboxId: 'inbox', location: '/mail/inbox/m7' }) + }) + + it('휴지통에서 시작하면 그 안에서 찾는다', async () => { + const { router, input } = await mountAt('/mail/trash') + + await input.setValue('회의') + await input.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=trash`) + }) + + it('한글 조합 중의 Enter 는 글자 확정이다 — 찾지 않는다 (isComposing · Safari 의 keyCode 229)', async () => { + const { router, input } = await mountAt('/mail/inbox') + await input.setValue('회의') + + await input.trigger('keydown', { key: 'Enter', isComposing: true }) + const safari = new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, cancelable: true }) + input.element.dispatchEvent(safari) + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox') + expect(safari.defaultPrevented).toBe(false) + }) + + it('낱말이 8개를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 친 글자는 그대로', async () => { + const { router, input } = await mountAt('/mail/inbox') + const words = '하나 둘 셋 넷 다섯 여섯 일곱 여덟 아홉' + await input.setValue(words) + + await input.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox') + expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001]) + expect((input.element as HTMLInputElement).value).toBe(words) + }) + + it('빈 검색어의 Enter 는 아무것도 하지 않는다', async () => { + const { router, input } = await mountAt('/mail/inbox') + await input.setValue(' ') + + await input.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox') + }) + + it('검색 화면 — 칸에 검색어를 보이고 테두리가 강조색, 끝에 ✕("검색어 지우기") — ✕ 는 검색 전 메일함으로', async () => { + useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox/m7' }) + const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`) + + expect((input.element as HTMLInputElement).value).toBe('회의') + expect(wrapper.get('form').classes()).toContain('is-active') + const clear = wrapper.get('button') + expect(clear.attributes('aria-label')).toBe('검색어 지우기') + expect(clear.attributes('title')).toBe('검색어 지우기 (Esc)') + + await clear.trigger('click') + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m7') + expect((input.element as HTMLInputElement).value).toBe('') + }) + + it('검색 화면의 Esc 는 ✕ 와 같다 — 시작한 곳을 모르면(주소로 바로 들어옴) 그 범위의 메일함으로', async () => { + const { router, input } = await mountAt(`/mail/search?q=${Q}&in=trash`) + + await input.trigger('keydown', { key: 'Escape' }) + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/mail/trash') + }) + + it('검색 화면이 아니면 Esc 는 친 글자만 지운다', async () => { + const { router, input } = await mountAt('/mail/inbox') + await input.setValue('회의') + + await input.trigger('keydown', { key: 'Escape' }) + + expect((input.element as HTMLInputElement).value).toBe('') + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox') + }) + + it('검색 화면에서 새로 찾으면 지금 범위 그대로 바꿔 끼운다 (기록을 쌓지 않는다)', async () => { + const { router, input } = await mountAt(`/mail/search?q=${Q}&in=all`) + const push = vi.spyOn(router, 'push') + + await input.setValue('일정') + await input.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${encodeURIComponent('일정')}&in=all`) + expect(push).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/mail/MailSearchBox.vue b/frontend/src/components/mail/MailSearchBox.vue new file mode 100644 index 0000000..9e19d69 --- /dev/null +++ b/frontend/src/components/mail/MailSearchBox.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/frontend/src/components/mail/MailSearchEmpty.vue b/frontend/src/components/mail/MailSearchEmpty.vue new file mode 100644 index 0000000..9049fb4 --- /dev/null +++ b/frontend/src/components/mail/MailSearchEmpty.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/frontend/src/components/mail/MailSearchHeader.vue b/frontend/src/components/mail/MailSearchHeader.vue new file mode 100644 index 0000000..ff39e26 --- /dev/null +++ b/frontend/src/components/mail/MailSearchHeader.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/frontend/src/components/mail/MailSearchScope.vue b/frontend/src/components/mail/MailSearchScope.vue new file mode 100644 index 0000000..62d3b4d --- /dev/null +++ b/frontend/src/components/mail/MailSearchScope.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/frontend/src/components/mail/MailSelectionToolbar.spec.ts b/frontend/src/components/mail/MailSelectionToolbar.spec.ts new file mode 100644 index 0000000..aa02b91 --- /dev/null +++ b/frontend/src/components/mail/MailSelectionToolbar.spec.ts @@ -0,0 +1,379 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia, type Pinia } from 'pinia' + +import type { Mailbox, MessageSummary, MessagesPatch } from '@/shared/types/mail' +import { useAuthStore } from '@/stores/auth.store' +import { useMailStore } from '@/stores/mail.store' +import { useToastStore } from '@/stores/toast.store' +import { + fail, + flushPromises, + installMockApi, + ok, + restoreApiAdapter, + type MockRequest, + type MockResult, +} from '@/test-utils/mock-api' +import { createTestRouter } from '@/test-utils/router' +import MessageList from './MessageList.vue' +import MessageTable from './MessageTable.vue' + +// 여러 통 처리 — 도구 줄 · 이동 메뉴 · 알림 · 되돌리기 · 영구 삭제 확인 (디자인 원본 BulkSelect · ListStates) + +function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox { + return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0 } +} + +const MAILBOXES = [ + mailbox('inbox', 'inbox', '받은편지함'), + mailbox('sent', 'sent', '보낸편지함'), + mailbox('drafts', 'drafts', '임시보관함'), + mailbox('junk', 'junk', '스팸메일함'), + mailbox('trash', 'trash', '휴지통'), + mailbox('archive', 'archive', '보관함'), +] + +function summary(id: string, partial: Partial = {}): MessageSummary { + return { + id, + threadId: `t-${id}`, + mailboxIds: ['inbox'], + from: { name: '이도윤', email: 'doyun.lee@arasystem.test' }, + to: [], + subject: `제목 ${id}`, + preview: '', + receivedAt: '2026-10-27T01:00:00Z', + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 100, + ...partial, + } +} + +type Handler = (request: MockRequest) => MockResult | Promise | undefined + +describe('여러 통 처리 도구 줄', () => { + let pinia: Pinia + let requests: MockRequest[] + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' }) + }) + + afterEach(() => { + useToastStore().clear() + restoreApiAdapter() + document.body.innerHTML = '' + }) + + async function mountView( + options: { view?: 'split' | 'list'; boxId?: string; messages?: MessageSummary[]; path?: string; handler?: Handler } = {}, + ) { + const boxId = options.boxId ?? 'inbox' + const messages = options.messages ?? [summary('m1'), summary('m2'), summary('m3')] + requests = installMockApi((request) => { + const custom = options.handler?.(request) + if (custom) return custom + if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 6 } }) + if (request.url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 }) + if (request.url === '/messages' && request.method === 'GET') { + return ok({ items: messages, pageInfo: { hasNext: false, nextCursor: null, total: messages.length } }) + } + if (request.method === 'PATCH') { + const patch = request.data as MessagesPatch + return ok({ updatedIds: patch.ids, failedIds: [] }) + } + return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] }) + }) + const store = useMailStore() + store.mailboxes = MAILBOXES + store.currentMailboxId = boxId + store.messages = messages + store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length } + store.listStatus = 'ready' + const router = createTestRouter() + await router.push(options.path ?? `/mail/${boxId}`) + await router.isReady() + const wrapper = mount(options.view === 'split' ? MessageList : MessageTable, { + global: { plugins: [pinia, router] }, + attachTo: document.body, + }) + return { wrapper, router, store } + } + + const toastTexts = () => useToastStore().toasts.map((toast) => toast.message) + const bulkPatches = () => + requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data) + + it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => { + const { wrapper, store } = await mountView() + store.toggleCheck('m1') + store.toggleCheck('m2') + await flushPromises() + + expect(wrapper.get('.bulk-bar__count').text()).toBe('2통 선택됨') + const labels = wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label')) + expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '삭제', '선택 해제']) + expect(wrapper.get('[data-tool="clear"]').attributes('title')).toBe('선택 해제 (Esc)') + expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--list') + expect(wrapper.get('[data-tool="markRead"]').text()).toBe('읽음 표시') + }) + + it('읽음 · 안 읽음 · 중요 표시 — PATCH /messages 한 번, 목록이 바로 바뀌고 알리지 않는다 · 모두 중요면 "중요 해제"', async () => { + const { wrapper, store } = await mountView({ + messages: [summary('m1', { isRead: false }), summary('m2', { isRead: false }), summary('m3')], + }) + store.toggleCheck('m1') + store.toggleCheck('m2') + await flushPromises() + + await wrapper.get('[data-tool="markRead"]').trigger('click') + await flushPromises() + expect(bulkPatches()).toEqual([{ ids: ['m1', 'm2'], isRead: true }]) + expect(wrapper.findAll('tbody tr.is-unread')).toHaveLength(0) + + await wrapper.get('[data-tool="flag"]').trigger('click') + await flushPromises() + expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm2'], isFlagged: true }) + expect(wrapper.get('[data-tool="flag"]').attributes('aria-label')).toBe('중요 해제') + expect(toastTexts()).toEqual([]) + }) + + it('이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함, 아래 화살표로 열면 첫 항목에 초점 · Esc 는 메뉴만 닫는다', async () => { + const { wrapper, store } = await mountView() + store.toggleCheck('m1') + await flushPromises() + const move = wrapper.get('[data-tool="move"]') + + await move.trigger('keydown', { key: 'ArrowDown' }) + await flushPromises() + const items = wrapper.findAll('[role="menu"] [role="menuitem"]') + expect(wrapper.get('[role="menu"]').attributes('aria-label')).toBe('옮길 메일함') + expect(items.map((item) => item.text())).toEqual(['보낸편지함', '스팸메일함', '보관함']) + expect(document.activeElement).toBe(items[0]?.element) + expect(move.attributes('aria-expanded')).toBe('true') + + await items[0]?.trigger('keydown', { key: 'Escape' }) + await flushPromises() + expect(wrapper.find('[role="menu"]').exists()).toBe(false) + expect(document.activeElement).toBe(wrapper.get('[data-tool="move"]').element) + expect(store.checkedCount).toBe(1) + }) + + it('옮기면 옮긴 메일만 빼고 "메일 N통을 보관함으로 옮겼습니다." + [되돌리기] — 되돌리면 원래 메일함으로 옮기고 알린다', async () => { + const { wrapper, store } = await mountView() + store.toggleCheck('m1') + store.toggleCheck('m3') + await flushPromises() + + await wrapper.get('[data-tool="move"]').trigger('click') + await flushPromises() + const archive = wrapper.findAll('[role="menuitem"]').find((item) => item.text() === '보관함') + await archive?.trigger('click') + await flushPromises() + + expect(bulkPatches()).toEqual([{ ids: ['m1', 'm3'], mailboxId: 'archive' }]) + expect(store.messages.map((message) => message.id)).toEqual(['m2']) + expect(store.checkedCount).toBe(0) + const [toast] = useToastStore().toasts + expect(toast?.message).toBe('메일 2통을 보관함으로 옮겼습니다.') + expect(toast?.action?.label).toBe('되돌리기') + + if (toast?.action && 'run' in toast.action) toast.action.run() + await flushPromises() + expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm3'], mailboxId: 'inbox' }) + expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.') + }) + + it('일부만 옮겼으면 알림 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다."', async () => { + const { wrapper, store } = await mountView({ + handler: (request) => + request.method === 'PATCH' ? ok({ updatedIds: ['m1', 'm2'], failedIds: ['m3'] }) : undefined, + }) + store.toggleCheckAll() + await flushPromises() + + await wrapper.get('[data-tool="move"]').trigger('click') + await flushPromises() + await wrapper.findAll('[role="menuitem"]').find((item) => item.text() === '보낸편지함')?.trigger('click') + await flushPromises() + + expect(toastTexts()).toEqual([ + '메일 2통을 보낸편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.', + ]) + }) + + it('휴지통 밖의 삭제는 휴지통으로 옮긴다 — DELETE 가 아니라 PATCH { mailboxId: 휴지통 }', async () => { + const { wrapper, store } = await mountView() + store.toggleCheck('m2') + await flushPromises() + + await wrapper.get('[data-tool="delete"]').trigger('click') + await flushPromises() + + expect(bulkPatches()).toEqual([{ ids: ['m2'], mailboxId: 'trash' }]) + expect(requests.some((request) => request.method === 'DELETE')).toBe(false) + expect(toastTexts()).toEqual(['메일 1통을 휴지통으로 옮겼습니다.']) + }) + + it('요청이 실패하면 인터셉터 알림만 — 고른 것을 그대로 둔다', async () => { + const { wrapper, store } = await mountView({ + handler: (request) => (request.method === 'PATCH' ? fail(503, 'MAIL_001') : undefined), + }) + store.toggleCheck('m1') + await flushPromises() + + await wrapper.get('[data-tool="delete"]').trigger('click') + await flushPromises() + + expect(store.messages).toHaveLength(3) + expect(store.checkedCount).toBe(1) + expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error']) + }) + + describe('휴지통 — 영구 삭제 확인', () => { + const trashed = () => [summary('t1', { mailboxIds: ['trash'] }), summary('t2', { mailboxIds: ['trash'] })] + + it('"영구 삭제"를 누르면 먼저 묻는다 — 제목 · 글, 처음 초점은 「취소」 · 취소하면 지우지 않는다', async () => { + const { wrapper, store } = await mountView({ boxId: 'trash', messages: trashed() }) + store.toggleCheckAll() + await flushPromises() + const purge = wrapper.get('[data-tool="delete"]') + expect(purge.attributes('aria-label')).toBe('영구 삭제') + + await purge.trigger('click') + await flushPromises() + const dialog = wrapper.get('dialog') + expect(dialog.attributes('open')).toBeDefined() + expect(dialog.get('#bulk-purge-title').text()).toBe('메일 2통을 영구 삭제할까요?') + expect(dialog.get('#bulk-purge-message').text()).toBe('휴지통에서 지운 메일은 되돌릴 수 없습니다.') + expect(document.activeElement).toBe(dialog.get('[data-purge-cancel]').element) + + // 대화상자 안의 Esc 는 고른 것을 풀지 않는다 (목록 칸의 Esc 와 겹치지 않게) + await dialog.get('[data-purge-cancel]').trigger('keydown', { key: 'Escape' }) + await dialog.get('[data-purge-cancel]').trigger('click') + await flushPromises() + expect(wrapper.get('dialog').attributes('open')).toBeUndefined() + expect(store.checkedCount).toBe(2) + expect(requests.some((request) => request.url === '/message-deletions')).toBe(false) + }) + + it('「영구 삭제」를 누르면 POST /message-deletions — 지운 메일을 빼고 "메일 N통을 영구 삭제했습니다."', async () => { + const { wrapper, store } = await mountView({ boxId: 'trash', messages: trashed() }) + store.toggleCheckAll() + await flushPromises() + + await wrapper.get('[data-tool="delete"]').trigger('click') + await flushPromises() + await wrapper.get('[data-purge-confirm]').trigger('click') + await flushPromises() + + const deletion = requests.find((request) => request.url === '/message-deletions') + expect(deletion).toMatchObject({ method: 'POST', data: { ids: ['t1', 't2'] } }) + expect(store.messages).toEqual([]) + expect(toastTexts()).toEqual(['메일 2통을 영구 삭제했습니다.']) + expect(requests.some((request) => request.method === 'DELETE')).toBe(false) + }) + }) + + it('분할 보기 — 열어 둔 메일을 옮기면 목록 주소로 바꿔 끼운다', async () => { + const { wrapper, router, store } = await mountView({ view: 'split', path: '/mail/inbox/m2' }) + store.selectedId = 'm2' + store.toggleCheck('m2') + await flushPromises() + expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--split') + + await wrapper.get('[data-tool="delete"]').trigger('click') + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox') + }) + + it('도구 줄 안에서는 화살표로 단추를 옮긴다 (로빙 tabindex) · 선택 해제는 고른 것을 푼다', async () => { + const { wrapper, store } = await mountView() + store.toggleCheck('m1') + await flushPromises() + const first = wrapper.get('[data-tool="markRead"]') + expect(first.attributes('tabindex')).toBe('0') + expect(wrapper.get('[data-tool="markUnread"]').attributes('tabindex')).toBe('-1') + + await first.trigger('keydown', { key: 'ArrowRight' }) + expect(document.activeElement).toBe(wrapper.get('[data-tool="markUnread"]').element) + await wrapper.get('[data-tool="markUnread"]').trigger('keydown', { key: 'End' }) + expect(document.activeElement).toBe(wrapper.get('[data-tool="clear"]').element) + + await wrapper.get('[data-tool="clear"]').trigger('click') + await flushPromises() + expect(store.checkedCount).toBe(0) + expect(wrapper.find('.bulk-bar').exists()).toBe(false) + }) + + it('옮기는 동안 눌리지 않는 단추(이동 · 삭제)는 Tab 자리 · 화살표에서 건너뛰고, 초점이 그 단추에 있었으면 다음 단추로 옮긴다', async () => { + let release: (result: MockResult) => void = () => undefined + const { wrapper, store } = await mountView({ + handler: (request) => + request.method === 'PATCH' + ? new Promise((resolve) => { + release = resolve + }) + : undefined, + }) + store.toggleCheck('m1') + await flushPromises() + const tool = (id: string) => wrapper.get(`[data-tool="${id}"]`) + ;(tool('delete').element as HTMLElement).focus() + await flushPromises() + expect(tool('delete').attributes('tabindex')).toBe('0') + + await tool('delete').trigger('click') + await flushPromises() + + expect(tool('delete').attributes('disabled')).toBeDefined() + expect(tool('move').attributes('disabled')).toBeDefined() + expect(tool('delete').attributes('tabindex')).toBe('-1') + expect(tool('clear').attributes('tabindex')).toBe('0') + expect(document.activeElement).toBe(tool('clear').element) + + await tool('flag').trigger('keydown', { key: 'ArrowRight' }) + expect(document.activeElement).toBe(tool('clear').element) + await tool('clear').trigger('keydown', { key: 'ArrowLeft' }) + expect(document.activeElement).toBe(tool('flag').element) + + release(ok({ updatedIds: ['m1'], failedIds: [] })) + await flushPromises() + expect(store.checkedCount).toBe(0) + }) + + it('초점이 도구 줄 밖에 있어도 Tab 자리는 누를 수 있는 단추로 옮긴다 — 초점은 빼앗지 않는다', async () => { + let release: (result: MockResult) => void = () => undefined + const { wrapper, store } = await mountView({ + handler: (request) => + request.method === 'PATCH' + ? new Promise((resolve) => { + release = resolve + }) + : undefined, + }) + store.toggleCheck('m1') + await flushPromises() + const tool = (id: string) => wrapper.get(`[data-tool="${id}"]`) + ;(tool('delete').element as HTMLElement).focus() + const outside = document.createElement('button') + document.body.append(outside) + outside.focus() + await flushPromises() + + await tool('delete').trigger('click') + await flushPromises() + + expect(tool('delete').attributes('tabindex')).toBe('-1') + expect(tool('clear').attributes('tabindex')).toBe('0') + expect(document.activeElement).toBe(outside) + release(ok({ updatedIds: ['m1'], failedIds: [] })) + await flushPromises() + }) +}) diff --git a/frontend/src/components/mail/MailSelectionToolbar.vue b/frontend/src/components/mail/MailSelectionToolbar.vue new file mode 100644 index 0000000..83f1265 --- /dev/null +++ b/frontend/src/components/mail/MailSelectionToolbar.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/frontend/src/components/mail/MessageDeleteButton.vue b/frontend/src/components/mail/MessageDeleteButton.vue new file mode 100644 index 0000000..be1524c --- /dev/null +++ b/frontend/src/components/mail/MessageDeleteButton.vue @@ -0,0 +1,86 @@ + + + diff --git a/frontend/src/components/mail/MessageList.spec.ts b/frontend/src/components/mail/MessageList.spec.ts index 31d7033..c1996b2 100644 --- a/frontend/src/components/mail/MessageList.spec.ts +++ b/frontend/src/components/mail/MessageList.spec.ts @@ -2,22 +2,25 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia, type Pinia } from 'pinia' +import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail' import type { Mailbox, MessageSummary } from '@/shared/types/mail' import { useAuthStore } from '@/stores/auth.store' import { useMailStore } from '@/stores/mail.store' -import { restoreApiAdapter } from '@/test-utils/mock-api' +import { flushPromises, restoreApiAdapter } from '@/test-utils/mock-api' import { createTestRouter } from '@/test-utils/router' import MessageList from './MessageList.vue' -const DRAFTS: Mailbox = { - id: 'drafts', - name: '임시보관함', - role: 'drafts', +const INBOX: Mailbox = { + id: 'inbox', + name: '받은편지함', + role: 'inbox', parentId: null, - sortOrder: 3, - totalCount: 2, + sortOrder: 0, + totalCount: 3, unreadCount: 0, } +const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', sortOrder: 3, totalCount: 2 } +const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent' } function summary(id: string, partial: Partial = {}): MessageSummary { return { @@ -50,21 +53,22 @@ describe('MessageList (분할 보기 목록)', () => { restoreApiAdapter() }) - async function mountList(mailbox: Mailbox, messages: MessageSummary[]) { + async function mountList(mailbox: Mailbox, messages: MessageSummary[], path = `/mail/${mailbox.id}`) { const store = useMailStore() - store.mailboxes = [mailbox] + store.mailboxes = [INBOX, SENT, DRAFTS] store.currentMailboxId = mailbox.id store.messages = messages store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length } store.listStatus = 'ready' const router = createTestRouter() - await router.push(`/mail/${mailbox.id}`) + await router.push(path) await router.isReady() - return mount(MessageList, { props: { mailboxId: mailbox.id }, global: { plugins: [pinia, router] } }) + const wrapper = mount(MessageList, { global: { plugins: [pinia, router] } }) + return { wrapper, router, store } } it('임시보관함 — 보낸 사람 자리에 받는 사람, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => { - const wrapper = await mountList(DRAFTS, [ + const { wrapper } = await mountList(DRAFTS, [ summary('d1', { isRead: false, to: [{ name: '박준호', email: 'junho.park@arasystem.test' }, { name: '인프라팀', email: 'infra@arasystem.test' }] }), summary('d2', { to: [], subject: '' }), ]) @@ -80,11 +84,76 @@ describe('MessageList (분할 보기 목록)', () => { }) it('보낸편지함은 그대로 "받는 사람: …" 이고 읽기 화면으로 간다', async () => { - const sent: Mailbox = { ...DRAFTS, id: 'sent', name: '보낸편지함', role: 'sent' } - const wrapper = await mountList(sent, [summary('s1', { mailboxIds: ['sent'] })]) + const { wrapper } = await mountList(SENT, [summary('s1', { mailboxIds: ['sent'] })]) const row = wrapper.get('.message-row') expect(row.get('.message-row__who').text()).toBe('받는 사람: 이도윤') expect(row.get('.message-row__main').attributes('href')).toBe('/mail/sent/s1') }) + + describe('여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기")', () => { + const inboxRows = () => [ + summary('m1', { mailboxIds: ['inbox'] }), + summary('m2', { mailboxIds: ['inbox'] }), + summary('m3', { mailboxIds: ['inbox'] }), + ] + + it('행마다 고르기 상자("고르기: 제목")가 있고, 하나라도 고르면 모든 행이 고르기 칸을 보인다', async () => { + const { wrapper, store } = await mountList(INBOX, inboxRows()) + + expect(wrapper.findAll('.message-row.is-checking')).toHaveLength(0) + const box = wrapper.findAll('.message-row [role="checkbox"]')[1] + expect(box?.attributes('aria-label')).toBe('고르기: 제목 m2') + + await box?.trigger('click') + + expect(store.checkedIds.has('m2')).toBe(true) + expect(wrapper.findAll('.message-row.is-checking')).toHaveLength(3) + expect(wrapper.findAll('.message-row')[1]?.classes()).toContain('is-checked') + // 머리 자리가 도구 줄로 — 분할 보기의 목록 칸에서는 아이콘만(이름은 aria-label · 툴팁) + expect(wrapper.get('#message-list-title').text()).toBe('1통 선택됨') + expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--split') + expect(wrapper.get('[data-tool="markRead"]').attributes('aria-label')).toBe('읽음 표시') + }) + + it('행을 누르면 지금처럼 메일을 열고, Ctrl(⌘)+클릭 · Shift+클릭은 열지 않고 고른다', async () => { + const { wrapper, router, store } = await mountList(INBOX, inboxRows()) + const main = (index: number) => wrapper.findAll('.message-row__main')[index] + + await main(0)?.trigger('click', { metaKey: true }) + await main(2)?.trigger('click', { shiftKey: true }) + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox') + expect([...store.checkedIds].sort()).toEqual(['m1', 'm2', 'm3']) + + await main(1)?.trigger('click') + await flushPromises() + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2') + expect(store.checkedCount).toBe(3) + }) + }) + + it('전체 메일함 검색 결과 — 시각 앞에 "보낸편지함 ·", 보낸 메일은 "받는 사람: 이름", 누르면 검색 결과 안의 주소', async () => { + const q = encodeURIComponent('회의') + const { wrapper, store } = await mountList( + INBOX, + [ + summary('r1', { mailboxIds: ['inbox'], from: { name: '이도윤', email: 'doyun.lee@arasystem.test' } }), + summary('r2', { mailboxIds: ['sent'], to: [{ name: '정하늘', email: 'haneul@arasystem.test' }] }), + ], + `/mail/search?q=${q}&in=all`, + ) + store.listSource = { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL } + await flushPromises() + const rows = wrapper.findAll('.message-row') + + expect(rows.map((row) => row.get('.message-row__who').text())).toEqual(['이도윤', '받는 사람: 정하늘']) + expect(rows.map((row) => row.get('.message-row__mailbox').text())).toEqual(['받은편지함 ·', '보낸편지함 ·']) + expect(rows[0]?.get('.message-row__main').attributes('href')).toBe(`/mail/search/r1?q=${q}&in=all`) + // 머리 — 검색 결과 · ‘회의’ · 2통 · 범위 줄(전체일 때 안내) + expect(wrapper.get('#message-list-title').text()).toBe('검색 결과') + expect(wrapper.get('.search-header__count').text()).toBe('‘회의’ · 2통') + expect(wrapper.get('.search-scope').text()).toContain('휴지통 · 스팸메일함은 빼고 찾습니다') + }) }) diff --git a/frontend/src/components/mail/MessageList.vue b/frontend/src/components/mail/MessageList.vue index 1e371df..35e6ed7 100644 --- a/frontend/src/components/mail/MessageList.vue +++ b/frontend/src/components/mail/MessageList.vue @@ -1,111 +1,50 @@ diff --git a/frontend/src/components/mail/MessageListItem.vue b/frontend/src/components/mail/MessageListItem.vue index d5acb14..811402f 100644 --- a/frontend/src/components/mail/MessageListItem.vue +++ b/frontend/src/components/mail/MessageListItem.vue @@ -1,73 +1,105 @@