feat(frontend): 필터 고치기 창 — 이미 받은 메일에도 적용 · 나가기 확인, 부재중 동작 안 함 줄

- 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」(창을 열 때마다 꺼짐): 저장한 뒤 cursor 가 끝날 때까지
  보내며 진행 알림을 고치고, 끝나면 그 자리에서 결과(적용한 수 · [{메일함} 보기], 멈추면 [다시 시도]로 이어서)
- 이미 받은 메일에 할 일이 없는 필터(지워진 메일함 · 받은편지함으로만 · 전달만)와 이 탭에서 적용 중에는 칸을 끈다.
  실패 알림은 응답이 온 때 세션이 그대로일 때만, 나아가지 못한 응답이 세 번이면 멈춘다
- 고치기 창의 나가기 확인([취소] · Esc · 다른 화면 · 탭 닫기 — 6단계의 한계를 고친다). 브라우저가 스스로 닫은 창은
  ModalDialog 의 reshow() 로 다시 띄우고 초점을 닫히기 전 자리로 돌려준다
- 부재중 화면: 켜 둔 자동 응답이 엔진에서 꺼져 있으면(isActive: false) 머리 아래 노란 줄
- 진행 알림의 결과에도 단추를 붙인다(useToast)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 08:38:01 +09:00
parent 6fd0911b86
commit aef67aa310
33 changed files with 2479 additions and 84 deletions
@@ -6,7 +6,15 @@ 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 type {
Mailbox,
MailFilterRule,
MailFilterRun,
MailFilterRunRequest,
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'
@@ -19,10 +27,10 @@ import {
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import MailFilterPage from './MailFilterPage.vue'
// 설정 › 메일 › 필터 (디자인 원본 SettingsFilter · MailFilterStates · 계약 2절 "메일 필터")
// 설정 › 메일 › 필터 (디자인 원본 SettingsFilter · MailFilterStates · MailFilterApply · MailFilterApplyStates · 계약 2절 "메일 필터" · "이미 받은 메일에 적용")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
@@ -95,6 +103,8 @@ const SET: MailFilterSet = { rules: RULES, version: 'v1', isActive: true }
interface Server {
filters?: MailFilterSet | MockResult
save?: (request: MockRequest) => MockResult | Promise<MockResult>
/** POST /mail-filter-runs — 이미 받은 메일에 적용 (7단계) */
run?: (request: MockRequest) => MockResult | Promise<MockResult>
}
/** 서버 흉내 — 받은 목록을 그대로 저장하고 version 을 올린다 */
@@ -103,6 +113,12 @@ function echo(request: MockRequest): MockResult {
return ok({ rules: body.rules, version: `${body.version}+`, isActive: true })
}
/** 적용 한 차례의 응답 */
function step(scanned: number, matched: number, remaining: number, cursor: string | null): MockResult {
const run: MailFilterRun = { scanned, matched, remaining, cursor }
return ok(run)
}
function deferred() {
let finish: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((resolve) => {
@@ -141,6 +157,9 @@ describe('MailFilterPage', () => {
return typeof filters === 'string' || 'status' in filters ? filters : ok(filters)
}
if (request.url === '/mail-filters' && request.method === 'PUT') return (server.save ?? echo)(request)
if (request.url === '/mail-filter-runs' && request.method === 'POST') {
return server.run ? server.run(request) : step(0, 0, 0, null)
}
return ok(null)
})
}
@@ -150,7 +169,10 @@ describe('MailFilterPage', () => {
const router = createTestRouter()
await router.push(path)
await router.isReady()
const wrapper = mount(MailFilterPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
const wrapper = mount(MailFilterPage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
})
mounted.push(wrapper)
await flushPromises()
return { wrapper, router }
@@ -223,7 +245,17 @@ describe('MailFilterPage', () => {
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.get('[data-filter-notes]').findAll('p').map((note) => note.text())).toEqual([
'·위에서부터 차례로 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.',
'·이미 받은 메일에도 적용하려면 필터를 저장할 때 「받은편지함에 이미 있는 메일에도 적용」을 고릅니다. 받은편지함의 메일만 보고, 그 필터 하나만 적용합니다.',
'·스팸으로 판정되어 들어오는 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.',
'·전달은 사내 주소로만, 새로 오는 메일만 합니다. 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.',
'·부재중 자동 응답은 필터와 함께 동작합니다.',
])
expect(wrapper.get('[data-filter-note-apply] strong').text()).toBe('「받은편지함에 이미 있는 메일에도 적용」')
expect(wrapper.get('.settings-page__description').text()).toBe(
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에 적용하고, 고를 때는 받은편지함에 이미 있는 메일에도 적용합니다.',
)
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)
@@ -781,4 +813,475 @@ describe('MailFilterPage', () => {
expect(document.activeElement?.id).toBe('mail-filter-move')
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
})
describe('받은편지함에 이미 있는 메일에도 적용 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates)', () => {
const applyBox = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply')
const applyInput = (wrapper: VueWrapper) => applyBox(wrapper).element as HTMLInputElement
const applyHelp = (wrapper: VueWrapper) => wrapper.get('#mail-filter-apply-help').text()
const runs = () =>
requests.filter((request) => request.url === '/mail-filter-runs').map((request) => request.data as MailFilterRunRequest)
const HELP = '저장한 뒤 받은편지함에서 조건에 맞는 메일을 찾아 바로 적용합니다.'
it('할 일 아래 — 창을 열 때마다 꺼져 있고 설명은 칸의 aria-describedby, 전달도 하면 설명에 더한다 · 상자 아래 스팸 한 줄', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
expect(applyInput(wrapper).checked).toBe(false)
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
expect(applyBox(wrapper).attributes('aria-describedby')).toBe('mail-filter-apply-help')
expect(wrapper.get('[data-filter-apply] label').text()).toBe('받은편지함에 이미 있는 메일에도 적용')
expect(applyHelp(wrapper)).toBe(HELP)
expect(wrapper.get('[data-filter-apply-spam]').text()).toBe('스팸으로 판정되어 들어오는 메일에는 적용하지 않습니다.')
// 6단계의 안내 띠는 없다
expect(wrapper.text()).not.toContain('새로 오는 메일에만 적용됩니다')
await wrapper.get('#mail-filter-forward').setValue(true)
expect(applyHelp(wrapper)).toBe(`${HELP} 전달은 새로 오는 메일에만 합니다.`)
// 고르고 저장하지 않고 닫았다가 다시 열면 꺼져 있다 (필터의 설정이 아니다)
await applyBox(wrapper).setValue(true)
await click(wrapper.get('#mail-filter-cancel'))
await click(wrapper.get('#mail-filter-leave-confirm'))
await openRule(wrapper, 'r2')
expect(applyInput(wrapper).checked).toBe(false)
})
it('전달만 하는 필터는 칸을 끄고 이름을 흐리게, 설명이 까닭 — 읽음을 고르면 다시 고를 수 있다', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r1')
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
expect(wrapper.get('[data-filter-apply] label').classes()).toContain('is-disabled')
expect(applyHelp(wrapper)).toBe('전달만 하는 필터는 이미 받은 메일에 할 일이 없습니다 — 전달은 새로 오는 메일에만 합니다.')
await wrapper.get('#mail-filter-read').setValue(true)
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
expect(applyHelp(wrapper)).toBe(`${HELP} 전달은 새로 오는 메일에만 합니다.`)
})
it('옮길 메일함이 지워진 필터 · 받은편지함으로 옮기기만 하는 필터도 칸을 끈다 — 읽음 · 중요를 고르면 다시 고를 수 있다', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r5')
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
expect(applyHelp(wrapper)).toBe('옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다 — 다른 메일함을 고르세요.')
await click(wrapper.get('#mail-filter-cancel'))
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-move-target').setValue('inbox')
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
expect(applyHelp(wrapper)).toBe('받은편지함으로 옮기기만 하는 필터는 이미 받은 메일에 할 일이 없습니다.')
await wrapper.get('#mail-filter-flag').setValue(true)
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
expect(applyHelp(wrapper)).toBe(HELP)
})
it('고른 채 전달만 하는 필터로 바꾸면 꺼진 채로 보이고 — 되돌리면 골라 둔 값이 다시 켜진다', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r3')
await applyBox(wrapper).setValue(true)
await wrapper.get('#mail-filter-forward').setValue(true)
expect(applyInput(wrapper).checked).toBe(true)
await wrapper.get('#mail-filter-read').setValue(false)
await wrapper.get('#mail-filter-flag').setValue(false)
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
expect(applyInput(wrapper).checked).toBe(false)
await wrapper.get('#mail-filter-read').setValue(true)
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
expect(applyInput(wrapper).checked).toBe(true)
})
it('저장한 뒤 — 응답 목록의 그 규칙으로(전달 없이) cursor 가 null 이 될 때까지 보내고, "필터를 저장했습니다." 대신 진행 → 끝 알림 [거래처 보기]', async () => {
const second = deferred()
const { wrapper } = await mountPage({
// 서버가 저장한 값(앞뒤 공백을 지운 값 등)이 보낸 값과 달라도 적용은 저장한 그 값으로 한다
save: (request) => {
const body = request.data as MailFilterSetPayload
const rules = body.rules.map((item) =>
item.id === 'r2'
? { ...item, conditions: [{ field: 'from' as const, operator: 'contains' as const, value: '@hanbit.co.kr' }] }
: item,
)
return ok({ rules, version: 'v2', isActive: true })
},
run: (request) =>
(request.data as MailFilterRunRequest).cursor === null ? step(4500, 30, 3912, 'c1') : second.promise,
})
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-forward').setValue(true)
await wrapper.get('#mail-filter-forward-to').setValue('doyun.lee@arasystem.it.kr')
await applyBox(wrapper).setValue(true)
await save(wrapper)
expect(isOpen(wrapper)).toBe(false)
expect(puts()).toHaveLength(1)
const rule: Omit<MailFilterRunRequest, 'cursor'> = {
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit.co.kr' }],
actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false },
}
expect(runs()).toEqual([
{ ...rule, cursor: null },
{ ...rule, cursor: 'c1' },
])
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,500 / 8,412통 확인'],
])
second.finish(step(3912, 7, 0, null))
await flushPromises()
const [done] = useToastStore().toasts
expect(useToastStore().toasts).toHaveLength(1)
expect(done).toMatchObject({
kind: 'success',
message: '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.',
action: { label: '거래처 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'vendor' } } },
})
expect(toasts()).not.toContain('필터를 저장했습니다.')
})
it('적용하는 동안 다른 필터의 창에서는 칸을 끄고 까닭 — 끝나면 다시 고를 수 있다 · 그동안 화면을 떠나도 이어 한다', async () => {
const first = deferred()
const { wrapper, router } = await mountPage({ run: () => first.promise })
await openRule(wrapper, 'r3')
await applyBox(wrapper).setValue(true)
await save(wrapper)
expect(runs()).toHaveLength(1)
await openRule(wrapper, 'r2')
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
expect(applyHelp(wrapper)).toBe('다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤에 고를 수 있습니다.')
await click(wrapper.get('#mail-filter-cancel'))
// 다른 화면으로 가도 이어 한다
await router.push('/mail/inbox')
wrapper.unmount()
first.finish(step(8412, 12, 0, null))
await flushPromises()
expect(toasts()).toEqual(['‘결재 알림’ 필터를 받은편지함의 메일 12통에 적용했습니다.'])
expect(useToastStore().toasts[0]?.action).toBeNull()
const { wrapper: again } = await mountPage()
await openRule(again, 'r2')
expect(applyBox(again).attributes('disabled')).toBeUndefined()
})
it('「적용」을 고르고 저장하는 중에 창을 닫거나 화면을 떠나도 — 저장이 끝나면 적용한다', async () => {
const reply = deferred()
const { wrapper } = await mountPage({ save: () => reply.promise, run: () => step(8412, 37, 0, null) })
await openRule(wrapper, 'r2')
await applyBox(wrapper).setValue(true)
await save(wrapper)
await click(wrapper.get('#mail-filter-cancel'))
expect(isOpen(wrapper)).toBe(false)
expect(runs()).toEqual([])
reply.finish(echo(requests.find((request) => request.method === 'PUT') as MockRequest))
await flushPromises()
expect(runs().map((request) => request.cursor)).toEqual([null])
expect(toasts()).toEqual(['‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.'])
useToastStore().clear()
const leaving = deferred()
const { wrapper: other, router } = await mountPage({ save: () => leaving.promise, run: () => step(8412, 37, 0, null) })
await openRule(other, 'r2')
await applyBox(other).setValue(true)
await save(other)
// 저장하는 중이라 묻지 않고 떠난다
await router.push('/mail/inbox')
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
other.unmount()
leaving.finish(echo(requests.find((request) => request.method === 'PUT') as MockRequest))
await flushPromises()
expect(runs().map((request) => request.cursor)).toEqual([null])
expect(toasts()).toEqual(['‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.'])
})
it('저장하는 중에 세션이 바뀌면 적용 요청을 보내지 않는다 — 알림도 없다', async () => {
const reply = deferred()
const { wrapper } = await mountPage({ save: () => reply.promise })
await openRule(wrapper, 'r2')
await applyBox(wrapper).setValue(true)
await save(wrapper)
useAuthStore().clearAuth()
await flushPromises()
reply.finish(echo(requests.find((request) => request.method === 'PUT') as MockRequest))
await flushPromises()
expect(runs()).toEqual([])
expect(toasts()).toEqual([])
})
it('적용이 끝나면 메일함 개수 · 사용량을 다시 받는다 — 실시간 연결이 없는 탭에서도 옛 개수가 남지 않게', async () => {
const { wrapper } = await mountPage({ run: () => step(8412, 37, 0, null) })
await openRule(wrapper, 'r2')
await applyBox(wrapper).setValue(true)
await save(wrapper)
const afterRun = requests.slice(requests.findIndex((request) => request.url === '/mail-filter-runs') + 1)
expect(afterRun.map((request) => `${request.method} ${request.url}`).sort()).toEqual(['GET /mail-quota', 'GET /mailboxes'])
})
it('고르지 않았거나 저장하지 못하면 적용하지 않는다', async () => {
let reject = true
const { wrapper } = await mountPage({ save: (request) => (reject ? fail(400, 'MAIL_013') : echo(request)) })
await openRule(wrapper, 'r2')
await applyBox(wrapper).setValue(true)
await save(wrapper)
expect(runs()).toEqual([])
expect(isOpen(wrapper)).toBe(true)
reject = false
await applyBox(wrapper).setValue(false)
await save(wrapper)
expect(runs()).toEqual([])
expect(toasts()).toEqual(['필터를 저장했습니다.'])
})
})
describe('고치기 창의 나가기 확인 (7단계 — 6단계 한계를 고친다)', () => {
const leaveOpen = (wrapper: VueWrapper) => wrapper.get('#mail-filter-leave-dialog').attributes('open') !== undefined
/** 창 닫기 · 새로고침 때 브라우저 확인창을 띄우려 하는가 */
function blocksUnload(): boolean {
const event = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(event)
return event.defaultPrevented
}
it('바꾼 것이 없으면 [취소] · Esc 는 묻지 않고 닫는다 (바꿨다 되돌린 것도)', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
await click(wrapper.get('#mail-filter-cancel'))
expect(isOpen(wrapper)).toBe(false)
expect(leaveOpen(wrapper)).toBe(false)
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('다른 이름')
await wrapper.get('#mail-filter-name').setValue('거래처 메일')
expect(blocksUnload()).toBe(false)
dialog(wrapper).element.dispatchEvent(new Event('cancel', { cancelable: true }))
await flushPromises()
expect(isOpen(wrapper)).toBe(false)
expect(leaveOpen(wrapper)).toBe(false)
expect(document.activeElement).toBe(row(wrapper, 'r2').get('[data-filter-edit]').element)
})
it('바꾸고 [취소]하면 고치기 창 위에 확인 창 — [계속 고치기](처음 초점)면 창 · 쓴 것 · 초점이 그대로, [닫기]면 닫는다', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
expect(blocksUnload()).toBe(true)
await click(wrapper.get('#mail-filter-cancel'))
expect(leaveOpen(wrapper)).toBe(true)
expect(isOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-leave-dialog').attributes('role')).toBe('alertdialog')
expect(wrapper.get('#mail-filter-leave-title').text()).toBe('저장하지 않은 필터가 있습니다')
expect(wrapper.get('#mail-filter-leave-message').text()).toBe('창을 닫으면 고친 내용이 사라집니다.')
expect(wrapper.get('#mail-filter-leave-stay').text()).toBe('계속 고치기')
expect(wrapper.get('#mail-filter-leave-confirm').text()).toBe('닫기')
expect(document.activeElement?.id).toBe('mail-filter-leave-stay')
await click(wrapper.get('#mail-filter-leave-stay'))
expect(leaveOpen(wrapper)).toBe(false)
expect(isOpen(wrapper)).toBe(true)
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('한빛 거래처')
expect(document.activeElement?.id).toBe('mail-filter-cancel')
await click(wrapper.get('#mail-filter-cancel'))
await click(wrapper.get('#mail-filter-leave-confirm'))
expect(leaveOpen(wrapper)).toBe(false)
expect(isOpen(wrapper)).toBe(false)
expect(puts()).toEqual([])
expect(blocksUnload()).toBe(false)
expect(document.activeElement).toBe(row(wrapper, 'r2').get('[data-filter-edit]').element)
})
it('Esc 도 같다 — 확인 창의 Esc 는 [계속 고치기] · 「…에도 적용」만 골라도 바꾼 것이다', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-apply').setValue(true)
const escape = new Event('cancel', { cancelable: true })
dialog(wrapper).element.dispatchEvent(escape)
await flushPromises()
expect(escape.defaultPrevented).toBe(true)
expect(leaveOpen(wrapper)).toBe(true)
wrapper.get('#mail-filter-leave-dialog').element.dispatchEvent(new Event('cancel', { cancelable: true }))
await flushPromises()
expect(leaveOpen(wrapper)).toBe(false)
expect(isOpen(wrapper)).toBe(true)
expect((wrapper.get('#mail-filter-apply').element as HTMLInputElement).checked).toBe(true)
})
it('읽기 화면에서 채워 연 새 필터는 채운 그대로면 묻지 않는다 — 고치면 묻는다', async () => {
const { wrapper } = await mountPage({}, '/settings/mail/filters?from=seojun.park%40arasystem.it.kr')
await wrapper.get('#mail-filter-read').setValue(true)
await click(wrapper.get('#mail-filter-cancel'))
expect(leaveOpen(wrapper)).toBe(true)
await click(wrapper.get('#mail-filter-leave-stay'))
await wrapper.get('#mail-filter-read').setValue(false)
await click(wrapper.get('#mail-filter-cancel'))
expect(leaveOpen(wrapper)).toBe(false)
expect(isOpen(wrapper)).toBe(false)
})
it('창을 연 채 다른 화면으로 가면 "이 화면을 나가면 …" [나가기] — [계속 고치기]면 머물고, 바꾼 것이 없으면 묻지 않는다', async () => {
const { wrapper, router } = await mountPage()
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
const stay = router.push('/mail/inbox')
await flushPromises()
expect(leaveOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-leave-message').text()).toBe('이 화면을 나가면 고친 내용이 사라집니다.')
expect(wrapper.get('#mail-filter-leave-confirm').text()).toBe('나가기')
await click(wrapper.get('#mail-filter-leave-stay'))
await stay
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
expect(isOpen(wrapper)).toBe(true)
const leave = router.push('/mail/inbox')
await flushPromises()
await click(wrapper.get('#mail-filter-leave-confirm'))
await leave
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
const { wrapper: clean, router: other } = await mountPage()
await openRule(clean, 'r2')
await other.push('/mail/inbox')
await flushPromises()
expect(leaveOpen(clean)).toBe(false)
expect(other.currentRoute.value.fullPath).toBe('/mail/inbox')
})
it('이동 확인 창의 Esc 는 [계속 고치기] — 그 이동을 취소하고 머문다', async () => {
const { wrapper, router } = await mountPage()
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
const moving = router.push('/mail/inbox')
await flushPromises()
expect(wrapper.get('#mail-filter-leave-confirm').text()).toBe('나가기')
const escape = new Event('cancel', { cancelable: true })
wrapper.get('#mail-filter-leave-dialog').element.dispatchEvent(escape)
await moving
await flushPromises()
expect(escape.defaultPrevented).toBe(true)
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
expect(leaveOpen(wrapper)).toBe(false)
expect(isOpen(wrapper)).toBe(true)
})
it('닫기 확인이 떠 있는 동안 다른 화면으로 가려 하면 같은 창이 이동 확인으로 바뀐다', async () => {
const { wrapper, router } = await mountPage()
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
await click(wrapper.get('#mail-filter-cancel'))
expect(wrapper.get('#mail-filter-leave-confirm').text()).toBe('닫기')
const back = router.push('/mail/inbox')
await flushPromises()
expect(leaveOpen(wrapper)).toBe(true)
expect(wrapper.get('#mail-filter-leave-confirm').text()).toBe('나가기')
await click(wrapper.get('#mail-filter-leave-stay'))
await back
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
expect(leaveOpen(wrapper)).toBe(false)
expect(isOpen(wrapper)).toBe(true)
})
it('저장하는 중에는 묻지 않는다 — [취소]로 바로 닫고 결과는 알림으로', async () => {
const reply = deferred()
const { wrapper } = await mountPage({ save: () => reply.promise })
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
await save(wrapper)
await click(wrapper.get('#mail-filter-cancel'))
expect(leaveOpen(wrapper)).toBe(false)
expect(isOpen(wrapper)).toBe(false)
reply.finish(echo(requests[requests.length - 1] as MockRequest))
await flushPromises()
expect(toasts()).toEqual(['필터를 저장했습니다.'])
})
it('저장하는 중에도 탭 닫기 · 새로고침은 묻는다(beforeunload) — 저장이 끝나 창이 닫히면 그만 묻는다', async () => {
const reply = deferred()
const { wrapper } = await mountPage({ save: () => reply.promise })
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
await save(wrapper)
expect(blocksUnload()).toBe(true)
reply.finish(echo(requests[requests.length - 1] as MockRequest))
await flushPromises()
expect(isOpen(wrapper)).toBe(false)
expect(blocksUnload()).toBe(false)
})
/** 브라우저가 창을 스스로 닫는 것을 흉내 낸다 (Esc 를 거듭 누름) — 초점은 close 이벤트 전에 창 밖으로 나간다 */
async function browserCloses(wrapper: VueWrapper) {
const element = dialog(wrapper).element
element.removeAttribute('open')
;(document.activeElement as HTMLElement | null)?.blur()
element.dispatchEvent(new Event('close'))
await flushPromises()
}
it('브라우저가 창을 스스로 닫았는데(Esc 를 거듭 누름) 바꾼 것이 있으면 묻고 — [계속 고치기]면 창을 다시 띄우고 초점을 제자리로', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
;(wrapper.get('#mail-filter-read').element as HTMLElement).focus()
await browserCloses(wrapper)
expect(leaveOpen(wrapper)).toBe(true)
await click(wrapper.get('#mail-filter-leave-stay'))
expect(isOpen(wrapper)).toBe(true)
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('한빛 거래처')
expect(document.activeElement?.id).toBe('mail-filter-read')
})
it('스스로 닫힌 창에서 [닫기]를 고르면 닫는다 — 고친 것은 버리고, 다른 필터의 창을 다시 열 수 있다', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
await browserCloses(wrapper)
await click(wrapper.get('#mail-filter-leave-confirm'))
expect(leaveOpen(wrapper)).toBe(false)
expect(isOpen(wrapper)).toBe(false)
expect(puts()).toEqual([])
await openRule(wrapper, 'r3')
expect(isOpen(wrapper)).toBe(true)
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('결재 알림')
})
it('묻는 중에 세션이 끝나면 확인 창을 닫는다 (세션 만료 대화상자와 겹치지 않는다)', async () => {
const { wrapper } = await mountPage()
await openRule(wrapper, 'r2')
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
await click(wrapper.get('#mail-filter-cancel'))
expect(leaveOpen(wrapper)).toBe(true)
useAuthStore().expireSession()
await flushPromises()
// 이전 사용자의 필터 화면은 내려간다 — 확인 창이 남지 않는다
expect(document.querySelector('#mail-filter-leave-dialog[open]')).toBeNull()
expect(blocksUnload()).toBe(false)
})
})
})
@@ -12,6 +12,8 @@
// - 읽기 화면 ⋯ 「이런 메일로 필터 만들기」는 ?from=<보낸 사람 주소> 로 온다 — 받은 뒤 새 필터 창을 그 주소로 채워 열고(50개면 열지 않는다)
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
// - 창의 「받은편지함에 이미 있는 메일에도 적용」(7단계)을 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
// 이 탭에서 적용하는 중이면 창의 그 칸을 끈다(mail-filter-run.store 의 running — 화면을 떠나도 적용은 이어진다)
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -30,10 +32,12 @@ import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mai
import type { MailFilterRule } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
import { useMailFilterStore } from '@/stores/mail-filter.store'
import { useMailStore } from '@/stores/mail.store'
const filterStore = useMailFilterStore()
const filterRunStore = useMailFilterRunStore()
const mailStore = useMailStore()
const authStore = useAuthStore()
const route = useRoute()
@@ -278,7 +282,8 @@ watch(
:busy="filterStore.saving"
:error="dialogError"
:conflict="dialogConflict"
@save="(rule) => settleDialog(saveDialog(rule))"
:apply-busy="filterRunStore.running"
@save="(rule, apply) => settleDialog(saveDialog(rule, { apply }))"
@remove="settleDialog(removeDialog())"
@cancel="cancelDialog"
@clear-error="clearDialogError"
@@ -3,7 +3,7 @@ import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import type { MailVacation } from '@/shared/types/mail'
import type { MailVacation, MailVacationState } from '@/shared/types/mail'
import { formatDate } from '@/shared/utils/format'
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
import { useAuthStore } from '@/stores/auth.store'
@@ -35,7 +35,7 @@ const SAVED: MailVacation = {
}
interface Server {
vacation?: MailVacation | MockResult
vacation?: MailVacation | MailVacationState | MockResult
save?: (request: MockRequest) => MockResult | Promise<MockResult>
}
@@ -277,6 +277,56 @@ describe('MailVacationPage', () => {
])
})
describe('엔진에서 규칙이 꺼졌을 때 (7단계 — 디자인 원본 MailFilterApplyStates)', () => {
const INACTIVE_TEXT =
'켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다.'
it('켜 두었는데 isActive: false 면 머리 아래 노란 줄(단추 없음) — 저장하면 isActive 없이 보내고 응답의 값으로 내린다', async () => {
const { wrapper } = await mountPage({
vacation: { ...SAVED, isActive: false },
save: (request) => ok({ ...(request.data as MailVacation), isActive: true }),
})
const notice = wrapper.get('[data-vacation-inactive]')
expect(notice.text()).toBe(INACTIVE_TEXT)
expect(notice.attributes('role')).toBe('status')
expect(notice.find('button').exists()).toBe(false)
// 머리 아래 — 칸보다 앞
expect(notice.element.compareDocumentPosition(wrapper.get('#vacation-enabled').element)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
)
// 칸을 끄기만 하면(저장하지 않음) 그대로 — 저장한 값으로 본다
await wrapper.get('#vacation-enabled').setValue(false)
expect(wrapper.find('[data-vacation-inactive]').exists()).toBe(true)
await wrapper.get('#vacation-enabled').setValue(true)
await save(wrapper)
expect(puts()).toEqual([SAVED])
expect(wrapper.find('[data-vacation-inactive]').exists()).toBe(false)
})
it('꺼 두었으면 isActive: false 여도 줄이 없다 · 동작하고 있으면(true) 없다', async () => {
const { wrapper: off } = await mountPage({ vacation: { ...SAVED, isEnabled: false, isActive: false } })
expect(off.find('[data-vacation-inactive]').exists()).toBe(false)
const { wrapper: on } = await mountPage({ vacation: { ...SAVED, isActive: true } })
expect(on.find('[data-vacation-inactive]').exists()).toBe(false)
})
it('꺼 두고 저장해도 줄을 내린다 — 서버가 아직 isActive: false 를 돌려줘도 (꺼진 자동 응답은 알릴 것이 없다)', async () => {
const { wrapper } = await mountPage({
vacation: { ...SAVED, isActive: false },
save: (request) => ok({ ...(request.data as MailVacation), isActive: false }),
})
await wrapper.get('#vacation-enabled').setValue(false)
await save(wrapper)
expect(puts()).toEqual([{ ...SAVED, isEnabled: false }])
expect(wrapper.find('[data-vacation-inactive]').exists()).toBe(false)
})
})
describe('나가기 확인 (서명 설정과 같다)', () => {
const dialogOpen = () => document.getElementById('vacation-leave-dialog')?.hasAttribute('open') ?? false
@@ -1,7 +1,11 @@
<script setup lang="ts">
// 설정 › 메일 › 부재중 자동 응답 (디자인 원본 SettingsVacation · README "4단계 › 부재중 자동 응답") — /settings/mail/vacation
// 들어올 때마다 새로 받는다(다른 창 · 메일 프로그램에서 바꿨을 수 있다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도".
// 저장한 값이 켜짐인데 엔진에서 우리 규칙이 꺼져 있으면(isActive: false — 7단계, 디자인 원본 MailFilterApplyStates) 머리 아래 노란 줄
// "켜 둔 자동 응답이 지금은 동작하지 않습니다 …"(필터 화면의 줄과 같은 모양, 단추 없음 — [저장]은 늘 누를 수 있다).
// 저장하면 응답의 isActive 로 맞춘다(저장하면 다시 켜진다). 꺼 두었으면 보이지 않는다 — 칸을 고친 값이 아니라 저장한 값으로 본다
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MailFilterNotice from '@/components/settings/MailFilterNotice.vue'
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
import VacationForm from '@/components/settings/VacationForm.vue'
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
@@ -29,10 +33,15 @@ reload()
leave-label=""
@retry="reload"
/>
<VacationForm
v-else-if="vacationStore.status === 'ready' && vacationStore.vacation"
:vacation="vacationStore.vacation"
/>
<template v-else-if="vacationStore.status === 'ready' && vacationStore.vacation">
<MailFilterNotice
v-if="vacationStore.vacation.isEnabled && !vacationStore.vacation.isActive"
icon="alert-circle"
:message="VACATION_TEXT.inactive"
data-vacation-inactive
/>
<VacationForm :vacation="vacationStore.vacation" />
</template>
<div
v-else
class="vacation-page__placeholder"