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)
+}
@@ -53,12 +70,13 @@ function isToggle(role: string | undefined): boolean {