feat(frontend): 설정 › 메일 › 필터 · 읽기 화면에서 필터 만들기

확정 보드(SettingsFilter · MailFilterStates)대로 필터 목록 · 켜기 · 순서 · 지우기(되돌리기) ·
고치기 창을 만들고, 읽기 화면 ⋯ 에 「이런 메일로 필터 만들기」를 둔다.

- 저장은 한 번에 하나다. 되돌리기는 누른 차례대로, 앞 저장이 끝난 뒤에 보낸다 —
  연달아 누르면 하나가 사라지던 경합을 리뷰에서 찾았다
- 다른 곳에서 먼저 바꿨으면 창 안에서도 다시 불러올 수 있고, 쓴 내용은 지킨다
- 메뉴의 꺼진 항목은 aria-disabled 로 둔다(편집기 메뉴 공용) — 키보드로 가서 까닭을 들을 수 있다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 20:59:53 +09:00
parent 7ce234750d
commit 894550732e
45 changed files with 5410 additions and 80 deletions
@@ -0,0 +1,784 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { RouterView } from 'vue-router'
import MessageReader from '@/components/mail/MessageReader.vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { Mailbox, MailFilterRule, MailFilterSet, MailFilterSetPayload, MessageDetail } 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 MailFilterPage from './MailFilterPage.vue'
// 설정 › 메일 › 필터 (디자인 원본 SettingsFilter · MailFilterStates · 계약 2절 "메일 필터")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
function mailbox(id: string, name: string, role: Mailbox['role'] = null): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 }
}
const MAILBOXES: Mailbox[] = [
mailbox('inbox', '받은편지함', 'inbox'),
mailbox('sent', '보낸편지함', 'sent'),
mailbox('drafts', '임시보관함', 'drafts'),
mailbox('junk', '스팸메일함', 'junk'),
mailbox('trash', '휴지통', 'trash'),
mailbox('archive', '보관함', 'archive'),
mailbox('vendor', '거래처'),
mailbox('notice', '전체 공지'),
]
const ACTIONS = { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: null }
/** 디자인 원본 SettingsFilter 의 시안과 같은 다섯 규칙 */
const RULES: MailFilterRule[] = [
{
id: 'r1',
name: '휴가 중 전달',
isEnabled: false,
match: 'always',
conditions: [],
actions: { ...ACTIONS, forwardTo: 'doyun.lee@arasystem.it.kr' },
},
{
id: 'r2',
name: '거래처 메일',
isEnabled: true,
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
actions: { ...ACTIONS, moveToMailboxId: 'vendor' },
},
{
id: 'r3',
name: '결재 알림',
isEnabled: true,
match: 'all',
conditions: [{ field: 'subject', operator: 'contains', value: '[결재]' }],
actions: { ...ACTIONS, markAsRead: true, flag: true },
},
{
id: 'r4',
name: '',
isEnabled: false,
match: 'all',
conditions: [{ field: 'to', operator: 'contains', value: 'all@arasystem.it.kr' }],
actions: { ...ACTIONS, moveToMailboxId: 'notice', markAsRead: true },
},
{
id: 'r5',
name: '알파 프로젝트',
isEnabled: true,
match: 'any',
conditions: [
{ field: 'subject', operator: 'contains', value: '알파' },
{ field: 'body', operator: 'contains', value: 'Project Alpha' },
],
actions: { ...ACTIONS, moveToMailboxId: 'gone' },
},
]
const SET: MailFilterSet = { rules: RULES, version: 'v1', isActive: true }
interface Server {
filters?: MailFilterSet | MockResult
save?: (request: MockRequest) => MockResult | Promise<MockResult>
}
/** 서버 흉내 — 받은 목록을 그대로 저장하고 version 을 올린다 */
function echo(request: MockRequest): MockResult {
const body = request.data as MailFilterSetPayload
return ok({ rules: body.rules, version: `${body.version}+`, isActive: true })
}
function deferred() {
let finish: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((resolve) => {
finish = resolve
})
return { promise, finish }
}
describe('MailFilterPage', () => {
let pinia: Pinia
let requests: MockRequest[]
const mounted: VueWrapper[] = []
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().startSession(USER)
})
afterEach(() => {
for (const wrapper of mounted.splice(0)) {
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
}
useToastStore().clear()
restoreApiAdapter()
document.body.innerHTML = ''
})
function installServer(server: Server = {}) {
requests = installMockApi((request) => {
if (request.url === '/mailboxes') {
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
}
if (request.url === '/mail-filters' && request.method === 'GET') {
const filters = server.filters ?? SET
return typeof filters === 'string' || 'status' in filters ? filters : ok(filters)
}
if (request.url === '/mail-filters' && request.method === 'PUT') return (server.save ?? echo)(request)
return ok(null)
})
}
async function mountPage(server: Server = {}, path = '/settings/mail/filters') {
installServer(server)
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(MailFilterPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(wrapper)
await flushPromises()
return { wrapper, router }
}
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data as MailFilterSetPayload)
const rows = (wrapper: VueWrapper) => wrapper.findAll('[data-filter-rule]')
const row = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-filter-rule="${id}"]`)
const dialog = (wrapper: VueWrapper) => wrapper.get('#mail-filter-dialog')
const isOpen = (wrapper: VueWrapper) => dialog(wrapper).attributes('open') !== undefined
const toasts = () => useToastStore().toasts.map((toast) => toast.message)
const conditionValues = (wrapper: VueWrapper) =>
wrapper.findAll('[data-filter-condition-value]').map((input) => (input.element as HTMLInputElement).value)
/** 사람이 누른 것처럼 — 초점을 먼저 준다 (창을 닫으면 초점이 돌아올 곳) */
async function click(target: Pick<DOMWrapper<Element>, 'element' | 'trigger'>) {
;(target.element as HTMLElement).focus()
await target.trigger('click')
await flushPromises()
}
async function openRule(wrapper: VueWrapper, id: string) {
await click(row(wrapper, id).get('[data-filter-edit]'))
}
async function save(wrapper: VueWrapper) {
await click(wrapper.get('#mail-filter-save'))
}
it('목록 — 순서 번호 · 켜기 스위치 · 이름과 요약 한 줄, 꺼 둔 규칙은 흐리게, 지워진 메일함은 빨간 안내', async () => {
const { wrapper } = await mountPage()
expect(wrapper.get('h1').text()).toBe('필터')
expect(requests.map((request) => `${request.method} ${request.url}`).sort()).toEqual([
'GET /mail-filters',
'GET /mailboxes',
])
expect(rows(wrapper).map((item) => item.get('.filter-row__no').text())).toEqual(['1', '2', '3', '4', '5'])
expect(rows(wrapper).map((item) => item.get('.filter-row__title').text())).toEqual([
'휴가 중 전달',
'거래처 메일',
'결재 알림',
'받는 사람에 ‘all@arasystem.it.kr’ 포함',
'알파 프로젝트',
])
expect(rows(wrapper).map((item) => item.get('.filter-row__summary').text())).toEqual([
'모든 메일 → 전달: doyun.lee@arasystem.it.kr',
'보낸 사람에 ‘@hanbit-partners.co.kr’ 포함 → 옮기기: 거래처',
'제목에 ‘[결재]’ 포함 → 읽음으로 표시 · 중요 표시',
'→ 옮기기: 전체 공지 · 읽음으로 표시',
'제목에 ‘알파’ 포함 또는 본문에 ‘Project Alpha’ 포함 → 옮기기: 없는 메일함',
])
const switches = wrapper.findAll('[data-filter-switch]')
expect(switches.map((item) => item.attributes('aria-checked'))).toEqual(['false', 'true', 'true', 'false', 'true'])
expect(switches[1]?.attributes()).toMatchObject({ role: 'switch', 'aria-label': '‘거래처 메일’ 필터 사용' })
// 이름 없는 필터 — 조건 글이 이름이라 작은따옴표가 겹치지 않게 큰따옴표로 묶는다
expect(switches[3]?.attributes('aria-label')).toBe('“받는 사람에 ‘all@arasystem.it.kr’ 포함” 필터 사용')
expect(row(wrapper, 'r4').get('[data-filter-up]').attributes('aria-label')).toBe(
'“받는 사람에 ‘all@arasystem.it.kr’ 포함” 위로',
)
// 켜기마다 목록 전체가 바쁜 것으로 읽히지 않게 — 칸마다 aria-disabled
expect(wrapper.get('ol').attributes('aria-busy')).toBeUndefined()
expect(rows(wrapper).map((item) => item.classes('is-off'))).toEqual([true, false, false, true, false])
expect(wrapper.findAll('[data-filter-missing]').map((item) => item.text())).toEqual([
'옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.',
])
expect(row(wrapper, 'r5').get('[data-filter-edit]').attributes('aria-label')).toBe('‘알파 프로젝트’ 필터 고치기')
// 맨 위의 ▲ · 맨 아래의 ▼ 는 누를 수 없다 (초점은 그대로 받는다)
expect(row(wrapper, 'r1').get('[data-filter-up]').attributes('aria-disabled')).toBe('true')
expect(row(wrapper, 'r1').get('[data-filter-down]').attributes('aria-disabled')).toBeUndefined()
expect(row(wrapper, 'r5').get('[data-filter-down]').attributes('aria-disabled')).toBe('true')
expect(wrapper.get('[data-filter-count]').text()).toBe('필터 5개 · 50개까지 만들 수 있습니다. 행을 누르면 고칩니다.')
expect(wrapper.get('[data-filter-notes]').findAll('p')).toHaveLength(4)
expect(wrapper.find('[data-filter-empty]').exists()).toBe(false)
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
expect(wrapper.find('[data-filter-inactive]').exists()).toBe(false)
})
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도" — 알림은 띄우지 않는다', async () => {
let reply: MailFilterSet | MockResult = fail(503, 'MAIL_001')
const { wrapper } = await mountPage({
get filters() {
return reply
},
})
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
expect(toasts()).toEqual([])
expect(wrapper.get('[data-filter-create]').attributes('aria-disabled')).toBe('true')
reply = SET
await wrapper.get('.mail-load-error__action').trigger('click')
await flushPromises()
expect(rows(wrapper)).toHaveLength(5)
})
it('필터가 없으면 목록 자리에 안내 — 안의 [새 필터]는 머리의 단추와 같은 일을 한다', async () => {
const { wrapper } = await mountPage({ filters: { rules: [], version: 'v0', isActive: true } })
const empty = wrapper.get('[data-filter-empty]')
expect(empty.text()).toContain('아직 필터가 없습니다')
expect(wrapper.find('[data-filter-count]').exists()).toBe(false)
await click(empty.get('[data-filter-create]'))
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('새 필터')
expect(wrapper.find('#mail-filter-remove').exists()).toBe(false)
expect(document.activeElement?.id).toBe('mail-filter-name')
})
it('켜기 스위치 — 화면을 먼저 바꾸고 전체 교체로 저장한다 (알리지 않는다), 실패하면 되돌리고 오류 알림', async () => {
const reply = deferred()
const { wrapper } = await mountPage({ save: () => reply.promise })
const toggle = row(wrapper, 'r2').get('[data-filter-switch]')
await toggle.trigger('click')
expect(toggle.attributes('aria-checked')).toBe('false')
expect(puts()).toHaveLength(1)
expect(puts()[0]?.version).toBe('v1')
expect(puts()[0]?.rules.map((rule) => rule.isEnabled)).toEqual([false, false, true, false, true])
// 저장하는 동안은 다른 켜기 · 순서를 보내지 않는다 (막아도 초점은 그대로 — aria-disabled)
const other = row(wrapper, 'r3').get('[data-filter-switch]')
expect(other.attributes('aria-disabled')).toBe('true')
await other.trigger('click')
await row(wrapper, 'r3').get('[data-filter-up]').trigger('click')
expect(puts()).toHaveLength(1)
reply.finish(fail(503, 'MAIL_001'))
await flushPromises()
expect(toggle.attributes('aria-checked')).toBe('true')
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
})
it('켜기가 저장되면 알림이 없다', async () => {
const { wrapper } = await mountPage()
await row(wrapper, 'r1').get('[data-filter-switch]').trigger('click')
await flushPromises()
expect(row(wrapper, 'r1').get('[data-filter-switch]').attributes('aria-checked')).toBe('true')
expect(row(wrapper, 'r1').classes('is-off')).toBe(false)
expect(toasts()).toEqual([])
})
it('▲ ▼ — 한 칸 옮겨 저장하고, 누른 단추에 초점이 남는다 (맨 위로 오면 그 ▲ 는 꺼진 채로 초점을 둔다)', async () => {
const { wrapper } = await mountPage()
await click(row(wrapper, 'r2').get('[data-filter-up]'))
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r2', 'r1', 'r3', 'r4', 'r5'])
expect(puts()[0]?.rules.map((rule) => rule.id)).toEqual(['r2', 'r1', 'r3', 'r4', 'r5'])
const up = row(wrapper, 'r2').get('[data-filter-up]')
expect(document.activeElement).toBe(up.element)
expect(up.attributes('aria-disabled')).toBe('true')
// 맨 위에서 다시 눌러도 보내지 않는다
await click(up)
expect(puts()).toHaveLength(1)
expect(toasts()).toEqual([])
})
it('행을 누르면 그 규칙으로 채운 고치기 창 — [저장]하면 그 자리에 저장하고 "필터를 저장했습니다.", 초점은 그 행으로', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('필터 고치기')
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('거래처 메일')
expect((wrapper.get('#mail-filter-match').element as HTMLSelectElement).value).toBe('all')
expect(conditionValues(wrapper)).toEqual(['@hanbit-partners.co.kr'])
expect((wrapper.get('#mail-filter-move').element as HTMLInputElement).checked).toBe(true)
expect((wrapper.get('#mail-filter-move-target').element as HTMLSelectElement).value).toBe('vendor')
expect(wrapper.get('#mail-filter-forward-to').attributes('disabled')).toBeDefined()
expect(wrapper.get('#mail-filter-remove').text()).toBe('이 필터 지우기')
expect(document.activeElement?.id).toBe('mail-filter-name')
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
await wrapper.get('#mail-filter-read').setValue(true)
await save(wrapper)
expect(puts()).toEqual([
{
version: 'v1',
rules: [
RULES[0],
{ ...RULES[1], name: '한빛 거래처', actions: { ...RULES[1]?.actions, markAsRead: true } },
RULES[2],
RULES[3],
RULES[4],
],
},
])
expect(isOpen(wrapper)).toBe(false)
expect(toasts()).toEqual(['필터를 저장했습니다.'])
expect(row(wrapper, 'r2').get('.filter-row__title').text()).toBe('한빛 거래처')
expect(document.activeElement).toBe(row(wrapper, 'r2').get('[data-filter-edit]').element)
})
it('새 필터 — 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만, 첫 잘못된 칸에 초점 (알림 없음 · 보내지 않는다)', async () => {
const { wrapper } = await mountPage()
await click(wrapper.get('[data-filter-create]'))
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('새 필터')
await save(wrapper)
expect(wrapper.get('#mail-filter-condition-0-error').text()).toBe(
'비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.',
)
expect(wrapper.get('#mail-filter-actions-error').text()).toBe('할 일을 하나 이상 골라 주세요.')
const value = wrapper.get('[data-filter-condition-value]')
expect(value.attributes()).toMatchObject({ 'aria-invalid': 'true', 'aria-describedby': 'mail-filter-condition-0-error' })
expect(document.activeElement).toBe(value.element)
await value.setValue('가격 ${price}')
expect(wrapper.find('#mail-filter-condition-0-error').exists()).toBe(false)
await wrapper.get('#mail-filter-forward').setValue(true)
expect(wrapper.find('#mail-filter-actions-error').exists()).toBe(false)
await wrapper.get('#mail-filter-forward-to').setValue('me@gmail.com')
await save(wrapper)
expect(wrapper.get('#mail-filter-condition-0-error').text()).toBe(
'‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.',
)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('사내 주소(@arasystem.it.kr)로만 전달할 수 있습니다.')
expect(document.activeElement).toBe(value.element)
await value.setValue('견적서')
await save(wrapper)
expect(document.activeElement?.id).toBe('mail-filter-forward-to')
await wrapper.get('#mail-filter-forward-to').setValue('Ara.Kim@arasystem.it.kr')
await save(wrapper)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('내 주소로는 전달할 수 없습니다.')
expect(puts()).toEqual([])
expect(toasts()).toEqual([])
await wrapper.get('#mail-filter-forward-to').setValue('doyun.lee@arasystem.it.kr')
expect(wrapper.get('#mail-filter-forward-help').text()).toBe(
'사내 주소로만 전달할 수 있습니다. 전달해도 내 메일함에 그대로 남습니다.',
)
await save(wrapper)
const added = puts()[0]?.rules.at(-1)
expect(puts()[0]?.rules).toHaveLength(6)
expect(added).toMatchObject({
name: '',
isEnabled: true,
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '견적서' }],
actions: { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: 'doyun.lee@arasystem.it.kr' },
})
expect(added?.id).toMatch(/^[0-9a-f-]{36}$/)
expect(isOpen(wrapper)).toBe(false)
expect(toasts()).toEqual(['필터를 저장했습니다.'])
})
it('조건 줄 — [조건 더하기]는 5개까지(새 줄은 제목), 하나만 남으면 ✕ 를 끈다 · 모든 메일이면 조건을 보내지 않는다', async () => {
const { wrapper } = await mountPage()
await click(wrapper.get('[data-filter-create]'))
for (let count = 1; count < 5; count += 1) await wrapper.get('#mail-filter-add-condition').trigger('click')
await flushPromises()
const fields = wrapper.findAll('[data-filter-condition-field]')
expect(fields.map((field) => (field.element as HTMLSelectElement).value)).toEqual([
'from',
'subject',
'subject',
'subject',
'subject',
])
expect(document.activeElement).toBe(fields[4]?.element)
expect(wrapper.get('#mail-filter-add-condition').attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-filter-condition-value]').attributes('placeholder')).toBe(
'예: @hanbit-partners.co.kr 또는 이름',
)
for (let count = 0; count < 4; count += 1) await wrapper.get('[data-filter-condition-remove]').trigger('click')
await flushPromises()
expect(wrapper.findAll('[data-filter-condition-value]')).toHaveLength(1)
expect(wrapper.get('[data-filter-condition-remove]').attributes('disabled')).toBeDefined()
await wrapper.get('#mail-filter-match').setValue('always')
expect(wrapper.find('[data-filter-condition-value]').exists()).toBe(false)
await wrapper.get('#mail-filter-flag').setValue(true)
await save(wrapper)
expect(puts()[0]?.rules.at(-1)).toMatchObject({ match: 'always', conditions: [], actions: { flag: true } })
})
it('옮길 메일함 — 받은편지함 · 내 메일함 · 보관함 · 스팸메일함 · 휴지통, 지워진 메일함은 "(지워진 메일함)"이 골라진 채로', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r5')
const select = wrapper.get('#mail-filter-move-target')
const options = select.findAll('option')
expect(options.map((option) => option.text())).toEqual([
'(지워진 메일함) — 다른 메일함을 고르세요',
'받은편지함',
'거래처',
'전체 공지',
'보관함',
'스팸메일함',
'휴지통',
])
expect(options[0]?.attributes('disabled')).toBeDefined()
expect((select.element as HTMLSelectElement).value).toBe('gone')
await select.setValue('junk')
await save(wrapper)
expect(puts()[0]?.rules[4]?.actions.moveToMailboxId).toBe('junk')
expect(wrapper.find('[data-filter-missing]').exists()).toBe(false)
})
it('서버의 거절(MAIL_013 · VAL_001)은 창 안에 사전 문구로 — 알림 없이 창을 그대로 두고, 고치면 내린다', async () => {
const { wrapper } = await mountPage({ save: () => fail(400, 'MAIL_013') })
await openRule(wrapper, 'r3')
await save(wrapper)
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_013)
expect(toasts()).toEqual([])
await wrapper.get('#mail-filter-name').setValue('결재')
expect(wrapper.find('#mail-filter-error').exists()).toBe(false)
})
it('저장하는 동안 [저장] · [이 필터 지우기]는 aria-disabled — 다시 눌러도 보내지 않는다', async () => {
const reply = deferred()
const { wrapper } = await mountPage({ save: () => reply.promise })
await openRule(wrapper, 'r3')
await save(wrapper)
expect(wrapper.get('#mail-filter-save').attributes()).toMatchObject({ 'aria-disabled': 'true', 'aria-busy': 'true' })
expect(wrapper.get('#mail-filter-remove').attributes('aria-disabled')).toBe('true')
await save(wrapper)
await click(wrapper.get('#mail-filter-remove'))
expect(puts()).toHaveLength(1)
reply.finish(echo(requests[requests.length - 1] as MockRequest))
await flushPromises()
expect(isOpen(wrapper)).toBe(false)
})
it('그사이 다른 곳에서 바뀜(409 MAIL_012) — 알림 없이 목록 위 줄 + 창 안 문구, 창은 닫지 않는다 · [다시 불러오기]는 새로 받고 창을 닫는다', async () => {
let filters: MailFilterSet = SET
const { wrapper } = await mountPage({
get filters() {
return filters
},
save: () => fail(409, 'MAIL_012'),
})
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('고친 이름')
await save(wrapper)
expect(isOpen(wrapper)).toBe(true)
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('고친 이름')
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
const bar = wrapper.get('[data-filter-conflict]')
expect(bar.attributes('role')).toBe('alert')
expect(bar.text()).toContain('다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.')
expect(toasts()).toEqual([])
filters = { rules: [RULES[2] as MailFilterRule], version: 'v9', isActive: true }
await click(bar.get('button'))
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
expect(isOpen(wrapper)).toBe(false)
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r3'])
})
interface ConflictServer {
filters: MailFilterSet | MockResult
conflict: boolean
}
/** 고치기 창에서 409 를 받은 상태 — 창 안의 [다시 불러오기]를 누르기 직전 */
async function conflictInDialog(server: ConflictServer) {
const { wrapper } = await mountPage({
get filters() {
return server.filters
},
save: (request) => (server.conflict ? fail(409, 'MAIL_012') : echo(request)),
})
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('고친 이름')
await save(wrapper)
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
return wrapper
}
it('그사이 바뀜 — 창 안의 [다시 불러오기]는 목록만 다시 받고 창 · 쓴 것은 그대로, 초점은 [저장] · 다음 [저장]은 다시 받은 목록 위에', async () => {
const server: ConflictServer = { filters: SET, conflict: true }
const wrapper = await conflictInDialog(server)
const reload = wrapper.get('#mail-filter-reload')
expect(reload.text()).toBe('다시 불러오기')
expect(reload.attributes('aria-describedby')).toBe('mail-filter-error')
// 칸을 고쳐도 그사이 바뀜 안내는 남는다 — 다시 받아야 풀린다
await wrapper.get('#mail-filter-name').setValue('고친 이름 2')
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
// 다른 곳에서 r2 를 끄고 r1 을 지웠다
const others = [{ ...(RULES[1] as MailFilterRule), isEnabled: false, name: '다른 곳' }, ...RULES.slice(2)]
server.filters = { rules: others, version: 'v7', isActive: true }
server.conflict = false
await click(reload)
expect(isOpen(wrapper)).toBe(true)
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('고친 이름 2')
expect(wrapper.find('#mail-filter-error').exists()).toBe(false)
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
expect(document.activeElement?.id).toBe('mail-filter-save')
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r2', 'r3', 'r4', 'r5'])
expect(toasts()).toEqual([])
await save(wrapper)
// 같은 id 자리에서 바꾸고, 켜짐은 다시 받은 목록의 값(꺼짐)이다 — 창은 켜기를 고치지 않는다
expect(puts().at(-1)).toEqual({
version: 'v7',
rules: [{ ...RULES[1], name: '고친 이름 2', isEnabled: false }, ...RULES.slice(2)],
})
expect(isOpen(wrapper)).toBe(false)
expect(toasts()).toEqual(['필터를 저장했습니다.'])
})
it('다시 받은 목록에서 그 규칙이 다른 곳에서 지워졌으면 맨 아래에 넣는다', async () => {
const server: ConflictServer = { filters: SET, conflict: true }
const wrapper = await conflictInDialog(server)
server.filters = { rules: [RULES[0] as MailFilterRule, RULES[2] as MailFilterRule], version: 'v7', isActive: true }
server.conflict = false
await click(wrapper.get('#mail-filter-reload'))
await save(wrapper)
expect(puts().at(-1)?.version).toBe('v7')
expect(puts().at(-1)?.rules.map((rule) => [rule.id, rule.name])).toEqual([
['r1', '휴가 중 전달'],
['r3', '결재 알림'],
['r2', '고친 이름'],
])
})
it('지워진 규칙을 맨 아래에 넣으면 50개를 넘을 때 — 보내지 않고 창 안에 "필터는 50개까지 …" (알림 없음)', async () => {
const server: ConflictServer = { filters: SET, conflict: true }
const wrapper = await conflictInDialog(server)
const fifty = Array.from({ length: 50 }, (_, index) => ({ ...(RULES[2] as MailFilterRule), id: `f${index}` }))
server.filters = { rules: fifty, version: 'v7', isActive: true }
server.conflict = false
await click(wrapper.get('#mail-filter-reload'))
await save(wrapper)
expect(puts()).toHaveLength(1)
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-error').text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.')
expect(wrapper.find('#mail-filter-reload').exists()).toBe(false)
expect(toasts()).toEqual([])
})
it('창 안의 [다시 불러오기]를 실패하면 인터셉터 알림만 — 창과 안내 · 단추는 그대로', async () => {
const server: ConflictServer = { filters: SET, conflict: true }
const wrapper = await conflictInDialog(server)
// 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 앞 테스트의 MAIL_001 과 섞이지 않게 다른 코드로
server.filters = fail(500, 'SYS_001')
await click(wrapper.get('#mail-filter-reload'))
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
expect(wrapper.find('#mail-filter-reload').exists()).toBe(true)
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(true)
})
it('전달 칸 — 비우면 "전달할 주소를 입력해 주세요.", 형식이 틀리면 "전달할 주소 형식이 올바르지 않습니다." (칸 아래 · 알림 없음)', async () => {
const { wrapper } = await mountPage()
await click(wrapper.get('[data-filter-create]'))
await wrapper.get('[data-filter-condition-value]').setValue('견적')
await wrapper.get('#mail-filter-forward').setValue(true)
await save(wrapper)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('전달할 주소를 입력해 주세요.')
expect(wrapper.get('#mail-filter-forward-to').attributes('aria-invalid')).toBe('true')
expect(document.activeElement?.id).toBe('mail-filter-forward-to')
await wrapper.get('#mail-filter-forward-to').setValue('doyun..lee@arasystem.it.kr')
await save(wrapper)
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('전달할 주소 형식이 올바르지 않습니다.')
expect(puts()).toEqual([])
expect(toasts()).toEqual([])
})
it('받는 사람 칸 · 전달 칸의 보기는 로그인한 직원 주소의 도메인으로', async () => {
useAuthStore().startSession({ email: 'mina.choi@ara.test', name: '최미나' })
const { wrapper } = await mountPage()
await click(wrapper.get('[data-filter-create]'))
await wrapper.get('[data-filter-condition-field]').setValue('to')
expect(wrapper.get('[data-filter-condition-value]').attributes('placeholder')).toBe('예: all@ara.test')
expect(wrapper.get('#mail-filter-forward-to').attributes('placeholder')).toBe('이름@ara.test')
})
it('지우기 — 확인 없이 지우고 알림 [되돌리기], 초점은 다음 행 · 되돌리면 지운 자리에 다시 저장한다', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
await click(wrapper.get('#mail-filter-remove'))
expect(puts()[0]?.rules.map((rule) => rule.id)).toEqual(['r1', 'r3', 'r4', 'r5'])
expect(isOpen(wrapper)).toBe(false)
expect(rows(wrapper)).toHaveLength(4)
expect(document.activeElement).toBe(row(wrapper, 'r3').get('[data-filter-edit]').element)
const [toast] = useToastStore().toasts
expect(toast?.message).toBe('‘거래처 메일’ 필터를 지웠습니다.')
expect(toast?.action?.label).toBe('되돌리기')
if (toast?.action && 'run' in toast.action) toast.action.run()
await flushPromises()
expect(puts()[1]).toEqual({ version: 'v1+', rules: RULES })
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r1', 'r2', 'r3', 'r4', 'r5'])
expect(toasts().at(-1)).toBe('필터를 되돌렸습니다.')
})
it('50개를 다 만들었으면 [새 필터]를 끄고 까닭을 옆에 — 눌러도 열지 않는다', async () => {
const fifty = Array.from({ length: 50 }, (_, index) => ({ ...(RULES[2] as MailFilterRule), id: `f${index}` }))
const { wrapper } = await mountPage({ filters: { rules: fifty, version: 'v1', isActive: true } })
const create = wrapper.get('[data-filter-create]')
expect(create.attributes()).toMatchObject({ 'aria-disabled': 'true', 'aria-describedby': 'mail-filter-full-note' })
expect(wrapper.get('#mail-filter-full-note').text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.')
await click(create)
expect(isOpen(wrapper)).toBe(false)
})
it('동작하지 않을 때(isActive: false) — 목록 위 줄 + [다시 켜기]는 지금 목록 그대로 저장한다', async () => {
const { wrapper } = await mountPage({ filters: { ...SET, isActive: false } })
const bar = wrapper.get('[data-filter-inactive]')
expect(bar.text()).toContain('지금은 필터와 부재중 자동 응답이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다.')
await click(bar.get('button'))
expect(puts()).toEqual([{ rules: RULES, version: 'v1' }])
expect(wrapper.find('[data-filter-inactive]').exists()).toBe(false)
// 누른 단추가 사라졌다 — 머리의 [새 필터]로
expect(document.activeElement).toBe(wrapper.get('[data-filter-create]').element)
})
it('읽기 화면에서 — ?from= 이면 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 열리고 초점은 할 일의 첫 칸, 주소에서 지운다', async () => {
const { wrapper, router } = await mountPage({}, '/settings/mail/filters?from=seojun.park%40arasystem.it.kr')
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('새 필터')
expect((wrapper.get('[data-filter-condition-field]').element as HTMLSelectElement).value).toBe('from')
expect((wrapper.get('[data-filter-condition-operator]').element as HTMLSelectElement).value).toBe('contains')
expect(conditionValues(wrapper)).toEqual(['seojun.park@arasystem.it.kr'])
expect(document.activeElement?.id).toBe('mail-filter-move')
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
// 취소하면 — 창을 연 단추가 없다(주소로 왔다) · 머리의 [새 필터]로
await click(wrapper.get('#mail-filter-cancel'))
expect(isOpen(wrapper)).toBe(false)
expect(document.activeElement).toBe(wrapper.get('[data-filter-create]').element)
})
it('?from= 이어도 50개를 다 만들었으면 창을 열지 않는다 (주소에서는 지운다)', async () => {
const fifty = Array.from({ length: 50 }, (_, index) => ({ ...(RULES[2] as MailFilterRule), id: `f${index}` }))
const { wrapper, router } = await mountPage(
{ filters: { rules: fifty, version: 'v1', isActive: true } },
'/settings/mail/filters?from=seojun.park%40arasystem.it.kr',
)
expect(isOpen(wrapper)).toBe(false)
expect(router.currentRoute.value.query).toEqual({})
})
it('읽기 화면 ⋯ 「이런 메일로 필터 만들기」를 누르면 — 설정 › 필터의 새 필터 창이 그 메일의 보낸 사람 주소로 채워져 열린다', async () => {
installServer()
const message: MessageDetail = {
id: 'm1',
threadId: 't1',
mailboxIds: ['inbox'],
from: { name: '박서준', email: 'seojun.park@arasystem.it.kr' },
to: [{ name: '김아라', email: USER.email }],
subject: '견적서 보내 드립니다',
preview: '',
receivedAt: '2026-10-28T01:42:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
cc: [],
bcc: [],
replyTo: [],
sentAt: null,
messageId: null,
references: [],
htmlBody: null,
textBody: '본문',
hasBlockedImages: false,
attachments: [],
}
const mailStore = useMailStore()
mailStore.selected = message
mailStore.selectedId = message.id
mailStore.selectedStatus = 'ready'
// 앱과 같은 이름의 라우트 — 읽기 화면과 필터 화면만 실제 화면으로 (같은 이름을 다시 더하면 바꿔 낀다)
const router = createTestRouter()
router.addRoute({ path: '/mail/:mailboxId/:messageId', name: 'mail-message', component: MessageReader })
router.addRoute({ path: '/settings/mail/filters', name: 'settings-mail-filters', component: MailFilterPage })
await router.push('/mail/inbox/m1')
await router.isReady()
const app = mount({ render: () => h(RouterView) }, { attachTo: document.body, global: { plugins: [pinia, router] } })
mounted.push(app)
await app.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' })
await flushPromises()
const item = app.findAll('[role="menuitem"]').find((menuItem) => menuItem.text() === '이런 메일로 필터 만들기')
await item?.trigger('click')
await flushPromises()
expect(app.get('h1').text()).toBe('필터')
expect(app.get('#mail-filter-dialog').attributes('open')).toBeDefined()
expect(app.get('#mail-filter-dialog-title').text()).toBe('새 필터')
expect(
app.findAll('[data-filter-condition-value]').map((input) => (input.element as HTMLInputElement).value),
).toEqual(['seojun.park@arasystem.it.kr'])
expect((app.get('#mail-filter-move').element as HTMLInputElement).checked).toBe(false)
expect(document.activeElement?.id).toBe('mail-filter-move')
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
})
})
@@ -0,0 +1,330 @@
<script setup lang="ts">
// 설정 › 메일 › 필터 (디자인 원본 SettingsFilter · MailFilterStates · README "6단계 — 메일 필터") — /settings/mail/filters · 탭 제목 "메일 필터"
// 머리 "필터" + 설명 + 오른쪽 [새 필터](50개면 끄고 까닭을 옆에) → 목록 위 노란 줄(다른 곳에서 바뀜 · 동작하지 않음) →
// 목록(MailFilterList — 없으면 MailFilterEmpty) + "필터 N개 · 50개까지 …" → 안내 상자(MailFilterNotes). 행 · [새 필터]는 고치기 창(MailFilterDialog)을 연다.
// - 들어올 때마다 필터를 새로 받는다(다른 탭 · PC 에서 바꿨을 수 있다). 메일함은 메일 화면이 받아 둔 목록이 있으면 그대로 쓰며 다시 맞추고
// (다른 탭에서 지웠을 수 있다) 없으면 받는다 — 옮길 메일함의 이름 · 지워진 메일함 안내에 쓴다. 둘 다 받아야 목록을 그린다
// (받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도")
// - 켜기 · 순서는 화면을 먼저 바꾸고 실패하면 되돌린다(스토어 — 오류 알림은 인터셉터). 창의 저장 · 지우기는 useMailFilterDialog, 그 알림은 useMailFilterActions
// - 409 MAIL_012 는 알리지 않고 목록 위 줄 + [다시 불러오기](목록을 새로 받고 창을 닫는다). 고치던 창은 닫지 않고, 창 안에도
// 같은 사전 문구와 [다시 불러오기]를 둔다(창이 모달이라 뒤의 줄을 누를 수 없다) — 창 안의 것은 창 · 쓴 내용을 그대로 두고 목록만 다시 받는다.
// 받으면 초점을 [저장]으로 옮기고, 다음 [저장]은 다시 받은 목록 위에 넣는다
// - 읽기 화면 ⋯ 「이런 메일로 필터 만들기」는 ?from=<보낸 사람 주소> 로 온다 — 받은 뒤 새 필터 창을 그 주소로 채워 열고(50개면 열지 않는다)
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailFilterCreateButton from '@/components/settings/MailFilterCreateButton.vue'
import MailFilterDialog from '@/components/settings/MailFilterDialog.vue'
import MailFilterEmpty from '@/components/settings/MailFilterEmpty.vue'
import MailFilterList from '@/components/settings/MailFilterList.vue'
import MailFilterNotes from '@/components/settings/MailFilterNotes.vue'
import MailFilterNotice from '@/components/settings/MailFilterNotice.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import type { MailFilterRowPart } from '@/components/settings/mail-filter-ui'
import { getErrorMessage, type ApiError } from '@/composables/useApi'
import { useMailFilterDialog, type MailFilterDialogResult } from '@/composables/useMailFilterDialog'
import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { MailFilterRule } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterStore } from '@/stores/mail-filter.store'
import { useMailStore } from '@/stores/mail.store'
const filterStore = useMailFilterStore()
const mailStore = useMailStore()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const {
state: dialog,
pending: dialogPending,
error: dialogError,
conflict: dialogConflict,
open: openDialog,
close: closeDialog,
clearError: clearDialogError,
save: saveDialog,
remove: removeDialog,
reload: reloadDialog,
} = useMailFilterDialog()
const createRef = ref<InstanceType<typeof MailFilterCreateButton> | null>(null)
const listRef = ref<InstanceType<typeof MailFilterList> | null>(null)
const dialogRef = ref<InstanceType<typeof MailFilterDialog> | null>(null)
/** 이 화면이 떠 있다고 스토어에 알린 것을 내린다 (화면 밖 되돌리기의 실패 알림 — useMailFilterActions) */
let detachPage: (() => void) | null = null
/** 목록 위 줄의 단추를 누르고 기다리는 중 */
const noticeBusy = ref<'reload' | 'reactivate' | null>(null)
/** ?from= 을 이미 받아 창을 열었다 — 주소에서 지울 때까지 다시 열지 않는다 */
let fromHandled = false
const rules = computed<readonly MailFilterRule[]>(() => filterStore.filterSet?.rules ?? [])
const ready = computed(
() => filterStore.status === 'ready' && filterStore.filterSet !== null && mailStore.mailboxesStatus === 'ready',
)
const loadError = computed<ApiError | null>(() => {
if (filterStore.status === 'error' && filterStore.error) return filterStore.error
if (mailStore.mailboxesStatus === 'error' && mailStore.mailboxesError) return mailStore.mailboxesError
return null
})
const full = computed(() => ready.value && filterStore.isFull)
const countText = computed(() => MAIL_FILTER_TEXT.count(formatCount(rules.value.length)))
const conflictText = getErrorMessage('MAIL_012')
/** 메일함 — 받아 둔 목록이 있으면 그대로 보이며 다시 맞춘다(다른 탭에서 지웠을 수 있다), 없으면 받는다 */
function loadMailboxes() {
if (mailStore.mailboxesStatus === 'ready') void mailStore.loadMailboxes({ background: true })
else void mailStore.ensureMailboxes()
}
function retry() {
if (filterStore.status === 'error') void filterStore.load()
if (mailStore.mailboxesStatus === 'error') void mailStore.ensureMailboxes()
}
/** 초점을 잃었다 — 본문으로 갔거나, 닫힌 창 안에 남았다(창을 연 단추가 사라져 돌아갈 곳이 없을 때) */
function focusLost(): boolean {
const active = document.activeElement
if (active === null || active === document.body) return true
const dialogElement = active.closest('dialog')
return dialogElement !== null && !dialogElement.hasAttribute('open')
}
/** 누른 단추가 사라져 초점을 잃었으면 — 그 규칙 행의 단추, 없으면 머리의 [새 필터]로 */
async function restoreFocus(ruleId: string | null, part: MailFilterRowPart = 'edit') {
await nextTick()
if (!focusLost()) return
if (ruleId !== null && listRef.value?.focusRule(ruleId, part)) return
createRef.value?.focus()
}
function openNew() {
if (ready.value && !filterStore.isFull) openDialog(null)
}
function cancelDialog() {
const ruleId = dialog.value?.rule?.id ?? null
closeDialog()
void restoreFocus(ruleId)
}
/** 창의 저장 · 지우기가 끝나 창을 닫았으면 — 초점이 사라진 단추에 남지 않게 */
async function settleDialog(result: Promise<MailFilterDialogResult>) {
const { closed, focusRuleId } = await result
if (closed) void restoreFocus(focusRuleId)
}
function toggleRule(rule: MailFilterRule) {
void filterStore.setRuleEnabled(rule.id, !rule.isEnabled)
}
/** 순서 바꾸기 — 행을 옮기면(실패해서 되돌릴 때도) 누른 ▲ · ▼ 가 초점을 잃을 수 있어 같은 단추로 돌려준다 */
async function moveRule(rule: MailFilterRule, offset: -1 | 1) {
const part: MailFilterRowPart = offset < 0 ? 'up' : 'down'
const moving = filterStore.moveRule(rule.id, offset)
void restoreFocus(rule.id, part)
if (!(await moving)) void restoreFocus(rule.id, part)
}
/** 창 안의 [다시 불러오기] — 창 · 쓴 내용은 그대로 두고 목록을 다시 받는다. 받았으면 [저장]으로 */
async function reloadInDialog() {
if (await reloadDialog()) {
await nextTick()
dialogRef.value?.focusSave()
}
}
/** 목록 위 줄의 [다시 불러오기] — 목록을 새로 받고 고치기 창을 닫는다 */
async function reload() {
if (noticeBusy.value) return
noticeBusy.value = 'reload'
const done = await filterStore.reload()
noticeBusy.value = null
if (!done) return
closeDialog()
void restoreFocus(null)
}
/** [다시 켜기] — 지금 목록 그대로 저장한다 */
async function reactivate() {
if (noticeBusy.value || filterStore.saving) return
noticeBusy.value = 'reactivate'
await filterStore.reactivate()
noticeBusy.value = null
void restoreFocus(null)
}
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게
void filterStore.load()
loadMailboxes()
onMounted(() => {
detachPage = filterStore.attachPage()
})
onBeforeUnmount(() => {
detachPage?.()
})
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(?from=<주소>) — 받은 뒤 한 번 새 필터 창을 열고(50개면 열지 않는다) 주소에서 지운다
watch(
[ready, () => route.query[MAIL_FILTER_FROM_QUERY]],
([isReady, from]) => {
if (from === undefined) {
fromHandled = false
return
}
if (!isReady || fromHandled) return
fromHandled = true
const address = typeof from === 'string' ? from.trim() : ''
if (address && !filterStore.isFull) openDialog(null, address)
const query = Object.fromEntries(Object.entries(route.query).filter(([key]) => key !== MAIL_FILTER_FROM_QUERY))
void router.replace({ query })
},
{ immediate: true },
)
</script>
<template>
<SettingsPageFrame
title-id="filter-page-title"
:title="MAIL_FILTER_TEXT.title"
:description="MAIL_FILTER_TEXT.description"
>
<template #actions>
<div class="filter-page__create">
<MailFilterCreateButton
ref="createRef"
:disabled="!ready || full"
:described-by="full ? 'mail-filter-full-note' : undefined"
@create="openNew"
/>
<p
v-if="full"
id="mail-filter-full-note"
class="filter-page__full-note"
>
{{ MAIL_FILTER_TEXT.fullNote }}
</p>
</div>
</template>
<MailLoadError
v-if="!ready && loadError"
:error="loadError"
leave-label=""
@retry="retry"
/>
<template v-else-if="ready">
<MailFilterNotice
v-if="filterStore.conflict"
icon="rotate-cw"
:message="conflictText"
:action-label="MAIL_FILTER_TEXT.reload"
:busy="noticeBusy === 'reload'"
live="alert"
data-filter-conflict
@action="reload"
/>
<MailFilterNotice
v-if="filterStore.filterSet?.isActive === false"
icon="alert-circle"
:message="MAIL_FILTER_TEXT.inactive"
:action-label="MAIL_FILTER_TEXT.reactivate"
:busy="noticeBusy === 'reactivate' || filterStore.saving"
data-filter-inactive
@action="reactivate"
/>
<div
v-if="rules.length > 0"
class="filter-page__list"
>
<MailFilterList
ref="listRef"
:rules="rules"
:mailboxes="mailStore.mailboxes"
:busy="filterStore.saving"
@toggle="toggleRule"
@move="moveRule"
@edit="(rule) => openDialog(rule)"
/>
<p
class="filter-page__count"
data-filter-count
>
{{ countText }}
</p>
</div>
<MailFilterEmpty
v-else
@create="openNew"
/>
<MailFilterNotes />
<MailFilterDialog
ref="dialogRef"
:open="dialog !== null"
:rule="dialog?.rule ?? null"
:prefill-from="dialog?.prefillFrom ?? null"
:mailboxes="mailStore.mailboxes"
:user-email="authStore.user?.email ?? ''"
:pending="dialogPending"
:busy="filterStore.saving"
:error="dialogError"
:conflict="dialogConflict"
@save="(rule) => settleDialog(saveDialog(rule))"
@remove="settleDialog(removeDialog())"
@cancel="cancelDialog"
@clear-error="clearDialogError"
@reload="reloadInDialog"
/>
</template>
<div
v-else
class="filter-page__placeholder"
aria-busy="true"
/>
</SettingsPageFrame>
</template>
<style scoped>
/* [새 필터] + 50개를 다 만들었을 때의 까닭 (디자인 원본 MailFilterStates) */
.filter-page__create {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-10) var(--space-14);
}
.filter-page__full-note {
max-width: 17.5rem;
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.filter-page__list {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.filter-page__count {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
/* 받는 동안의 자리 — 목록 몇 줄만큼 */
.filter-page__placeholder {
min-height: 15rem;
border-radius: var(--radius-lg);
background: var(--color-border-subtle);
}
</style>
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth.store'
import { createTestRouter } from '@/test-utils/router'
import SettingsLayout from './SettingsLayout.vue'
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계")
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계")
describe('SettingsLayout', () => {
let pinia: Pinia
@@ -34,7 +34,7 @@ describe('SettingsLayout', () => {
return { wrapper, router }
}
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 알림), 묶음 이름이 목록의 이름이다', async () => {
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 알림), 묶음 이름이 목록의 이름이다', async () => {
const { wrapper } = await mountAt('/settings/account/password')
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
@@ -46,6 +46,7 @@ describe('SettingsLayout', () => {
[
['서명', '/settings/mail/signature'],
['부재중 자동 응답', '/settings/mail/vacation'],
['필터', '/settings/mail/filters'],
['알림', '/settings/mail/notify'],
],
])
@@ -69,4 +70,23 @@ describe('SettingsLayout', () => {
expect(router.resolve({ name: 'settings-mail-vacation' }).fullPath).toBe('/settings/mail/vacation')
expect(router.resolve({ name: 'settings-mail-notify' }).fullPath).toBe('/settings/mail/notify')
})
it('설정 › 메일 › 필터 — /settings/mail/filters, 탭 제목 "메일 필터" (앱의 라우트 표 그대로)', async () => {
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/settings/mail/filters?from=seojun.park%40arasystem.it.kr')
expect(router.currentRoute.value.name).toBe('settings-mail-filters')
expect(router.currentRoute.value.meta.title).toBe('메일 필터')
expect(router.currentRoute.value.meta.requiresAuth).toBe(true)
expect(router.currentRoute.value.query).toEqual({ from: 'seojun.park@arasystem.it.kr' })
expect(router.resolve({ name: 'settings-mail-filters' }).fullPath).toBe('/settings/mail/filters')
})
it('필터 메뉴는 깔때기 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
const { wrapper } = await mountAt('/settings/mail/filters')
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
expect(current.text()).toBe('필터')
expect(current.find('polygon').attributes('points')).toBe('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')
})
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계", 목록은 settings-menu.ts).
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계", 목록은 settings-menu.ts).
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
import AppIcon from '@/components/AppIcon.vue'
import AppShell from '@/components/shell/AppShell.vue'