feat(frontend): 목록을 대화로 묶어 보게 한다 (14단계 화면 3차)

메일함 목록을 대화 한 줄로 묶어 보는 [대화로 묶기]와 설정 › 보내기 · 읽기의
한 줄을 더한다. 켜면 목록 출처가 GET /api/conversations 이고, 줄에 하는 일
(고르기 · 읽음 · 중요 · 이동 · 삭제)은 그 줄의 messageIds 에서 붙잡힌 메일을
뺀 메일을 지금의 여러 통 API 로 보낸다 — 붙잡힌 메일은 취소로만 보낸편지함을
떠난다(9단계).

- 대화 목록에서는 messages 가 비므로 읽기 화면 · 삭제 · 예약 취소가 줄 기준
  도우미(rowCount · rowIndexOfMessage · isListedMessage 등)로 같은 규칙을 탄다
- 첫 진입은 이 브라우저가 그 직원에게서 마지막으로 안 값으로 열고, 설정을
  받지 못해도 [대화로 묶기]를 쓸 수 있다
- 리뷰 M1: 로그아웃 · 세션 만료 직후 설정 reset 이 watcher 를 깨워 떠나는
  화면의 목록을 다시 부르던 회귀 — canUseApp 이 false 면 부르지 않고 reset 은
  보이는 값을 뒤집지 않는다
- 리뷰 L7: 버린 목록 요청은 AbortController 로 끊는다(엔진 방식은 한 요청이
  15초까지)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 21:56:13 +09:00
