From 894550732e07187d7e9eb8c6ed3422eae3ad2ebb Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 28 Oct 2026 20:59:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=E2=80=BA=20=EB=A9=94=EC=9D=BC=20=E2=80=BA=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=C2=B7=20=EC=9D=BD=EA=B8=B0=20=ED=99=94=EB=A9=B4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=95=84=ED=84=B0=20=EB=A7=8C=EB=93=A4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확정 보드(SettingsFilter · MailFilterStates)대로 필터 목록 · 켜기 · 순서 · 지우기(되돌리기) · 고치기 창을 만들고, 읽기 화면 ⋯ 에 「이런 메일로 필터 만들기」를 둔다. - 저장은 한 번에 하나다. 되돌리기는 누른 차례대로, 앞 저장이 끝난 뒤에 보낸다 — 연달아 누르면 하나가 사라지던 경합을 리뷰에서 찾았다 - 다른 곳에서 먼저 바꿨으면 창 안에서도 다시 불러올 수 있고, 쓴 내용은 지킨다 - 메뉴의 꺼진 항목은 aria-disabled 로 둔다(편집기 메뉴 공용) — 키보드로 가서 까닭을 들을 수 있다 Co-Authored-By: Claude Opus 5.5 --- .../src/components/feedback/ModalDialog.vue | 23 +- .../src/components/mail/MailboxPanel.spec.ts | 8 +- .../src/components/mail/MessageMoreMenu.vue | 54 +- .../src/components/mail/MessageReader.spec.ts | 112 ++- .../src/components/mail/MessageReader.vue | 8 +- .../src/components/mail/editor/EditorMenu.vue | 73 +- .../components/mail/editor/EditorPopup.vue | 4 + .../src/components/mail/editor/editor-menu.ts | 2 + .../settings/MailFilterActionFields.vue | 278 +++++++ .../settings/MailFilterConditionFields.vue | 193 +++++ .../settings/MailFilterConditionRow.vue | 197 +++++ .../settings/MailFilterCreateButton.vue | 81 ++ .../components/settings/MailFilterDialog.vue | 466 +++++++++++ .../components/settings/MailFilterEmpty.vue | 71 ++ .../components/settings/MailFilterList.vue | 75 ++ .../components/settings/MailFilterNotes.vue | 52 ++ .../components/settings/MailFilterNotice.vue | 97 +++ .../src/components/settings/MailFilterRow.vue | 306 +++++++ .../components/settings/SettingsPageFrame.vue | 39 +- .../src/components/settings/mail-filter-ui.ts | 4 + frontend/src/composables/useApi.spec.ts | 3 + frontend/src/composables/useMail.spec.ts | 41 +- frontend/src/composables/useMail.ts | 19 + .../composables/useMailFilterActions.spec.ts | 163 ++++ .../src/composables/useMailFilterActions.ts | 59 ++ .../src/composables/useMailFilterDialog.ts | 161 ++++ .../src/pages/settings/MailFilterPage.spec.ts | 784 ++++++++++++++++++ .../src/pages/settings/MailFilterPage.vue | 330 ++++++++ .../src/pages/settings/SettingsLayout.spec.ts | 24 +- .../src/pages/settings/SettingsLayout.vue | 2 +- frontend/src/router/routes.ts | 9 +- frontend/src/shared/constants/icons.ts | 8 + frontend/src/shared/constants/mail-filter.ts | 159 ++++ frontend/src/shared/constants/mail.ts | 2 + .../src/shared/constants/settings-menu.ts | 8 +- frontend/src/shared/types/mail.ts | 59 ++ frontend/src/shared/utils/idempotency-key.ts | 30 +- frontend/src/shared/utils/mail-filter.spec.ts | 356 ++++++++ frontend/src/shared/utils/mail-filter.ts | 378 +++++++++ frontend/src/shared/utils/mailbox.ts | 4 +- frontend/src/shared/utils/uuid.ts | 33 + frontend/src/stores/mail-filter.store.spec.ts | 407 +++++++++ frontend/src/stores/mail-filter.store.ts | 291 +++++++ frontend/src/styles/tokens.css | 11 + frontend/src/test-utils/router.ts | 6 + 45 files changed, 5410 insertions(+), 80 deletions(-) create mode 100644 frontend/src/components/settings/MailFilterActionFields.vue create mode 100644 frontend/src/components/settings/MailFilterConditionFields.vue create mode 100644 frontend/src/components/settings/MailFilterConditionRow.vue create mode 100644 frontend/src/components/settings/MailFilterCreateButton.vue create mode 100644 frontend/src/components/settings/MailFilterDialog.vue create mode 100644 frontend/src/components/settings/MailFilterEmpty.vue create mode 100644 frontend/src/components/settings/MailFilterList.vue create mode 100644 frontend/src/components/settings/MailFilterNotes.vue create mode 100644 frontend/src/components/settings/MailFilterNotice.vue create mode 100644 frontend/src/components/settings/MailFilterRow.vue create mode 100644 frontend/src/components/settings/mail-filter-ui.ts create mode 100644 frontend/src/composables/useMailFilterActions.spec.ts create mode 100644 frontend/src/composables/useMailFilterActions.ts create mode 100644 frontend/src/composables/useMailFilterDialog.ts create mode 100644 frontend/src/pages/settings/MailFilterPage.spec.ts create mode 100644 frontend/src/pages/settings/MailFilterPage.vue create mode 100644 frontend/src/shared/constants/mail-filter.ts create mode 100644 frontend/src/shared/utils/mail-filter.spec.ts create mode 100644 frontend/src/shared/utils/mail-filter.ts create mode 100644 frontend/src/shared/utils/uuid.ts create mode 100644 frontend/src/stores/mail-filter.store.spec.ts create mode 100644 frontend/src/stores/mail-filter.store.ts 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) +}