generated from ttipo/project-base
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user