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
@@ -0,0 +1,78 @@
import { computed, ref, watch } from 'vue'
import { useLeaveConfirm } from './useLeaveConfirm'
import { useAuthStore } from '@/stores/auth.store'
// 고치던 창(모달)을 저장하지 않고 닫거나 떠나기 전 확인 — 필터 고치기 창 (디자인 README "7단계 › 저장하지 않고 닫을 때")
// 화면의 나가기 확인(useLeaveConfirm)에 "창 닫기" 길을 더한다. 컴포넌트의 setup 에서 부른다 (이동 가드가 RouterView 의 화면에 걸린다)
// - [취소] · Esc(브라우저가 스스로 닫은 것도 — ModalDialog 의 cancel): 바꾼 것이 있으면 확인 창(asking 'close') —
// [계속 고치기]면 창을 그대로 두고(stayed — 브라우저가 닫았으면 다시 띄운다), [닫기]면 close
// - 창을 연 채 다른 화면으로: 이동 가드가 답을 기다린다(asking 'route' — [나가기]면 그대로 간다, [계속 고치기]면 이동을 취소한다)
// - 탭 닫기 · 새로고침: 바꾼 것이 있는 동안 beforeunload (브라우저 확인창 — 보내는 중에도)
// - 묻지 않는 때: 바꾼 것이 없다 · 보내는 중(저장 · 지우기 — 결과는 알림으로 알린다) · 세션이 끝났다 (서명 · 부재중 설정과 같다).
// 묻는 중에 그렇게 되면 확인 창을 닫는다 — 닫기 확인은 창을 그대로 두고, 이동 확인은 그 이동을 취소한다(useLeaveConfirm)
// - 닫기 확인이 떠 있는 동안 다른 화면으로 가려 하면(뒤로 가기) 이동 확인이 이어받는다 — 같은 확인 창에 글만 바뀐다
export interface DialogLeaveConfirmOptions {
/** 창이 열려 있고 연 때와 다르다 — 반응형으로 읽는다 */
changed: () => boolean
/** 보내는 중 — 이때는 묻지 않는다 (반응형) */
sending: () => boolean
/** 창을 닫는다 — 바꾼 것이 없거나 [닫기]를 골랐다 */
close: () => void
/** [계속 고치기]를 골랐다 — 창이 보이게 한다 (브라우저가 스스로 닫았을 수 있다 — ModalDialog 의 reshow) */
stayed?: () => void
}
/** 지금 띄운 확인 — close: [취소] · Esc 로 닫으려 함 · route: 다른 화면으로 가려 함 · null: 묻지 않는 중 */
export type DialogLeaveAsking = 'close' | 'route' | null
export function useDialogLeaveConfirm(options: DialogLeaveConfirmOptions) {
const authStore = useAuthStore()
const closeConfirming = ref<boolean>(false)
/** 지금 닫거나 떠나면 잃는 것이 있다 — 이때만 묻는다 */
const guarded = () => options.changed() && !options.sending() && authStore.isLoggedIn
const route = useLeaveConfirm({
active: guarded,
unloadActive: () => options.changed() && authStore.isLoggedIn,
})
const asking = computed<DialogLeaveAsking>(() => {
if (route.confirming.value) return 'route'
return closeConfirming.value ? 'close' : null
})
/** [취소] · Esc — 잃을 것이 있으면 묻고, 없으면 닫는다 */
function requestClose() {
if (guarded()) closeConfirming.value = true
else options.close()
}
/** [계속 고치기] (확인 창의 Esc 포함) */
function stay() {
if (route.confirming.value) route.stay()
closeConfirming.value = false
options.stayed?.()
}
/** [닫기] · [나가기] */
function leave() {
if (route.confirming.value) {
route.leave()
return
}
closeConfirming.value = false
options.close()
}
watch(route.confirming, (confirming) => {
if (confirming) closeConfirming.value = false
})
watch(guarded, (active) => {
if (!active) closeConfirming.value = false
})
return { asking, requestClose, stay, leave }
}
+36 -1
View File
@@ -1,7 +1,14 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type { MailFilterSet, RejectedRecipient, SendMessagePayload } from '@/shared/types/mail'
import { MAIL_FILTER_RUN_TIMEOUT_MS } from '@/shared/constants/mail'
import type {
MailFilterRun,
MailFilterRunRequest,
MailFilterSet,
RejectedRecipient,
SendMessagePayload,
} from '@/shared/types/mail'
import { installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { useMail } from './useMail'
@@ -79,3 +86,31 @@ describe('useMail 메일 필터 — GET · PUT /mail-filters (계약 2절 "메
expect(requests[1]?.data).toEqual({ rules: SET.rules, version: 'v1' })
})
})
describe('useMail 이미 받은 메일에 적용 — POST /mail-filter-runs (계약 2절 "이미 받은 메일에 적용")', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
restoreApiAdapter()
})
it('규칙(전달 없이)과 cursor 를 본문으로 보내고 — 한 요청이 25초까지 걸리므로 기본 10초보다 길게 기다린다', async () => {
const run: MailFilterRun = { scanned: 4500, matched: 37, remaining: 3912, cursor: 'c1' }
const requests = installMockApi(() => ok(run))
const request: MailFilterRunRequest = {
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false },
cursor: null,
}
await expect(useMail().runFilter(request, { silent: ['REQ_001'] })).resolves.toEqual(run)
expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual(['POST /mail-filter-runs'])
expect(requests[0]?.data).toEqual(request)
expect(requests[0]?.config.timeout).toBe(MAIL_FILTER_RUN_TIMEOUT_MS)
expect(requests[0]?.config.silent).toEqual(['REQ_001'])
})
})
+44 -6
View File
@@ -2,6 +2,7 @@ import type { AxiosRequestConfig } from 'axios'
import { buildApiUrl, confirmSession, useApi } from './useApi'
import {
MAIL_FILTER_RUN_TIMEOUT_MS,
MAIL_IDEMPOTENCY_HEADER,
MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
MAIL_SEND_TIMEOUT_MS,
@@ -12,12 +13,15 @@ import type {
Draft,
DraftPayload,
DraftSaved,
MailFilterRun,
MailFilterRunRequest,
MailFilterSet,
MailFilterSetPayload,
MailQuota,
MailSignature,
MailState,
MailVacation,
MailVacationState,
Mailbox,
MailboxBulkResult,
MailboxCreatePayload,
@@ -54,6 +58,17 @@ export interface SendMessageOptions extends MailRequestOptions {
/** POST /messages 가 실제로 주는 모양 — 옛 서버는 rejectedRecipients 가 없다 (sendMessage 가 배열로 맞춘다) */
type SentMessageReply = Omit<SentMessage, 'rejectedRecipients'> & { rejectedRecipients?: unknown }
/** GET · PUT /mail-vacation 이 실제로 주는 모양 — 7단계 앞의 서버는 isActive 가 없다 (vacationStateOf 가 맞춘다) */
type MailVacationReply = MailVacation & { isActive?: unknown }
/**
* 부재중 응답 → 화면의 값. isActive 가 없거나 불리언이 아니면(옛 서버 · 응답을 바꾼 게이트웨이) 동작하는 것으로 본다 —
* "지금은 동작하지 않습니다" 줄을 잘못 띄우지 않게 (계약 2절 "부재중 자동 응답"의 MailVacationState)
*/
function vacationStateOf(reply: MailVacationReply): MailVacationState {
return { ...reply, isActive: typeof reply.isActive === 'boolean' ? reply.isActive : true }
}
export interface UploadOptions {
/** 0~100 */
onProgress?: (percent: number) => void
@@ -137,14 +152,24 @@ export function useMail() {
// ── 부재중 자동 응답 (계약 2절 "부재중 자동 응답" — 4단계) ──────────
/** 설정한 적 없으면 꺼짐 · 빈 제목 · 빈 내용이 온다 */
function getVacation(options?: MailRequestOptions): Promise<MailVacation> {
return api.get<MailVacation, MailVacation>('/mail-vacation', options)
/** 설정한 적 없으면 꺼짐 · 빈 제목 · 빈 내용이 온다. isActive 는 엔진에서 지금 동작하는지 (7단계) */
async function getVacation(options?: MailRequestOptions): Promise<MailVacationState> {
return vacationStateOf(await api.get<MailVacationReply, MailVacationReply>('/mail-vacation', options))
}
/** 전체 교체 — 꺼도 제목 · 내용은 남긴다 */
function saveVacation(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacation> {
return api.put<MailVacation, MailVacation>('/mail-vacation', payload, options)
/**
* 전체 교체 — 꺼도 제목 · 내용은 남긴다. 응답은 저장한 값 + isActive (저장하면 엔진에서 다시 켜진다).
* 본문은 다섯 칸만 골라 보낸다 — 받은 응답(isActive 가 든 MailVacationState)을 그대로 넘겨도 isActive 가 실리지 않게 (서버가 400 VAL_001)
*/
async function saveVacation(payload: MailVacation, options?: MailRequestOptions): Promise<MailVacationState> {
const body: MailVacation = {
isEnabled: payload.isEnabled,
fromDate: payload.fromDate,
toDate: payload.toDate,
subject: payload.subject,
textBody: payload.textBody,
}
return vacationStateOf(await api.put<MailVacationReply, MailVacationReply>('/mail-vacation', body, options))
}
// ── 메일 필터 (계약 2절 "메일 필터" — 6단계) ──────────────────────
@@ -162,6 +187,18 @@ export function useMail() {
return api.put<MailFilterSet, MailFilterSet>('/mail-filters', payload, options)
}
/**
* 받은편지함에 이미 있는 메일에 규칙 하나를 적용한다 (계약 2절 "이미 받은 메일에 적용" — 7단계). 한 요청은 25초 안에 끝나고
* cursor 를 돌려준다 — null 이 될 때까지 같은 본문에 그 cursor 를 넣어 다시 보낸다(부르는 쪽 — mail-filter-run.store).
* 같은 직원의 적용이 다른 곳에서 진행 중이면 429 REQ_001, 규칙 · cursor 가 틀리면 400 VAL_001, 하나도 확인하지 못했으면 503 MAIL_001
*/
function runFilter(request: MailFilterRunRequest, options?: MailRequestOptions): Promise<MailFilterRun> {
return api.post<MailFilterRun, MailFilterRun>('/mail-filter-runs', request, {
timeout: MAIL_FILTER_RUN_TIMEOUT_MS,
...options,
})
}
/**
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
@@ -358,6 +395,7 @@ export function useMail() {
saveVacation,
getFilters,
saveFilters,
runFilter,
messageSourceUrl,
downloadMessageSource,
getMailState,
@@ -1,4 +1,5 @@
import { notifyApiError } from './useApi'
import { useMailFilterRun } from './useMailFilterRun'
import { useToast } from './useToast'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { MailFilterRule } from '@/shared/types/mail'
@@ -10,21 +11,32 @@ import {
} from '@/stores/mail-filter.store'
// 메일 필터 설정 화면의 저장 · 지우기 알림 (디자인 README "6단계 › 상태 · 들어오는 길" · 디자인 원본 MailFilterStates "알림 문구")
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다."
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다." — 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면(7단계) 그 대신
// 저장한 그 규칙(응답 목록의 같은 id — 서버가 앞뒤 공백을 지운 값)으로 받은편지함에 적용하는 진행 알림 (useMailFilterRun)
// - 지우기는 확인 없이 하고 "‘거래처 메일’ 필터를 지웠습니다." + [되돌리기](10초 — 단추가 있는 알림, 띄운 세션에서만 움직인다 — useToast).
// 되돌리기는 지운 규칙을 지운 자리(앞 · 뒤 규칙 기준)에 다시 저장한다 → "필터를 되돌렸습니다." 알림 여럿의 [되돌리기]는 누른 차례대로 보낸다(스토어).
// 실패는 인터셉터가 알린다. 그사이 바뀜(409)만은 알림을 끄고 목록 위 줄이 알리는데, 필터 화면이 떠 있지 않으면 볼 곳이 없으니
// 알림으로 알린다(고치기 창의 notifyUnseen 과 같은 규칙). 다시 넣으면 50개를 넘으면 보내지 않고 오류 알림(fullNote)
// - 켜기 · 끄기 · 순서 바꾸기는 알리지 않는다 — 목록이 바로 바뀐다(실패하면 스토어가 되돌리고 인터셉터가 알린다)
export interface MailFilterSaveOptions {
/** 「받은편지함에 이미 있는 메일에도 적용」 — 저장한 뒤 이번에 한 번 (필터의 설정이 아니다) */
apply?: boolean
}
export function useMailFilterActions() {
const filterStore = useMailFilterStore()
const filterRun = useMailFilterRun()
const toast = useToast()
/** 고치기 창의 [저장] — 칸 안에 알릴 거절(VAL_001 · MAIL_013 · MAIL_012 · 50개)은 부르는 쪽이 그린다 */
async function save(rule: MailFilterRule): Promise<MailFilterSaveOutcome> {
async function save(rule: MailFilterRule, options: MailFilterSaveOptions = {}): Promise<MailFilterSaveOutcome> {
const outcome = await filterStore.saveRule(rule)
if (outcome.ok) toast.success(MAIL_FILTER_TEXT.saved)
if (!outcome.ok) return outcome
const saved = outcome.filterSet.rules.find((item) => item.id === rule.id) ?? rule
// 적용을 시작했으면 진행 알림이 저장 알림을 대신한다 (할 일이 전달뿐이면 시작하지 않는다)
if (options.apply && filterRun.start(saved)) return outcome
toast.success(MAIL_FILTER_TEXT.saved)
return outcome
}
@@ -1,7 +1,7 @@
import { onBeforeUnmount, ref } from 'vue'
import { notifyApiError, type ApiError } from './useApi'
import { useMailFilterActions } from './useMailFilterActions'
import { useMailFilterActions, type MailFilterSaveOptions } from './useMailFilterActions'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
import type { MailFilterRule } from '@/shared/types/mail'
@@ -14,6 +14,8 @@ import { useMailFilterStore } from '@/stores/mail-filter.store'
// - 그사이 바뀜이면 창 안에 [다시 불러오기]를 둔다(conflict — 창이 모달이라 목록 위 줄을 누를 수 없다). 다시 받으면 창 · 쓴 내용은 그대로 두고 안내를 내린다 —
// 다음 [저장]은 다시 받은 목록 · version 위에 넣는다(스토어의 saveRule). 칸을 고쳐도 이 안내는 남는다(다시 받아야 풀린다)
// - 끝나면 초점을 줄 규칙을 돌려준다 — 저장은 그 규칙, 지우기는 이웃 행(다음, 마지막이었으면 앞). 초점은 화면이 옮긴다
// - [저장]이 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면(7단계 — options.apply) 저장한 뒤 적용한다(useMailFilterActions) —
// 창을 그사이 닫았어도 저장했으면 적용한다(고른 사람이 저장을 보냈다)
export type MailFilterDialogPending = 'save' | 'remove' | 'reload' | null
@@ -88,12 +90,12 @@ export function useMailFilterDialog() {
}
/** [저장] — 지금 목록 위에 넣는다(있는 규칙은 그 자리, 새 규칙 · 다른 곳에서 지워진 규칙은 맨 아래 — 스토어의 saveRule) */
async function save(rule: MailFilterRule): Promise<MailFilterDialogResult> {
async function save(rule: MailFilterRule, options: MailFilterSaveOptions = {}): Promise<MailFilterDialogResult> {
const current = state.value
if (!current || pending.value || filterStore.saving) return KEPT
pending.value = 'save'
showError(null)
const outcome = await filterActions.save(rule)
const outcome = await filterActions.save(rule, options)
if (!isCurrent(current.token)) {
if (!outcome.ok) notifyUnseen(outcome.error, DIALOG_ERROR_CODES)
return KEPT
@@ -0,0 +1,403 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
import type { Mailbox, MailFilterRule, MailFilterRun, MailFilterRunRequest } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore, type Toast } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { useMailFilterRun } from './useMailFilterRun'
// 이미 받은 메일에 필터 적용 — 진행 · 결과 알림 · 끝난 뒤 개수 · 목록 맞추기 (디자인 원본 MailFilterApply · MailFilterApplyStates · 계약 2절 "이미 받은 메일에 적용")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
function mailbox(id: string, name: string, role: Mailbox['role'] = null, totalCount = 0): Mailbox {
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount: 0 }
}
const MAILBOXES: Mailbox[] = [
mailbox('inbox', '받은편지함', 'inbox', 8412),
mailbox('vendor', '거래처'),
mailbox('notice', '전체 공지'),
]
function rule(patch: Partial<MailFilterRule> = {}): MailFilterRule {
return {
id: 'r2',
name: '거래처 메일',
isEnabled: true,
match: 'all',
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false, forwardTo: null },
...patch,
}
}
const NO_ACTIONS = { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: null }
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) => {
finish = resolve
})
return { promise, finish }
}
describe('useMailFilterRun', () => {
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().startSession(USER)
useMailStore().mailboxes = MAILBOXES.map((item) => ({ ...item }))
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
/** 적용 요청에 차례로 돌려줄 응답 — 다 쓰면 마지막 것을 되풀이한다. 개수 · 목록 맞추기의 조회는 그대로 답한다 */
function replies(...results: (MockResult | Promise<MockResult>)[]) {
let next = 0
requests = installMockApi((request) => {
if (request.url === '/mail-filter-runs') {
const result = results[Math.min(next, results.length - 1)] as MockResult | Promise<MockResult>
next += 1
return result
}
if (request.url === '/mailboxes') {
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
}
if (request.url === '/messages') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
return ok(null)
})
}
const runs = () => requests.filter((request) => request.url === '/mail-filter-runs')
const cursors = () => runs().map((request) => (request.data as MailFilterRunRequest).cursor)
const shown = () => useToastStore().toasts.map((toast) => [toast.kind, toast.message])
/** 개수 · 목록 맞추기로 보낸 조회 — "GET /mailboxes" · "GET /mail-quota" · "GET /messages?inbox" */
const syncs = () =>
requests
.filter((request) => request.url !== '/mail-filter-runs')
.map((request) => {
const mailboxId = request.params.mailboxId
return `${request.method} ${request.url}${typeof mailboxId === 'string' ? `?${mailboxId}` : ''}`
})
function press(toast: Toast | undefined) {
if (toast?.action && 'run' in toast.action) toast.action.run()
}
/** 단추가 있는 알림 — 진행 알림이 바뀐 결과 · 바쁨 알림 (사전 문구의 오류 알림은 단추가 없다) */
const withAction = () => useToastStore().toasts.find((toast) => toast.action !== null)
/** 메일 화면이 그 메일함의 목록을 보이고 있다 */
function viewing(mailboxId: string) {
const mailStore = useMailStore()
mailStore.listSource = { kind: 'mailbox', mailboxId }
mailStore.listStatus = 'ready'
}
it('진행 알림이 남았다가 끝나면 그 자리에서 결과로 — "…메일 37통에 적용했습니다." + [거래처 보기](그 메일함으로)', async () => {
const first = deferred()
const second = deferred()
replies(first.promise, second.promise)
expect(useMailFilterRun().start(rule())).toBe(true)
// 첫 응답 전 — 전체는 받은편지함의 메일 수
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인']])
const id = useToastStore().toasts[0]?.id
first.finish(step(4500, 30, 3912, 'c1'))
await flushPromises()
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,500 / 8,412통 확인']])
second.finish(step(3912, 7, 0, null))
await flushPromises()
const [toast] = useToastStore().toasts
expect(useToastStore().toasts).toHaveLength(1)
expect(toast).toMatchObject({
id,
kind: 'success',
message: '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.',
action: { label: '거래처 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'vendor' } } },
})
expect(cursors()).toEqual([null, 'c1'])
})
it.each([
['읽음 · 중요만 하는 필터', rule({ name: '결재 알림', actions: { ...NO_ACTIONS, markAsRead: true, flag: true } }), 12, '‘결재 알림’ 필터를 받은편지함의 메일 12통에 적용했습니다.'],
['받은편지함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'inbox', markAsRead: true } }), 3, '‘거래처 메일’ 필터를 받은편지함의 메일 3통에 적용했습니다.'],
['지금 메일함 목록에 없는 메일함으로 옮기며 읽음 표시하는 필터', rule({ actions: { ...NO_ACTIONS, moveToMailboxId: 'gone', markAsRead: true } }), 3, '‘거래처 메일’ 필터를 받은편지함의 메일 3통에 적용했습니다.'],
['맞는 메일이 없는 옮기는 필터', rule(), 0, '받은편지함에 ‘거래처 메일’ 필터에 맞는 메일이 없습니다.'],
])('%s — 단추 없이 끝난다', async (_label, target, matched, message) => {
replies(step(8412, matched, 0, null))
useMailFilterRun().start(target)
await flushPromises()
expect(useToastStore().toasts).toMatchObject([{ kind: 'success', message, action: null }])
})
it.each([
['전달만 하는 규칙', { ...NO_ACTIONS, forwardTo: 'doyun.lee@arasystem.it.kr' }],
['받은편지함으로 옮기기만 하는 규칙', { ...NO_ACTIONS, moveToMailboxId: 'inbox' }],
['지워진 메일함으로 옮기기만 하는 규칙', { ...NO_ACTIONS, moveToMailboxId: 'gone' }],
])('%s — 이미 받은 메일에 할 일이 없어 시작하지 않는다 (보내지도 알리지도 않는다 — 부르는 쪽이 "필터를 저장했습니다.")', (_label, actions) => {
replies(step(1, 1, 0, null))
expect(useMailFilterRun().start(rule({ actions }))).toBe(false)
expect(requests).toHaveLength(0)
expect(shown()).toEqual([])
})
it('메일함을 아직 받지 않아 받은편지함의 수를 모르면 첫 진행 문구에 수를 뺀다', async () => {
useMailStore().mailboxes = []
const first = deferred()
replies(first.promise)
useMailFilterRun().start(rule({ actions: { ...NO_ACTIONS, markAsRead: true } }))
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중']])
first.finish(step(10, 0, 0, null))
await flushPromises()
})
it('도중에 멈추면 멈춤 문구 + [다시 시도] — 마지막 cursor 로 이어서 수를 이어 센다 (까닭은 사전 문구 알림)', async () => {
replies(step(4200, 12, 4212, 'c1'), fail(503, 'MAIL_001'), step(4212, 25, 0, null))
useMailFilterRun().start(rule())
await flushPromises()
await flushPromises()
// 진행 알림이 그 자리에서 멈춤 문구로 — 까닭은 오류 알림이 함께
expect(shown()).toEqual([
['error', '‘거래처 메일’ 필터 — 8,412통 가운데 4,200통까지 확인하고 멈췄습니다(12통에 적용). 다시 시도해 주세요.'],
['error', ERROR_CODE_MESSAGES.MAIL_001],
])
const stopped = withAction()
expect(stopped?.action?.label).toBe('다시 시도')
useToastStore().clear()
press(stopped)
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인']])
await flushPromises()
expect(cursors()).toEqual([null, 'c1', 'c1'])
expect(shown()).toEqual([['success', '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.']])
})
it('처음 요청부터 실패하면 "필터는 저장했지만 …" + [다시 시도] — 처음부터 다시 한다', async () => {
replies(fail(500, 'SYS_001'), step(8412, 37, 0, null))
useMailFilterRun().start(rule())
await flushPromises()
expect(shown()).toEqual([
['error', '필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요.'],
['error', ERROR_CODE_MESSAGES.SYS_001],
])
const failed = withAction()
expect(failed?.action?.label).toBe('다시 시도')
useToastStore().clear()
press(failed)
expect(shown()).toEqual([['progress', '‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인']])
await flushPromises()
expect(cursors()).toEqual([null, null])
expect(shown()).toEqual([['success', '‘거래처 메일’ 필터를 받은편지함의 메일 37통에 적용했습니다.']])
})
it('다른 창 · PC 에서 적용하는 중이면(429) "다른 창에서 …" 알림 하나만 — 처음 요청이면 단추가 없다', async () => {
replies(fail(429, 'REQ_001'))
useMailFilterRun().start(rule())
await flushPromises()
expect(useToastStore().toasts).toMatchObject([
{ kind: 'error', message: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: null },
])
})
it('도중의 429 — 같은 문구에 [다시 시도]가 마지막 cursor 로 이어서', async () => {
replies(step(4200, 12, 4212, 'c1'), fail(429, 'REQ_001'), step(4212, 25, 0, null))
useMailFilterRun().start(rule())
await flushPromises()
await flushPromises()
const [toast] = useToastStore().toasts
expect(useToastStore().toasts).toHaveLength(1)
expect(toast).toMatchObject({
kind: 'error',
message: '다른 창에서 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
action: { label: '다시 시도' },
})
press(toast)
await flushPromises()
expect(cursors()).toEqual([null, 'c1', 'c1'])
})
it('세션이 바뀌면 진행 알림을 닫고 다음 요청을 보내지 않는다 — 늦게 온 응답으로 알림을 다시 띄우지 않는다 · 목록도 맞추지 않는다', async () => {
const first = deferred()
replies(first.promise)
useMailFilterRun().start(rule())
expect(shown()).toHaveLength(1)
useAuthStore().clearAuth()
await nextTick()
expect(shown()).toEqual([])
first.finish(step(4500, 30, 3912, 'c1'))
await flushPromises()
expect(runs()).toHaveLength(1)
expect(shown()).toEqual([])
expect(syncs()).toEqual([])
})
it('세션이 바뀐 뒤 늦게 온 실패 — 알림이 남지 않는다 (사전 문구 알림도 · 멈춤 알림도)', async () => {
const first = deferred()
replies(first.promise)
useMailFilterRun().start(rule())
useAuthStore().clearAuth()
useAuthStore().startSession(USER)
await nextTick()
// 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 이 파일에서 이 테스트만 쓰는 코드로
first.finish(fail(403, 'AUTH_003'))
await flushPromises()
expect(shown()).toEqual([])
expect(useMailFilterRunStore().running).toBe(false)
})
it('[다시 시도]는 띄운 세션에서만 움직인다 — 다시 로그인한 뒤 눌러도 보내지 않는다', async () => {
replies(fail(500, 'SYS_001'))
useMailFilterRun().start(rule())
await flushPromises()
const failed = withAction()
useAuthStore().clearAuth()
useAuthStore().startSession(USER)
await nextTick()
press(failed)
await flushPromises()
expect(runs()).toHaveLength(1)
})
it('이 탭에서 다른 필터를 적용하는 중이면 시작하지 않고 알린다 — 그 알림의 [다시 시도]로 앞 적용이 끝난 뒤 같은 자리에서 이어 한다', async () => {
const other = deferred()
// 하나: 4,200통에서 멈춘다 → 둘이 도는 동안 하나의 [다시 시도] → 바쁨 알림 → 둘이 끝난 뒤 바쁨 알림의 [다시 시도]
replies(step(4200, 12, 4212, 'c1'), fail(503, 'MAIL_001'), other.promise, step(4212, 25, 0, null))
const run = useMailFilterRun()
run.start(rule({ id: 'a', name: '하나' }))
await flushPromises()
await flushPromises()
const stopped = withAction()
useToastStore().clear()
expect(run.start(rule({ id: 'b', name: '둘' }))).toBe(true)
expect(useMailFilterRunStore().running).toBe(true)
// 저장 쪽에서 온 시작도 바쁘면 알리기만 한다 — [다시 시도](처음부터)를 붙여서
expect(run.start(rule({ id: 'c', name: '셋' }))).toBe(false)
press(stopped)
await flushPromises()
expect(runs()).toHaveLength(3)
const busy = '다른 필터를 받은편지함에 적용하는 중입니다. 끝난 뒤 다시 시도해 주세요.'
expect(shown()).toEqual([
['progress', '‘둘’ 필터를 받은편지함에 적용하는 중 · 0 / 8,412통 확인'],
['error', busy],
['error', busy],
])
const [, fromSave, resumed] = useToastStore().toasts
expect(fromSave?.action?.label).toBe('다시 시도')
expect(resumed?.action?.label).toBe('다시 시도')
other.finish(step(8412, 0, 0, null))
await flushPromises()
useToastStore().clear()
// 앞 적용이 끝났다 — 바쁨 알림의 [다시 시도]는 멈춘 자리(cursor c1 · 4,200통 · 12통)에서 이어 한다
press(resumed)
expect(shown()).toEqual([['progress', '‘하나’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인']])
await flushPromises()
expect(cursors()).toEqual([null, 'c1', null, 'c1'])
expect(shown()).toEqual([['success', '‘하나’ 필터를 받은편지함의 메일 37통에 적용했습니다.']])
})
describe('끝난 뒤 개수 · 목록 맞추기 (4단계 오래 걸리는 처리와 같은 결)', () => {
it('끝나면 메일함 개수 · 사용량을 다시 받고, 받은편지함 목록을 보고 있으면 그 목록도 맞춘다', async () => {
viewing('inbox')
replies(step(8412, 37, 0, null))
useMailFilterRun().start(rule())
await flushPromises()
expect(syncs().sort()).toEqual(['GET /mail-quota', 'GET /mailboxes', 'GET /messages?inbox'])
})
it('옮긴 메일함의 목록을 보고 있어도 맞추고, 다른 메일함이면 개수만 — 전체 메일함 검색은 건드리지 않는다', async () => {
viewing('vendor')
replies(step(8412, 37, 0, null))
useMailFilterRun().start(rule())
await flushPromises()
expect(syncs().sort()).toEqual(['GET /mail-quota', 'GET /mailboxes', 'GET /messages?vendor'])
viewing('notice')
replies(step(8412, 37, 0, null))
useMailFilterRun().start(rule())
await flushPromises()
expect(syncs().sort()).toEqual(['GET /mail-quota', 'GET /mailboxes'])
useMailStore().listSource = { kind: 'search', q: '견적', scope: SEARCH_SCOPE_ALL }
replies(step(8412, 37, 0, null))
useMailFilterRun().start(rule())
await flushPromises()
expect(syncs().sort()).toEqual(['GET /mail-quota', 'GET /mailboxes'])
})
it('이번에 나아간 뒤 멈췄으면 맞추고, 처음 요청부터 실패했거나 이어 하기가 곧바로 멈췄으면 맞추지 않는다', async () => {
viewing('inbox')
replies(step(4200, 12, 4212, 'c1'), fail(503, 'MAIL_001'))
useMailFilterRun().start(rule())
await flushPromises()
await flushPromises()
expect(syncs().sort()).toEqual(['GET /mail-quota', 'GET /mailboxes', 'GET /messages?inbox'])
const stopped = withAction()
// 이어 하기가 곧바로 멈췄다 — 앞 멈춤이 이미 맞췄다
replies(fail(503, 'MAIL_001'))
press(stopped)
await flushPromises()
expect(syncs()).toEqual([])
replies(fail(503, 'MAIL_001'))
useMailFilterRun().start(rule())
await flushPromises()
expect(syncs()).toEqual([])
})
})
})
@@ -0,0 +1,135 @@
import { useToast, type ProgressToast } from './useToast'
import { MAIL_FILTER_RUN_TEXT } from '@/shared/constants/mail-filter'
import type { MailFilterRule, MailFilterRunRule } from '@/shared/types/mail'
import {
filterRunRuleOf,
filterRunTarget,
mailFilterRunCopy,
type MailFilterRunCopy,
type MailFilterRunTally,
} from '@/shared/utils/mail-filter-run'
import { listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
import { useAuthStore } from '@/stores/auth.store'
import { useMailFilterRunStore, type MailFilterRunOutcome } from '@/stores/mail-filter-run.store'
import { useMailStore } from '@/stores/mail.store'
import type { ToastAction } from '@/stores/toast.store'
// 이미 받은 메일에 필터 적용 — 진행 · 결과 알림 (디자인 README "7단계 › 저장한 뒤 — 진행 · 결과 알림" · 디자인 원본 MailFilterApply · MailFilterApplyStates)
// - 고치기 창의 [저장]이 성공했고 「받은편지함에 이미 있는 메일에도 적용」을 골랐으면 "필터를 저장했습니다." 대신 진행 알림
// "‘거래처 메일’ 필터를 받은편지함에 적용하는 중 · 4,200 / 8,412통 확인"(첫 응답 전의 전체는 받은편지함의 메일 수 — 모르면 수를 뺀다)이
// 남았다가 끝나면 그 자리에서 결과로 바뀐다 (4단계 오래 걸리는 처리 — useMailboxActions 의 runOperation 과 같은 결)
// - 끝: "‘…’ 필터를 받은편지함의 메일 N통에 적용했습니다." + 옮기는 필터이고 1통 이상이면 [{메일함} 보기](filterRunTarget — 받은편지함 ·
// 지금 메일함 목록에 없는 메일함이면 단추 없음). 맞는 메일이 없으면 "받은편지함에 ‘…’ 필터에 맞는 메일이 없습니다."
// - 멈춤(이어 할 자리가 있다): "‘…’ 필터 — {전체}통 가운데 {확인}통까지 확인하고 멈췄습니다(N통에 적용). 다시 시도해 주세요." +
// [다시 시도](그 cursor 로 이어서 — 수는 이어 센다)
// - 처음 요청부터 실패: "필터는 저장했지만 받은편지함에 적용하지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터)
// - 429 REQ_001(같은 직원의 적용이 다른 창 · PC 에서 진행 중 — 알림은 이것 하나): "다른 창에서 …".
// 이어 할 자리가 있을 때만 [다시 시도] (처음 요청이면 보드처럼 단추 없음)
// 실패 까닭(서버 · 연결)은 스토어가 사전 문구로 알렸다 — 진행 알림은 결과로만 바뀌고 같은 오류를 다시 띄우지 않는다(4단계와 같다).
// 단추는 띄운 세션에서만 움직인다 (useToast)
// - 끝났거나 이번에 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다(4단계 settle 과 같은 결) — 실시간 연결이 없는 탭에서 1분까지 옛 개수가 보이지 않게
// - 이 탭에서 다른 적용이 돌고 있으면 시작하지 않고 "다른 필터를 … 끝난 뒤 다시 시도해 주세요." + 같은 [다시 시도](같은 자리 · 같은 수) —
// 앞 적용이 끝난 뒤 다시 누를 수 있다 (고치기 창은 그동안 칸을 끈다 — 이 길은 앞 실패 알림의 [다시 시도]에서 온다)
// - 그동안 다른 화면으로 가도 이어 한다 — 스토어가 돌리고, 이 함수가 만든 단추도 화면이 사라진 뒤에 움직인다. 세션이 바뀌면 멈추고 알림을 닫는다
/** 시작할 자리 — 처음부터(cursor null · 수 0) 또는 앞 실패의 자리 */
type RunFrom = MailFilterRunTally & { cursor: string | null }
type FailedOutcome = Extract<MailFilterRunOutcome, { status: 'failed' }>
export function useMailFilterRun() {
const runStore = useMailFilterRunStore()
const mailStore = useMailStore()
const authStore = useAuthStore()
const toast = useToast()
/** 처음부터 — 진행 알림의 전체는 받은편지함의 메일 수 (메일함을 아직 받지 않았으면 0 — 수를 뺀다) */
function fromStart(): RunFrom {
const inboxTotal = Math.max(0, mailStore.mailboxByRole('inbox')?.totalCount ?? 0)
return { cursor: null, scanned: 0, matched: 0, total: inboxTotal }
}
/** 끝 알림의 단추 — 옮기는 필터이고 1통 이상 적용했을 때만 */
function viewAction(rule: MailFilterRule, matched: number): ToastAction | undefined {
const target = matched > 0 ? filterRunTarget(rule, mailStore.mailboxes) : null
if (!target) return undefined
return {
label: MAIL_FILTER_RUN_TEXT.viewMailbox(target.name),
to: listLocationOf({ kind: 'mailbox', mailboxId: target.id }),
}
}
function retryAction(rule: MailFilterRule, request: MailFilterRunRule, from: RunFrom): ToastAction {
return { label: MAIL_FILTER_RUN_TEXT.retry, run: () => void execute(rule, request, from) }
}
/**
* 적용으로 바뀐 개수 · 목록을 맞춘다 (4단계 mailbox-admin 의 settle 과 같은 결) — 메일함 개수 · 사용량을 다시 받고,
* 보이는 목록이 받은편지함이나 옮긴 메일함(그 안의 검색 포함)이면 그 목록도 맞춘다. 전체 메일함 검색은 건드리지 않는다
*/
function syncAfterRun(rule: MailFilterRule) {
mailStore.syncCounts()
const scope = scopeMailboxIdOf(mailStore.listSource)
if (scope === null) return
const inboxId = mailStore.mailboxByRole('inbox')?.id ?? null
if (scope === inboxId || scope === rule.actions.moveToMailboxId) void mailStore.syncVisibleList({ includeSearch: true })
}
/** 멈췄다 — 이어 할 자리가 있으면 그 자리에서, 없으면 처음부터 다시 하는 단추 (429 의 처음 요청만 단추가 없다) */
function settleFailure(
rule: MailFilterRule,
request: MailFilterRunRule,
copy: MailFilterRunCopy,
outcome: FailedOutcome,
progress: ProgressToast,
) {
const { cursor, scanned, matched, total } = outcome
const resume = cursor !== null ? retryAction(rule, request, { cursor, scanned, matched, total }) : undefined
if (outcome.error.code === 'REQ_001') progress.fail(MAIL_FILTER_RUN_TEXT.otherWindow, resume)
else if (resume) progress.fail(copy.stopped(outcome), resume)
else progress.fail(MAIL_FILTER_RUN_TEXT.notApplied, retryAction(rule, request, fromStart()))
}
/**
* 진행 알림을 띄우고 끝날 때까지 보낸다. 이 탭에서 다른 적용이 돌고 있으면 시작하지 않고 알린다 —
* 같은 자리 · 같은 수의 [다시 시도]를 붙여 앞 적용이 끝난 뒤 다시 누를 수 있게
*/
async function execute(rule: MailFilterRule, request: MailFilterRunRule, from: RunFrom): Promise<void> {
if (runStore.running) {
toast.error(MAIL_FILTER_RUN_TEXT.busy, retryAction(rule, request, from))
return
}
const session = authStore.generation
const copy = mailFilterRunCopy(rule)
const progress = toast.progress(copy.progress(from))
const outcome = await runStore.run({ ...from, rule: request }, (tally) => progress.update(copy.progress(tally)))
// 세션이 바뀌었다(진행 알림은 이미 닫혔다 — 다음 사람의 목록을 맞추지 않는다) · 이 탭의 다른 적용과 겹쳤다(위에서 막는다)
if (outcome.status === 'canceled' || outcome.status === 'busy' || authStore.generation !== session) {
progress.dismiss()
return
}
if (outcome.status === 'done') {
progress.succeed(copy.done(outcome.matched), viewAction(rule, outcome.matched))
syncAfterRun(rule)
return
}
settleFailure(rule, request, copy, outcome, progress)
// 이번에 나아간 것이 있으면 — 거기까지 바뀐 개수 · 목록을 맞춘다 (이어 하기에서 곧바로 멈췄으면 앞 멈춤이 이미 맞췄다)
if (outcome.scanned > from.scanned) syncAfterRun(rule)
}
/**
* 저장한 규칙(응답 목록의 그 규칙 — 앞뒤 공백을 지운 값)을 받은편지함에 적용한다. 진행 알림이 저장 알림을 대신하면 true.
* 이미 받은 메일에 할 일이 없으면(고치기 창의 칸과 같은 판정 — 칸이 꺼져 있었다) 보내지 않는다. 이 탭에서 다른 적용이 돌고 있으면
* 그렇게 알리기만 한다([다시 시도] 단추와 함께). 둘 다 false — 부르는 쪽이 "필터를 저장했습니다."를 띄운다
*/
function start(rule: MailFilterRule): boolean {
const request = filterRunRuleOf(rule, mailStore.mailboxes)
if (!request) return false
const started = !runStore.running
void execute(rule, request, fromStart())
return started
}
return { start }
}
+13 -9
View File
@@ -1,14 +1,17 @@
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore, type TimedToastKind, type ToastAction } from '@/stores/toast.store'
/** 오래 걸리는 처리의 진행 알림 — 하는 중에는 남아 있다가 끝나면 그 자리에서 결과로 바뀐다 */
/**
* 오래 걸리는 처리의 진행 알림 — 하는 중에는 남아 있다가 끝나면 그 자리에서 결과로 바뀐다.
* 결과에 단추 · 링크(action)를 붙일 수 있다 — 단추가 있는 알림의 시간(10초)과 세션 묶기(띄운 세션에서만 움직인다)는 다른 알림과 같다
*/
export interface ProgressToast {
/** 진행 문구를 바꾼다 ("…하는 중 · 600 / 1,234통") */
update: (message: string) => void
/** 끝났다 — 성공 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다) */
succeed: (message: string) => void
/** 도중에 멈췄다 — 오류 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다) */
fail: (message: string) => void
/** 끝났다 — 성공 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다). 예: 필터 적용의 [거래처 보기] */
succeed: (message: string, action?: ToastAction) => void
/** 도중에 멈췄다 — 오류 알림으로 바꾼다 (그사이 닫혔으면 새로 띄운다). 예: 필터 적용의 [다시 시도] */
fail: (message: string, action?: ToastAction) => void
/** 알리지 않고 닫는다 */
dismiss: () => void
}
@@ -54,17 +57,18 @@ export function useToast() {
let id = store.show({ kind: 'progress', message })
const sameSession = () => authStore.generation === session
function settle(kind: TimedToastKind, text: string) {
function settle(kind: TimedToastKind, text: string, action?: ToastAction) {
if (!sameSession()) return
if (!store.update(id, { kind, message: text })) id = store.show({ kind, message: text })
const bound = action ? sessionBound(action) : null
if (!store.update(id, { kind, message: text, action: bound })) id = store.show({ kind, message: text, action: bound })
}
return {
update: (text) => {
if (sameSession()) store.update(id, { message: text })
},
succeed: (text) => settle('success', text),
fail: (text) => settle('error', text),
succeed: (text, action) => settle('success', text, action),
fail: (text, action) => settle('error', text, action),
dismiss: () => store.dismiss(id),
}
}