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:
@@ -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 }
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user