parent 1a4f1d3b56
commit 726f9f854f
46 changed files with 3782 additions and 183 deletions
@@ -262,7 +262,7 @@ describe('ComposePage — 보내기 취소 · 예약 발송 (9단계)', () => {
it('보내기 · 읽기 설정을 받아 두었으면 설정한 초 − 1초에 닫는다 — 이 PC 시계가 늦어 보낼 시각이 멀리 보여도 늘어나지 않는다 (리뷰 #11)', async () => {
const settings = useMailSettingsStore()
settings.settings = { undoSendSeconds: 5, externalImages: 'ask' }
settings.settings = { undoSendSeconds: 5, externalImages: 'ask', groupByConversation: false }
settings.status = 'ready'
const far = ok({ id: 'sent-1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(NOW + 600_000).toISOString() } }, 201)
const { wrapper } = await mountCompose(server([far]))
@@ -0,0 +1,585 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router'
import { CLOCK } from '@/composables/useClock'
import { routes } from '@/router/routes'
import { MAIL_STATE_POLL_INTERVAL_MS } from '@/shared/constants/mail'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type {
ConversationSummary,
Mailbox,
MailSettings,
MessageDetail,
MessagesPatch,
MessageSummary,
} from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { groupByConversationHintKey } from '@/stores/mail-settings.store'
import { mailViewModeStorageKey } from '@/stores/mail-view.store'
import { useToastStore } from '@/stores/toast.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
// 14단계 3차 — 목록의 "대화로 묶어 보기" (디자인 원본 ListThreads14 · 계약 2절 "대화로 묶어 보기")
// 실제 메일 화면(MailLayout → MailboxPage → 목록 · 읽기 화면)을 띄운다 — 설정(groupByConversation)이 목록 출처를 정한다
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
const ME = { name: '김아라', email: USER.email }
const DOYUN = { name: '이도윤', email: 'doyun.lee@arasystem.test' }
const HR = { name: '인사팀', email: 'hr@arasystem.test' }
const MINSU = { name: '김민수', email: 'minsu.kim@arasystem.test' }
function mailbox(id: string, role: Mailbox['role'], name: string, totalCount: number, unreadCount: number): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount }
}
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: DOYUN,
to: [ME],
subject: `제목 ${id}`,
preview: '',
// 한국 시간 · UTC 의 날짜가 같은 시각 (시간대와 상관없이 같은 글)
receivedAt: '2026-10-20T03:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
pendingSend: null,
...partial,
}
}
function detail(base: MessageSummary): MessageDetail {
return {
...base,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: `${base.id} 본문`,
hasBlockedImages: false,
attachments: [],
}
}
function conversation(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
return {
id,
latest: summary(messageIds[0] ?? id),
participants: [DOYUN],
messageCount: messageIds.length,
messageIds,
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
...partial,
}
}
const ROWS: ConversationSummary[] = [
conversation('c1', ['m6', 'm5'], {
latest: summary('m6', { subject: 'Re: 전사 회의 참석 안내', isRead: false, hasAttachment: true }),
participants: [HR, ME, MINSU],
messageCount: 6,
isRead: false,
hasAttachment: true,
}),
conversation('c2', ['m4'], { latest: summary('m4', { subject: '11월 비용 정산 안내' }) }),
conversation('c3', ['m3', 'm2'], { latest: summary('m3', { subject: 'Re: 유지보수 계약 갱신 건' }), participants: [ME, DOYUN] }),
]
const page = (items: unknown[], total: number | null = items.length) =>
ok({ items, pageInfo: { hasNext: false, nextCursor: null, total } })
describe('MailboxPage — 대화로 묶어 보기 (14단계)', () => {
let pinia: Pinia
let clock: ManualClock
let requests: MockRequest[]
let settings: MailSettings
let rows: ConversationSummary[]
let state: string
let patchSettings: ((request: MockRequest) => MockResult) | null
/** GET /mail-settings 의 응답을 바꾼다 (받지 못함 등) */
let loadSettings: (() => MockResult) | null
/** GET /messages 의 응답을 바꾼다 (붙잡아 두기 등) */
let listMessages: (() => MockResult | Promise<MockResult>) | null
const mounted: VueWrapper[] = []
beforeEach(() => {
window.localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(new Date(2026, 10, 6, 15).getTime())
settings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: true }
rows = ROWS
state = 's1'
patchSettings = null
loadSettings = null
listMessages = null
requests = installMockApi((request) => {
const { url, method } = request
if (url === '/mail-state') return ok({ state })
if (url === '/mailboxes') {
return page([
mailbox('inbox', 'inbox', '받은편지함', 9, 2),
mailbox('drafts', 'drafts', '임시보관함', 1, 0),
mailbox('trash', 'trash', '휴지통', 0, 0),
])
}
if (url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
if (url === '/mail-signature') {
return ok({ bodyHtml: null, body: null, isForNew: true, isForReply: true, images: [], updatedAt: null })
}
if (url === '/mail-vacation') {
return ok({ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', isActive: true })
}
if (url === '/mail-blocks') return ok({ senders: [], isActive: true })
if (url === '/mail-settings' && method === 'GET') return loadSettings ? loadSettings() : ok(settings)
if (url === '/mail-settings' && method === 'PATCH') {
if (patchSettings) return patchSettings(request)
settings = { ...settings, ...(request.data as Partial<MailSettings>) }
return ok(settings)
}
if (url === '/scheduled-messages') return page([])
if (url === '/messages' && method === 'PATCH') {
const data = request.data as MessagesPatch
// 옮긴 메일은 서버의 묶은 목록에서도 빠진다 — 다시 맞춘 목록이 옛 줄을 되살리지 않게
if (data.mailboxId) {
rows = rows
.map((row) => {
const messageIds = row.messageIds.filter((id) => !data.ids.includes(id))
const latestId = messageIds[0]
return latestId && latestId !== row.latest.id ? { ...row, messageIds, latest: summary(latestId) } : { ...row, messageIds }
})
.filter((row) => row.messageIds.length > 0)
}
return ok({ updatedIds: data.ids, failedIds: [] })
}
if (url === '/message-deletions') return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
if (url === '/conversations') return ok({ items: rows, pageInfo: { hasNext: false, nextCursor: null, total: 6 }, basis: 'index' })
if (url === '/messages' && method === 'GET') {
return listMessages ? listMessages() : page([summary('m6', { isRead: false }), summary('m4')], 9)
}
if (url.endsWith('/conversation')) return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 1 }, basis: 'index' })
const opened = /^\/messages\/([^/]+)$/.exec(url)?.[1]
if (opened && method === 'GET') return ok(detail(summary(opened)))
return ok(null)
})
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
document.title = ''
})
async function settle() {
for (let round = 0; round < 4; round += 1) await flushPromises()
}
async function mountMail(path = '/mail/inbox'): Promise<{ wrapper: VueWrapper; router: Router }> {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push(path)
await router.isReady()
const wrapper = mount(
{ render: () => h(RouterView) },
{ attachTo: document.body, global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } } },
)
mounted.push(wrapper)
await settle()
return { wrapper, router }
}
/** 이 브라우저가 마지막으로 안 묶어 보기 값 — 설정을 받기 전의 짐작 (mail-settings.store) */
function rememberGrouped(grouped = true) {
window.localStorage.setItem(groupByConversationHintKey(USER.email), String(grouped))
}
const threadRequests = () => requests.filter((request) => request.url === '/conversations')
const messageListRequests = () => requests.filter((request) => request.url === '/messages' && request.method === 'GET')
const rowTexts = (wrapper: VueWrapper) =>
wrapper.findAll('.thread-row').map((row) => ({
who: row.get('.thread-row__who').text(),
count: row.find('[data-thread-count]').exists() ? row.get('[data-thread-count]').text() : null,
subject: row.get('.thread-row__subject').text(),
unread: row.classes('is-unread'),
}))
it('켜져 있으면 묶은 목록 — 참여자(나는 끝에) · 통 수(한 통이면 없음) · 마지막 메일 제목 · 하나라도 안 읽었으면 굵게, 머리는 대화 수', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
expect(threadRequests().map((request) => request.params)).toEqual([{ mailboxId: 'inbox', limit: 20 }])
// 짐작(이 브라우저가 마지막으로 안 값)이 있으면 메일 목록을 받았다 버리지 않는다
expect(messageListRequests()).toHaveLength(0)
expect(rowTexts(wrapper)).toEqual([
{ who: '안 읽음, 인사팀, 김민수, 나', count: '6통', subject: 'Re: 전사 회의 참석 안내', unread: true },
{ who: '이도윤', count: null, subject: '11월 비용 정산 안내', unread: false },
{ who: '이도윤, 나', count: '2통', subject: 'Re: 유지보수 계약 갱신 건', unread: false },
])
expect(wrapper.findAll('.thread-row')[0]?.find('[aria-label="첨부 있음"]').exists()).toBe(true)
expect(wrapper.get('.list-header__count').text()).toBe('대화 6개, 안 읽은 메일 2통')
const toggle = wrapper.get('[data-group-toggle]')
expect(toggle.attributes('aria-pressed')).toBe('true')
// 분할 보기의 목록 칸은 아이콘만 — 이름은 title · 화면 낭독
expect(toggle.attributes('title')).toBe('대화로 묶기')
expect(toggle.text()).toBe('대화로 묶기')
})
it('처음 이 브라우저(짐작 없음)면 메일 목록으로 열었다가 설정을 받으면 묶은 목록으로 — 받은 값을 짐작으로 기억한다', async () => {
const { wrapper } = await mountMail()
expect(messageListRequests()).toHaveLength(1)
expect(threadRequests()).toHaveLength(1)
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
expect(window.localStorage.getItem(groupByConversationHintKey(USER.email))).toBe('true')
})
it('짐작과 서버 값이 다르면 서버 값이 이긴다 — 다른 PC 에서 껐다', async () => {
rememberGrouped()
settings = { ...settings, groupByConversation: false }
const { wrapper } = await mountMail()
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
expect(wrapper.findAll('.message-row')).toHaveLength(2)
expect(window.localStorage.getItem(groupByConversationHintKey(USER.email))).toBe('false')
})
it('[대화로 묶기]를 누르면 저장하고 곧바로 바꾼다 — 끄면 메일 목록과 "메일 9통, 안 읽은 메일 2통", 다시 켜면 처음 쪽부터', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
await wrapper.get('[data-group-toggle]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH').map((request) => request.data)).toEqual([
{ groupByConversation: false },
])
expect(messageListRequests()).toHaveLength(1)
expect(wrapper.findAll('.thread-row')).toHaveLength(0)
expect(wrapper.findAll('.message-row')).toHaveLength(2)
expect(wrapper.get('.list-header__count').text()).toBe('메일 9통, 안 읽은 메일 2통')
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
await wrapper.get('[data-group-toggle]').trigger('click')
await settle()
expect(threadRequests()).toHaveLength(2)
expect(threadRequests()[1]?.params).toEqual({ mailboxId: 'inbox', limit: 20 })
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
})
it('저장하지 못하면 묶은 목록으로 되돌린다 — 알림은 인터셉터가', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
patchSettings = () => fail(400, 'VAL_001')
await wrapper.get('[data-group-toggle]').trigger('click')
await settle()
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('true')
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
})
it('줄을 누르면 그 메일함의 마지막 메일을 연다 — 연 메일이 든 줄이 골라진 모양', async () => {
rememberGrouped()
const { wrapper, router } = await mountMail()
await wrapper.findAll('.thread-row__main')[2]?.trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m3')
expect(wrapper.get('#reader-subject').text()).toBe('제목 m3')
expect(wrapper.findAll('.thread-row')[2]?.classes()).toContain('is-selected')
})
it('메일이 모두 보내기를 기다리는 줄은 고를 수 없다 — 고르기 칸 · 별이 aria-disabled 이고 누르면 아무것도 하지 않는다', async () => {
rememberGrouped()
rows = [conversation('held', ['m9'], { pendingSendIds: ['m9'] }), ...ROWS.slice(1)]
const { wrapper } = await mountMail()
const heldRow = wrapper.findAll('.thread-row')[0]
expect(heldRow?.get('[role="checkbox"]').attributes('aria-disabled')).toBe('true')
expect(heldRow?.get('[data-thread-star]').attributes('aria-disabled')).toBe('true')
await heldRow?.get('[role="checkbox"]').trigger('click')
await heldRow?.get('[data-thread-star]').trigger('click')
await settle()
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
expect(requests.filter((request) => request.method === 'PATCH')).toEqual([])
})
it('고르면 도구 줄 "대화 1개 선택됨" — 하는 일은 그 줄의 메일 모두', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
expect(wrapper.get('.bulk-bar__count').text()).toBe('대화 1개 선택됨')
await wrapper.get('[data-tool="markRead"]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
{ ids: ['m6', 'm5'], isRead: true },
])
expect(wrapper.findAll('.thread-row')[0]?.classes()).not.toContain('is-unread')
})
it('휴지통에서 고른 대화를 영구 삭제 — 확인 창의 수는 메일 수이고 그 줄의 메일을 POST /message-deletions', async () => {
rememberGrouped()
rows = [conversation('t1', ['m8', 'm7'], { latest: summary('m8', { mailboxIds: ['trash'] }) }), conversation('t2', ['m1'])]
const { wrapper } = await mountMail('/mail/trash')
await wrapper.findAll('.thread-row')[0]?.get('[role="checkbox"]').trigger('click')
await wrapper.get('[data-tool="delete"]').trigger('click')
await settle()
const dialog = wrapper.get('#bulk-purge-title')
expect(dialog.text()).toBe('메일 2통을 영구 삭제할까요?')
await wrapper.get('[data-purge-confirm]').trigger('click')
await settle()
expect(requests.filter((request) => request.url === '/message-deletions').map((request) => request.data)).toEqual([
{ ids: ['m8', 'm7'] },
])
expect(useToastStore().toasts.map((toast) => toast.message)).toContain('메일 2통을 영구 삭제했습니다.')
})
it('목록 보기 — 크기 칸이 없고 머리 고르기는 "불러온 대화 모두 고르기". 연 메일의 위치는 대화 기준이고 [다음]은 다음 대화의 마지막 메일', async () => {
rememberGrouped()
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
const { wrapper, router } = await mountMail()
const heads = wrapper.findAll('table.mail-table thead th').map((th) => th.text())
expect(heads).not.toContain('크기')
expect(wrapper.get('thead th [role="checkbox"]').attributes('aria-label')).toBe('불러온 대화 모두 고르기')
expect(wrapper.get('table.mail-table caption').text()).toBe('받은편지함 대화 목록')
expect(wrapper.findAll('tbody tr').map((row) => row.get('[data-message-id]').attributes('data-message-id'))).toEqual([
'm6',
'm4',
'm3',
])
// 목록 보기에서는 이름까지
expect(wrapper.get('[data-group-toggle]').attributes('title')).toBeUndefined()
await router.push('/mail/inbox/m4')
await settle()
expect(wrapper.get('.nav-position').text()).toBe('2 / 6')
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m3')
})
it('새 메일 확인 — 바뀌었으면 묶은 목록을 불러온 줄 수만큼 조용히 다시 받는다', async () => {
rememberGrouped()
const { wrapper } = await mountMail()
expect(threadRequests()).toHaveLength(1)
rows = [conversation('c9', ['m9'], { latest: summary('m9', { subject: '새 대화' }) }), ...ROWS]
state = 's2'
clock.advance(MAIL_STATE_POLL_INTERVAL_MS)
await settle()
expect(threadRequests()).toHaveLength(2)
expect(threadRequests()[1]?.params).toEqual({ mailboxId: 'inbox', limit: 20 })
expect(threadRequests()[1]?.config.silent).toBe(true)
expect(messageListRequests()).toHaveLength(0)
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c9', 'c1', 'c2', 'c3'])
})
describe('세션이 끝나면 목록을 부르지 않는다 (리뷰 M1)', () => {
it.each([
['로그아웃', () => useAuthStore().clearAuth()],
['세션 만료', () => useAuthStore().expireSession()],
])('묶기를 켠 채 %s — 메일 목록 · 묶은 목록 요청이 0건이고 비운 목록이 다시 차지 않는다', async (_name, end) => {
rememberGrouped()
await mountMail()
const before = { messages: messageListRequests().length, threads: threadRequests().length }
end()
await settle()
expect({ messages: messageListRequests().length, threads: threadRequests().length }).toEqual(before)
expect(useMailStore().conversations).toEqual([])
expect(useMailStore().listSource).toBeNull()
})
})
describe('읽기 화면 [삭제] 뒤 갈 곳 — 분할 보기 (리뷰 L3)', () => {
it('그 줄에 메일이 남으면 그 줄의 다음 칸(더 오래된 메일), 줄이 비면 다음 줄의 마지막 메일', async () => {
rememberGrouped()
const { wrapper, router } = await mountMail('/mail/inbox/m6')
await wrapper.get('[data-reader-delete]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
{ ids: ['m6'], mailboxId: 'trash' },
])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m5')
expect(wrapper.findAll('.thread-row')[0]?.classes()).toContain('is-selected')
await wrapper.get('[data-reader-delete]').trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c2', 'c3'])
})
})
describe('목록 보기의 읽기 화면 — 대화 사이를 오간다', () => {
beforeEach(() => {
rememberGrouped()
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
})
it('줄의 마지막 메일이 아닌 메일도 그 메일이 든 줄의 위치 — [다음]은 다음 대화, [이전]은 앞 대화의 마지막 메일', async () => {
const { wrapper, router } = await mountMail('/mail/inbox/m5')
expect(wrapper.get('.nav-position').text()).toBe('1 / 6')
expect(wrapper.get('button[aria-label="이전 메일"]').attributes('disabled')).toBeDefined()
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
await router.push('/mail/inbox/m2')
await settle()
expect(wrapper.get('.nav-position').text()).toBe('3 / 6')
await wrapper.get('button[aria-label="이전 메일"]').trigger('click')
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m4')
})
it('[목록으로] — 표로 돌아가 그 메일이 든 줄의 링크(마지막 메일)로 초점을 돌려준다', async () => {
const { wrapper, router } = await mountMail('/mail/inbox/m5')
const back = wrapper.findAll('a').find((link) => link.text() === '목록으로')
await back?.trigger('click', { button: 0 })
await settle()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(document.activeElement?.getAttribute('data-message-id')).toBe('m6')
})
it('메일이 모두 보내기를 기다리는 줄 — Ctrl · Shift + 클릭으로도 고르지 않고, 날짜 칸은 예약 표시', async () => {
rows = [
conversation('held', ['m9'], {
latest: summary('m9', { pendingSend: { sendAt: '2026-11-10T01:00:00Z', kind: 'scheduled' } }),
pendingSendIds: ['m9'],
}),
...ROWS,
]
const { wrapper } = await mountMail()
const heldRow = wrapper.get('tr[data-row-id="held"]')
await heldRow.get('td.mail-table__subject').trigger('click', { ctrlKey: true })
await heldRow.get('td.mail-table__subject').trigger('click', { shiftKey: true })
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
expect(heldRow.get('td.mail-table__date').text()).toMatch(/^예약 · /)
// 다른 줄은 Ctrl + 클릭으로 고른다
await wrapper.get('tr[data-row-id="c2"] td.mail-table__subject').trigger('click', { ctrlKey: true })
expect(wrapper.get('.bulk-bar__count').text()).toBe('대화 1개 선택됨')
})
})
it('빠르게 켬 → 끔 → 켬 — 마지막 목록만 남고 버린 메일 목록 요청은 끊는다 (리뷰 L7)', async () => {
rememberGrouped()
let releaseMessages: (result: MockResult) => void = () => undefined
listMessages = () =>
new Promise<MockResult>((resolve) => {
releaseMessages = resolve
})
const { wrapper } = await mountMail()
const toggle = () => wrapper.get('[data-group-toggle]')
await toggle().trigger('click')
await settle()
expect(messageListRequests()).toHaveLength(1)
await toggle().trigger('click')
await settle()
expect(messageListRequests()[0]?.config.signal?.aborted).toBe(true)
releaseMessages(page([summary('late')], 1))
await settle()
expect(toggle().attributes('aria-pressed')).toBe('true')
expect(wrapper.findAll('.message-row')).toHaveLength(0)
expect(wrapper.findAll('.thread-row').map((row) => row.attributes('data-row-id'))).toEqual(['c1', 'c2', 'c3'])
expect(settings.groupByConversation).toBe(true)
})
it('설정을 받지 못했으면 짐작대로 묶고 [대화로 묶기]도 쓸 수 있다 — 저장 응답을 받은 설정으로 (리뷰 L4)', async () => {
rememberGrouped()
loadSettings = () => fail(503, 'SYS_001')
const { wrapper } = await mountMail()
expect(wrapper.findAll('.thread-row')).toHaveLength(3)
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('true')
await wrapper.get('[data-group-toggle]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/mail-settings').map((request) => request.data)).toEqual([
{ groupByConversation: false },
])
expect(wrapper.findAll('.message-row')).toHaveLength(2)
expect(wrapper.get('[data-group-toggle]').attributes('aria-pressed')).toBe('false')
})
it('줄의 중요 표시가 보내기를 기다리는 메일에만 있으면 별로 풀리지 않는다 — 다시 맞춰 보고 그 까닭을 알린다', async () => {
rememberGrouped()
rows = [conversation('hf', ['m9', 'm8'], { isFlagged: true, pendingSendIds: ['m9'] }), ...ROWS.slice(1)]
const { wrapper } = await mountMail()
const syncs = threadRequests().length
await wrapper.get('.thread-row[data-row-id="hf"] [data-thread-star]').trigger('click')
await settle()
expect(requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)).toEqual([
{ ids: ['m8'], isFlagged: false },
])
expect(threadRequests().length).toBe(syncs + 1)
expect(wrapper.get('.thread-row[data-row-id="hf"] [data-thread-star]').attributes('aria-pressed')).toBe('true')
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
'보내기를 기다리는 메일은 목록에서 중요 표시를 바꿀 수 없습니다. 메일을 열어 바꿔 주세요.',
])
})
it('임시보관함의 제목 없는 저장본 — 흐린 "(제목 없음)", 굵게 하지 않고 누르면 쓰기 화면에서 이어 쓴다 (리뷰 L5)', async () => {
rememberGrouped()
rows = [
conversation('d1', ['d1'], {
latest: summary('d1', { subject: '', mailboxIds: ['drafts'], isRead: false }),
participants: [ME],
isRead: false,
}),
]
const { wrapper } = await mountMail('/mail/drafts')
const row = wrapper.get('.thread-row')
expect(row.get('.thread-row__subject').text()).toBe('(제목 없음)')
expect(row.get('.thread-row__subject').classes()).toContain('is-placeholder')
expect(row.classes()).not.toContain('is-unread')
expect(row.get('.thread-row__who').text()).toBe('받는 사람: 김아라')
expect(row.get('.thread-row__main').attributes('href')).toBe('/mail/compose?draft=d1')
})
it('검색 결과는 묶지 않는다 — 켜져 있어도 GET /messages 이고 [대화로 묶기]가 없다', async () => {
rememberGrouped()
const { wrapper } = await mountMail('/mail/search?q=%ED%9A%8C%EC%9D%98&in=inbox')
expect(threadRequests()).toHaveLength(0)
expect(messageListRequests()[0]?.params).toMatchObject({ q: '회의', mailboxId: 'inbox' })
expect(wrapper.find('[data-group-toggle]').exists()).toBe(false)
})
})
+14 -3
View File
@@ -7,6 +7,10 @@
// - 목록: 메일을 열지 않았으면 게시판형 표(MainList), 열었으면 표 자리에 본문을 넓게(MainListRead)
// 검색 결과는 메일함 목록과 같은 목록 · 표 · 본문을 쓴다 — 목록의 출처만 다르다 (mail-list-source.ts)
// 예약됨(9단계)은 고르기 · 여러 통 처리가 없는 목록(ScheduledMessageList — 받는 사람 · 제목 · 보낼 시각)을 쓰고, 연 메일은 같은 본문이다
// 대화로 묶어 보기(14단계 — 설정 groupByConversation)를 켰으면 메일함 목록의 출처가 GET /conversations 다 — 주소는 같고 한 줄이 대화 하나다.
// 설정을 받기 전 · 받지 못했으면 이 브라우저의 짐작(mail-settings.store — 처음이면 끔)으로 연다(기다리게 하지 않는다) — 받아 보니 다르면 그때 바꾼다.
// 켜고 끄면(목록 머리의 [대화로 묶기] · 설정 화면) 앞 목록의 요청을 끊고 처음 쪽부터 받는다. 검색 결과 · 예약됨은 묶지 않는다.
// 로그아웃 · 세션 만료 · 제한된 세션(canUseApp false)에는 목록을 부르지 않는다 — 떠나는 화면이 막 비운 목록을 다시 채우지 않게 (리뷰 M1)
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -20,12 +24,16 @@ import ScheduledMessageList from '@/components/mail/ScheduledMessageList.vue'
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
import { isScheduledRouteName, isSearchTooLong, listKeyOf } from '@/shared/utils/mail-list-source'
import { messageHighlightKey, messageHighlightQueryOf } from '@/shared/utils/mail-search'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
import { useMailViewStore } from '@/stores/mail-view.store'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const mailStore = useMailStore()
const settingsStore = useMailSettingsStore()
const viewStore = useMailViewStore()
const { isSearching, routeSearch, closeSearch } = useMailSearchNavigation()
@@ -41,10 +49,13 @@ const isListView = computed(() => viewStore.mode === 'list')
/** 목록이 있는 화면 — 메일함 · 검색 결과 · 예약됨 */
const hasList = computed(() => Boolean(mailboxId.value) || isSearching.value || isScheduled.value)
// 메일함 · 묶어 보기가 바뀔 때 — 같은 목록으로 돌아오면(쓰기 화면에서 복귀 등) 스토어가 보이던 채로 새로 고친다.
// 세션이 끝났으면 부르지 않는다 (머리말 — 로그아웃은 스토어를 비우고 로그인 화면으로 간다)
watch(
mailboxId,
(id) => {
if (id) void mailStore.openMailbox(id)
[mailboxId, () => settingsStore.groupByConversation, () => authStore.canUseApp],
([id, grouped, usable]) => {
if (!id || !usable) return
void (grouped ? mailStore.openConversations(id) : mailStore.openMailbox(id))
},
{ immediate: true },
)
@@ -13,10 +13,10 @@ import MailSendReadPage from './MailSendReadPage.vue'
// 설정 › 메일 › 보내기 · 읽기 (디자인 원본 SettingsSendRead · 계약 2절 "보내기 · 읽기 설정") — 고르는 대로 PATCH, "저장했습니다"
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
const DEFAULTS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask' }
const DEFAULTS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false }
interface Server {
settings?: MailSettings
settings?: Partial<MailSettings>
/** GET /mail-settings 의 응답을 바꾼다 (실패 등) */
load?: MockResult
patch?: (request: MockRequest) => MockResult | Promise<MockResult>
@@ -118,6 +118,37 @@ describe('MailSendReadPage', () => {
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
})
it('대화로 묶어 보기(14단계) — 끔 · 켬 전환과 설명, 고르면 그 칸만 PATCH 하고 "저장했습니다"', async () => {
const wrapper = await mountPage()
const grouping = () =>
wrapper.findAll('[data-group-by-conversation]').map((button) => [button.text(), button.attributes('aria-pressed')])
expect(wrapper.get('#send-read-group-title').text()).toBe('대화로 묶어 보기')
expect(grouping()).toEqual([
['끔', 'true'],
['켬', 'false'],
])
expect(wrapper.get('#send-read-group-hint').text()).toBe(
'답장과 전달로 이어진 메일을 메일함 목록에서 한 줄로 묶어 보여 줍니다. 목록 위의 [대화로 묶기]로도 켜고 끌 수 있습니다.',
)
await wrapper.get('[data-group-by-conversation="true"]').trigger('click')
await flushPromises()
expect(patches()).toEqual([{ groupByConversation: true }])
expect(grouping()).toEqual([
['끔', 'false'],
['켬', 'true'],
])
expect(wrapper.get('[data-settings-status]').text()).toBe('저장했습니다')
})
it('묶어 보기 필드가 없는 서버(14단계 앞)면 끔으로 그린다', async () => {
const wrapper = await mountPage({ load: ok({ undoSendSeconds: 10, externalImages: 'ask' }) })
expect(wrapper.get('[data-group-by-conversation="false"]').attributes('aria-pressed')).toBe('true')
})
it('지금 값을 다시 누르면 보내지 않는다', async () => {
const wrapper = await mountPage()
@@ -3,14 +3,20 @@
// — /settings/mail/send-read · 탭 제목 "보내기 · 읽기"
// 머리 "보내기 · 읽기" + 설명 + 오른쪽에 "저장했습니다"(role=status — 저장 단추 없음) →
// 「보내기 취소」 전환(36 — .segmented 밀도 예외: 끔 · 5초 · 10초 · 20초 · 30초) + 설명 → 가는 선 →
// 「외부 이미지」 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기 — 줄마다 아래 설명) + 회색 안내 상자.
// 「외부 이미지」 라디오 셋(늘 묻기 · 주소록 · 사내 직원의 메일은 늘 보기 · 늘 보기 — 줄마다 아래 설명) + 회색 안내 상자 → 가는 선 →
// 「대화로 묶어 보기」 전환(36 — 끔 · 켬, 14단계 — 목록 머리의 [대화로 묶기]와 같은 설정) + 설명.
// - 들어올 때마다 새로 받는다(다른 PC 에서 바꿨을 수 있다 — 서버에 저장되어 어느 PC 에서나 같다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"
// - 고르는 대로 그 칸만 PATCH 한다(mail-settings.store — 화면을 먼저 바꾸고 실패하면 고르기 전 값으로 되돌린다, 알림은 인터셉터).
// 저장이 끝나면 "저장했습니다", 다시 고르면 내린다
import AppIcon from '@/components/AppIcon.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import { EXTERNAL_IMAGES_OPTIONS, MAIL_SETTINGS_TEXT, UNDO_SEND_OPTIONS } from '@/shared/constants/mail-settings'
import {
EXTERNAL_IMAGES_OPTIONS,
GROUP_BY_CONVERSATION_OPTIONS,
MAIL_SETTINGS_TEXT,
UNDO_SEND_OPTIONS,
} from '@/shared/constants/mail-settings'
import type { ExternalImagesSetting, UndoSendSeconds } from '@/shared/types/mail'
import { useMailSettingsStore } from '@/stores/mail-settings.store'
@@ -28,6 +34,10 @@ function chooseImages(option: ExternalImagesSetting) {
void settingsStore.update('externalImages', option)
}
function chooseGrouping(grouped: boolean) {
void settingsStore.update('groupByConversation', grouped)
}
// 처음 그리기 전에 — 지난번에 받아 둔 값을 잠깐 그렸다 바꾸지 않게
void settingsStore.load()
</script>
@@ -135,6 +145,44 @@ void settingsStore.load()
{{ MAIL_SETTINGS_TEXT.imagesNote }}
</p>
</fieldset>
<hr class="send-read__rule">
<section
class="send-read__group"
aria-labelledby="send-read-group-title"
>
<h2
id="send-read-group-title"
class="send-read__title"
>
{{ MAIL_SETTINGS_TEXT.groupTitle }}
</h2>
<div
role="group"
class="segmented segmented--outline send-read__undo"
aria-labelledby="send-read-group-title"
aria-describedby="send-read-group-hint"
>
<button
v-for="grouped in GROUP_BY_CONVERSATION_OPTIONS"
:key="String(grouped)"
type="button"
class="segmented__item"
:aria-pressed="settingsStore.settings.groupByConversation === grouped"
:data-group-by-conversation="String(grouped)"
@click="chooseGrouping(grouped)"
>
{{ grouped ? MAIL_SETTINGS_TEXT.groupOn : MAIL_SETTINGS_TEXT.groupOff }}
</button>
</div>
<p
id="send-read-group-hint"
class="form-message"
>
{{ MAIL_SETTINGS_TEXT.groupHint }}
</p>
</section>
</template>
<div