diff --git a/frontend/src/components/feedback/ModalDialog.vue b/frontend/src/components/feedback/ModalDialog.vue index 3359b46..e834481 100644 --- a/frontend/src/components/feedback/ModalDialog.vue +++ b/frontend/src/components/feedback/ModalDialog.vue @@ -1,7 +1,8 @@ @@ -78,7 +111,7 @@ function onSelect(id: string) { v-if="open" class="reader-more__menu" :label="MAIL_READER_MORE_TEXT.button" - :items="ITEMS" + :items="items" :trigger="trigger" align="end" :focus-content="!byKeyboard" @@ -113,8 +146,9 @@ function onSelect(id: string) { color: var(--color-text); } -/* 메뉴 폭 200 (디자인 원본 MailActions4) */ +/* 메뉴 폭 200 (디자인 원본 MailActions4) — 필터 50개의 까닭(note)은 280 안에서 줄을 바꾼다 */ .reader-more__menu { min-width: 12.5rem; + max-width: 17.5rem; } diff --git a/frontend/src/components/mail/MessageReader.spec.ts b/frontend/src/components/mail/MessageReader.spec.ts index bfc6466..3bcdf94 100644 --- a/frontend/src/components/mail/MessageReader.spec.ts +++ b/frontend/src/components/mail/MessageReader.spec.ts @@ -5,7 +5,7 @@ import { h } from 'vue' import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated' import { MAIL_BODY_NOTICES } from '@/shared/constants/mail' -import type { Mailbox, MessageDetail, MessagesPatch } from '@/shared/types/mail' +import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch } from '@/shared/types/mail' import type { MailListSource } from '@/shared/utils/mail-list-source' import { useAuthStore } from '@/stores/auth.store' import { useMailStore } from '@/stores/mail.store' @@ -47,6 +47,9 @@ const DETAIL: MessageDetail = { attachments: [], } +/** 필터가 없는 계정 — 읽기 화면 ⋯ 를 열면 50개 여부를 알려고 받는다 */ +const NO_FILTERS: MailFilterSet = { rules: [], version: 'v1', isActive: true } + describe('MessageReader 도구 모음', () => { let pinia: Pinia @@ -487,12 +490,20 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자 }) async function mountWith( - options: { box?: string; mailboxes?: Mailbox[]; me?: MockResult; refresh?: MockResult } = {}, + options: { + box?: string + mailboxes?: Mailbox[] + me?: MockResult + refresh?: MockResult + filters?: MailFilterSet + from?: MessageDetail['from'] + } = {}, ) { const box = options.box ?? 'inbox' const mailboxes = options.mailboxes ?? [INBOX, JUNK, TRASH] requests = installMockApi((request) => { if (request.url === '/auth/me') return options.me ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' }) + if (request.url === '/mail-filters') return ok(options.filters ?? NO_FILTERS) if (request.url === '/auth/refresh') return options.refresh ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' }) if (request.url === '/mailboxes') return ok({ items: mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 3 } }) if (request.url === '/messages' && request.method === 'GET') { @@ -504,7 +515,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자 const store = useMailStore() store.mailboxes = mailboxes store.listSource = { kind: 'mailbox', mailboxId: box } - const open = { ...DETAIL, mailboxIds: [box] } + const open = { ...DETAIL, mailboxIds: [box], from: options.from === undefined ? DETAIL.from : options.from } store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }] store.pageInfo = { hasNext: false, nextCursor: null, total: 2 } store.listStatus = 'ready' @@ -580,13 +591,14 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자 await more.trigger('keydown', { key: 'ArrowDown' }) await flushPromises() const items = wrapper.findAll('[role="menu"] [role="menuitem"]') - expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)']) + expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)', '이런 메일로 필터 만들기']) expect(document.activeElement).toBe(items[0]?.element) await items[0]?.trigger('click') await flushPromises() - expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /auth/me']) + // 메뉴를 열 때 필터를 한 번 받아 본다 (50개를 다 만들었는지 — 「이런 메일로 필터 만들기」) + expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /mail-filters', 'GET /auth/me']) expect(downloads).toEqual(['/api/messages/m1/source']) expect(wrapper.find('[role="menu"]').exists()).toBe(false) expect(document.activeElement).toBe(more.element) @@ -603,4 +615,94 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자 expect(downloads).toEqual([]) expect(useAuthStore().sessionExpired).toBe(true) }) + + it('⋯ → 「이런 메일로 필터 만들기」(원문 내려받기 아래) — 설정 › 필터로 보낸 사람 주소를 싣고 간다', async () => { + const { wrapper, router } = await mountWith() + + await wrapper.get('[data-reader-more]').trigger('click') + await flushPromises() + const item = wrapper.findAll('[role="menuitem"]')[1] + expect(item?.find('polygon').attributes('points')).toBe('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3') + expect(item?.attributes('disabled')).toBeUndefined() + + await item?.trigger('click') + await flushPromises() + expect(router.currentRoute.value.name).toBe('settings-mail-filters') + expect(router.currentRoute.value.query).toEqual({ from: 'doyun.lee@arasystem.it.kr' }) + }) + + const fiftyFilters = (): MailFilterRule[] => + Array.from({ length: 50 }, (_, index) => ({ + id: `f${index}`, + name: '', + isEnabled: true, + match: 'always', + conditions: [], + actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: null }, + })) + + it('필터를 50개 다 만들었으면 그 항목을 끄고 까닭을 아래에 — 키보드로 가서 까닭은 듣되 고를 수 없다 (WAI-ARIA 메뉴)', async () => { + const { wrapper, router } = await mountWith({ filters: { rules: fiftyFilters(), version: 'v1', isActive: true } }) + + await wrapper.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' }) + await flushPromises() + const items = wrapper.findAll('[role="menuitem"]') + const item = items[1] + // 꺼진 항목은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점을 받는다 + expect(item?.attributes('aria-disabled')).toBe('true') + expect(item?.attributes('disabled')).toBeUndefined() + const note = item?.get('.menu__note') + expect(note?.text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.') + expect(item?.attributes('aria-describedby')).toBe(note?.attributes('id')) + expect(document.activeElement).toBe(items[0]?.element) + await items[0]?.trigger('keydown', { key: 'ArrowDown' }) + expect(document.activeElement).toBe(item?.element) + + await item?.trigger('click') + await flushPromises() + expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1') + // 꺼진 항목을 골라도 메뉴는 그대로다 + expect(wrapper.find('[role="menu"]').exists()).toBe(true) + }) + + it('50개로 받아 둔 뒤 다른 곳에서 지웠으면 — ⋯ 를 다시 열 때 알림 없이 다시 받아 항목을 켠다', async () => { + const server: { filters: MailFilterSet } = { filters: { rules: fiftyFilters(), version: 'v1', isActive: true } } + const { wrapper } = await mountWith({ + get filters() { + return server.filters + }, + }) + const more = wrapper.get('[data-reader-more]') + const filterItem = () => wrapper.findAll('[role="menuitem"]')[1] + const filterGets = () => requests.filter((request) => request.url === '/mail-filters') + + await more.trigger('click') + await flushPromises() + expect(filterItem()?.attributes('aria-disabled')).toBe('true') + await more.trigger('click') + + server.filters = { rules: fiftyFilters().slice(1), version: 'v2', isActive: true } + await more.trigger('click') + await flushPromises() + expect(filterItem()?.attributes('aria-disabled')).toBeUndefined() + expect(filterItem()?.find('.menu__note').exists()).toBe(false) + expect(filterGets()).toHaveLength(2) + + // 49개면 다시 받지 않는다 + await more.trigger('click') + await more.trigger('click') + await flushPromises() + expect(filterGets()).toHaveLength(2) + expect(useToastStore().toasts).toEqual([]) + }) + + it('보낸 사람 주소가 없는 메일에는 「이런 메일로 필터 만들기」를 두지 않는다 (필터도 받지 않는다)', async () => { + const { wrapper } = await mountWith({ from: null }) + + await wrapper.get('[data-reader-more]').trigger('click') + await flushPromises() + + expect(wrapper.findAll('[role="menuitem"]').map((item) => item.text())).toEqual(['원문 내려받기 (.eml)']) + expect(requests.some((request) => request.url === '/mail-filters')).toBe(false) + }) }) diff --git a/frontend/src/components/mail/MessageReader.vue b/frontend/src/components/mail/MessageReader.vue index cf8d0ae..0011240 100644 --- a/frontend/src/components/mail/MessageReader.vue +++ b/frontend/src/components/mail/MessageReader.vue @@ -3,7 +3,8 @@ // 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다. // 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통. // 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색"). -// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기) (디자인 README "4단계 › 스팸 · 원문"). +// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기 · 이런 메일로 필터 만들기) +// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길"). // 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu. // 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다. import { computed } from 'vue' @@ -110,7 +111,10 @@ function leaveToList() { :message="message" :layout="layout" /> - + diff --git a/frontend/src/components/mail/editor/EditorMenu.vue b/frontend/src/components/mail/editor/EditorMenu.vue index b25a9a0..bd3ab79 100644 --- a/frontend/src/components/mail/editor/EditorMenu.vue +++ b/frontend/src/components/mail/editor/EditorMenu.vue @@ -2,6 +2,10 @@ // 툴바 메뉴 — 글꼴 · 글자 크기 · 더보기 (디자인 원본 ComposeEditor "더보기 메뉴" · "글꼴 · 글자 크기") // 항목 높이 40 · 아이콘 16 · 이름 · 단축키(흐린 글자) · 켜진 것은 오른쪽 ✓ · 한 단계 더 여는 것은 › // 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯ +// 꺼진 항목(disabled)은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴 권고). +// 누를 수 없는 까닭은 이름 아래 작은 글(note)이고 항목이 aria-describedby 로 가리킨다 — 꺼진 항목이어도 그 글은 흐리지 않는다 +import { useId } from 'vue' + import AppIcon from '@/components/AppIcon.vue' import EditorColorGlyph from '@/components/mail/editor/EditorColorGlyph.vue' import EditorPopup from '@/components/mail/editor/EditorPopup.vue' @@ -28,9 +32,22 @@ withDefaults(defineProps(), { }) const emit = defineEmits() +/** 이 메뉴의 고유 접두 — 까닭(note)의 id */ +const menuId = useId() + function isToggle(role: string | undefined): boolean { return role === 'menuitemcheckbox' || role === 'menuitemradio' } + +function noteId(itemId: string): string { + return `${menuId}-${itemId}-note` +} + +/** 꺼진 항목은 골라도 아무것도 하지 않는다 (메뉴도 닫지 않는다) */ +function select(item: EditorMenuItem, event: MouseEvent) { + if (item.separator || item.disabled) return + emit('select', item.id, event.detail === 0) +}