generated from ttipo/project-base
feat(frontend): 설정 › 메일 › 필터 · 읽기 화면에서 필터 만들기
확정 보드(SettingsFilter · MailFilterStates)대로 필터 목록 · 켜기 · 순서 · 지우기(되돌리기) · 고치기 창을 만들고, 읽기 화면 ⋯ 에 「이런 메일로 필터 만들기」를 둔다. - 저장은 한 번에 하나다. 되돌리기는 누른 차례대로, 앞 저장이 끝난 뒤에 보낸다 — 연달아 누르면 하나가 사라지던 경합을 리뷰에서 찾았다 - 다른 곳에서 먼저 바꿨으면 창 안에서도 다시 불러올 수 있고, 쓴 내용은 지킨다 - 메뉴의 꺼진 항목은 aria-disabled 로 둔다(편집기 메뉴 공용) — 키보드로 가서 까닭을 들을 수 있다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,9 @@ describe('에러 코드 사전', () => {
|
||||
'MAIL_009', // 같은 자리에 같은 이름의 메일함 — 메일함 이름 대화상자가 칸 아래에 보인다
|
||||
// 6단계 앞 수정
|
||||
'MAIL_010', // 엔진이 받는 사람을 모두 거절(없는 주소 등) — 쓰기 화면이 받는 사람 칸 아래 띠에 보인다
|
||||
// 웹메일 6단계
|
||||
'MAIL_012', // 그사이 다른 곳에서 필터가 바뀜 — 필터 화면이 알림 없이 목록 위 줄 · 고치기 창 안에 사전 문구를 직접 꺼내 보이고 [다시 불러오기]를 둔다
|
||||
'MAIL_013', // 필터가 엔진 한도를 넘음 — 필터 고치기 창이 알림 없이 창 안에 보인다
|
||||
]
|
||||
|
||||
for (const code of usedByClient) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import type { RejectedRecipient, SendMessagePayload } from '@/shared/types/mail'
|
||||
import type { MailFilterSet, RejectedRecipient, SendMessagePayload } from '@/shared/types/mail'
|
||||
import { installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { useMail } from './useMail'
|
||||
|
||||
@@ -40,3 +40,42 @@ describe('useMail.sendMessage — 받지 못한 사람은 늘 배열로 (계약
|
||||
expect(requests[0]?.config.headers.get('Idempotency-Key')).toBe('key-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMail 메일 필터 — GET · PUT /mail-filters (계약 2절 "메일 필터")', () => {
|
||||
const SET: MailFilterSet = {
|
||||
rules: [
|
||||
{
|
||||
id: '0f3c2a1e-7b8d-4c5e-9f10-2a3b4c5d6e7f',
|
||||
name: '거래처 메일',
|
||||
isEnabled: true,
|
||||
match: 'all',
|
||||
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
|
||||
actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false, forwardTo: null },
|
||||
},
|
||||
],
|
||||
version: 'v1',
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
it('읽기는 GET, 저장은 받은 version 과 목록 전체를 PUT 본문 { rules, version } 으로', async () => {
|
||||
const requests = installMockApi((request) => ok(request.method === 'GET' ? SET : { ...SET, version: 'v2' }))
|
||||
const mail = useMail()
|
||||
|
||||
await expect(mail.getFilters({ silent: true })).resolves.toEqual(SET)
|
||||
await expect(mail.saveFilters({ rules: SET.rules, version: SET.version })).resolves.toMatchObject({ version: 'v2' })
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'GET /mail-filters',
|
||||
'PUT /mail-filters',
|
||||
])
|
||||
expect(requests[1]?.data).toEqual({ rules: SET.rules, version: 'v1' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
Draft,
|
||||
DraftPayload,
|
||||
DraftSaved,
|
||||
MailFilterSet,
|
||||
MailFilterSetPayload,
|
||||
MailQuota,
|
||||
MailSignature,
|
||||
MailState,
|
||||
@@ -145,6 +147,21 @@ export function useMail() {
|
||||
return api.put<MailVacation, MailVacation>('/mail-vacation', payload, options)
|
||||
}
|
||||
|
||||
// ── 메일 필터 (계약 2절 "메일 필터" — 6단계) ──────────────────────
|
||||
|
||||
/** 규칙 · 지문(version) · 동작 여부 — 저장해 둔 원본이 없거나 깨진 계정은 빈 규칙 + isActive: false 로 온다 */
|
||||
function getFilters(options?: MailRequestOptions): Promise<MailFilterSet> {
|
||||
return api.get<MailFilterSet, MailFilterSet>('/mail-filters', options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 교체 — 받은 version 을 그대로 돌려보낸다. 그사이 다른 곳(다른 탭 · PC)에서 규칙이 바뀌었으면 저장하지 않고 409 MAIL_012,
|
||||
* 만든 엔진 규칙이 한도를 넘으면 400 MAIL_013. 저장하면 꺼져 있던 규칙(isActive: false)도 다시 켜진다
|
||||
*/
|
||||
function saveFilters(payload: MailFilterSetPayload, options?: MailRequestOptions): Promise<MailFilterSet> {
|
||||
return api.put<MailFilterSet, MailFilterSet>('/mail-filters', payload, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
|
||||
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
|
||||
@@ -339,6 +356,8 @@ export function useMail() {
|
||||
emptyMailbox,
|
||||
getVacation,
|
||||
saveVacation,
|
||||
getFilters,
|
||||
saveFilters,
|
||||
messageSourceUrl,
|
||||
downloadMessageSource,
|
||||
getMailState,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MailFilterRule, MailFilterSet, MailFilterSetPayload } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailFilterStore } from '@/stores/mail-filter.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 { useMailFilterActions } from './useMailFilterActions'
|
||||
|
||||
// 메일 필터의 지우기 · 되돌리기 알림 (디자인 원본 MailFilterStates "알림 문구" · 계약 2절 "메일 필터")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
|
||||
function rule(id: string, patch: Partial<MailFilterRule> = {}): MailFilterRule {
|
||||
return {
|
||||
id,
|
||||
name: `필터 ${id}`,
|
||||
isEnabled: true,
|
||||
match: 'all',
|
||||
conditions: [{ field: 'subject', operator: 'contains', value: id }],
|
||||
actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: null },
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
return { promise, finish }
|
||||
}
|
||||
|
||||
/** 서버 흉내 — 받은 목록을 그대로 저장하고 version 을 올린다 */
|
||||
function echo(request: MockRequest): MockResult {
|
||||
const body = request.data as MailFilterSetPayload
|
||||
return ok({ rules: body.rules, version: `${body.version}+`, isActive: true })
|
||||
}
|
||||
|
||||
describe('useMailFilterActions', () => {
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().startSession(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function setup(set: MailFilterSet, save: (request: MockRequest) => MockResult | Promise<MockResult> = echo) {
|
||||
requests = installMockApi((request) => (request.method === 'GET' ? ok(set) : save(request)))
|
||||
const store = useMailFilterStore()
|
||||
await store.load()
|
||||
return { store, actions: useMailFilterActions() }
|
||||
}
|
||||
|
||||
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data as MailFilterSetPayload)
|
||||
const messages = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
|
||||
function undo(toast: Toast | undefined) {
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
}
|
||||
|
||||
it('저장 하나를 기다리는 되돌리기 둘 — 둘 다 보내고 둘 다 성공 알림', async () => {
|
||||
const toggle = deferred()
|
||||
// 세 번째 PUT(켜기)만 손으로 끝낸다 — 앞의 둘은 지우기
|
||||
const { store, actions } = await setup({ rules: [rule('a'), rule('b'), rule('c'), rule('d')], version: 'v1', isActive: true }, (request) =>
|
||||
puts().length === 3 ? toggle.promise : echo(request),
|
||||
)
|
||||
|
||||
await actions.remove('b')
|
||||
await actions.remove('d')
|
||||
const [removedB, removedD] = useToastStore().toasts
|
||||
expect(messages()).toEqual(['‘필터 b’ 필터를 지웠습니다.', '‘필터 d’ 필터를 지웠습니다.'])
|
||||
|
||||
// 켜기가 저장되는 동안 두 알림의 [되돌리기]를 누른다
|
||||
const switching = store.setRuleEnabled('a', false)
|
||||
undo(removedB)
|
||||
undo(removedD)
|
||||
await flushPromises()
|
||||
expect(puts()).toHaveLength(3)
|
||||
|
||||
toggle.finish(echo(requests[requests.length - 1] as MockRequest))
|
||||
await switching
|
||||
await flushPromises()
|
||||
|
||||
expect(puts().slice(3).map((payload) => payload.rules.map((item) => item.id))).toEqual([
|
||||
['a', 'b', 'c'],
|
||||
['a', 'b', 'c', 'd'],
|
||||
])
|
||||
expect(store.filterSet?.rules.map((item) => item.id)).toEqual(['a', 'b', 'c', 'd'])
|
||||
expect(messages().filter((message) => message === '필터를 되돌렸습니다.')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('필터 화면이 떠 있지 않을 때 되돌리기가 그사이 바뀜(409)으로 실패하면 알림으로 — 화면이 떠 있으면 목록 위 줄만', async () => {
|
||||
let conflictNow = false
|
||||
const { store, actions } = await setup({ rules: [rule('a'), rule('b')], version: 'v1', isActive: true }, (request) =>
|
||||
conflictNow ? fail(409, 'MAIL_012') : echo(request),
|
||||
)
|
||||
await actions.remove('a')
|
||||
await actions.remove('b')
|
||||
const [removedA, removedB] = useToastStore().toasts
|
||||
useToastStore().clear()
|
||||
// 그사이 다른 곳에서 바꿨다 — 되돌리기는 409
|
||||
conflictNow = true
|
||||
|
||||
// 화면이 떠 있다 — 목록 위 줄이 알린다
|
||||
const detach = store.attachPage()
|
||||
undo(removedA)
|
||||
await flushPromises()
|
||||
expect(store.conflict).toBe(true)
|
||||
expect(messages()).toEqual([])
|
||||
detach()
|
||||
|
||||
// 화면 밖 — 볼 곳이 없으니 알림으로
|
||||
undo(removedB)
|
||||
await flushPromises()
|
||||
expect(messages()).toEqual([ERROR_CODE_MESSAGES.MAIL_012])
|
||||
})
|
||||
|
||||
it('되돌리면 50개를 넘으면 보내지 않고 오류 알림 — "필터는 50개까지 만들 수 있습니다. …"', async () => {
|
||||
const rules = Array.from({ length: 50 }, (_, index) => rule(`r${index}`))
|
||||
const { store, actions } = await setup({ rules, version: 'v1', isActive: true })
|
||||
|
||||
await actions.remove('r0')
|
||||
const [removed] = useToastStore().toasts
|
||||
// 그사이 다른 곳에서 하나를 더 만들어 다시 50개가 됐다
|
||||
await store.saveRule(rule('new'))
|
||||
useToastStore().clear()
|
||||
undo(removed)
|
||||
await flushPromises()
|
||||
|
||||
expect(puts()).toHaveLength(2)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', '필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.'],
|
||||
])
|
||||
})
|
||||
|
||||
it('지운 알림의 이름 — 이름 없는 필터는 조건 글이 50자에서 잘리고 큰따옴표로 묶인다 (작은따옴표가 겹치지 않게)', async () => {
|
||||
const long = rule('a', {
|
||||
name: '',
|
||||
conditions: [{ field: 'subject', operator: 'contains', value: '견적'.repeat(100) }],
|
||||
})
|
||||
const { actions } = await setup({ rules: [long], version: 'v1', isActive: true })
|
||||
|
||||
await actions.remove('a')
|
||||
|
||||
expect(messages()).toEqual([`“제목에 ‘${'견적'.repeat(22)}견…” 필터를 지웠습니다.`])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { MailFilterRule } from '@/shared/types/mail'
|
||||
import { quotedRuleTitle, type MailFilterRestorePoint } from '@/shared/utils/mail-filter'
|
||||
import {
|
||||
useMailFilterStore,
|
||||
type MailFilterRemoveOutcome,
|
||||
type MailFilterSaveOutcome,
|
||||
} from '@/stores/mail-filter.store'
|
||||
|
||||
// 메일 필터 설정 화면의 저장 · 지우기 알림 (디자인 README "6단계 › 상태 · 들어오는 길" · 디자인 원본 MailFilterStates "알림 문구")
|
||||
// - 고치기 창의 [저장]이 끝나면 "필터를 저장했습니다."
|
||||
// - 지우기는 확인 없이 하고 "‘거래처 메일’ 필터를 지웠습니다." + [되돌리기](10초 — 단추가 있는 알림, 띄운 세션에서만 움직인다 — useToast).
|
||||
// 되돌리기는 지운 규칙을 지운 자리(앞 · 뒤 규칙 기준)에 다시 저장한다 → "필터를 되돌렸습니다." 알림 여럿의 [되돌리기]는 누른 차례대로 보낸다(스토어).
|
||||
// 실패는 인터셉터가 알린다. 그사이 바뀜(409)만은 알림을 끄고 목록 위 줄이 알리는데, 필터 화면이 떠 있지 않으면 볼 곳이 없으니
|
||||
// 알림으로 알린다(고치기 창의 notifyUnseen 과 같은 규칙). 다시 넣으면 50개를 넘으면 보내지 않고 오류 알림(fullNote)
|
||||
// - 켜기 · 끄기 · 순서 바꾸기는 알리지 않는다 — 목록이 바로 바뀐다(실패하면 스토어가 되돌리고 인터셉터가 알린다)
|
||||
|
||||
export function useMailFilterActions() {
|
||||
const filterStore = useMailFilterStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** 고치기 창의 [저장] — 칸 안에 알릴 거절(VAL_001 · MAIL_013 · MAIL_012 · 50개)은 부르는 쪽이 그린다 */
|
||||
async function save(rule: MailFilterRule): Promise<MailFilterSaveOutcome> {
|
||||
const outcome = await filterStore.saveRule(rule)
|
||||
if (outcome.ok) toast.success(MAIL_FILTER_TEXT.saved)
|
||||
return outcome
|
||||
}
|
||||
|
||||
async function restore(point: MailFilterRestorePoint): Promise<void> {
|
||||
const outcome = await filterStore.restoreRule(point)
|
||||
if (outcome.ok) {
|
||||
toast.success(MAIL_FILTER_TEXT.restored)
|
||||
return
|
||||
}
|
||||
if (outcome.full) {
|
||||
toast.error(MAIL_FILTER_TEXT.fullNote)
|
||||
return
|
||||
}
|
||||
// 되돌리기는 그사이 바뀜(MAIL_012)만 알림을 끈다 — 필터 화면이 떠 있으면 목록 위 줄이 알린다
|
||||
if (outcome.error?.code === 'MAIL_012' && !filterStore.pageVisible) notifyApiError(outcome.error)
|
||||
}
|
||||
|
||||
/** [이 필터 지우기] — 지웠으면 [되돌리기] 알림 */
|
||||
async function remove(id: string): Promise<MailFilterRemoveOutcome> {
|
||||
const outcome = await filterStore.removeRule(id)
|
||||
if (outcome.ok) {
|
||||
const { point } = outcome
|
||||
toast.success(MAIL_FILTER_TEXT.removed(quotedRuleTitle(point.rule)), {
|
||||
label: MAIL_FILTER_TEXT.undo,
|
||||
run: () => void restore(point),
|
||||
})
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
return { save, remove }
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { notifyApiError, type ApiError } from './useApi'
|
||||
import { useMailFilterActions } 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'
|
||||
import { useMailFilterStore } from '@/stores/mail-filter.store'
|
||||
|
||||
// 설정 › 필터의 고치기 창 상태 — 연 창 · 보내는 중 · 창 안의 거절 (MailFilterPage 가 setup 에서 부른다)
|
||||
// - 저장 · 지우기 · 다시 불러오기는 한 번에 하나. 결과가 늦게 와도 그사이 닫고 다시 연 창에는 쓰지 않는다(연 차례 token)
|
||||
// - 창 안에 보이는 거절은 형식(VAL_001) · 엔진 한도(MAIL_013) · 50개 넘음(fullNote) · 그사이 바뀜(MAIL_012 — 목록 위 줄과 함께)이다.
|
||||
// 그 밖은 인터셉터가 알렸다. 알림을 끈 그 거절이 창(또는 화면)이 사라진 뒤에 오면 알림으로 알린다 — 그사이 바뀜은 화면이 남아 있으면 목록 위 줄이 알린다
|
||||
// - 그사이 바뀜이면 창 안에 [다시 불러오기]를 둔다(conflict — 창이 모달이라 목록 위 줄을 누를 수 없다). 다시 받으면 창 · 쓴 내용은 그대로 두고 안내를 내린다 —
|
||||
// 다음 [저장]은 다시 받은 목록 · version 위에 넣는다(스토어의 saveRule). 칸을 고쳐도 이 안내는 남는다(다시 받아야 풀린다)
|
||||
// - 끝나면 초점을 줄 규칙을 돌려준다 — 저장은 그 규칙, 지우기는 이웃 행(다음, 마지막이었으면 앞). 초점은 화면이 옮긴다
|
||||
|
||||
export type MailFilterDialogPending = 'save' | 'remove' | 'reload' | null
|
||||
|
||||
export interface MailFilterDialogState {
|
||||
/** 고칠 규칙 — null 이면 새 필터 */
|
||||
rule: MailFilterRule | null
|
||||
/** 새 필터를 채울 보낸 사람 주소 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」) */
|
||||
prefillFrom: string | null
|
||||
/** 연 차례 */
|
||||
token: number
|
||||
}
|
||||
|
||||
/** closed — 끝나서 창을 닫았다(그 뒤의 초점은 focusRuleId 의 행, 없으면 화면이 정한다). false 면 창이 그대로다 */
|
||||
export interface MailFilterDialogResult {
|
||||
closed: boolean
|
||||
focusRuleId: string | null
|
||||
}
|
||||
|
||||
/** 창 안에 사전 문구로 보이는 저장 거절 */
|
||||
const DIALOG_ERROR_CODES: readonly ErrorCode[] = ['VAL_001', 'MAIL_013', 'MAIL_012']
|
||||
const KEPT: MailFilterDialogResult = { closed: false, focusRuleId: null }
|
||||
|
||||
export function useMailFilterDialog() {
|
||||
const filterStore = useMailFilterStore()
|
||||
const filterActions = useMailFilterActions()
|
||||
|
||||
const state = ref<MailFilterDialogState | null>(null)
|
||||
const pending = ref<MailFilterDialogPending>(null)
|
||||
const error = ref<string | null>(null)
|
||||
/** 창 안의 거절이 그사이 바뀜(MAIL_012)이다 — 창 안에 [다시 불러오기]를 둔다 */
|
||||
const conflict = ref<boolean>(false)
|
||||
let token = 0
|
||||
/** 화면이 사라졌다 — 창 안 · 목록 위 줄의 글을 볼 곳이 없다 */
|
||||
let disposed = false
|
||||
|
||||
function showError(message: string | null, isConflict = false) {
|
||||
error.value = message
|
||||
conflict.value = message !== null && isConflict
|
||||
}
|
||||
|
||||
function open(rule: MailFilterRule | null, prefillFrom: string | null = null) {
|
||||
token += 1
|
||||
state.value = { rule, prefillFrom, token }
|
||||
pending.value = null
|
||||
showError(null)
|
||||
}
|
||||
|
||||
function close() {
|
||||
state.value = null
|
||||
pending.value = null
|
||||
showError(null)
|
||||
}
|
||||
|
||||
/** 칸을 고쳤다 — 창 안의 거절을 내린다. 그사이 바뀜은 다시 불러와야 풀리므로 그대로 둔다 */
|
||||
function clearError() {
|
||||
if (!conflict.value) showError(null)
|
||||
}
|
||||
|
||||
/** 그 창이 아직 열려 있고 화면도 그대로다 — 결과를 창 안에 보일 수 있다 */
|
||||
function isCurrent(opened: number): boolean {
|
||||
return !disposed && state.value?.token === opened
|
||||
}
|
||||
|
||||
function dialogMessageOf(apiError: ApiError | null): string | null {
|
||||
return apiError && DIALOG_ERROR_CODES.includes(apiError.code) ? apiError.message : null
|
||||
}
|
||||
|
||||
/** 알림을 끈 거절이 보일 곳 없이 왔다 — silenced: 그 요청이 알림을 끈 코드 */
|
||||
function notifyUnseen(apiError: ApiError | null, silenced: readonly ErrorCode[]) {
|
||||
if (!apiError || !silenced.includes(apiError.code)) return
|
||||
if (disposed || apiError.code !== 'MAIL_012') notifyApiError(apiError)
|
||||
}
|
||||
|
||||
/** [저장] — 지금 목록 위에 넣는다(있는 규칙은 그 자리, 새 규칙 · 다른 곳에서 지워진 규칙은 맨 아래 — 스토어의 saveRule) */
|
||||
async function save(rule: MailFilterRule): 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)
|
||||
if (!isCurrent(current.token)) {
|
||||
if (!outcome.ok) notifyUnseen(outcome.error, DIALOG_ERROR_CODES)
|
||||
return KEPT
|
||||
}
|
||||
pending.value = null
|
||||
if (outcome.ok) {
|
||||
close()
|
||||
return { closed: true, focusRuleId: rule.id }
|
||||
}
|
||||
if (outcome.full) showError(MAIL_FILTER_TEXT.fullNote)
|
||||
else showError(dialogMessageOf(outcome.error), outcome.error?.code === 'MAIL_012')
|
||||
return KEPT
|
||||
}
|
||||
|
||||
/** [이 필터 지우기] — 확인 없이 (알림의 [되돌리기]는 useMailFilterActions) */
|
||||
async function remove(): Promise<MailFilterDialogResult> {
|
||||
const current = state.value
|
||||
const target = current?.rule
|
||||
if (!current || !target || pending.value || filterStore.saving) return KEPT
|
||||
const rules = filterStore.filterSet?.rules ?? []
|
||||
const index = rules.findIndex((rule) => rule.id === target.id)
|
||||
// 다시 불러온 목록에 없다 — 다른 곳에서 이미 지웠다. 지울 것이 없으니 닫는다
|
||||
if (index < 0) {
|
||||
close()
|
||||
return { closed: true, focusRuleId: null }
|
||||
}
|
||||
const neighbor = rules[index + 1]?.id ?? rules[index - 1]?.id ?? null
|
||||
pending.value = 'remove'
|
||||
showError(null)
|
||||
const outcome = await filterActions.remove(target.id)
|
||||
if (!isCurrent(current.token)) {
|
||||
// 지우기는 그사이 바뀜만 알림을 끈다
|
||||
if (!outcome.ok) notifyUnseen(outcome.error, ['MAIL_012'])
|
||||
return KEPT
|
||||
}
|
||||
pending.value = null
|
||||
if (outcome.ok) {
|
||||
close()
|
||||
return { closed: true, focusRuleId: neighbor }
|
||||
}
|
||||
if (outcome.error?.code === 'MAIL_012') showError(outcome.error.message, true)
|
||||
return KEPT
|
||||
}
|
||||
|
||||
/**
|
||||
* 창 안의 [다시 불러오기] — 목록을 다시 받고 창 · 쓴 내용은 그대로 둔다. 받으면 창 안 안내와 목록 위 줄을 내리고 true.
|
||||
* 못 받으면 인터셉터가 알리고 안내는 그대로 둔다
|
||||
*/
|
||||
async function reload(): Promise<boolean> {
|
||||
const current = state.value
|
||||
if (!current || pending.value) return false
|
||||
pending.value = 'reload'
|
||||
const done = await filterStore.reload()
|
||||
if (!isCurrent(current.token)) return false
|
||||
pending.value = null
|
||||
if (done) showError(null)
|
||||
return done
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
})
|
||||
|
||||
return { state, pending, error, conflict, open, close, clearError, save, remove, reload }
|
||||
}
|
||||
Reference in New Issue
Block a user