generated from ttipo/project-base
feat(frontend): 설정 › 메일 › 필터 · 읽기 화면에서 필터 만들기
확정 보드(SettingsFilter · MailFilterStates)대로 필터 목록 · 켜기 · 순서 · 지우기(되돌리기) · 고치기 창을 만들고, 읽기 화면 ⋯ 에 「이런 메일로 필터 만들기」를 둔다. - 저장은 한 번에 하나다. 되돌리기는 누른 차례대로, 앞 저장이 끝난 뒤에 보낸다 — 연달아 누르면 하나가 사라지던 경합을 리뷰에서 찾았다 - 다른 곳에서 먼저 바꿨으면 창 안에서도 다시 불러올 수 있고, 쓴 내용은 지킨다 - 메뉴의 꺼진 항목은 aria-disabled 로 둔다(편집기 메뉴 공용) — 키보드로 가서 까닭을 들을 수 있다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user