generated from ttipo/project-base
chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답) ·
|
||||
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog)
|
||||
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog) ·
|
||||
// 메일 필터 고치기 창(MailFilterDialog — 넓은 창 620, wide)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
@@ -21,6 +22,8 @@ interface Props {
|
||||
describedBy?: string
|
||||
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
|
||||
role?: 'alertdialog'
|
||||
/** 칸이 많은 창 — 폭 620 · 좌우 여백 20 (디자인 원본 SettingsFilter 의 고치기 창). 주지 않으면 공용 폭 360 */
|
||||
wide?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -33,6 +36,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
messageId: undefined,
|
||||
describedBy: undefined,
|
||||
role: undefined,
|
||||
wide: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
@@ -97,7 +101,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="modal-dialog"
|
||||
:class="['modal-dialog', { 'modal-dialog--wide': wide }]"
|
||||
:role="role"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="descriptionId"
|
||||
@@ -135,6 +139,12 @@ onMounted(() => {
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
/* 넓은 창 — 칸이 많은 고치기 창 (디자인 원본 SettingsFilter: 폭 620 · 안쪽 여백 18 20 14) */
|
||||
.modal-dialog--wide {
|
||||
width: min(38.75rem, calc(100vw - var(--space-48)));
|
||||
padding: var(--space-18) var(--space-20) var(--space-14);
|
||||
}
|
||||
|
||||
.modal-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -178,13 +188,14 @@ onMounted(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action:not(:disabled):hover) {
|
||||
:slotted(.modal-dialog__action:not(:disabled):not([aria-disabled='true']):hover) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/*
|
||||
* 아직 누를 수 없는 단추 (메일함 이름을 비웠다 · 보내는 중 · 함께 돌 수 없는 처리가 도는 중) — 마우스를 올려도 그대로.
|
||||
* 마우스를 올린 모양은 모두 :not(:disabled) 로만 그린다 — 꺼진 빨강 · 보조 단추가 브랜드색으로 바뀌지 않게
|
||||
* 마우스를 올린 모양은 모두 :not(:disabled):not([aria-disabled='true']) 로만 그린다 — 꺼진 빨강 · 보조 단추가 브랜드색으로 바뀌지 않게
|
||||
* (초점을 지키려고 disabled 대신 aria-disabled 로 막은 단추 — 필터 고치기 창의 [저장] · [이 필터 지우기] — 도 같다)
|
||||
*/
|
||||
:slotted(.modal-dialog__action:disabled) {
|
||||
opacity: 0.5;
|
||||
@@ -196,7 +207,7 @@ onMounted(() => {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--danger:not(:disabled):hover) {
|
||||
:slotted(.modal-dialog__action--danger:not(:disabled):not([aria-disabled='true']):hover) {
|
||||
background: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
@@ -207,7 +218,7 @@ onMounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--secondary:not(:disabled):hover) {
|
||||
:slotted(.modal-dialog__action--secondary:not(:disabled):not([aria-disabled='true']):hover) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -341,9 +341,9 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['progress', '‘거래처’ 메일함을 지우는 중 · 500 / 1,234통'],
|
||||
])
|
||||
// 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다
|
||||
// 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다 (꺼진 항목은 aria-disabled — 초점은 받는다)
|
||||
const items = await openMenu(wrapper, '거래처')
|
||||
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
|
||||
expect(items.every((item) => item.attributes('aria-disabled') === 'true')).toBe(true)
|
||||
|
||||
second.resolve(ok({ deleted: false, processed: 734, movedToTrash: 734, remaining: 0 }))
|
||||
await flushPromises()
|
||||
@@ -388,7 +388,7 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
expect(store.mailboxes.find((mailbox) => mailbox.id === 'inbox')?.unreadCount).toBe(734)
|
||||
// 처리하는 동안 다른 일을 해도 된다 — 같은 처리만 다시 누를 수 없다
|
||||
const items = await openMenu(wrapper, '받은편지함')
|
||||
expect(items.map((item) => [item.text(), item.attributes('disabled') !== undefined])).toEqual([
|
||||
expect(items.map((item) => [item.text(), item.attributes('aria-disabled') === 'true'])).toEqual([
|
||||
['모두 읽음으로 표시', true],
|
||||
['하위 메일함 만들기', false],
|
||||
])
|
||||
@@ -587,7 +587,7 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
|
||||
describe('휴지통 비우기 ↔ 메일함 지우기 — 함께 돌지 않는다', () => {
|
||||
const itemState = (items: DOMWrapper<Element>[]) =>
|
||||
items.map((item) => [item.text(), item.attributes('disabled') !== undefined])
|
||||
items.map((item) => [item.text(), item.attributes('aria-disabled') === 'true'])
|
||||
|
||||
it('휴지통을 비우는 동안은 「지우기」가 꺼진다 — 끝나면 다시 켜진다', async () => {
|
||||
const second = deferred()
|
||||
|
||||
@@ -1,49 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴에 "원문 내려받기 (.eml)" (디자인 원본 MailActions4 · 계약 2절 "스팸 · 원문")
|
||||
// 과 그 아래 「이런 메일로 필터 만들기」(깔때기 — 디자인 원본 MailFilterStates · 계약 2절 "메일 필터"의 "클라이언트")
|
||||
// 메뉴는 이동 ▾ 과 같은 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에,
|
||||
// 마우스로 열면 메뉴 자체에 초점. Esc 는 메뉴만 닫고 이 단추로 돌아간다.
|
||||
// 원문 내려받기는 첨부 내려받기처럼 이동하기 전에 세션을 확인한다 (useMail 의 downloadMessageSource — confirmSession)
|
||||
// 필터 만들기는 설정 › 필터로 간다 — /settings/mail/filters?from=<보낸 사람 주소> (새 필터 창이 그 주소로 채워져 열린다).
|
||||
// 보낸 사람 주소가 없는 메일에는 두지 않는다. 필터를 50개 다 만들었으면 끄고 까닭을 아래에 보인다 —
|
||||
// 메뉴를 열 때 필터를 아직 받지 않았으면 받아 본다(알리지 않는다 — 모르는 동안은 켜 두고, 설정 화면이 다시 본다)
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import { useMailFilterStore } from '@/stores/mail-filter.store'
|
||||
|
||||
interface Props {
|
||||
messageId: string
|
||||
/** 보낸 사람 주소 — 없으면 「이런 메일로 필터 만들기」를 두지 않는다 */
|
||||
senderEmail?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
senderEmail: null,
|
||||
})
|
||||
|
||||
const { downloadMessageSource } = useMail()
|
||||
const filterStore = useMailFilterStore()
|
||||
const router = useRouter()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const open = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
|
||||
const ITEMS: readonly EditorMenuItem[] = [
|
||||
{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' },
|
||||
]
|
||||
const sender = computed(() => props.senderEmail?.trim() ?? '')
|
||||
const items = computed<EditorMenuItem[]>(() => {
|
||||
const list: EditorMenuItem[] = [{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' }]
|
||||
if (sender.value) {
|
||||
list.push({
|
||||
id: 'filter',
|
||||
label: MAIL_READER_MORE_TEXT.createFilter,
|
||||
icon: 'filter',
|
||||
disabled: filterStore.isFull,
|
||||
note: filterStore.isFull ? MAIL_FILTER_TEXT.fullNote : undefined,
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
|
||||
|
||||
function openMenu(keyboard: boolean) {
|
||||
byKeyboard.value = keyboard
|
||||
open.value = true
|
||||
// 필터를 50개 다 만들었는지 — 받아 둔 것이 없을 때만 받아 본다
|
||||
if (sender.value) filterStore.ensureLoaded()
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
// detail 0 — Enter · Space 로 눌렀다
|
||||
byKeyboard.value = event.detail === 0
|
||||
open.value = true
|
||||
openMenu(event.detail === 0)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' || open.value) return
|
||||
event.preventDefault()
|
||||
byKeyboard.value = true
|
||||
open.value = true
|
||||
openMenu(true)
|
||||
}
|
||||
|
||||
function onClose(restoreFocus: boolean) {
|
||||
@@ -55,6 +85,9 @@ function onSelect(id: string) {
|
||||
open.value = false
|
||||
buttonRef.value?.focus()
|
||||
if (id === 'source') void downloadMessageSource(props.messageId)
|
||||
if (id === 'filter' && sender.value && !filterStore.isFull) {
|
||||
void router.push({ name: SETTINGS_ROUTES.filters, query: { [MAIL_FILTER_FROM_QUERY]: sender.value } })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -78,7 +111,7 @@ function onSelect(id: string) {
|
||||
v-if="open"
|
||||
class="reader-more__menu"
|
||||
:label="MAIL_READER_MORE_TEXT.button"
|
||||
:items="ITEMS"
|
||||
:items="items"
|
||||
:trigger="trigger"
|
||||
align="end"
|
||||
:focus-content="!byKeyboard"
|
||||
@@ -113,8 +146,9 @@ function onSelect(id: string) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 메뉴 폭 200 (디자인 원본 MailActions4) */
|
||||
/* 메뉴 폭 200 (디자인 원본 MailActions4) — 필터 50개의 까닭(note)은 280 안에서 줄을 바꾼다 */
|
||||
.reader-more__menu {
|
||||
min-width: 12.5rem;
|
||||
max-width: 17.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { h } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MessageDetail, MessagesPatch } from '@/shared/types/mail'
|
||||
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch } from '@/shared/types/mail'
|
||||
import type { MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -47,6 +47,9 @@ const DETAIL: MessageDetail = {
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
/** 필터가 없는 계정 — 읽기 화면 ⋯ 를 열면 50개 여부를 알려고 받는다 */
|
||||
const NO_FILTERS: MailFilterSet = { rules: [], version: 'v1', isActive: true }
|
||||
|
||||
describe('MessageReader 도구 모음', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
@@ -487,12 +490,20 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
})
|
||||
|
||||
async function mountWith(
|
||||
options: { box?: string; mailboxes?: Mailbox[]; me?: MockResult; refresh?: MockResult } = {},
|
||||
options: {
|
||||
box?: string
|
||||
mailboxes?: Mailbox[]
|
||||
me?: MockResult
|
||||
refresh?: MockResult
|
||||
filters?: MailFilterSet
|
||||
from?: MessageDetail['from']
|
||||
} = {},
|
||||
) {
|
||||
const box = options.box ?? 'inbox'
|
||||
const mailboxes = options.mailboxes ?? [INBOX, JUNK, TRASH]
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/auth/me') return options.me ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
if (request.url === '/mail-filters') return ok(options.filters ?? NO_FILTERS)
|
||||
if (request.url === '/auth/refresh') return options.refresh ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
if (request.url === '/mailboxes') return ok({ items: mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
|
||||
if (request.url === '/messages' && request.method === 'GET') {
|
||||
@@ -504,7 +515,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
const store = useMailStore()
|
||||
store.mailboxes = mailboxes
|
||||
store.listSource = { kind: 'mailbox', mailboxId: box }
|
||||
const open = { ...DETAIL, mailboxIds: [box] }
|
||||
const open = { ...DETAIL, mailboxIds: [box], from: options.from === undefined ? DETAIL.from : options.from }
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
@@ -580,13 +591,14 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
await more.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
|
||||
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
|
||||
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)', '이런 메일로 필터 만들기'])
|
||||
expect(document.activeElement).toBe(items[0]?.element)
|
||||
|
||||
await items[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /auth/me'])
|
||||
// 메뉴를 열 때 필터를 한 번 받아 본다 (50개를 다 만들었는지 — 「이런 메일로 필터 만들기」)
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /mail-filters', 'GET /auth/me'])
|
||||
expect(downloads).toEqual(['/api/messages/m1/source'])
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(more.element)
|
||||
@@ -603,4 +615,94 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
expect(downloads).toEqual([])
|
||||
expect(useAuthStore().sessionExpired).toBe(true)
|
||||
})
|
||||
|
||||
it('⋯ → 「이런 메일로 필터 만들기」(원문 내려받기 아래) — 설정 › 필터로 보낸 사람 주소를 싣고 간다', async () => {
|
||||
const { wrapper, router } = await mountWith()
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
const item = wrapper.findAll('[role="menuitem"]')[1]
|
||||
expect(item?.find('polygon').attributes('points')).toBe('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')
|
||||
expect(item?.attributes('disabled')).toBeUndefined()
|
||||
|
||||
await item?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('settings-mail-filters')
|
||||
expect(router.currentRoute.value.query).toEqual({ from: 'doyun.lee@arasystem.it.kr' })
|
||||
})
|
||||
|
||||
const fiftyFilters = (): MailFilterRule[] =>
|
||||
Array.from({ length: 50 }, (_, index) => ({
|
||||
id: `f${index}`,
|
||||
name: '',
|
||||
isEnabled: true,
|
||||
match: 'always',
|
||||
conditions: [],
|
||||
actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: null },
|
||||
}))
|
||||
|
||||
it('필터를 50개 다 만들었으면 그 항목을 끄고 까닭을 아래에 — 키보드로 가서 까닭은 듣되 고를 수 없다 (WAI-ARIA 메뉴)', async () => {
|
||||
const { wrapper, router } = await mountWith({ filters: { rules: fiftyFilters(), version: 'v1', isActive: true } })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menuitem"]')
|
||||
const item = items[1]
|
||||
// 꺼진 항목은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점을 받는다
|
||||
expect(item?.attributes('aria-disabled')).toBe('true')
|
||||
expect(item?.attributes('disabled')).toBeUndefined()
|
||||
const note = item?.get('.menu__note')
|
||||
expect(note?.text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.')
|
||||
expect(item?.attributes('aria-describedby')).toBe(note?.attributes('id'))
|
||||
expect(document.activeElement).toBe(items[0]?.element)
|
||||
await items[0]?.trigger('keydown', { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(item?.element)
|
||||
|
||||
await item?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
// 꺼진 항목을 골라도 메뉴는 그대로다
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('50개로 받아 둔 뒤 다른 곳에서 지웠으면 — ⋯ 를 다시 열 때 알림 없이 다시 받아 항목을 켠다', async () => {
|
||||
const server: { filters: MailFilterSet } = { filters: { rules: fiftyFilters(), version: 'v1', isActive: true } }
|
||||
const { wrapper } = await mountWith({
|
||||
get filters() {
|
||||
return server.filters
|
||||
},
|
||||
})
|
||||
const more = wrapper.get('[data-reader-more]')
|
||||
const filterItem = () => wrapper.findAll('[role="menuitem"]')[1]
|
||||
const filterGets = () => requests.filter((request) => request.url === '/mail-filters')
|
||||
|
||||
await more.trigger('click')
|
||||
await flushPromises()
|
||||
expect(filterItem()?.attributes('aria-disabled')).toBe('true')
|
||||
await more.trigger('click')
|
||||
|
||||
server.filters = { rules: fiftyFilters().slice(1), version: 'v2', isActive: true }
|
||||
await more.trigger('click')
|
||||
await flushPromises()
|
||||
expect(filterItem()?.attributes('aria-disabled')).toBeUndefined()
|
||||
expect(filterItem()?.find('.menu__note').exists()).toBe(false)
|
||||
expect(filterGets()).toHaveLength(2)
|
||||
|
||||
// 49개면 다시 받지 않는다
|
||||
await more.trigger('click')
|
||||
await more.trigger('click')
|
||||
await flushPromises()
|
||||
expect(filterGets()).toHaveLength(2)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('보낸 사람 주소가 없는 메일에는 「이런 메일로 필터 만들기」를 두지 않는다 (필터도 받지 않는다)', async () => {
|
||||
const { wrapper } = await mountWith({ from: null })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('[role="menuitem"]').map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
|
||||
expect(requests.some((request) => request.url === '/mail-filters')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
|
||||
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
|
||||
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
|
||||
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기) (디자인 README "4단계 › 스팸 · 원문").
|
||||
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기 · 이런 메일로 필터 만들기)
|
||||
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길").
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
|
||||
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
|
||||
import { computed } from 'vue'
|
||||
@@ -110,7 +111,10 @@ function leaveToList() {
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
<MessageMoreMenu :message-id="message.id" />
|
||||
<MessageMoreMenu
|
||||
:message-id="message.id"
|
||||
:sender-email="message.from?.email ?? null"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
// 툴바 메뉴 — 글꼴 · 글자 크기 · 더보기 (디자인 원본 ComposeEditor "더보기 메뉴" · "글꼴 · 글자 크기")
|
||||
// 항목 높이 40 · 아이콘 16 · 이름 · 단축키(흐린 글자) · 켜진 것은 오른쪽 ✓ · 한 단계 더 여는 것은 ›
|
||||
// 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯
|
||||
// 꺼진 항목(disabled)은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴 권고).
|
||||
// 누를 수 없는 까닭은 이름 아래 작은 글(note)이고 항목이 aria-describedby 로 가리킨다 — 꺼진 항목이어도 그 글은 흐리지 않는다
|
||||
import { useId } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorColorGlyph from '@/components/mail/editor/EditorColorGlyph.vue'
|
||||
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
|
||||
@@ -28,9 +32,22 @@ withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 이 메뉴의 고유 접두 — 까닭(note)의 id */
|
||||
const menuId = useId()
|
||||
|
||||
function isToggle(role: string | undefined): boolean {
|
||||
return role === 'menuitemcheckbox' || role === 'menuitemradio'
|
||||
}
|
||||
|
||||
function noteId(itemId: string): string {
|
||||
return `${menuId}-${itemId}-note`
|
||||
}
|
||||
|
||||
/** 꺼진 항목은 골라도 아무것도 하지 않는다 (메뉴도 닫지 않는다) */
|
||||
function select(item: EditorMenuItem, event: MouseEvent) {
|
||||
if (item.separator || item.disabled) return
|
||||
emit('select', item.id, event.detail === 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,12 +70,13 @@ function isToggle(role: string | undefined): boolean {
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger' }]"
|
||||
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger', 'menu__item--noted': item.note }]"
|
||||
:role="item.role ?? 'menuitem'"
|
||||
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
|
||||
:aria-haspopup="item.opens"
|
||||
:disabled="item.disabled"
|
||||
@click="(event: MouseEvent) => emit('select', item.id, event.detail === 0)"
|
||||
:aria-disabled="item.disabled ? 'true' : undefined"
|
||||
:aria-describedby="item.note ? noteId(item.id) : undefined"
|
||||
@click="(event: MouseEvent) => select(item, event)"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="item.icon"
|
||||
@@ -71,7 +89,22 @@ function isToggle(role: string | undefined): boolean {
|
||||
:color="item.colorGlyph.color"
|
||||
class="menu__icon"
|
||||
/>
|
||||
<span :class="['menu__label', item.preview ? `preview--${item.preview}` : '']">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.note"
|
||||
class="menu__text"
|
||||
>
|
||||
<span class="menu__label">{{ item.label }}</span>
|
||||
<!-- 이름에 섞이지 않게 숨기고 aria-describedby 로만 읽힌다 -->
|
||||
<span
|
||||
:id="noteId(item.id)"
|
||||
class="menu__note"
|
||||
aria-hidden="true"
|
||||
>{{ item.note }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
:class="['menu__label', item.preview ? `preview--${item.preview}` : '']"
|
||||
>{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="menu__badge"
|
||||
@@ -117,11 +150,11 @@ function isToggle(role: string | undefined): boolean {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.menu__item:hover:not(:disabled) {
|
||||
.menu__item:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.menu__item:disabled {
|
||||
.menu__item[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -131,7 +164,7 @@ function isToggle(role: string | undefined): boolean {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.menu__item--danger:hover:not(:disabled) {
|
||||
.menu__item--danger:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-danger-surface);
|
||||
}
|
||||
|
||||
@@ -148,6 +181,32 @@ function isToggle(role: string | undefined): boolean {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 이름 + 아래 작은 글 (note) — 글은 메뉴 폭 안에서 줄을 바꾼다 */
|
||||
.menu__text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-6) 0;
|
||||
}
|
||||
|
||||
.menu__note {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* 까닭이 붙은 꺼진 항목 — 이름 · 아이콘만 흐리게, 까닭은 그대로 읽힌다 */
|
||||
.menu__item--noted[aria-disabled='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.menu__item--noted[aria-disabled='true'] .menu__label,
|
||||
.menu__item--noted[aria-disabled='true'] .menu__icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.menu__badge,
|
||||
.menu__hint {
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -33,6 +33,10 @@ const emit = defineEmits<Emits>()
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/**
|
||||
* 화살표로 오가는 항목 — 꺼진 메뉴 항목은 aria-disabled 라 여기에 든다(초점은 받되 고를 수 없다 — WAI-ARIA 메뉴, EditorMenu).
|
||||
* disabled 단추는 초점을 받을 수 없어 건너뛴다
|
||||
*/
|
||||
const MENU_ITEM = '[role^="menuitem"]:not(:disabled)'
|
||||
const FOCUSABLE = 'button:not(:disabled), input:not(:disabled), [tabindex="0"]'
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface EditorMenuEntry {
|
||||
hint?: string
|
||||
checked?: boolean
|
||||
disabled?: boolean
|
||||
/** 이름 아래 작은 글 — 누를 수 없는 까닭 등 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」 — 필터 50개). 꺼져도 흐리지 않는다 */
|
||||
note?: string
|
||||
/** danger: 되돌릴 수 없는 일 — 빨간 글자 (메일함 ⋯ 메뉴의 지우기 · 비우기) */
|
||||
tone?: 'danger'
|
||||
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<script setup lang="ts">
|
||||
// 고치기 창의 "할 일" (디자인 원본 SettingsFilter) — 줄마다 44:
|
||||
// [x] 메일함으로 옮기기 + 옮길 메일함(40) · "옮기면 이 아래 필터는 적용하지 않습니다." / [x] 읽음으로 표시 / [x] 중요 표시 /
|
||||
// [x] 동료에게 전달 + 사내 주소(40) · 안내(잘못이면 그 자리가 빨간 글)
|
||||
// 끈 할 일의 칸은 흐리게 — 고른 메일함 · 쓴 주소는 남긴다. 할 일을 하나도 고르지 않았으면 묶음 아래 빨간 글(첫 칸이 그 글을 가리킨다).
|
||||
// 옮길 메일함은 받은편지함 · 내 메일함 · 스팸메일함 · 휴지통(filterMoveTargets). 골라 둔 메일함이 지워졌으면
|
||||
// "(지워진 메일함) — 다른 메일함을 고르세요"가 골라진 채로 보인다(다시 고를 수는 없다). 고를 수 없는 곳(보관함 등)을 가리키면 그 이름을 그대로 보인다
|
||||
// 고정 id — #mail-filter-move · #mail-filter-move-target · #mail-filter-move-hint · #mail-filter-read · #mail-filter-flag ·
|
||||
// #mail-filter-forward · #mail-filter-forward-to · #mail-filter-forward-help · #mail-filter-actions-error
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { MAIL_FILTER_DIALOG_TEXT, MAIL_FILTER_FORWARD_MAX_LENGTH } from '@/shared/constants/mail-filter'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import type { MailFilterActionDraft } from '@/shared/utils/mail-filter'
|
||||
|
||||
interface Props {
|
||||
value: MailFilterActionDraft
|
||||
/** 고를 수 있는 메일함 — 이 차례로 보인다 */
|
||||
targets: readonly Mailbox[]
|
||||
/** 모든 메일함 — 골라 둔 메일함이 있는지 본다 */
|
||||
mailboxes: readonly Mailbox[]
|
||||
/** 로그인한 직원 주소의 도메인 — 주소 칸의 보기 */
|
||||
domain: string
|
||||
/** 할 일을 하나도 고르지 않았다 — 칸 문구 */
|
||||
actionsError: string | null
|
||||
/** 전달 주소의 잘못 — 칸 문구 (안내 자리에 빨간 글로) */
|
||||
forwardError: string | null
|
||||
/** 처음 초점을 첫 칸(메일함으로 옮기기)에 — 읽기 화면에서 만든 새 필터 */
|
||||
autofocusFirst: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update', patch: Partial<MailFilterActionDraft>): void
|
||||
}
|
||||
|
||||
interface MoveOption {
|
||||
id: string
|
||||
label: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const moveRef = ref<HTMLInputElement | null>(null)
|
||||
const forwardRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const options = computed<MoveOption[]>(() => {
|
||||
const list = props.targets.map((mailbox) => ({ id: mailbox.id, label: mailbox.name, disabled: false }))
|
||||
const target = props.value.moveTarget
|
||||
if (target === '' || list.some((option) => option.id === target)) return list
|
||||
const known = props.mailboxes.find((mailbox) => mailbox.id === target)
|
||||
const current = known
|
||||
? { id: known.id, label: known.name, disabled: false }
|
||||
: { id: target, label: MAIL_FILTER_DIALOG_TEXT.missingMailboxOption, disabled: true }
|
||||
return [current, ...list]
|
||||
})
|
||||
|
||||
function checked(event: Event): boolean {
|
||||
return (event.target as HTMLInputElement).checked
|
||||
}
|
||||
|
||||
function valueOf(event: Event): string {
|
||||
return (event.target as HTMLInputElement | HTMLSelectElement).value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/** 할 일이 없을 때 · 읽기 화면에서 만든 새 필터 — 첫 칸(메일함으로 옮기기)으로 */
|
||||
focusFirst: () => moveRef.value?.focus(),
|
||||
/** 전달 주소가 잘못됐을 때 */
|
||||
focusForward: () => forwardRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset class="filter-actions">
|
||||
<legend class="filter-actions__legend">
|
||||
{{ MAIL_FILTER_DIALOG_TEXT.actionsLegend }}
|
||||
</legend>
|
||||
|
||||
<div class="filter-actions__line">
|
||||
<label class="filter-actions__check filter-actions__check--lead">
|
||||
<input
|
||||
id="mail-filter-move"
|
||||
ref="moveRef"
|
||||
type="checkbox"
|
||||
class="filter-actions__box"
|
||||
:checked="value.move"
|
||||
:autofocus="autofocusFirst || undefined"
|
||||
:aria-describedby="actionsError ? 'mail-filter-actions-error' : undefined"
|
||||
@change="emit('update', { move: checked($event) })"
|
||||
>
|
||||
<span>{{ MAIL_FILTER_DIALOG_TEXT.move }}</span>
|
||||
</label>
|
||||
<select
|
||||
id="mail-filter-move-target"
|
||||
class="form-input filter-actions__select"
|
||||
:aria-label="MAIL_FILTER_DIALOG_TEXT.moveTarget"
|
||||
aria-describedby="mail-filter-move-hint"
|
||||
:value="value.moveTarget"
|
||||
:disabled="!value.move"
|
||||
@change="emit('update', { moveTarget: valueOf($event) })"
|
||||
>
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
:disabled="option.disabled"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p
|
||||
id="mail-filter-move-hint"
|
||||
class="form-message filter-actions__hint filter-actions__hint--spaced"
|
||||
>
|
||||
{{ MAIL_FILTER_DIALOG_TEXT.moveHint }}
|
||||
</p>
|
||||
|
||||
<label class="filter-actions__check">
|
||||
<input
|
||||
id="mail-filter-read"
|
||||
type="checkbox"
|
||||
class="filter-actions__box"
|
||||
:checked="value.markAsRead"
|
||||
@change="emit('update', { markAsRead: checked($event) })"
|
||||
>
|
||||
<span>{{ MAIL_FILTER_DIALOG_TEXT.markAsRead }}</span>
|
||||
</label>
|
||||
<label class="filter-actions__check">
|
||||
<input
|
||||
id="mail-filter-flag"
|
||||
type="checkbox"
|
||||
class="filter-actions__box"
|
||||
:checked="value.flag"
|
||||
@change="emit('update', { flag: checked($event) })"
|
||||
>
|
||||
<span>{{ MAIL_FILTER_DIALOG_TEXT.flag }}</span>
|
||||
</label>
|
||||
|
||||
<div class="filter-actions__line">
|
||||
<label class="filter-actions__check filter-actions__check--lead">
|
||||
<input
|
||||
id="mail-filter-forward"
|
||||
type="checkbox"
|
||||
class="filter-actions__box"
|
||||
:checked="value.forward"
|
||||
@change="emit('update', { forward: checked($event) })"
|
||||
>
|
||||
<span>{{ MAIL_FILTER_DIALOG_TEXT.forward }}</span>
|
||||
</label>
|
||||
<input
|
||||
id="mail-filter-forward-to"
|
||||
ref="forwardRef"
|
||||
class="form-input filter-actions__forward"
|
||||
type="email"
|
||||
:value="value.forwardTo"
|
||||
:maxlength="MAIL_FILTER_FORWARD_MAX_LENGTH"
|
||||
:placeholder="MAIL_FILTER_DIALOG_TEXT.forwardPlaceholder(domain)"
|
||||
:aria-label="MAIL_FILTER_DIALOG_TEXT.forwardTarget"
|
||||
:aria-invalid="forwardError ? 'true' : 'false'"
|
||||
aria-describedby="mail-filter-forward-help"
|
||||
:disabled="!value.forward"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
@input="emit('update', { forwardTo: valueOf($event) })"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
id="mail-filter-forward-help"
|
||||
:class="['form-message', 'filter-actions__hint', { 'form-message--error': forwardError }]"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="forwardError"
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ forwardError ?? MAIL_FILTER_DIALOG_TEXT.forwardHint }}</span>
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="actionsError"
|
||||
id="mail-filter-actions-error"
|
||||
class="form-message form-message--error filter-actions__error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ actionsError }}</span>
|
||||
</p>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.filter-actions__legend {
|
||||
margin: 0 0 var(--space-6);
|
||||
padding: 0;
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 고르기 + 칸 한 줄 — 좁으면 칸이 아래로 내려간다 */
|
||||
.filter-actions__line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4) var(--space-12);
|
||||
min-height: var(--size-touch);
|
||||
}
|
||||
|
||||
.filter-actions__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 칸 앞의 이름 — 두 줄의 칸이 같은 선에서 시작한다 (176) */
|
||||
.filter-actions__check--lead {
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
.filter-actions__box {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 칸 높이 40 — 밀도 예외 (tokens.css "6단계") */
|
||||
.filter-actions__select,
|
||||
.filter-actions__forward {
|
||||
min-height: var(--size-filter-control);
|
||||
height: var(--size-filter-control);
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.filter-actions__select {
|
||||
min-width: 12.5rem;
|
||||
}
|
||||
|
||||
.filter-actions__forward {
|
||||
flex: 1 1 15rem;
|
||||
min-width: 0;
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
/* 고르기 상자(18) + 사이(10) 만큼 들여 쓴다 */
|
||||
.filter-actions__hint {
|
||||
margin-left: calc(var(--size-option-input) + var(--space-10));
|
||||
}
|
||||
|
||||
.filter-actions__hint--spaced {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.filter-actions__error {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
// 고치기 창의 "어떤 메일에" (디자인 원본 SettingsFilter) — 고르기(모두 · 하나라도 · 모든 메일 — 44, 최대 320) + 조건 줄(MailFilterConditionRow) +
|
||||
// [조건 더하기](40 — 5개까지, 새 줄은 제목) · "5개까지 · 글자는 대소문자를 가리지 않습니다". 모든 메일이면 조건 줄을 숨긴다(쓴 조건은 부모가 남겨 둔다).
|
||||
// 줄을 더하면 새 줄의 "어디에서"로, 지우면 같은 자리(마지막이었으면 앞) 줄의 찾을 글로 초점을 옮긴다 — 누른 단추가 사라지거나 꺼지므로
|
||||
// 고정 id — #mail-filter-match · #mail-filter-add-condition (줄의 오류 id 는 MailFilterConditionRow)
|
||||
import { nextTick, type ComponentPublicInstance } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailFilterConditionRow from '@/components/settings/MailFilterConditionRow.vue'
|
||||
import {
|
||||
MAIL_FILTER_DIALOG_TEXT,
|
||||
MAIL_FILTER_MATCH_LABELS,
|
||||
MAIL_FILTER_MAX_CONDITIONS,
|
||||
} from '@/shared/constants/mail-filter'
|
||||
import type { MailFilterCondition, MailFilterMatch } from '@/shared/types/mail'
|
||||
|
||||
interface Props {
|
||||
match: MailFilterMatch
|
||||
conditions: readonly MailFilterCondition[]
|
||||
/** 줄마다의 잘못 — 칸 문구 (차례는 conditions 와 같다) */
|
||||
errors: readonly (string | null)[]
|
||||
/** 로그인한 직원 주소의 도메인 — 받는 사람 칸의 보기 */
|
||||
domain: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:match', match: MailFilterMatch): void
|
||||
(e: 'update-condition', index: number, patch: Partial<MailFilterCondition>): void
|
||||
(e: 'add'): void
|
||||
(e: 'remove', index: number): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const MATCHES = Object.keys(MAIL_FILTER_MATCH_LABELS) as MailFilterMatch[]
|
||||
|
||||
/** 조건 줄 — 차례대로 (잘못된 줄 · 새 줄에 초점을 준다) */
|
||||
const rows: (InstanceType<typeof MailFilterConditionRow> | null)[] = []
|
||||
|
||||
function setRow(index: number, row: Element | ComponentPublicInstance | null) {
|
||||
rows[index] = row as InstanceType<typeof MailFilterConditionRow> | null
|
||||
}
|
||||
|
||||
function setMatch(event: Event) {
|
||||
const match = MATCHES.find((candidate) => candidate === (event.target as HTMLSelectElement).value)
|
||||
if (match) emit('update:match', match)
|
||||
}
|
||||
|
||||
function add() {
|
||||
if (props.conditions.length >= MAIL_FILTER_MAX_CONDITIONS) return
|
||||
const added = props.conditions.length
|
||||
emit('add')
|
||||
void nextTick(() => rows[added]?.focusField())
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
if (props.conditions.length <= 1) return
|
||||
const next = Math.min(index, props.conditions.length - 2)
|
||||
emit('remove', index)
|
||||
void nextTick(() => rows[next]?.focusValue())
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/** 잘못된 줄의 찾을 글 칸으로 */
|
||||
focusValue: (index: number) => rows[index]?.focusValue(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset class="filter-conditions">
|
||||
<legend class="filter-conditions__legend">
|
||||
{{ MAIL_FILTER_DIALOG_TEXT.matchLegend }}
|
||||
</legend>
|
||||
<select
|
||||
id="mail-filter-match"
|
||||
class="form-input filter-conditions__match"
|
||||
:aria-label="MAIL_FILTER_DIALOG_TEXT.matchLegend"
|
||||
:value="match"
|
||||
@change="setMatch"
|
||||
>
|
||||
<option
|
||||
v-for="option in MATCHES"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ MAIL_FILTER_MATCH_LABELS[option] }}
|
||||
</option>
|
||||
</select>
|
||||
<template v-if="match !== 'always'">
|
||||
<ul class="filter-conditions__list">
|
||||
<MailFilterConditionRow
|
||||
v-for="(condition, index) in conditions"
|
||||
:key="index"
|
||||
:ref="(row) => setRow(index, row)"
|
||||
:condition="condition"
|
||||
:index="index"
|
||||
:can-remove="conditions.length > 1"
|
||||
:error="errors[index] ?? null"
|
||||
:domain="domain"
|
||||
@update="(patch) => emit('update-condition', index, patch)"
|
||||
@remove="remove(index)"
|
||||
/>
|
||||
</ul>
|
||||
<div class="filter-conditions__add">
|
||||
<button
|
||||
id="mail-filter-add-condition"
|
||||
type="button"
|
||||
class="filter-conditions__add-button"
|
||||
:disabled="conditions.length >= MAIL_FILTER_MAX_CONDITIONS"
|
||||
@click="add"
|
||||
>
|
||||
<AppIcon
|
||||
name="plus"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<span>{{ MAIL_FILTER_DIALOG_TEXT.addCondition }}</span>
|
||||
</button>
|
||||
<span class="filter-conditions__hint">{{ MAIL_FILTER_DIALOG_TEXT.conditionHint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-conditions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.filter-conditions__legend {
|
||||
margin: 0 0 var(--space-6);
|
||||
padding: 0;
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.filter-conditions__match {
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.filter-conditions__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.filter-conditions__add {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4) var(--space-10);
|
||||
}
|
||||
|
||||
/* [조건 더하기] 높이 40 — 밀도 예외 (tokens.css "6단계") */
|
||||
.filter-conditions__add-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-filter-control);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-conditions__add-button:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.filter-conditions__add-button:disabled {
|
||||
color: var(--color-icon-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.filter-conditions__hint {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
// 고치기 창의 조건 한 줄 (디자인 원본 SettingsFilter) — 어디에서(116) · 포함/포함하지 않음(132) · 찾을 글 · ✕(36×40). 칸 높이 40 — 밀도 예외(tokens.css "6단계").
|
||||
// 잘못은 [저장]을 누를 때 그 줄 아래 빨간 글로 (디자인 원본 MailFilterStates) — 찾을 글 칸이 aria-invalid · aria-describedby 로 가리킨다.
|
||||
// 조건이 하나뿐이면 ✕ 를 끈다 (모두 · 하나라도는 조건이 1개 이상)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import {
|
||||
MAIL_FILTER_DIALOG_TEXT,
|
||||
MAIL_FILTER_FIELD_LABELS,
|
||||
MAIL_FILTER_OPERATOR_LABELS,
|
||||
MAIL_FILTER_VALUE_MAX_LENGTH,
|
||||
MAIL_FILTER_VALUE_PLACEHOLDERS,
|
||||
} from '@/shared/constants/mail-filter'
|
||||
import type { MailFilterCondition, MailFilterField, MailFilterOperator } from '@/shared/types/mail'
|
||||
|
||||
interface Props {
|
||||
condition: MailFilterCondition
|
||||
/** 0부터 — 칸 이름은 "조건 1 — …" */
|
||||
index: number
|
||||
canRemove: boolean
|
||||
/** 이 줄의 잘못 (칸 · 사전 문구) — 없으면 null */
|
||||
error: string | null
|
||||
/** 로그인한 직원 주소의 도메인 — 받는 사람 칸의 보기("예: all@<도메인>") */
|
||||
domain: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update', patch: Partial<MailFilterCondition>): void
|
||||
(e: 'remove'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const FIELDS = Object.keys(MAIL_FILTER_FIELD_LABELS) as MailFilterField[]
|
||||
const OPERATORS = Object.keys(MAIL_FILTER_OPERATOR_LABELS) as MailFilterOperator[]
|
||||
|
||||
const fieldRef = ref<HTMLSelectElement | null>(null)
|
||||
const valueRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const no = computed(() => props.index + 1)
|
||||
const errorId = computed(() => `mail-filter-condition-${props.index}-error`)
|
||||
|
||||
function selected(event: Event): string {
|
||||
return (event.target as HTMLSelectElement).value
|
||||
}
|
||||
|
||||
function setField(event: Event) {
|
||||
const field = FIELDS.find((candidate) => candidate === selected(event))
|
||||
if (field) emit('update', { field })
|
||||
}
|
||||
|
||||
function setOperator(event: Event) {
|
||||
const operator = OPERATORS.find((candidate) => candidate === selected(event))
|
||||
if (operator) emit('update', { operator })
|
||||
}
|
||||
|
||||
function setValue(event: Event) {
|
||||
emit('update', { value: (event.target as HTMLInputElement).value })
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/** 잘못된 줄 — 찾을 글 칸으로 */
|
||||
focusValue: () => valueRef.value?.focus(),
|
||||
/** 방금 더한 줄 — 어디에서 칸으로 */
|
||||
focusField: () => fieldRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="filter-condition">
|
||||
<div class="filter-condition__row">
|
||||
<select
|
||||
ref="fieldRef"
|
||||
class="form-input filter-condition__control"
|
||||
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionField(no)"
|
||||
:value="condition.field"
|
||||
data-filter-condition-field
|
||||
@change="setField"
|
||||
>
|
||||
<option
|
||||
v-for="field in FIELDS"
|
||||
:key="field"
|
||||
:value="field"
|
||||
>
|
||||
{{ MAIL_FILTER_FIELD_LABELS[field] }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
class="form-input filter-condition__control"
|
||||
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionOperator(no)"
|
||||
:value="condition.operator"
|
||||
data-filter-condition-operator
|
||||
@change="setOperator"
|
||||
>
|
||||
<option
|
||||
v-for="operator in OPERATORS"
|
||||
:key="operator"
|
||||
:value="operator"
|
||||
>
|
||||
{{ MAIL_FILTER_OPERATOR_LABELS[operator] }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
ref="valueRef"
|
||||
class="form-input filter-condition__control filter-condition__value"
|
||||
type="text"
|
||||
:value="condition.value"
|
||||
:maxlength="MAIL_FILTER_VALUE_MAX_LENGTH"
|
||||
:placeholder="MAIL_FILTER_VALUE_PLACEHOLDERS[condition.field](domain)"
|
||||
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionValue(no)"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="error ? errorId : undefined"
|
||||
autocomplete="off"
|
||||
data-filter-condition-value
|
||||
@input="setValue"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-condition__remove"
|
||||
:aria-label="MAIL_FILTER_DIALOG_TEXT.conditionRemove(no)"
|
||||
:disabled="!canRemove"
|
||||
data-filter-condition-remove
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="error"
|
||||
:id="errorId"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-condition {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* 어디에서 116 · 포함/포함하지 않음 132 · 찾을 글 · ✕ 36 (디자인 원본 SettingsFilter) */
|
||||
.filter-condition__row {
|
||||
display: grid;
|
||||
grid-template-columns: 7.25rem 8.25rem minmax(0, 1fr) var(--size-filter-remove);
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
/* 칸 높이 40 — 밀도 예외 */
|
||||
.filter-condition__control {
|
||||
min-width: 0;
|
||||
min-height: var(--size-filter-control);
|
||||
height: var(--size-filter-control);
|
||||
padding: 0 var(--space-8);
|
||||
}
|
||||
|
||||
.filter-condition__value {
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.filter-condition__remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-filter-remove);
|
||||
height: var(--size-filter-control);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.filter-condition__remove:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.filter-condition__remove:disabled {
|
||||
color: var(--color-border-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
// [새 필터] — 설정 › 필터 머리 오른쪽 · 필터가 없을 때의 안내 안 (디자인 원본 SettingsFilter · MailFilterStates — 브랜드색 44, + 아이콘 15)
|
||||
// 누를 수 없을 때(50개를 다 만들었다 · 아직 받는 중)는 disabled 가 아니라 aria-disabled — 고치기 창을 닫으면 초점이 이 단추로 돌아오기 때문이다.
|
||||
// 까닭은 옆의 글을 describedBy 로 가리킨다
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
/** 누를 수 없는 까닭의 id */
|
||||
describedBy?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'create'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
describedBy: undefined,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
function onClick() {
|
||||
if (!props.disabled) emit('create')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: () => buttonRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
class="filter-create"
|
||||
:aria-disabled="disabled ? 'true' : undefined"
|
||||
:aria-describedby="describedBy"
|
||||
data-filter-create
|
||||
@click="onClick"
|
||||
>
|
||||
<AppIcon
|
||||
name="plus"
|
||||
:size="15"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ MAIL_FILTER_TEXT.create }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-create {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-create:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 꺼진 모양 (디자인 원본 MailFilterStates "50개를 다 만들었을 때") */
|
||||
.filter-create[aria-disabled='true'] {
|
||||
background: var(--color-skeleton);
|
||||
color: var(--color-icon-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,466 @@
|
||||
<script setup lang="ts">
|
||||
// 필터 고치기 창 (디자인 원본 SettingsFilter "새 필터" · "필터 고치기" — 모달 620, 공용 모양 ModalDialog 의 넓은 창)
|
||||
// 이름(선택, 50자) → "어떤 메일에"(MailFilterConditionFields — 모두 · 하나라도 · 모든 메일 + 조건 줄 5개까지) →
|
||||
// 할 일(MailFilterActionFields) → 안내 띠 → [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
|
||||
// - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 할 일 없음 · 전달 주소(빈 칸 · 형식 · 사내 주소가 아님 · 내 주소) ·
|
||||
// 이름 · 찾을 글의 길이 · 제어 문자(사전의 VAL_001).
|
||||
// 서버와 같은 규칙(filterDraftProblems)이고, 첫 잘못된 칸에 초점을 준다(알림 없음). 칸을 고치면 그 칸의 잘못을 내린다.
|
||||
// 서버의 거절(VAL_001 · MAIL_013 · 그사이 바뀜 MAIL_012)과 50개 넘음은 단추 줄 위에 — 부모가 error 로 준다(무엇이든 고치면 clear-error).
|
||||
// 그사이 바뀜(conflict)이면 그 옆에 [다시 불러오기] — 누르면 부모가 목록을 다시 받고, 받았으면 [저장]으로 초점을 옮긴다(focusSave)
|
||||
// - 열 때마다 칸을 새로 채운다 — 새 필터는 빈 조건 한 줄(읽기 화면에서 왔으면 "보낸 사람 · 포함 · 그 주소"), 고치기는 그 규칙.
|
||||
// 처음 초점은 이름 칸, 읽기 화면에서 만든 새 필터는 할 일의 첫 칸 (디자인 원본 MailFilterStates)
|
||||
// - 보내는 · 다시 받는 동안(pending)과 다른 저장을 기다리는 동안(busy)은 [저장] · [이 필터 지우기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게.
|
||||
// [취소] · Esc 는 언제든 닫는다(보낸 저장의 결과는 부모가 알림으로 알린다)
|
||||
// 고정 id — #mail-filter-dialog · #mail-filter-dialog-title · #mail-filter-name · #mail-filter-name-error · #mail-filter-error · #mail-filter-reload ·
|
||||
// #mail-filter-remove · #mail-filter-save · #mail-filter-cancel (조건 · 할 일 칸의 id 는 각 칸 컴포넌트에)
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import MailFilterActionFields from '@/components/settings/MailFilterActionFields.vue'
|
||||
import MailFilterConditionFields from '@/components/settings/MailFilterConditionFields.vue'
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import type { MailFilterDialogPending } from '@/composables/useMailFilterDialog'
|
||||
import { MAIL_FILTER_DIALOG_TEXT, MAIL_FILTER_NAME_MAX_LENGTH, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { Mailbox, MailFilterCondition, MailFilterMatch, MailFilterRule } from '@/shared/types/mail'
|
||||
import {
|
||||
defaultMoveTarget,
|
||||
emptyCondition,
|
||||
filterDraftOf,
|
||||
filterDraftProblems,
|
||||
filterMoveTargets,
|
||||
filterRuleOf,
|
||||
hasDraftAction,
|
||||
hasFilterProblems,
|
||||
mailDomainOf,
|
||||
newFilterDraft,
|
||||
newFilterRuleBase,
|
||||
noFilterProblems,
|
||||
type MailFilterActionDraft,
|
||||
type MailFilterConditionProblem,
|
||||
type MailFilterDraft,
|
||||
type MailFilterForwardProblem,
|
||||
type MailFilterProblems,
|
||||
} from '@/shared/utils/mail-filter'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 고칠 규칙 — null 이면 새 필터 */
|
||||
rule: MailFilterRule | null
|
||||
/** 새 필터를 채울 보낸 사람 주소 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」) */
|
||||
prefillFrom?: string | null
|
||||
/** 모든 메일함 — 옮길 메일함 고르기 · 지워진 메일함 판정 */
|
||||
mailboxes: readonly Mailbox[]
|
||||
/** 로그인한 직원 주소 — 사내 도메인 · 자기 주소 판정 */
|
||||
userEmail: string
|
||||
pending?: MailFilterDialogPending
|
||||
/** 다른 저장(켜기 · 순서)을 기다리는 중 */
|
||||
busy?: boolean
|
||||
/** 서버가 거절한 까닭 — 사전 문구 (50개 넘음은 fullNote) */
|
||||
error?: string | null
|
||||
/** 그 거절이 그사이 바뀜(MAIL_012)이다 — 옆에 [다시 불러오기]를 둔다 */
|
||||
conflict?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'save', rule: MailFilterRule): void
|
||||
(e: 'remove'): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'clear-error'): void
|
||||
/** 그사이 바뀜 — 목록을 다시 받아 달라 (창 · 쓴 내용은 그대로) */
|
||||
(e: 'reload'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
prefillFrom: null,
|
||||
pending: null,
|
||||
busy: false,
|
||||
error: null,
|
||||
conflict: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const nameRef = ref<HTMLInputElement | null>(null)
|
||||
const conditionsRef = ref<InstanceType<typeof MailFilterConditionFields> | null>(null)
|
||||
const actionsRef = ref<InstanceType<typeof MailFilterActionFields> | null>(null)
|
||||
const saveRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
const targets = computed(() => filterMoveTargets(props.mailboxes))
|
||||
const domain = computed(() => mailDomainOf(props.userEmail))
|
||||
/** 읽기 화면에서 만든 새 필터 — 처음 초점이 할 일의 첫 칸이다 */
|
||||
const prefilled = computed(() => props.rule === null && Boolean(props.prefillFrom))
|
||||
const locked = computed(() => props.pending !== null || props.busy)
|
||||
const title = computed(() =>
|
||||
props.rule ? MAIL_FILTER_DIALOG_TEXT.editTitle : MAIL_FILTER_DIALOG_TEXT.createTitle,
|
||||
)
|
||||
|
||||
const draft = reactive<MailFilterDraft>(initialDraft())
|
||||
const problems = ref<MailFilterProblems>(noFilterProblems(draft.conditions.length))
|
||||
/** 새 규칙의 id · 켜짐 — 창을 열 때 한 번 만든다 (서버가 거절한 뒤 다시 눌러도 같은 규칙이다) */
|
||||
let base = baseOf()
|
||||
|
||||
const actionsValue = computed<MailFilterActionDraft>(() => ({
|
||||
move: draft.move,
|
||||
moveTarget: draft.moveTarget,
|
||||
markAsRead: draft.markAsRead,
|
||||
flag: draft.flag,
|
||||
forward: draft.forward,
|
||||
forwardTo: draft.forwardTo,
|
||||
}))
|
||||
const nameError = computed(() => (problems.value.name ? getErrorMessage('VAL_001') : null))
|
||||
const conditionErrors = computed(() => problems.value.conditions.map(conditionMessage))
|
||||
const actionsError = computed(() => (problems.value.actions ? MAIL_FILTER_DIALOG_TEXT.actionsEmpty : null))
|
||||
const forwardError = computed(() => forwardMessage(problems.value.forward))
|
||||
|
||||
function initialDraft(): MailFilterDraft {
|
||||
const target = defaultMoveTarget(targets.value)
|
||||
return props.rule
|
||||
? filterDraftOf(props.rule, target)
|
||||
: newFilterDraft({ moveTarget: target, from: props.prefillFrom })
|
||||
}
|
||||
|
||||
function baseOf(): Pick<MailFilterRule, 'id' | 'isEnabled'> {
|
||||
return props.rule ? { id: props.rule.id, isEnabled: props.rule.isEnabled } : newFilterRuleBase()
|
||||
}
|
||||
|
||||
/** 열 때마다 — 그 규칙(또는 새 필터)으로 칸을 다시 채운다 */
|
||||
function resetDraft() {
|
||||
Object.assign(draft, initialDraft())
|
||||
problems.value = noFilterProblems(draft.conditions.length)
|
||||
base = baseOf()
|
||||
}
|
||||
|
||||
function conditionMessage(problem: MailFilterConditionProblem | null): string | null {
|
||||
if (problem === 'empty') return MAIL_FILTER_DIALOG_TEXT.conditionEmpty
|
||||
if (problem === 'variable') return MAIL_FILTER_DIALOG_TEXT.conditionVariable
|
||||
if (problem === 'invalid') return getErrorMessage('VAL_001')
|
||||
return null
|
||||
}
|
||||
|
||||
function forwardMessage(problem: MailFilterForwardProblem | null): string | null {
|
||||
if (problem === 'empty') return MAIL_FILTER_DIALOG_TEXT.forwardEmpty
|
||||
if (problem === 'invalid') return MAIL_FILTER_DIALOG_TEXT.forwardInvalid
|
||||
if (problem === 'external') return MAIL_FILTER_DIALOG_TEXT.forwardExternal(domain.value)
|
||||
if (problem === 'self') return MAIL_FILTER_DIALOG_TEXT.forwardSelf
|
||||
if (problem === 'variable') return MAIL_FILTER_DIALOG_TEXT.conditionVariable
|
||||
return null
|
||||
}
|
||||
|
||||
/** 칸을 고쳤다 — 그 칸의 잘못(patch)과 서버의 거절을 내린다 */
|
||||
function edited(patch: Partial<MailFilterProblems>) {
|
||||
problems.value = { ...problems.value, ...patch }
|
||||
if (props.error) emit('clear-error')
|
||||
}
|
||||
|
||||
function setName(event: Event) {
|
||||
draft.name = (event.target as HTMLInputElement).value
|
||||
edited({ name: false })
|
||||
}
|
||||
|
||||
function setMatch(match: MailFilterMatch) {
|
||||
draft.match = match
|
||||
if (match !== 'always' && draft.conditions.length === 0) draft.conditions = [emptyCondition()]
|
||||
edited({ conditions: draft.conditions.map(() => null) })
|
||||
}
|
||||
|
||||
function updateCondition(index: number, patch: Partial<MailFilterCondition>) {
|
||||
draft.conditions = draft.conditions.map((condition, at) => (at === index ? { ...condition, ...patch } : condition))
|
||||
edited({ conditions: problems.value.conditions.map((problem, at) => (at === index ? null : problem)) })
|
||||
}
|
||||
|
||||
function addCondition() {
|
||||
draft.conditions = [...draft.conditions, emptyCondition('subject')]
|
||||
edited({ conditions: [...problems.value.conditions, null] })
|
||||
}
|
||||
|
||||
function removeCondition(index: number) {
|
||||
draft.conditions = draft.conditions.filter((_, at) => at !== index)
|
||||
edited({ conditions: problems.value.conditions.filter((_, at) => at !== index) })
|
||||
}
|
||||
|
||||
function updateActions(patch: Partial<MailFilterActionDraft>) {
|
||||
Object.assign(draft, patch)
|
||||
const touchesForward = 'forward' in patch || 'forwardTo' in patch
|
||||
edited({
|
||||
actions: problems.value.actions && !hasDraftAction(draft),
|
||||
forward: touchesForward ? null : problems.value.forward,
|
||||
})
|
||||
}
|
||||
|
||||
/** 첫 잘못된 칸 — 이름 → 조건(차례대로) → 할 일 → 전달 주소 */
|
||||
function focusFirstProblem(found: MailFilterProblems) {
|
||||
const condition = found.conditions.findIndex((problem) => problem !== null)
|
||||
if (found.name) nameRef.value?.focus()
|
||||
else if (condition >= 0) conditionsRef.value?.focusValue(condition)
|
||||
else if (found.actions) actionsRef.value?.focusFirst()
|
||||
else if (found.forward) actionsRef.value?.focusForward()
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (locked.value) return
|
||||
const found = filterDraftProblems(draft, props.userEmail)
|
||||
problems.value = found
|
||||
if (hasFilterProblems(found)) {
|
||||
focusFirstProblem(found)
|
||||
return
|
||||
}
|
||||
emit('save', filterRuleOf(draft, base))
|
||||
}
|
||||
|
||||
function remove() {
|
||||
if (!locked.value) emit('remove')
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (props.pending === null) emit('reload')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/** 다시 받은 뒤 — [저장]으로 (다시 받은 목록 위에 저장한다) */
|
||||
focusSave: () => saveRef.value?.focus(),
|
||||
})
|
||||
|
||||
// 열 때마다(연 채로 다른 규칙을 받아도) 칸을 새로 채운다 — 창을 그리기 전에(처음 초점이 새 칸에 간다)
|
||||
watch(
|
||||
() => [props.open, props.rule, props.prefillFrom] as const,
|
||||
([open]) => {
|
||||
if (open) resetDraft()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="mail-filter-dialog"
|
||||
:open="open"
|
||||
:title="title"
|
||||
title-id="mail-filter-dialog-title"
|
||||
wide
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<div class="filter-dialog">
|
||||
<div class="filter-dialog__field">
|
||||
<label
|
||||
for="mail-filter-name"
|
||||
class="filter-dialog__label"
|
||||
>
|
||||
{{ MAIL_FILTER_DIALOG_TEXT.nameLabel }}
|
||||
<span class="filter-dialog__optional">{{ MAIL_FILTER_DIALOG_TEXT.nameOptional }}</span>
|
||||
</label>
|
||||
<input
|
||||
id="mail-filter-name"
|
||||
ref="nameRef"
|
||||
class="form-input"
|
||||
type="text"
|
||||
:value="draft.name"
|
||||
:maxlength="MAIL_FILTER_NAME_MAX_LENGTH"
|
||||
:placeholder="MAIL_FILTER_DIALOG_TEXT.namePlaceholder"
|
||||
:aria-invalid="nameError ? 'true' : 'false'"
|
||||
:aria-describedby="nameError ? 'mail-filter-name-error' : undefined"
|
||||
:autofocus="!prefilled || undefined"
|
||||
autocomplete="off"
|
||||
@input="setName"
|
||||
>
|
||||
<p
|
||||
v-if="nameError"
|
||||
id="mail-filter-name-error"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ nameError }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MailFilterConditionFields
|
||||
ref="conditionsRef"
|
||||
:match="draft.match"
|
||||
:conditions="draft.conditions"
|
||||
:errors="conditionErrors"
|
||||
:domain="domain"
|
||||
@update:match="setMatch"
|
||||
@update-condition="updateCondition"
|
||||
@add="addCondition"
|
||||
@remove="removeCondition"
|
||||
/>
|
||||
|
||||
<MailFilterActionFields
|
||||
ref="actionsRef"
|
||||
:value="actionsValue"
|
||||
:targets="targets"
|
||||
:mailboxes="mailboxes"
|
||||
:domain="domain"
|
||||
:actions-error="actionsError"
|
||||
:forward-error="forwardError"
|
||||
:autofocus-first="prefilled"
|
||||
@update="updateActions"
|
||||
/>
|
||||
|
||||
<p class="filter-dialog__notice">
|
||||
{{ MAIL_FILTER_DIALOG_TEXT.notice }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="filter-dialog__error"
|
||||
>
|
||||
<p
|
||||
id="mail-filter-error"
|
||||
class="form-message form-message--error filter-dialog__error-text"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
<button
|
||||
v-if="conflict"
|
||||
id="mail-filter-reload"
|
||||
type="button"
|
||||
class="filter-dialog__reload"
|
||||
aria-describedby="mail-filter-error"
|
||||
:aria-disabled="pending !== null ? 'true' : undefined"
|
||||
:aria-busy="pending === 'reload' ? 'true' : undefined"
|
||||
@click="reload"
|
||||
>
|
||||
{{ MAIL_FILTER_TEXT.reload }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<button
|
||||
v-if="rule"
|
||||
id="mail-filter-remove"
|
||||
type="button"
|
||||
class="modal-dialog__action filter-dialog__remove"
|
||||
:aria-disabled="locked ? 'true' : undefined"
|
||||
:aria-busy="pending === 'remove' ? 'true' : undefined"
|
||||
@click="remove"
|
||||
>
|
||||
<AppIcon
|
||||
name="trash"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ MAIL_FILTER_DIALOG_TEXT.remove }}</span>
|
||||
</button>
|
||||
<button
|
||||
id="mail-filter-save"
|
||||
ref="saveRef"
|
||||
type="button"
|
||||
class="modal-dialog__action filter-dialog__save"
|
||||
:aria-disabled="locked ? 'true' : undefined"
|
||||
:aria-busy="pending === 'save' ? 'true' : undefined"
|
||||
@click="save"
|
||||
>
|
||||
{{ MAIL_FILTER_DIALOG_TEXT.save }}
|
||||
</button>
|
||||
<button
|
||||
id="mail-filter-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAIL_FILTER_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 묶음 사이 16 — 제목과의 사이도 16(창의 8 + 8), 단추 줄 앞도 18 (디자인 원본 SettingsFilter) */
|
||||
.filter-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-16);
|
||||
margin: var(--space-8) 0 var(--space-6);
|
||||
}
|
||||
|
||||
.filter-dialog__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.filter-dialog__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.filter-dialog__optional {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 단추 줄 위의 거절 — 그사이 바뀜이면 옆에 [다시 불러오기] (36 — 목록 위 줄의 단추와 같은 밀도 예외) */
|
||||
.filter-dialog__error {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4) var(--space-10);
|
||||
}
|
||||
|
||||
.filter-dialog__error-text {
|
||||
flex: 1 1 18rem;
|
||||
}
|
||||
|
||||
.filter-dialog__reload {
|
||||
flex: 0 0 auto;
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: transparent;
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-dialog__reload:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.filter-dialog__reload[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.filter-dialog__notice {
|
||||
margin: 0;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-panel);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* [이 필터 지우기] — 왼쪽 끝, 바탕 없는 빨간 글 (디자인 원본 SettingsFilter) */
|
||||
.modal-dialog__action.filter-dialog__remove {
|
||||
gap: var(--space-6);
|
||||
margin-right: auto;
|
||||
padding: 0 var(--space-12);
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.modal-dialog__action.filter-dialog__remove:not([aria-disabled='true']):hover {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
.modal-dialog__action.filter-dialog__save {
|
||||
padding: 0 var(--space-18);
|
||||
}
|
||||
|
||||
/* 보내는 동안 — 끄지 않고 흐리게만 (초점이 남는다) */
|
||||
.modal-dialog__action[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
// 필터가 아직 없을 때 — 목록 자리의 안내 (디자인 원본 MailFilterStates): 점선 상자 · 깔때기 36 · "아직 필터가 없습니다" · 설명 · [새 필터]
|
||||
// [새 필터]는 머리의 단추와 같은 일을 한다
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailFilterCreateButton from '@/components/settings/MailFilterCreateButton.vue'
|
||||
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
|
||||
interface Emits {
|
||||
(e: 'create'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="filter-empty"
|
||||
data-filter-empty
|
||||
>
|
||||
<AppIcon
|
||||
name="filter"
|
||||
:size="36"
|
||||
:stroke-width="1.5"
|
||||
class="filter-empty__icon"
|
||||
/>
|
||||
<p class="filter-empty__title">
|
||||
{{ MAIL_FILTER_TEXT.emptyTitle }}
|
||||
</p>
|
||||
<p class="filter-empty__text">
|
||||
{{ MAIL_FILTER_TEXT.emptyText }}
|
||||
</p>
|
||||
<MailFilterCreateButton
|
||||
class="filter-empty__create"
|
||||
@create="emit('create')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-40) var(--space-24);
|
||||
border: var(--border-width) dashed var(--color-border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filter-empty__icon {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.filter-empty__title {
|
||||
margin: var(--space-4) 0 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-empty__text {
|
||||
max-width: 25rem;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.filter-empty__create {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
// 필터 목록 — 테두리 상자 하나에 행(MailFilterRow) (디자인 원본 SettingsFilter). 위에서부터 차례로 적용하므로 순서 있는 목록(ol)이다.
|
||||
// 켜기 · ▲▼ · 행 누름(고치기)을 부모에 알리고, 부모가 행 안의 단추에 초점을 줄 수 있게 한다(focusRule — 순서를 바꾸거나 지운 뒤)
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
import MailFilterRow from '@/components/settings/MailFilterRow.vue'
|
||||
import type { MailFilterRowPart } from '@/components/settings/mail-filter-ui'
|
||||
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
|
||||
|
||||
interface Props {
|
||||
rules: readonly MailFilterRule[]
|
||||
mailboxes: readonly Mailbox[]
|
||||
/** 저장을 기다리는 중 — 행마다 켜기 · 순서를 막는다(aria-disabled — 목록 전체에 aria-busy 는 걸지 않는다) */
|
||||
busy: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle', rule: MailFilterRule): void
|
||||
(e: 'move', rule: MailFilterRule, offset: -1 | 1): void
|
||||
(e: 'edit', rule: MailFilterRule): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 규칙 id → 행 — 행이 옮겨 다녀도 같은 행을 찾는다 */
|
||||
const rows = new Map<string, InstanceType<typeof MailFilterRow>>()
|
||||
|
||||
function setRow(id: string, row: Element | ComponentPublicInstance | null) {
|
||||
if (row) rows.set(id, row as InstanceType<typeof MailFilterRow>)
|
||||
else rows.delete(id)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/** 그 규칙 행의 단추에 초점 — 행이 없으면 false */
|
||||
focusRule(id: string, part: MailFilterRowPart): boolean {
|
||||
const row = rows.get(id)
|
||||
row?.focus(part)
|
||||
return row !== undefined
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol
|
||||
class="filter-list"
|
||||
:aria-label="MAIL_FILTER_TEXT.listLabel"
|
||||
>
|
||||
<MailFilterRow
|
||||
v-for="(rule, index) in rules"
|
||||
:key="rule.id"
|
||||
:ref="(row) => setRow(rule.id, row)"
|
||||
:rule="rule"
|
||||
:index="index"
|
||||
:count="rules.length"
|
||||
:mailboxes="mailboxes"
|
||||
:busy="busy"
|
||||
@toggle="emit('toggle', rule)"
|
||||
@move="(offset) => emit('move', rule, offset)"
|
||||
@edit="emit('edit', rule)"
|
||||
/>
|
||||
</ol>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 필터 아래 안내 상자 (디자인 원본 SettingsFilter — 바탕 #F3F8F8, 최대 700):
|
||||
// 차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작. 첫 줄의 "위에서부터 차례로"는 굵게
|
||||
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
|
||||
const NOTES = [MAIL_FILTER_TEXT.noteSpam, MAIL_FILTER_TEXT.noteForward, MAIL_FILTER_TEXT.noteVacation] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="filter-notes"
|
||||
data-filter-notes
|
||||
>
|
||||
<p class="filter-notes__note">
|
||||
<span aria-hidden="true">·</span>
|
||||
<span><strong class="filter-notes__strong">{{ MAIL_FILTER_TEXT.noteOrderStrong }}</strong>{{
|
||||
MAIL_FILTER_TEXT.noteOrderAfter
|
||||
}}</span>
|
||||
</p>
|
||||
<p
|
||||
v-for="note in NOTES"
|
||||
:key="note"
|
||||
class="filter-notes__note"
|
||||
>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ note }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
max-width: 43.75rem;
|
||||
padding: var(--space-14) var(--space-16);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.filter-notes__note {
|
||||
display: flex;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-notes__strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
// 필터 목록 위 노란 줄 (디자인 원본 MailFilterStates) — 바탕 #FFF7E6 · 글 #7A5200, 아이콘 16 · 글 · 단추(36 — 밀도 예외 --size-notice-action)
|
||||
// 다른 곳에서 먼저 바꿈(409 MAIL_012 — [다시 불러오기]) · 동작하지 않음(isActive: false — [다시 켜기]) 두 곳에 쓴다.
|
||||
// 단추를 누르고 기다리는 동안은 disabled 가 아니라 aria-disabled · aria-busy — 초점이 그대로 남게 (다시 누르는 것은 부모가 막는다)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
|
||||
interface Props {
|
||||
icon: IconName
|
||||
message: string
|
||||
actionLabel: string
|
||||
busy?: boolean
|
||||
/** 저장이 막혀 바로 알려야 하면 alert(그사이 바뀜), 들어올 때부터 있던 상태면 status(동작하지 않음) */
|
||||
live?: 'alert' | 'status'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'action'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
busy: false,
|
||||
live: 'status',
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function onAction() {
|
||||
if (!props.busy) emit('action')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="filter-notice"
|
||||
:role="live"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="16"
|
||||
:stroke-width="1.9"
|
||||
class="filter-notice__icon"
|
||||
/>
|
||||
<span class="filter-notice__text">{{ message }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-notice__action"
|
||||
:aria-disabled="busy ? 'true' : undefined"
|
||||
:aria-busy="busy ? 'true' : undefined"
|
||||
@click="onAction"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-10) var(--space-8) var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-surface);
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.filter-notice__icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.filter-notice__text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* 줄 안의 단추 36 — 밀도 예외 (알림 권하기 줄의 단추와 같다) */
|
||||
.filter-notice__action {
|
||||
flex: 0 0 auto;
|
||||
min-height: var(--size-notice-action);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: transparent;
|
||||
color: var(--color-warning-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-notice__action:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-warning-hover);
|
||||
}
|
||||
|
||||
.filter-notice__action[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script setup lang="ts">
|
||||
// 필터 목록의 한 행 (디자인 원본 SettingsFilter — 행 60, 칸 22 · 40 · 나머지 · ▲▼)
|
||||
// 순서 번호 · 켜기 스위치(role="switch" — 그림은 36×20, 누르는 곳은 칸 전체 40×44 — 밀도 예외) · 이름(굵게)과 요약 한 줄(누르면 고치기 창) ·
|
||||
// ▲ ▼(32×40 — 밀도 예외, 메일함 ⋯ 와 같다). 단추 이름에 넣는 이름은 quotedRuleTitle(길면 자르고 따옴표가 겹치지 않게).
|
||||
// 꺼 둔 규칙은 글자를 흐리게, 옮길 메일함이 지워진 규칙은 빨간 안내 한 줄.
|
||||
// 저장하는 동안(busy)과 맨 위의 ▲ · 맨 아래의 ▼ 는 disabled 가 아니라 aria-disabled — 누른 단추가 초점을 잃지 않게 (5단계의 규칙과 같다)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { MailFilterRowPart } from '@/components/settings/mail-filter-ui'
|
||||
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
|
||||
import { isMoveTargetMissing, quotedRuleTitle, ruleSummary, ruleTitle } from '@/shared/utils/mail-filter'
|
||||
|
||||
interface Props {
|
||||
rule: MailFilterRule
|
||||
/** 0부터 */
|
||||
index: number
|
||||
count: number
|
||||
mailboxes: readonly Mailbox[]
|
||||
/** 다른 저장을 기다리는 중 — 켜기 · 순서를 막는다 */
|
||||
busy: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle'): void
|
||||
(e: 'move', offset: -1 | 1): void
|
||||
(e: 'edit'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const editRef = ref<HTMLButtonElement | null>(null)
|
||||
const upRef = ref<HTMLButtonElement | null>(null)
|
||||
const downRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
const title = computed(() => ruleTitle(props.rule))
|
||||
/** 단추 이름에 넣는 이름 — 따옴표로 묶고, 조건 글이 길면 자른다 */
|
||||
const quoted = computed(() => quotedRuleTitle(props.rule))
|
||||
const summary = computed(() => ruleSummary(props.rule, mailboxName))
|
||||
const missing = computed(() => isMoveTargetMissing(props.rule, props.mailboxes))
|
||||
const isFirst = computed(() => props.index === 0)
|
||||
const isLast = computed(() => props.index === props.count - 1)
|
||||
const summaryId = computed(() => `mail-filter-${props.index}-summary`)
|
||||
const missingId = computed(() => `mail-filter-${props.index}-missing`)
|
||||
|
||||
function mailboxName(id: string): string | null {
|
||||
return props.mailboxes.find((mailbox) => mailbox.id === id)?.name ?? null
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!props.busy) emit('toggle')
|
||||
}
|
||||
|
||||
function move(offset: -1 | 1) {
|
||||
const atEdge = offset < 0 ? isFirst.value : isLast.value
|
||||
if (!props.busy && !atEdge) emit('move', offset)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: (part: MailFilterRowPart) => ({ edit: editRef, up: upRef, down: downRef })[part].value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="['filter-row', { 'is-off': !rule.isEnabled, 'is-busy': busy }]"
|
||||
:data-filter-rule="rule.id"
|
||||
>
|
||||
<span class="filter-row__no">{{ index + 1 }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-row__switch"
|
||||
role="switch"
|
||||
:aria-checked="rule.isEnabled ? 'true' : 'false'"
|
||||
:aria-label="MAIL_FILTER_TEXT.switchLabel(quoted)"
|
||||
:aria-disabled="busy ? 'true' : undefined"
|
||||
data-filter-switch
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
class="filter-row__track"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="filter-row__knob" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
ref="editRef"
|
||||
type="button"
|
||||
class="filter-row__main"
|
||||
:aria-label="MAIL_FILTER_TEXT.editLabel(quoted)"
|
||||
:aria-describedby="missing ? `${summaryId} ${missingId}` : summaryId"
|
||||
data-filter-edit
|
||||
@click="emit('edit')"
|
||||
>
|
||||
<span class="filter-row__title">{{ title }}</span>
|
||||
<span
|
||||
:id="summaryId"
|
||||
class="filter-row__summary"
|
||||
>{{ summary }}</span>
|
||||
<span
|
||||
v-if="missing"
|
||||
:id="missingId"
|
||||
class="filter-row__missing"
|
||||
data-filter-missing
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ MAIL_FILTER_TEXT.missingMailbox }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="filter-row__order">
|
||||
<button
|
||||
ref="upRef"
|
||||
type="button"
|
||||
:class="['filter-row__arrow', { 'is-edge': isFirst }]"
|
||||
:aria-label="MAIL_FILTER_TEXT.moveUp(quoted)"
|
||||
:aria-disabled="busy || isFirst ? 'true' : undefined"
|
||||
data-filter-up
|
||||
@click="move(-1)"
|
||||
>
|
||||
<AppIcon
|
||||
name="chevron-up"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
ref="downRef"
|
||||
type="button"
|
||||
:class="['filter-row__arrow', { 'is-edge': isLast }]"
|
||||
:aria-label="MAIL_FILTER_TEXT.moveDown(quoted)"
|
||||
:aria-disabled="busy || isLast ? 'true' : undefined"
|
||||
data-filter-down
|
||||
@click="move(1)"
|
||||
>
|
||||
<AppIcon
|
||||
name="chevron-down"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 행 60 — 칸 22(순서) · 40(스위치) · 나머지(이름 · 요약) · ▲▼ (디자인 원본 SettingsFilter) */
|
||||
.filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.375rem 2.5rem minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: 3.75rem;
|
||||
padding: var(--space-6) var(--space-8) var(--space-6) var(--space-14);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.filter-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-row__no {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-icon-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 누르는 곳은 칸 전체 40×44(밀도 예외 — tokens.css "6단계", 툴바 아이콘 단추와 같은 폭), 보이는 스위치는 36×20 */
|
||||
.filter-row__switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-icon-button);
|
||||
height: var(--size-touch);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.filter-row__track {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 2.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 0.625rem;
|
||||
background: var(--color-border-strong);
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.filter-row__knob {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: var(--space-2);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--radius-round);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-switch-knob);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.filter-row__switch[aria-checked='true'] .filter-row__track {
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.filter-row__switch[aria-checked='true'] .filter-row__knob {
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
|
||||
.filter-row__main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-6) var(--space-8);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filter-row__main:hover {
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.filter-row__title,
|
||||
.filter-row__summary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-row__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-row__summary {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 꺼 둔 규칙 — 글자를 흐리게 */
|
||||
.filter-row.is-off .filter-row__title {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.filter-row.is-off .filter-row__summary {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.filter-row__missing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-danger);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.filter-row__order {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* ▲ ▼ 32×40 — 밀도 예외 (메일함 행의 ⋯ 와 같은 크기 — tokens.css "6단계") */
|
||||
.filter-row__arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-mailbox-more);
|
||||
height: var(--size-row-min);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.filter-row__arrow:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 맨 위의 ▲ · 맨 아래의 ▼ — 꺼진 모양 */
|
||||
.filter-row__arrow.is-edge {
|
||||
color: var(--color-border-strong);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 저장을 기다리는 동안 — 모양은 그대로, 누를 수만 없다 */
|
||||
.filter-row.is-busy .filter-row__switch,
|
||||
.filter-row.is-busy .filter-row__arrow:not(.is-edge) {
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
|
||||
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
|
||||
// 머리 오른쪽에 단추를 둘 때는 actions 슬롯에 넣는다 — 좁으면 제목 아래로 내려간다 (디자인 원본 SettingsFilter 의 [새 필터])
|
||||
// 한국어 안내 글은 낱말 가운데에서 줄을 바꾸지 않는다(keep-all) — 긴 주소 · 영문은 넘치지 않게 어디서든 끊는다(anywhere) (2026-10-28 화면 점검)
|
||||
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
|
||||
interface Props {
|
||||
@@ -19,16 +20,24 @@ defineProps<Props>()
|
||||
:aria-labelledby="titleId"
|
||||
>
|
||||
<div class="settings-page__column">
|
||||
<div class="settings-page__intro">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="settings-page__title"
|
||||
<div :class="['settings-page__head', { 'settings-page__head--actions': $slots.actions }]">
|
||||
<div class="settings-page__intro">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="settings-page__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="settings-page__description">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="settings-page__actions"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="settings-page__description">
|
||||
{{ description }}
|
||||
</p>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -53,6 +62,18 @@ defineProps<Props>()
|
||||
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
|
||||
}
|
||||
|
||||
/* 머리 오른쪽 단추 — 제목 · 설명이 360 보다 좁아지면 아래로 내려간다 (디자인 원본 SettingsFilter) */
|
||||
.settings-page__head--actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-12) var(--space-20);
|
||||
}
|
||||
|
||||
.settings-page__head--actions .settings-page__intro {
|
||||
flex: 1 1 22.5rem;
|
||||
}
|
||||
|
||||
.settings-page__intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// 메일 필터 화면의 목록(MailFilterList · MailFilterRow)과 화면(MailFilterPage)이 주고받는 값
|
||||
|
||||
/** 행 안에서 초점을 줄 곳 — 고치기(행) · ▲ · ▼ (순서를 바꾸거나 지운 뒤 화면이 정한다) */
|
||||
export type MailFilterRowPart = 'edit' | 'up' | 'down'
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import MessageReader from '@/components/mail/MessageReader.vue'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { Mailbox, MailFilterRule, MailFilterSet, MailFilterSetPayload, MessageDetail } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailFilterPage from './MailFilterPage.vue'
|
||||
|
||||
// 설정 › 메일 › 필터 (디자인 원본 SettingsFilter · MailFilterStates · 계약 2절 "메일 필터")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
|
||||
function mailbox(id: string, name: string, role: Mailbox['role'] = null): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
mailbox('inbox', '받은편지함', 'inbox'),
|
||||
mailbox('sent', '보낸편지함', 'sent'),
|
||||
mailbox('drafts', '임시보관함', 'drafts'),
|
||||
mailbox('junk', '스팸메일함', 'junk'),
|
||||
mailbox('trash', '휴지통', 'trash'),
|
||||
mailbox('archive', '보관함', 'archive'),
|
||||
mailbox('vendor', '거래처'),
|
||||
mailbox('notice', '전체 공지'),
|
||||
]
|
||||
|
||||
const ACTIONS = { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: null }
|
||||
|
||||
/** 디자인 원본 SettingsFilter 의 시안과 같은 다섯 규칙 */
|
||||
const RULES: MailFilterRule[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
name: '휴가 중 전달',
|
||||
isEnabled: false,
|
||||
match: 'always',
|
||||
conditions: [],
|
||||
actions: { ...ACTIONS, forwardTo: 'doyun.lee@arasystem.it.kr' },
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
name: '거래처 메일',
|
||||
isEnabled: true,
|
||||
match: 'all',
|
||||
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
|
||||
actions: { ...ACTIONS, moveToMailboxId: 'vendor' },
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
name: '결재 알림',
|
||||
isEnabled: true,
|
||||
match: 'all',
|
||||
conditions: [{ field: 'subject', operator: 'contains', value: '[결재]' }],
|
||||
actions: { ...ACTIONS, markAsRead: true, flag: true },
|
||||
},
|
||||
{
|
||||
id: 'r4',
|
||||
name: '',
|
||||
isEnabled: false,
|
||||
match: 'all',
|
||||
conditions: [{ field: 'to', operator: 'contains', value: 'all@arasystem.it.kr' }],
|
||||
actions: { ...ACTIONS, moveToMailboxId: 'notice', markAsRead: true },
|
||||
},
|
||||
{
|
||||
id: 'r5',
|
||||
name: '알파 프로젝트',
|
||||
isEnabled: true,
|
||||
match: 'any',
|
||||
conditions: [
|
||||
{ field: 'subject', operator: 'contains', value: '알파' },
|
||||
{ field: 'body', operator: 'contains', value: 'Project Alpha' },
|
||||
],
|
||||
actions: { ...ACTIONS, moveToMailboxId: 'gone' },
|
||||
},
|
||||
]
|
||||
|
||||
const SET: MailFilterSet = { rules: RULES, version: 'v1', isActive: true }
|
||||
|
||||
interface Server {
|
||||
filters?: MailFilterSet | MockResult
|
||||
save?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
/** 서버 흉내 — 받은 목록을 그대로 저장하고 version 을 올린다 */
|
||||
function echo(request: MockRequest): MockResult {
|
||||
const body = request.data as MailFilterSetPayload
|
||||
return ok({ rules: body.rules, version: `${body.version}+`, isActive: true })
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
return { promise, finish }
|
||||
}
|
||||
|
||||
describe('MailFilterPage', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().startSession(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function installServer(server: Server = {}) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/mailboxes') {
|
||||
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
|
||||
}
|
||||
if (request.url === '/mail-filters' && request.method === 'GET') {
|
||||
const filters = server.filters ?? SET
|
||||
return typeof filters === 'string' || 'status' in filters ? filters : ok(filters)
|
||||
}
|
||||
if (request.url === '/mail-filters' && request.method === 'PUT') return (server.save ?? echo)(request)
|
||||
return ok(null)
|
||||
})
|
||||
}
|
||||
|
||||
async function mountPage(server: Server = {}, path = '/settings/mail/filters') {
|
||||
installServer(server)
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailFilterPage, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data as MailFilterSetPayload)
|
||||
const rows = (wrapper: VueWrapper) => wrapper.findAll('[data-filter-rule]')
|
||||
const row = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-filter-rule="${id}"]`)
|
||||
const dialog = (wrapper: VueWrapper) => wrapper.get('#mail-filter-dialog')
|
||||
const isOpen = (wrapper: VueWrapper) => dialog(wrapper).attributes('open') !== undefined
|
||||
const toasts = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const conditionValues = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('[data-filter-condition-value]').map((input) => (input.element as HTMLInputElement).value)
|
||||
|
||||
/** 사람이 누른 것처럼 — 초점을 먼저 준다 (창을 닫으면 초점이 돌아올 곳) */
|
||||
async function click(target: Pick<DOMWrapper<Element>, 'element' | 'trigger'>) {
|
||||
;(target.element as HTMLElement).focus()
|
||||
await target.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function openRule(wrapper: VueWrapper, id: string) {
|
||||
await click(row(wrapper, id).get('[data-filter-edit]'))
|
||||
}
|
||||
|
||||
async function save(wrapper: VueWrapper) {
|
||||
await click(wrapper.get('#mail-filter-save'))
|
||||
}
|
||||
|
||||
it('목록 — 순서 번호 · 켜기 스위치 · 이름과 요약 한 줄, 꺼 둔 규칙은 흐리게, 지워진 메일함은 빨간 안내', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('필터')
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`).sort()).toEqual([
|
||||
'GET /mail-filters',
|
||||
'GET /mailboxes',
|
||||
])
|
||||
expect(rows(wrapper).map((item) => item.get('.filter-row__no').text())).toEqual(['1', '2', '3', '4', '5'])
|
||||
expect(rows(wrapper).map((item) => item.get('.filter-row__title').text())).toEqual([
|
||||
'휴가 중 전달',
|
||||
'거래처 메일',
|
||||
'결재 알림',
|
||||
'받는 사람에 ‘all@arasystem.it.kr’ 포함',
|
||||
'알파 프로젝트',
|
||||
])
|
||||
expect(rows(wrapper).map((item) => item.get('.filter-row__summary').text())).toEqual([
|
||||
'모든 메일 → 전달: doyun.lee@arasystem.it.kr',
|
||||
'보낸 사람에 ‘@hanbit-partners.co.kr’ 포함 → 옮기기: 거래처',
|
||||
'제목에 ‘[결재]’ 포함 → 읽음으로 표시 · 중요 표시',
|
||||
'→ 옮기기: 전체 공지 · 읽음으로 표시',
|
||||
'제목에 ‘알파’ 포함 또는 본문에 ‘Project Alpha’ 포함 → 옮기기: 없는 메일함',
|
||||
])
|
||||
const switches = wrapper.findAll('[data-filter-switch]')
|
||||
expect(switches.map((item) => item.attributes('aria-checked'))).toEqual(['false', 'true', 'true', 'false', 'true'])
|
||||
expect(switches[1]?.attributes()).toMatchObject({ role: 'switch', 'aria-label': '‘거래처 메일’ 필터 사용' })
|
||||
// 이름 없는 필터 — 조건 글이 이름이라 작은따옴표가 겹치지 않게 큰따옴표로 묶는다
|
||||
expect(switches[3]?.attributes('aria-label')).toBe('“받는 사람에 ‘all@arasystem.it.kr’ 포함” 필터 사용')
|
||||
expect(row(wrapper, 'r4').get('[data-filter-up]').attributes('aria-label')).toBe(
|
||||
'“받는 사람에 ‘all@arasystem.it.kr’ 포함” 위로',
|
||||
)
|
||||
// 켜기마다 목록 전체가 바쁜 것으로 읽히지 않게 — 칸마다 aria-disabled
|
||||
expect(wrapper.get('ol').attributes('aria-busy')).toBeUndefined()
|
||||
expect(rows(wrapper).map((item) => item.classes('is-off'))).toEqual([true, false, false, true, false])
|
||||
expect(wrapper.findAll('[data-filter-missing]').map((item) => item.text())).toEqual([
|
||||
'옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.',
|
||||
])
|
||||
expect(row(wrapper, 'r5').get('[data-filter-edit]').attributes('aria-label')).toBe('‘알파 프로젝트’ 필터 고치기')
|
||||
// 맨 위의 ▲ · 맨 아래의 ▼ 는 누를 수 없다 (초점은 그대로 받는다)
|
||||
expect(row(wrapper, 'r1').get('[data-filter-up]').attributes('aria-disabled')).toBe('true')
|
||||
expect(row(wrapper, 'r1').get('[data-filter-down]').attributes('aria-disabled')).toBeUndefined()
|
||||
expect(row(wrapper, 'r5').get('[data-filter-down]').attributes('aria-disabled')).toBe('true')
|
||||
expect(wrapper.get('[data-filter-count]').text()).toBe('필터 5개 · 50개까지 만들 수 있습니다. 행을 누르면 고칩니다.')
|
||||
expect(wrapper.get('[data-filter-notes]').findAll('p')).toHaveLength(4)
|
||||
expect(wrapper.find('[data-filter-empty]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-filter-inactive]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도" — 알림은 띄우지 않는다', async () => {
|
||||
let reply: MailFilterSet | MockResult = fail(503, 'MAIL_001')
|
||||
const { wrapper } = await mountPage({
|
||||
get filters() {
|
||||
return reply
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
expect(toasts()).toEqual([])
|
||||
expect(wrapper.get('[data-filter-create]').attributes('aria-disabled')).toBe('true')
|
||||
reply = SET
|
||||
await wrapper.get('.mail-load-error__action').trigger('click')
|
||||
await flushPromises()
|
||||
expect(rows(wrapper)).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('필터가 없으면 목록 자리에 안내 — 안의 [새 필터]는 머리의 단추와 같은 일을 한다', async () => {
|
||||
const { wrapper } = await mountPage({ filters: { rules: [], version: 'v0', isActive: true } })
|
||||
const empty = wrapper.get('[data-filter-empty]')
|
||||
|
||||
expect(empty.text()).toContain('아직 필터가 없습니다')
|
||||
expect(wrapper.find('[data-filter-count]').exists()).toBe(false)
|
||||
await click(empty.get('[data-filter-create]'))
|
||||
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('새 필터')
|
||||
expect(wrapper.find('#mail-filter-remove').exists()).toBe(false)
|
||||
expect(document.activeElement?.id).toBe('mail-filter-name')
|
||||
})
|
||||
|
||||
it('켜기 스위치 — 화면을 먼저 바꾸고 전체 교체로 저장한다 (알리지 않는다), 실패하면 되돌리고 오류 알림', async () => {
|
||||
const reply = deferred()
|
||||
const { wrapper } = await mountPage({ save: () => reply.promise })
|
||||
const toggle = row(wrapper, 'r2').get('[data-filter-switch]')
|
||||
|
||||
await toggle.trigger('click')
|
||||
expect(toggle.attributes('aria-checked')).toBe('false')
|
||||
expect(puts()).toHaveLength(1)
|
||||
expect(puts()[0]?.version).toBe('v1')
|
||||
expect(puts()[0]?.rules.map((rule) => rule.isEnabled)).toEqual([false, false, true, false, true])
|
||||
// 저장하는 동안은 다른 켜기 · 순서를 보내지 않는다 (막아도 초점은 그대로 — aria-disabled)
|
||||
const other = row(wrapper, 'r3').get('[data-filter-switch]')
|
||||
expect(other.attributes('aria-disabled')).toBe('true')
|
||||
await other.trigger('click')
|
||||
await row(wrapper, 'r3').get('[data-filter-up]').trigger('click')
|
||||
expect(puts()).toHaveLength(1)
|
||||
|
||||
reply.finish(fail(503, 'MAIL_001'))
|
||||
await flushPromises()
|
||||
expect(toggle.attributes('aria-checked')).toBe('true')
|
||||
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
|
||||
})
|
||||
|
||||
it('켜기가 저장되면 알림이 없다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
|
||||
await row(wrapper, 'r1').get('[data-filter-switch]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(row(wrapper, 'r1').get('[data-filter-switch]').attributes('aria-checked')).toBe('true')
|
||||
expect(row(wrapper, 'r1').classes('is-off')).toBe(false)
|
||||
expect(toasts()).toEqual([])
|
||||
})
|
||||
|
||||
it('▲ ▼ — 한 칸 옮겨 저장하고, 누른 단추에 초점이 남는다 (맨 위로 오면 그 ▲ 는 꺼진 채로 초점을 둔다)', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
|
||||
await click(row(wrapper, 'r2').get('[data-filter-up]'))
|
||||
|
||||
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r2', 'r1', 'r3', 'r4', 'r5'])
|
||||
expect(puts()[0]?.rules.map((rule) => rule.id)).toEqual(['r2', 'r1', 'r3', 'r4', 'r5'])
|
||||
const up = row(wrapper, 'r2').get('[data-filter-up]')
|
||||
expect(document.activeElement).toBe(up.element)
|
||||
expect(up.attributes('aria-disabled')).toBe('true')
|
||||
|
||||
// 맨 위에서 다시 눌러도 보내지 않는다
|
||||
await click(up)
|
||||
expect(puts()).toHaveLength(1)
|
||||
expect(toasts()).toEqual([])
|
||||
})
|
||||
|
||||
it('행을 누르면 그 규칙으로 채운 고치기 창 — [저장]하면 그 자리에 저장하고 "필터를 저장했습니다.", 초점은 그 행으로', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
|
||||
await openRule(wrapper, 'r2')
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('필터 고치기')
|
||||
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('거래처 메일')
|
||||
expect((wrapper.get('#mail-filter-match').element as HTMLSelectElement).value).toBe('all')
|
||||
expect(conditionValues(wrapper)).toEqual(['@hanbit-partners.co.kr'])
|
||||
expect((wrapper.get('#mail-filter-move').element as HTMLInputElement).checked).toBe(true)
|
||||
expect((wrapper.get('#mail-filter-move-target').element as HTMLSelectElement).value).toBe('vendor')
|
||||
expect(wrapper.get('#mail-filter-forward-to').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('#mail-filter-remove').text()).toBe('이 필터 지우기')
|
||||
expect(document.activeElement?.id).toBe('mail-filter-name')
|
||||
|
||||
await wrapper.get('#mail-filter-name').setValue('한빛 거래처')
|
||||
await wrapper.get('#mail-filter-read').setValue(true)
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toEqual([
|
||||
{
|
||||
version: 'v1',
|
||||
rules: [
|
||||
RULES[0],
|
||||
{ ...RULES[1], name: '한빛 거래처', actions: { ...RULES[1]?.actions, markAsRead: true } },
|
||||
RULES[2],
|
||||
RULES[3],
|
||||
RULES[4],
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(toasts()).toEqual(['필터를 저장했습니다.'])
|
||||
expect(row(wrapper, 'r2').get('.filter-row__title').text()).toBe('한빛 거래처')
|
||||
expect(document.activeElement).toBe(row(wrapper, 'r2').get('[data-filter-edit]').element)
|
||||
})
|
||||
|
||||
it('새 필터 — 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만, 첫 잘못된 칸에 초점 (알림 없음 · 보내지 않는다)', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
|
||||
await click(wrapper.get('[data-filter-create]'))
|
||||
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('새 필터')
|
||||
await save(wrapper)
|
||||
|
||||
expect(wrapper.get('#mail-filter-condition-0-error').text()).toBe(
|
||||
'비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.',
|
||||
)
|
||||
expect(wrapper.get('#mail-filter-actions-error').text()).toBe('할 일을 하나 이상 골라 주세요.')
|
||||
const value = wrapper.get('[data-filter-condition-value]')
|
||||
expect(value.attributes()).toMatchObject({ 'aria-invalid': 'true', 'aria-describedby': 'mail-filter-condition-0-error' })
|
||||
expect(document.activeElement).toBe(value.element)
|
||||
|
||||
await value.setValue('가격 ${price}')
|
||||
expect(wrapper.find('#mail-filter-condition-0-error').exists()).toBe(false)
|
||||
await wrapper.get('#mail-filter-forward').setValue(true)
|
||||
expect(wrapper.find('#mail-filter-actions-error').exists()).toBe(false)
|
||||
await wrapper.get('#mail-filter-forward-to').setValue('me@gmail.com')
|
||||
await save(wrapper)
|
||||
|
||||
expect(wrapper.get('#mail-filter-condition-0-error').text()).toBe(
|
||||
'‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.',
|
||||
)
|
||||
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('사내 주소(@arasystem.it.kr)로만 전달할 수 있습니다.')
|
||||
expect(document.activeElement).toBe(value.element)
|
||||
|
||||
await value.setValue('견적서')
|
||||
await save(wrapper)
|
||||
expect(document.activeElement?.id).toBe('mail-filter-forward-to')
|
||||
await wrapper.get('#mail-filter-forward-to').setValue('Ara.Kim@arasystem.it.kr')
|
||||
await save(wrapper)
|
||||
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('내 주소로는 전달할 수 없습니다.')
|
||||
|
||||
expect(puts()).toEqual([])
|
||||
expect(toasts()).toEqual([])
|
||||
|
||||
await wrapper.get('#mail-filter-forward-to').setValue('doyun.lee@arasystem.it.kr')
|
||||
expect(wrapper.get('#mail-filter-forward-help').text()).toBe(
|
||||
'사내 주소로만 전달할 수 있습니다. 전달해도 내 메일함에 그대로 남습니다.',
|
||||
)
|
||||
await save(wrapper)
|
||||
|
||||
const added = puts()[0]?.rules.at(-1)
|
||||
expect(puts()[0]?.rules).toHaveLength(6)
|
||||
expect(added).toMatchObject({
|
||||
name: '',
|
||||
isEnabled: true,
|
||||
match: 'all',
|
||||
conditions: [{ field: 'from', operator: 'contains', value: '견적서' }],
|
||||
actions: { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: 'doyun.lee@arasystem.it.kr' },
|
||||
})
|
||||
expect(added?.id).toMatch(/^[0-9a-f-]{36}$/)
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(toasts()).toEqual(['필터를 저장했습니다.'])
|
||||
})
|
||||
|
||||
it('조건 줄 — [조건 더하기]는 5개까지(새 줄은 제목), 하나만 남으면 ✕ 를 끈다 · 모든 메일이면 조건을 보내지 않는다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await click(wrapper.get('[data-filter-create]'))
|
||||
|
||||
for (let count = 1; count < 5; count += 1) await wrapper.get('#mail-filter-add-condition').trigger('click')
|
||||
await flushPromises()
|
||||
const fields = wrapper.findAll('[data-filter-condition-field]')
|
||||
expect(fields.map((field) => (field.element as HTMLSelectElement).value)).toEqual([
|
||||
'from',
|
||||
'subject',
|
||||
'subject',
|
||||
'subject',
|
||||
'subject',
|
||||
])
|
||||
expect(document.activeElement).toBe(fields[4]?.element)
|
||||
expect(wrapper.get('#mail-filter-add-condition').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('[data-filter-condition-value]').attributes('placeholder')).toBe(
|
||||
'예: @hanbit-partners.co.kr 또는 이름',
|
||||
)
|
||||
|
||||
for (let count = 0; count < 4; count += 1) await wrapper.get('[data-filter-condition-remove]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-filter-condition-value]')).toHaveLength(1)
|
||||
expect(wrapper.get('[data-filter-condition-remove]').attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.get('#mail-filter-match').setValue('always')
|
||||
expect(wrapper.find('[data-filter-condition-value]').exists()).toBe(false)
|
||||
await wrapper.get('#mail-filter-flag').setValue(true)
|
||||
await save(wrapper)
|
||||
expect(puts()[0]?.rules.at(-1)).toMatchObject({ match: 'always', conditions: [], actions: { flag: true } })
|
||||
})
|
||||
|
||||
it('옮길 메일함 — 받은편지함 · 내 메일함 · 보관함 · 스팸메일함 · 휴지통, 지워진 메일함은 "(지워진 메일함)"이 골라진 채로', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
|
||||
await openRule(wrapper, 'r5')
|
||||
const select = wrapper.get('#mail-filter-move-target')
|
||||
const options = select.findAll('option')
|
||||
expect(options.map((option) => option.text())).toEqual([
|
||||
'(지워진 메일함) — 다른 메일함을 고르세요',
|
||||
'받은편지함',
|
||||
'거래처',
|
||||
'전체 공지',
|
||||
'보관함',
|
||||
'스팸메일함',
|
||||
'휴지통',
|
||||
])
|
||||
expect(options[0]?.attributes('disabled')).toBeDefined()
|
||||
expect((select.element as HTMLSelectElement).value).toBe('gone')
|
||||
|
||||
await select.setValue('junk')
|
||||
await save(wrapper)
|
||||
expect(puts()[0]?.rules[4]?.actions.moveToMailboxId).toBe('junk')
|
||||
expect(wrapper.find('[data-filter-missing]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('서버의 거절(MAIL_013 · VAL_001)은 창 안에 사전 문구로 — 알림 없이 창을 그대로 두고, 고치면 내린다', async () => {
|
||||
const { wrapper } = await mountPage({ save: () => fail(400, 'MAIL_013') })
|
||||
|
||||
await openRule(wrapper, 'r3')
|
||||
await save(wrapper)
|
||||
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_013)
|
||||
expect(toasts()).toEqual([])
|
||||
await wrapper.get('#mail-filter-name').setValue('결재')
|
||||
expect(wrapper.find('#mail-filter-error').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('저장하는 동안 [저장] · [이 필터 지우기]는 aria-disabled — 다시 눌러도 보내지 않는다', async () => {
|
||||
const reply = deferred()
|
||||
const { wrapper } = await mountPage({ save: () => reply.promise })
|
||||
|
||||
await openRule(wrapper, 'r3')
|
||||
await save(wrapper)
|
||||
expect(wrapper.get('#mail-filter-save').attributes()).toMatchObject({ 'aria-disabled': 'true', 'aria-busy': 'true' })
|
||||
expect(wrapper.get('#mail-filter-remove').attributes('aria-disabled')).toBe('true')
|
||||
await save(wrapper)
|
||||
await click(wrapper.get('#mail-filter-remove'))
|
||||
expect(puts()).toHaveLength(1)
|
||||
|
||||
reply.finish(echo(requests[requests.length - 1] as MockRequest))
|
||||
await flushPromises()
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
})
|
||||
|
||||
it('그사이 다른 곳에서 바뀜(409 MAIL_012) — 알림 없이 목록 위 줄 + 창 안 문구, 창은 닫지 않는다 · [다시 불러오기]는 새로 받고 창을 닫는다', async () => {
|
||||
let filters: MailFilterSet = SET
|
||||
const { wrapper } = await mountPage({
|
||||
get filters() {
|
||||
return filters
|
||||
},
|
||||
save: () => fail(409, 'MAIL_012'),
|
||||
})
|
||||
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-name').setValue('고친 이름')
|
||||
await save(wrapper)
|
||||
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('고친 이름')
|
||||
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
|
||||
const bar = wrapper.get('[data-filter-conflict]')
|
||||
expect(bar.attributes('role')).toBe('alert')
|
||||
expect(bar.text()).toContain('다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.')
|
||||
expect(toasts()).toEqual([])
|
||||
|
||||
filters = { rules: [RULES[2] as MailFilterRule], version: 'v9', isActive: true }
|
||||
await click(bar.get('button'))
|
||||
|
||||
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r3'])
|
||||
})
|
||||
|
||||
interface ConflictServer {
|
||||
filters: MailFilterSet | MockResult
|
||||
conflict: boolean
|
||||
}
|
||||
|
||||
/** 고치기 창에서 409 를 받은 상태 — 창 안의 [다시 불러오기]를 누르기 직전 */
|
||||
async function conflictInDialog(server: ConflictServer) {
|
||||
const { wrapper } = await mountPage({
|
||||
get filters() {
|
||||
return server.filters
|
||||
},
|
||||
save: (request) => (server.conflict ? fail(409, 'MAIL_012') : echo(request)),
|
||||
})
|
||||
await openRule(wrapper, 'r2')
|
||||
await wrapper.get('#mail-filter-name').setValue('고친 이름')
|
||||
await save(wrapper)
|
||||
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('그사이 바뀜 — 창 안의 [다시 불러오기]는 목록만 다시 받고 창 · 쓴 것은 그대로, 초점은 [저장] · 다음 [저장]은 다시 받은 목록 위에', async () => {
|
||||
const server: ConflictServer = { filters: SET, conflict: true }
|
||||
const wrapper = await conflictInDialog(server)
|
||||
const reload = wrapper.get('#mail-filter-reload')
|
||||
expect(reload.text()).toBe('다시 불러오기')
|
||||
expect(reload.attributes('aria-describedby')).toBe('mail-filter-error')
|
||||
// 칸을 고쳐도 그사이 바뀜 안내는 남는다 — 다시 받아야 풀린다
|
||||
await wrapper.get('#mail-filter-name').setValue('고친 이름 2')
|
||||
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
|
||||
|
||||
// 다른 곳에서 r2 를 끄고 r1 을 지웠다
|
||||
const others = [{ ...(RULES[1] as MailFilterRule), isEnabled: false, name: '다른 곳' }, ...RULES.slice(2)]
|
||||
server.filters = { rules: others, version: 'v7', isActive: true }
|
||||
server.conflict = false
|
||||
await click(reload)
|
||||
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect((wrapper.get('#mail-filter-name').element as HTMLInputElement).value).toBe('고친 이름 2')
|
||||
expect(wrapper.find('#mail-filter-error').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(false)
|
||||
expect(document.activeElement?.id).toBe('mail-filter-save')
|
||||
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r2', 'r3', 'r4', 'r5'])
|
||||
expect(toasts()).toEqual([])
|
||||
|
||||
await save(wrapper)
|
||||
// 같은 id 자리에서 바꾸고, 켜짐은 다시 받은 목록의 값(꺼짐)이다 — 창은 켜기를 고치지 않는다
|
||||
expect(puts().at(-1)).toEqual({
|
||||
version: 'v7',
|
||||
rules: [{ ...RULES[1], name: '고친 이름 2', isEnabled: false }, ...RULES.slice(2)],
|
||||
})
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(toasts()).toEqual(['필터를 저장했습니다.'])
|
||||
})
|
||||
|
||||
it('다시 받은 목록에서 그 규칙이 다른 곳에서 지워졌으면 맨 아래에 넣는다', async () => {
|
||||
const server: ConflictServer = { filters: SET, conflict: true }
|
||||
const wrapper = await conflictInDialog(server)
|
||||
|
||||
server.filters = { rules: [RULES[0] as MailFilterRule, RULES[2] as MailFilterRule], version: 'v7', isActive: true }
|
||||
server.conflict = false
|
||||
await click(wrapper.get('#mail-filter-reload'))
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts().at(-1)?.version).toBe('v7')
|
||||
expect(puts().at(-1)?.rules.map((rule) => [rule.id, rule.name])).toEqual([
|
||||
['r1', '휴가 중 전달'],
|
||||
['r3', '결재 알림'],
|
||||
['r2', '고친 이름'],
|
||||
])
|
||||
})
|
||||
|
||||
it('지워진 규칙을 맨 아래에 넣으면 50개를 넘을 때 — 보내지 않고 창 안에 "필터는 50개까지 …" (알림 없음)', async () => {
|
||||
const server: ConflictServer = { filters: SET, conflict: true }
|
||||
const wrapper = await conflictInDialog(server)
|
||||
const fifty = Array.from({ length: 50 }, (_, index) => ({ ...(RULES[2] as MailFilterRule), id: `f${index}` }))
|
||||
|
||||
server.filters = { rules: fifty, version: 'v7', isActive: true }
|
||||
server.conflict = false
|
||||
await click(wrapper.get('#mail-filter-reload'))
|
||||
await save(wrapper)
|
||||
|
||||
expect(puts()).toHaveLength(1)
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect(wrapper.get('#mail-filter-error').text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.')
|
||||
expect(wrapper.find('#mail-filter-reload').exists()).toBe(false)
|
||||
expect(toasts()).toEqual([])
|
||||
})
|
||||
|
||||
it('창 안의 [다시 불러오기]를 실패하면 인터셉터 알림만 — 창과 안내 · 단추는 그대로', async () => {
|
||||
const server: ConflictServer = { filters: SET, conflict: true }
|
||||
const wrapper = await conflictInDialog(server)
|
||||
|
||||
// 같은 코드의 알림은 3초 안에 다시 띄우지 않는다(useApi) — 앞 테스트의 MAIL_001 과 섞이지 않게 다른 코드로
|
||||
server.filters = fail(500, 'SYS_001')
|
||||
await click(wrapper.get('#mail-filter-reload'))
|
||||
|
||||
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect(wrapper.get('#mail-filter-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_012)
|
||||
expect(wrapper.find('#mail-filter-reload').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-filter-conflict]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('전달 칸 — 비우면 "전달할 주소를 입력해 주세요.", 형식이 틀리면 "전달할 주소 형식이 올바르지 않습니다." (칸 아래 · 알림 없음)', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await click(wrapper.get('[data-filter-create]'))
|
||||
await wrapper.get('[data-filter-condition-value]').setValue('견적')
|
||||
await wrapper.get('#mail-filter-forward').setValue(true)
|
||||
|
||||
await save(wrapper)
|
||||
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('전달할 주소를 입력해 주세요.')
|
||||
expect(wrapper.get('#mail-filter-forward-to').attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement?.id).toBe('mail-filter-forward-to')
|
||||
|
||||
await wrapper.get('#mail-filter-forward-to').setValue('doyun..lee@arasystem.it.kr')
|
||||
await save(wrapper)
|
||||
expect(wrapper.get('#mail-filter-forward-help').text()).toBe('전달할 주소 형식이 올바르지 않습니다.')
|
||||
expect(puts()).toEqual([])
|
||||
expect(toasts()).toEqual([])
|
||||
})
|
||||
|
||||
it('받는 사람 칸 · 전달 칸의 보기는 로그인한 직원 주소의 도메인으로', async () => {
|
||||
useAuthStore().startSession({ email: 'mina.choi@ara.test', name: '최미나' })
|
||||
const { wrapper } = await mountPage()
|
||||
await click(wrapper.get('[data-filter-create]'))
|
||||
|
||||
await wrapper.get('[data-filter-condition-field]').setValue('to')
|
||||
expect(wrapper.get('[data-filter-condition-value]').attributes('placeholder')).toBe('예: all@ara.test')
|
||||
expect(wrapper.get('#mail-filter-forward-to').attributes('placeholder')).toBe('이름@ara.test')
|
||||
})
|
||||
|
||||
it('지우기 — 확인 없이 지우고 알림 [되돌리기], 초점은 다음 행 · 되돌리면 지운 자리에 다시 저장한다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
|
||||
await openRule(wrapper, 'r2')
|
||||
await click(wrapper.get('#mail-filter-remove'))
|
||||
|
||||
expect(puts()[0]?.rules.map((rule) => rule.id)).toEqual(['r1', 'r3', 'r4', 'r5'])
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(rows(wrapper)).toHaveLength(4)
|
||||
expect(document.activeElement).toBe(row(wrapper, 'r3').get('[data-filter-edit]').element)
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('‘거래처 메일’ 필터를 지웠습니다.')
|
||||
expect(toast?.action?.label).toBe('되돌리기')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
|
||||
expect(puts()[1]).toEqual({ version: 'v1+', rules: RULES })
|
||||
expect(rows(wrapper).map((item) => item.attributes('data-filter-rule'))).toEqual(['r1', 'r2', 'r3', 'r4', 'r5'])
|
||||
expect(toasts().at(-1)).toBe('필터를 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('50개를 다 만들었으면 [새 필터]를 끄고 까닭을 옆에 — 눌러도 열지 않는다', async () => {
|
||||
const fifty = Array.from({ length: 50 }, (_, index) => ({ ...(RULES[2] as MailFilterRule), id: `f${index}` }))
|
||||
const { wrapper } = await mountPage({ filters: { rules: fifty, version: 'v1', isActive: true } })
|
||||
const create = wrapper.get('[data-filter-create]')
|
||||
|
||||
expect(create.attributes()).toMatchObject({ 'aria-disabled': 'true', 'aria-describedby': 'mail-filter-full-note' })
|
||||
expect(wrapper.get('#mail-filter-full-note').text()).toBe('필터는 50개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.')
|
||||
await click(create)
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
})
|
||||
|
||||
it('동작하지 않을 때(isActive: false) — 목록 위 줄 + [다시 켜기]는 지금 목록 그대로 저장한다', async () => {
|
||||
const { wrapper } = await mountPage({ filters: { ...SET, isActive: false } })
|
||||
const bar = wrapper.get('[data-filter-inactive]')
|
||||
|
||||
expect(bar.text()).toContain('지금은 필터와 부재중 자동 응답이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다.')
|
||||
await click(bar.get('button'))
|
||||
|
||||
expect(puts()).toEqual([{ rules: RULES, version: 'v1' }])
|
||||
expect(wrapper.find('[data-filter-inactive]').exists()).toBe(false)
|
||||
// 누른 단추가 사라졌다 — 머리의 [새 필터]로
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-filter-create]').element)
|
||||
})
|
||||
|
||||
it('읽기 화면에서 — ?from= 이면 새 필터 창이 "보낸 사람 · 포함 · 그 주소"로 열리고 초점은 할 일의 첫 칸, 주소에서 지운다', async () => {
|
||||
const { wrapper, router } = await mountPage({}, '/settings/mail/filters?from=seojun.park%40arasystem.it.kr')
|
||||
|
||||
expect(isOpen(wrapper)).toBe(true)
|
||||
expect(wrapper.get('#mail-filter-dialog-title').text()).toBe('새 필터')
|
||||
expect((wrapper.get('[data-filter-condition-field]').element as HTMLSelectElement).value).toBe('from')
|
||||
expect((wrapper.get('[data-filter-condition-operator]').element as HTMLSelectElement).value).toBe('contains')
|
||||
expect(conditionValues(wrapper)).toEqual(['seojun.park@arasystem.it.kr'])
|
||||
expect(document.activeElement?.id).toBe('mail-filter-move')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
|
||||
|
||||
// 취소하면 — 창을 연 단추가 없다(주소로 왔다) · 머리의 [새 필터]로
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-filter-create]').element)
|
||||
})
|
||||
|
||||
it('?from= 이어도 50개를 다 만들었으면 창을 열지 않는다 (주소에서는 지운다)', async () => {
|
||||
const fifty = Array.from({ length: 50 }, (_, index) => ({ ...(RULES[2] as MailFilterRule), id: `f${index}` }))
|
||||
const { wrapper, router } = await mountPage(
|
||||
{ filters: { rules: fifty, version: 'v1', isActive: true } },
|
||||
'/settings/mail/filters?from=seojun.park%40arasystem.it.kr',
|
||||
)
|
||||
|
||||
expect(isOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.query).toEqual({})
|
||||
})
|
||||
|
||||
it('읽기 화면 ⋯ 「이런 메일로 필터 만들기」를 누르면 — 설정 › 필터의 새 필터 창이 그 메일의 보낸 사람 주소로 채워져 열린다', async () => {
|
||||
installServer()
|
||||
const message: MessageDetail = {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '박서준', email: 'seojun.park@arasystem.it.kr' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: '견적서 보내 드립니다',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-28T01:42:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '본문',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
const mailStore = useMailStore()
|
||||
mailStore.selected = message
|
||||
mailStore.selectedId = message.id
|
||||
mailStore.selectedStatus = 'ready'
|
||||
// 앱과 같은 이름의 라우트 — 읽기 화면과 필터 화면만 실제 화면으로 (같은 이름을 다시 더하면 바꿔 낀다)
|
||||
const router = createTestRouter()
|
||||
router.addRoute({ path: '/mail/:mailboxId/:messageId', name: 'mail-message', component: MessageReader })
|
||||
router.addRoute({ path: '/settings/mail/filters', name: 'settings-mail-filters', component: MailFilterPage })
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
const app = mount({ render: () => h(RouterView) }, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
mounted.push(app)
|
||||
|
||||
await app.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const item = app.findAll('[role="menuitem"]').find((menuItem) => menuItem.text() === '이런 메일로 필터 만들기')
|
||||
await item?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(app.get('h1').text()).toBe('필터')
|
||||
expect(app.get('#mail-filter-dialog').attributes('open')).toBeDefined()
|
||||
expect(app.get('#mail-filter-dialog-title').text()).toBe('새 필터')
|
||||
expect(
|
||||
app.findAll('[data-filter-condition-value]').map((input) => (input.element as HTMLInputElement).value),
|
||||
).toEqual(['seojun.park@arasystem.it.kr'])
|
||||
expect((app.get('#mail-filter-move').element as HTMLInputElement).checked).toBe(false)
|
||||
expect(document.activeElement?.id).toBe('mail-filter-move')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/filters')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,330 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 메일 › 필터 (디자인 원본 SettingsFilter · MailFilterStates · README "6단계 — 메일 필터") — /settings/mail/filters · 탭 제목 "메일 필터"
|
||||
// 머리 "필터" + 설명 + 오른쪽 [새 필터](50개면 끄고 까닭을 옆에) → 목록 위 노란 줄(다른 곳에서 바뀜 · 동작하지 않음) →
|
||||
// 목록(MailFilterList — 없으면 MailFilterEmpty) + "필터 N개 · 50개까지 …" → 안내 상자(MailFilterNotes). 행 · [새 필터]는 고치기 창(MailFilterDialog)을 연다.
|
||||
// - 들어올 때마다 필터를 새로 받는다(다른 탭 · PC 에서 바꿨을 수 있다). 메일함은 메일 화면이 받아 둔 목록이 있으면 그대로 쓰며 다시 맞추고
|
||||
// (다른 탭에서 지웠을 수 있다) 없으면 받는다 — 옮길 메일함의 이름 · 지워진 메일함 안내에 쓴다. 둘 다 받아야 목록을 그린다
|
||||
// (받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도")
|
||||
// - 켜기 · 순서는 화면을 먼저 바꾸고 실패하면 되돌린다(스토어 — 오류 알림은 인터셉터). 창의 저장 · 지우기는 useMailFilterDialog, 그 알림은 useMailFilterActions
|
||||
// - 409 MAIL_012 는 알리지 않고 목록 위 줄 + [다시 불러오기](목록을 새로 받고 창을 닫는다). 고치던 창은 닫지 않고, 창 안에도
|
||||
// 같은 사전 문구와 [다시 불러오기]를 둔다(창이 모달이라 뒤의 줄을 누를 수 없다) — 창 안의 것은 창 · 쓴 내용을 그대로 두고 목록만 다시 받는다.
|
||||
// 받으면 초점을 [저장]으로 옮기고, 다음 [저장]은 다시 받은 목록 위에 넣는다
|
||||
// - 읽기 화면 ⋯ 「이런 메일로 필터 만들기」는 ?from=<보낸 사람 주소> 로 온다 — 받은 뒤 새 필터 창을 그 주소로 채워 열고(50개면 열지 않는다)
|
||||
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
|
||||
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailFilterCreateButton from '@/components/settings/MailFilterCreateButton.vue'
|
||||
import MailFilterDialog from '@/components/settings/MailFilterDialog.vue'
|
||||
import MailFilterEmpty from '@/components/settings/MailFilterEmpty.vue'
|
||||
import MailFilterList from '@/components/settings/MailFilterList.vue'
|
||||
import MailFilterNotes from '@/components/settings/MailFilterNotes.vue'
|
||||
import MailFilterNotice from '@/components/settings/MailFilterNotice.vue'
|
||||
import SettingsPageFrame from '@/components/settings/SettingsPageFrame.vue'
|
||||
import type { MailFilterRowPart } from '@/components/settings/mail-filter-ui'
|
||||
import { getErrorMessage, type ApiError } from '@/composables/useApi'
|
||||
import { useMailFilterDialog, type MailFilterDialogResult } from '@/composables/useMailFilterDialog'
|
||||
import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { MailFilterRule } from '@/shared/types/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailFilterStore } from '@/stores/mail-filter.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const filterStore = useMailFilterStore()
|
||||
const mailStore = useMailStore()
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const {
|
||||
state: dialog,
|
||||
pending: dialogPending,
|
||||
error: dialogError,
|
||||
conflict: dialogConflict,
|
||||
open: openDialog,
|
||||
close: closeDialog,
|
||||
clearError: clearDialogError,
|
||||
save: saveDialog,
|
||||
remove: removeDialog,
|
||||
reload: reloadDialog,
|
||||
} = useMailFilterDialog()
|
||||
|
||||
const createRef = ref<InstanceType<typeof MailFilterCreateButton> | null>(null)
|
||||
const listRef = ref<InstanceType<typeof MailFilterList> | null>(null)
|
||||
const dialogRef = ref<InstanceType<typeof MailFilterDialog> | null>(null)
|
||||
/** 이 화면이 떠 있다고 스토어에 알린 것을 내린다 (화면 밖 되돌리기의 실패 알림 — useMailFilterActions) */
|
||||
let detachPage: (() => void) | null = null
|
||||
|
||||
/** 목록 위 줄의 단추를 누르고 기다리는 중 */
|
||||
const noticeBusy = ref<'reload' | 'reactivate' | null>(null)
|
||||
/** ?from= 을 이미 받아 창을 열었다 — 주소에서 지울 때까지 다시 열지 않는다 */
|
||||
let fromHandled = false
|
||||
|
||||
const rules = computed<readonly MailFilterRule[]>(() => filterStore.filterSet?.rules ?? [])
|
||||
const ready = computed(
|
||||
() => filterStore.status === 'ready' && filterStore.filterSet !== null && mailStore.mailboxesStatus === 'ready',
|
||||
)
|
||||
const loadError = computed<ApiError | null>(() => {
|
||||
if (filterStore.status === 'error' && filterStore.error) return filterStore.error
|
||||
if (mailStore.mailboxesStatus === 'error' && mailStore.mailboxesError) return mailStore.mailboxesError
|
||||
return null
|
||||
})
|
||||
const full = computed(() => ready.value && filterStore.isFull)
|
||||
const countText = computed(() => MAIL_FILTER_TEXT.count(formatCount(rules.value.length)))
|
||||
const conflictText = getErrorMessage('MAIL_012')
|
||||
|
||||
/** 메일함 — 받아 둔 목록이 있으면 그대로 보이며 다시 맞춘다(다른 탭에서 지웠을 수 있다), 없으면 받는다 */
|
||||
function loadMailboxes() {
|
||||
if (mailStore.mailboxesStatus === 'ready') void mailStore.loadMailboxes({ background: true })
|
||||
else void mailStore.ensureMailboxes()
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (filterStore.status === 'error') void filterStore.load()
|
||||
if (mailStore.mailboxesStatus === 'error') void mailStore.ensureMailboxes()
|
||||
}
|
||||
|
||||
/** 초점을 잃었다 — 본문으로 갔거나, 닫힌 창 안에 남았다(창을 연 단추가 사라져 돌아갈 곳이 없을 때) */
|
||||
function focusLost(): boolean {
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body) return true
|
||||
const dialogElement = active.closest('dialog')
|
||||
return dialogElement !== null && !dialogElement.hasAttribute('open')
|
||||
}
|
||||
|
||||
/** 누른 단추가 사라져 초점을 잃었으면 — 그 규칙 행의 단추, 없으면 머리의 [새 필터]로 */
|
||||
async function restoreFocus(ruleId: string | null, part: MailFilterRowPart = 'edit') {
|
||||
await nextTick()
|
||||
if (!focusLost()) return
|
||||
if (ruleId !== null && listRef.value?.focusRule(ruleId, part)) return
|
||||
createRef.value?.focus()
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
if (ready.value && !filterStore.isFull) openDialog(null)
|
||||
}
|
||||
|
||||
function cancelDialog() {
|
||||
const ruleId = dialog.value?.rule?.id ?? null
|
||||
closeDialog()
|
||||
void restoreFocus(ruleId)
|
||||
}
|
||||
|
||||
/** 창의 저장 · 지우기가 끝나 창을 닫았으면 — 초점이 사라진 단추에 남지 않게 */
|
||||
async function settleDialog(result: Promise<MailFilterDialogResult>) {
|
||||
const { closed, focusRuleId } = await result
|
||||
if (closed) void restoreFocus(focusRuleId)
|
||||
}
|
||||
|
||||
function toggleRule(rule: MailFilterRule) {
|
||||
void filterStore.setRuleEnabled(rule.id, !rule.isEnabled)
|
||||
}
|
||||
|
||||
/** 순서 바꾸기 — 행을 옮기면(실패해서 되돌릴 때도) 누른 ▲ · ▼ 가 초점을 잃을 수 있어 같은 단추로 돌려준다 */
|
||||
async function moveRule(rule: MailFilterRule, offset: -1 | 1) {
|
||||
const part: MailFilterRowPart = offset < 0 ? 'up' : 'down'
|
||||
const moving = filterStore.moveRule(rule.id, offset)
|
||||
void restoreFocus(rule.id, part)
|
||||
if (!(await moving)) void restoreFocus(rule.id, part)
|
||||
}
|
||||
|
||||
/** 창 안의 [다시 불러오기] — 창 · 쓴 내용은 그대로 두고 목록을 다시 받는다. 받았으면 [저장]으로 */
|
||||
async function reloadInDialog() {
|
||||
if (await reloadDialog()) {
|
||||
await nextTick()
|
||||
dialogRef.value?.focusSave()
|
||||
}
|
||||
}
|
||||
|
||||
/** 목록 위 줄의 [다시 불러오기] — 목록을 새로 받고 고치기 창을 닫는다 */
|
||||
async function reload() {
|
||||
if (noticeBusy.value) return
|
||||
noticeBusy.value = 'reload'
|
||||
const done = await filterStore.reload()
|
||||
noticeBusy.value = null
|
||||
if (!done) return
|
||||
closeDialog()
|
||||
void restoreFocus(null)
|
||||
}
|
||||
|
||||
/** [다시 켜기] — 지금 목록 그대로 저장한다 */
|
||||
async function reactivate() {
|
||||
if (noticeBusy.value || filterStore.saving) return
|
||||
noticeBusy.value = 'reactivate'
|
||||
await filterStore.reactivate()
|
||||
noticeBusy.value = null
|
||||
void restoreFocus(null)
|
||||
}
|
||||
|
||||
// 처음 그리기 전에 — 지난번에 받아 둔 목록을 잠깐 그렸다 바꾸지 않게
|
||||
void filterStore.load()
|
||||
loadMailboxes()
|
||||
|
||||
onMounted(() => {
|
||||
detachPage = filterStore.attachPage()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
detachPage?.()
|
||||
})
|
||||
|
||||
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(?from=<주소>) — 받은 뒤 한 번 새 필터 창을 열고(50개면 열지 않는다) 주소에서 지운다
|
||||
watch(
|
||||
[ready, () => route.query[MAIL_FILTER_FROM_QUERY]],
|
||||
([isReady, from]) => {
|
||||
if (from === undefined) {
|
||||
fromHandled = false
|
||||
return
|
||||
}
|
||||
if (!isReady || fromHandled) return
|
||||
fromHandled = true
|
||||
const address = typeof from === 'string' ? from.trim() : ''
|
||||
if (address && !filterStore.isFull) openDialog(null, address)
|
||||
const query = Object.fromEntries(Object.entries(route.query).filter(([key]) => key !== MAIL_FILTER_FROM_QUERY))
|
||||
void router.replace({ query })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageFrame
|
||||
title-id="filter-page-title"
|
||||
:title="MAIL_FILTER_TEXT.title"
|
||||
:description="MAIL_FILTER_TEXT.description"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="filter-page__create">
|
||||
<MailFilterCreateButton
|
||||
ref="createRef"
|
||||
:disabled="!ready || full"
|
||||
:described-by="full ? 'mail-filter-full-note' : undefined"
|
||||
@create="openNew"
|
||||
/>
|
||||
<p
|
||||
v-if="full"
|
||||
id="mail-filter-full-note"
|
||||
class="filter-page__full-note"
|
||||
>
|
||||
{{ MAIL_FILTER_TEXT.fullNote }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MailLoadError
|
||||
v-if="!ready && loadError"
|
||||
:error="loadError"
|
||||
leave-label=""
|
||||
@retry="retry"
|
||||
/>
|
||||
<template v-else-if="ready">
|
||||
<MailFilterNotice
|
||||
v-if="filterStore.conflict"
|
||||
icon="rotate-cw"
|
||||
:message="conflictText"
|
||||
:action-label="MAIL_FILTER_TEXT.reload"
|
||||
:busy="noticeBusy === 'reload'"
|
||||
live="alert"
|
||||
data-filter-conflict
|
||||
@action="reload"
|
||||
/>
|
||||
<MailFilterNotice
|
||||
v-if="filterStore.filterSet?.isActive === false"
|
||||
icon="alert-circle"
|
||||
:message="MAIL_FILTER_TEXT.inactive"
|
||||
:action-label="MAIL_FILTER_TEXT.reactivate"
|
||||
:busy="noticeBusy === 'reactivate' || filterStore.saving"
|
||||
data-filter-inactive
|
||||
@action="reactivate"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="rules.length > 0"
|
||||
class="filter-page__list"
|
||||
>
|
||||
<MailFilterList
|
||||
ref="listRef"
|
||||
:rules="rules"
|
||||
:mailboxes="mailStore.mailboxes"
|
||||
:busy="filterStore.saving"
|
||||
@toggle="toggleRule"
|
||||
@move="moveRule"
|
||||
@edit="(rule) => openDialog(rule)"
|
||||
/>
|
||||
<p
|
||||
class="filter-page__count"
|
||||
data-filter-count
|
||||
>
|
||||
{{ countText }}
|
||||
</p>
|
||||
</div>
|
||||
<MailFilterEmpty
|
||||
v-else
|
||||
@create="openNew"
|
||||
/>
|
||||
|
||||
<MailFilterNotes />
|
||||
|
||||
<MailFilterDialog
|
||||
ref="dialogRef"
|
||||
:open="dialog !== null"
|
||||
:rule="dialog?.rule ?? null"
|
||||
:prefill-from="dialog?.prefillFrom ?? null"
|
||||
:mailboxes="mailStore.mailboxes"
|
||||
:user-email="authStore.user?.email ?? ''"
|
||||
:pending="dialogPending"
|
||||
:busy="filterStore.saving"
|
||||
:error="dialogError"
|
||||
:conflict="dialogConflict"
|
||||
@save="(rule) => settleDialog(saveDialog(rule))"
|
||||
@remove="settleDialog(removeDialog())"
|
||||
@cancel="cancelDialog"
|
||||
@clear-error="clearDialogError"
|
||||
@reload="reloadInDialog"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="filter-page__placeholder"
|
||||
aria-busy="true"
|
||||
/>
|
||||
</SettingsPageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* [새 필터] + 50개를 다 만들었을 때의 까닭 (디자인 원본 MailFilterStates) */
|
||||
.filter-page__create {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-10) var(--space-14);
|
||||
}
|
||||
|
||||
.filter-page__full-note {
|
||||
max-width: 17.5rem;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.filter-page__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.filter-page__count {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 받는 동안의 자리 — 목록 몇 줄만큼 */
|
||||
.filter-page__placeholder {
|
||||
min-height: 15rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import SettingsLayout from './SettingsLayout.vue'
|
||||
|
||||
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계")
|
||||
// 설정 메뉴 — 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계")
|
||||
|
||||
describe('SettingsLayout', () => {
|
||||
let pinia: Pinia
|
||||
@@ -34,7 +34,7 @@ describe('SettingsLayout', () => {
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 알림), 묶음 이름이 목록의 이름이다', async () => {
|
||||
it('묶음 둘 — 계정(비밀번호) · 메일(서명 · 부재중 자동 응답 · 필터 · 알림), 묶음 이름이 목록의 이름이다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/account/password')
|
||||
const menu = wrapper.get('nav[aria-label="설정 메뉴"]')
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('SettingsLayout', () => {
|
||||
[
|
||||
['서명', '/settings/mail/signature'],
|
||||
['부재중 자동 응답', '/settings/mail/vacation'],
|
||||
['필터', '/settings/mail/filters'],
|
||||
['알림', '/settings/mail/notify'],
|
||||
],
|
||||
])
|
||||
@@ -69,4 +70,23 @@ describe('SettingsLayout', () => {
|
||||
expect(router.resolve({ name: 'settings-mail-vacation' }).fullPath).toBe('/settings/mail/vacation')
|
||||
expect(router.resolve({ name: 'settings-mail-notify' }).fullPath).toBe('/settings/mail/notify')
|
||||
})
|
||||
|
||||
it('설정 › 메일 › 필터 — /settings/mail/filters, 탭 제목 "메일 필터" (앱의 라우트 표 그대로)', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||
await router.push('/settings/mail/filters?from=seojun.park%40arasystem.it.kr')
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('settings-mail-filters')
|
||||
expect(router.currentRoute.value.meta.title).toBe('메일 필터')
|
||||
expect(router.currentRoute.value.meta.requiresAuth).toBe(true)
|
||||
expect(router.currentRoute.value.query).toEqual({ from: 'seojun.park@arasystem.it.kr' })
|
||||
expect(router.resolve({ name: 'settings-mail-filters' }).fullPath).toBe('/settings/mail/filters')
|
||||
})
|
||||
|
||||
it('필터 메뉴는 깔때기 아이콘 · 지금 화면이면 현재로 표시한다', async () => {
|
||||
const { wrapper } = await mountAt('/settings/mail/filters')
|
||||
const current = wrapper.get('nav[aria-label="설정 메뉴"]').get('a[aria-current="page"]')
|
||||
|
||||
expect(current.text()).toBe('필터')
|
||||
expect(current.find('polygon').attributes('points')).toBe('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 모듈 레이아웃 (디자인 원본 Settings · SettingsAccount · SettingsVacation) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
|
||||
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계", 목록은 settings-menu.ts).
|
||||
// 메뉴는 묶음 둘: 계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림 (디자인 README "4단계 › 설정 메뉴" · "5단계" · "6단계", 목록은 settings-menu.ts).
|
||||
// 묶음 이름은 12px #667085 굵게, 항목은 40 · 아이콘 16. 지금 화면의 항목은 aria-current="page" (RouterLink).
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
|
||||
@@ -47,7 +47,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 알림) + 하위 화면.
|
||||
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림) + 하위 화면.
|
||||
// /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
|
||||
path: '/settings',
|
||||
component: () => import('@/pages/settings/SettingsLayout.vue'),
|
||||
@@ -72,6 +72,13 @@ export const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/settings/MailVacationPage.vue'),
|
||||
meta: { title: '부재중 자동 응답' },
|
||||
},
|
||||
{
|
||||
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」는 ?from=<보낸 사람 주소> 로 온다 (화면이 받은 뒤 지운다)
|
||||
path: 'mail/filters',
|
||||
name: SETTINGS_ROUTES.filters,
|
||||
component: () => import('@/pages/settings/MailFilterPage.vue'),
|
||||
meta: { title: '메일 필터' },
|
||||
},
|
||||
{
|
||||
path: 'mail/notify',
|
||||
name: SETTINGS_ROUTES.notify,
|
||||
|
||||
@@ -252,6 +252,14 @@ export const ICONS = {
|
||||
// ── 5단계: 실시간 새 메일 · 바탕화면 알림 (디자인 원본 SettingsNotify · MailRealtime) ──
|
||||
/** 설정 메뉴의 알림 · 시험 알림 · 목록 위 권하기 줄 */
|
||||
bell: [path('M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9'), path('M13.73 21a2 2 0 0 1-3.46 0')],
|
||||
|
||||
// ── 6단계: 메일 필터 (디자인 원본 SettingsFilter · MailFilterStates) ──
|
||||
/** 설정 메뉴의 필터 · 필터가 없을 때 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 (깔때기) */
|
||||
filter: [polygon('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')],
|
||||
/** 필터 목록의 ▲ (순서 위로) */
|
||||
'chevron-up': [polyline('18 15 12 9 6 15')],
|
||||
/** 목록 위 줄 "다른 곳에서 필터가 바뀌어…" (한 바퀴 화살표) */
|
||||
'rotate-cw': [polyline('23 4 23 10 17 10'), path('M20.49 15a9 9 0 1 1-2.12-9.36L23 10')],
|
||||
} as const satisfies Record<string, readonly IconShape[]>
|
||||
|
||||
export type IconName = keyof typeof ICONS
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// 설정 › 메일 › 필터 상수 — 계약 2절 "메일 필터" · 디자인 README "6단계 — 메일 필터" (디자인 원본 SettingsFilter · MailFilterStates)
|
||||
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다(넘으면 400 VAL_001). 길이는 JS 문자열 길이(UTF-16 단위)로 센다.
|
||||
import type { MailFilterField, MailFilterMatch, MailFilterOperator } from '@/shared/types/mail'
|
||||
|
||||
/** 필터 수 — 50개까지 (다 만들면 [새 필터] · 「이런 메일로 필터 만들기」를 끄고 까닭을 보인다) */
|
||||
export const MAIL_FILTER_MAX_RULES = 50
|
||||
/** 이름 — 0~50자 (비우면 조건이 이름 자리에 온다) */
|
||||
export const MAIL_FILTER_NAME_MAX_LENGTH = 50
|
||||
/** 조건 수 — 모두 · 하나라도는 1~5개 */
|
||||
export const MAIL_FILTER_MAX_CONDITIONS = 5
|
||||
/** 조건의 글 — 앞뒤 공백을 뺀 1~200자 */
|
||||
export const MAIL_FILTER_VALUE_MAX_LENGTH = 200
|
||||
/** 전달 주소 — 메일 주소의 최대 길이 (서버와 같다) */
|
||||
export const MAIL_FILTER_FORWARD_MAX_LENGTH = 254
|
||||
/** 전달 주소의 @ 앞 — 64자까지 (RFC 5321 · 서버의 IsEmail 과 같다) */
|
||||
export const MAIL_FILTER_FORWARD_LOCAL_MAX_LENGTH = 64
|
||||
/**
|
||||
* 알림 · 단추 이름에 넣는 필터 이름 — 이보다 길면 자르고 "…"를 붙인다. 이름 한도와 같아 이름은 잘리지 않고,
|
||||
* 이름이 없어 조건 글(5개 × 200자까지)이 이름 자리에 올 때만 잘린다
|
||||
*/
|
||||
export const MAIL_FILTER_LABEL_MAX_LENGTH = MAIL_FILTER_NAME_MAX_LENGTH
|
||||
/**
|
||||
* 규칙 값에 쓸 수 없는 글 — 엔진(Stalwart)은 문자열의 `${…}` 를 늘 변수로 펼친다.
|
||||
* 펼쳐져 빈 값이 되면 `포함`이 모든 메일에 맞는다 (계약 2절 "메일 필터"의 "만드는 Sieve")
|
||||
*/
|
||||
export const MAIL_FILTER_FORBIDDEN_SEQUENCE = '${'
|
||||
/** 읽기 화면 ⋯ 「이런 메일로 필터 만들기」가 보낸 사람 주소를 싣는 쿼리 — /settings/mail/filters?from=<주소> */
|
||||
export const MAIL_FILTER_FROM_QUERY = 'from'
|
||||
|
||||
/** 조건의 "어디에서" — 고치기 창의 고르기와 목록 요약이 함께 쓴다 */
|
||||
export const MAIL_FILTER_FIELD_LABELS: Readonly<Record<MailFilterField, string>> = {
|
||||
from: '보낸 사람',
|
||||
to: '받는 사람',
|
||||
subject: '제목',
|
||||
body: '본문',
|
||||
}
|
||||
|
||||
export const MAIL_FILTER_OPERATOR_LABELS: Readonly<Record<MailFilterOperator, string>> = {
|
||||
contains: '포함',
|
||||
notContains: '포함하지 않음',
|
||||
}
|
||||
|
||||
/** "어떤 메일에" 고르기 */
|
||||
export const MAIL_FILTER_MATCH_LABELS: Readonly<Record<MailFilterMatch, string>> = {
|
||||
all: '다음 조건이 모두 맞는 메일',
|
||||
any: '다음 조건 가운데 하나라도 맞는 메일',
|
||||
always: '모든 메일',
|
||||
}
|
||||
|
||||
/** 찾을 글 칸의 보기 — 어디에서 찾는지에 따라. domain 은 로그인한 직원 주소의 도메인 (받는 사람의 보기는 사내 주소) */
|
||||
export const MAIL_FILTER_VALUE_PLACEHOLDERS: Readonly<Record<MailFilterField, (domain: string) => string>> = {
|
||||
from: () => '예: @hanbit-partners.co.kr 또는 이름',
|
||||
to: (domain) => `예: all@${domain}`,
|
||||
subject: () => '예: [결재]',
|
||||
body: () => '예: 견적서',
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 한 줄의 요약 — "보낸 사람에 ‘@hanbit-partners.co.kr’ 포함 → 옮기기: 거래처 · 읽음으로 표시".
|
||||
* 조건은 " 그리고 " / " 또는 ", 할 일은 " · " 로 잇는다. 이름이 없으면 조건이 이름 자리에 온다
|
||||
*/
|
||||
export const MAIL_FILTER_SUMMARY_TEXT = {
|
||||
condition: (field: string, value: string, operator: string) => `${field}에 ‘${value}’ ${operator}`,
|
||||
and: ' 그리고 ',
|
||||
or: ' 또는 ',
|
||||
always: '모든 메일',
|
||||
/** 조건과 할 일 사이 — 이름이 없으면 할 일 앞에만 */
|
||||
arrow: '→ ',
|
||||
move: (mailbox: string) => `옮기기: ${mailbox}`,
|
||||
/** 옮길 메일함이 지워졌다 */
|
||||
missingMailbox: '없는 메일함',
|
||||
markAsRead: '읽음으로 표시',
|
||||
flag: '중요 표시',
|
||||
forward: (address: string) => `전달: ${address}`,
|
||||
actionSeparator: ' · ',
|
||||
} as const
|
||||
|
||||
export const MAIL_FILTER_TEXT = {
|
||||
title: '필터',
|
||||
description:
|
||||
'받은 메일을 조건에 따라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다. 새로 오는 메일에만 적용됩니다.',
|
||||
create: '새 필터',
|
||||
/**
|
||||
* 다 만들었을 때 [새 필터] 옆 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」 아래 ·
|
||||
* 되돌리기 · 다른 곳에서 지워진 규칙의 저장이 한도를 넘을 때(오류 알림 · 고치기 창 안)
|
||||
*/
|
||||
fullNote: `필터는 ${MAIL_FILTER_MAX_RULES}개까지 만들 수 있습니다. 쓰지 않는 필터를 지워 주세요.`,
|
||||
listLabel: '필터 — 위에서부터 차례로 적용',
|
||||
count: (count: string) => `필터 ${count}개 · ${MAIL_FILTER_MAX_RULES}개까지 만들 수 있습니다. 행을 누르면 고칩니다.`,
|
||||
// 단추 이름 · 알림의 quoted 는 따옴표로 묶은 이름(quotedRuleTitle — 길면 자르고, 이름에 작은따옴표가 있으면 큰따옴표로 묶는다)
|
||||
/** 켜기 스위치(role="switch") — 켜짐 · 꺼짐은 aria-checked 가 알린다 (이름은 바꾸지 않는다) */
|
||||
switchLabel: (quoted: string) => `${quoted} 필터 사용`,
|
||||
editLabel: (quoted: string) => `${quoted} 필터 고치기`,
|
||||
moveUp: (quoted: string) => `${quoted} 위로`,
|
||||
moveDown: (quoted: string) => `${quoted} 아래로`,
|
||||
missingMailbox: '옮길 메일함이 지워져 받은편지함으로 들어옵니다 — 눌러서 고치세요.',
|
||||
emptyTitle: '아직 필터가 없습니다',
|
||||
emptyText:
|
||||
'보낸 사람 · 받는 사람 · 제목 · 본문의 글로 메일을 골라 메일함으로 옮기거나, 읽음 · 중요로 표시하거나, 동료에게 전달합니다.',
|
||||
// 목록 위 노란 줄 — 다른 곳에서 바뀜(409 MAIL_012 — 글은 에러 코드 사전) · 동작하지 않음(isActive: false)
|
||||
reload: '다시 불러오기',
|
||||
inactive: '지금은 필터와 부재중 자동 응답이 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다.',
|
||||
reactivate: '다시 켜기',
|
||||
// 아래 안내 상자 — 첫 줄의 "위에서부터 차례로"는 굵게
|
||||
noteOrderStrong: '위에서부터 차례로',
|
||||
noteOrderAfter: ' 적용합니다. 메일함으로 옮기는 필터에 맞으면 그 아래 필터는 적용하지 않습니다 — 순서는 ▲ ▼ 로 바꿉니다.',
|
||||
noteSpam: '스팸으로 판정된 메일에는 필터를 적용하지 않습니다 — 그대로 스팸메일함으로 갑니다.',
|
||||
noteForward:
|
||||
'전달은 사내 주소로만 할 수 있고, 메일 한 통은 한 곳으로만 전달됩니다(위의 필터가 먼저). 전달해도 내 메일함에 그대로 남습니다.',
|
||||
noteVacation: '부재중 자동 응답은 필터와 함께 동작합니다.',
|
||||
// 알림 — 켜기 · 끄기 · 순서 바꾸기는 목록이 바로 바뀌므로 알리지 않는다 (실패하면 되돌리고 오류 알림)
|
||||
saved: '필터를 저장했습니다.',
|
||||
removed: (quoted: string) => `${quoted} 필터를 지웠습니다.`,
|
||||
undo: '되돌리기',
|
||||
/** 되돌리기가 끝났을 때 (디자인 원본 SettingsFilter 의 시안) */
|
||||
restored: '필터를 되돌렸습니다.',
|
||||
} as const
|
||||
|
||||
/** 고치기 창 (모달 620 — 디자인 원본 SettingsFilter) */
|
||||
export const MAIL_FILTER_DIALOG_TEXT = {
|
||||
createTitle: '새 필터',
|
||||
editTitle: '필터 고치기',
|
||||
nameLabel: '이름',
|
||||
nameOptional: '(선택)',
|
||||
namePlaceholder: '비워 두면 조건이 이름이 됩니다',
|
||||
matchLegend: '어떤 메일에',
|
||||
conditionField: (no: number) => `조건 ${no} — 어디에서`,
|
||||
conditionOperator: (no: number) => `조건 ${no} — 포함 · 포함하지 않음`,
|
||||
conditionValue: (no: number) => `조건 ${no} — 찾을 글`,
|
||||
conditionRemove: (no: number) => `조건 ${no} 지우기`,
|
||||
addCondition: '조건 더하기',
|
||||
conditionHint: `${MAIL_FILTER_MAX_CONDITIONS}개까지 · 글자는 대소문자를 가리지 않습니다`,
|
||||
actionsLegend: '할 일',
|
||||
move: '메일함으로 옮기기',
|
||||
moveTarget: '옮길 메일함',
|
||||
moveHint: '옮기면 이 아래 필터는 적용하지 않습니다.',
|
||||
/** 옮길 메일함이 지워졌다 — 이 항목이 골라진 채로 보인다 (다시 고를 수는 없다) */
|
||||
missingMailboxOption: '(지워진 메일함) — 다른 메일함을 고르세요',
|
||||
markAsRead: '읽음으로 표시',
|
||||
flag: '중요 표시',
|
||||
forward: '동료에게 전달',
|
||||
forwardTarget: '전달할 사내 주소',
|
||||
/** domain 은 로그인한 직원 주소의 도메인 */
|
||||
forwardPlaceholder: (domain: string) => `이름@${domain}`,
|
||||
forwardHint: '사내 주소로만 전달할 수 있습니다. 전달해도 내 메일함에 그대로 남습니다.',
|
||||
notice: '새로 오는 메일에만 적용됩니다 — 이미 받은 메일은 그대로입니다. 스팸으로 판정된 메일에는 적용하지 않습니다.',
|
||||
remove: '이 필터 지우기',
|
||||
save: '저장',
|
||||
cancel: '취소',
|
||||
// 칸 아래 빨간 글 — [저장]을 누를 때 검사한다(알림은 띄우지 않는다). 그 밖의 형식 오류(이름 · 찾을 글의 길이 · 제어 문자)는 에러 코드 사전의 VAL_001
|
||||
conditionEmpty: '비어 있는 조건이 있습니다. 찾을 글을 적거나 그 조건을 지워 주세요.',
|
||||
conditionVariable: '‘${’ 는 쓸 수 없습니다 — 메일 서버가 다른 뜻으로 읽습니다.',
|
||||
// 전달 주소 — 캔버스에는 바깥 주소만 있다. 빈 칸 · 형식 · 내 주소는 2026-10-28 구현하며 정함 (형식 문구는 서버의 거절 문구와 같다)
|
||||
forwardEmpty: '전달할 주소를 입력해 주세요.',
|
||||
forwardInvalid: '전달할 주소 형식이 올바르지 않습니다.',
|
||||
forwardExternal: (domain: string) => `사내 주소(@${domain})로만 전달할 수 있습니다.`,
|
||||
forwardSelf: '내 주소로는 전달할 수 없습니다.',
|
||||
actionsEmpty: '할 일을 하나 이상 골라 주세요.',
|
||||
} as const
|
||||
@@ -312,6 +312,8 @@ export const MAIL_SPAM_TEXT = {
|
||||
export const MAIL_READER_MORE_TEXT = {
|
||||
button: '더 보기',
|
||||
downloadSource: '원문 내려받기 (.eml)',
|
||||
/** 설정 › 필터의 새 필터 창이 보낸 사람 주소로 채워져 열린다 (6단계 — 디자인 원본 MailFilterStates) */
|
||||
createFilter: '이런 메일로 필터 만들기',
|
||||
} as const
|
||||
|
||||
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림")
|
||||
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기])가
|
||||
// 이 이름을 함께 쓴다.
|
||||
// 설정 모듈의 왼쪽 메뉴 — 묶음(계정 · 메일) 아래 항목 (디자인 README "4단계 › 설정 메뉴" · "5단계 › 설정 › 메일 › 알림" · "6단계 › 설정 › 메일 › 필터")
|
||||
// 라우트(router/routes.ts) · 메뉴(SettingsLayout.vue) · 다른 화면의 링크(메일함 패널의 부재중 안내 · 알림을 막아 둔 오류 알림의 [설정 열기] ·
|
||||
// 읽기 화면 ⋯ 「이런 메일로 필터 만들기」)가 이 이름을 함께 쓴다.
|
||||
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
|
||||
@@ -9,6 +9,7 @@ export const SETTINGS_ROUTES = {
|
||||
password: 'settings-account-password',
|
||||
signature: 'settings-mail-signature',
|
||||
vacation: 'settings-mail-vacation',
|
||||
filters: 'settings-mail-filters',
|
||||
notify: 'settings-mail-notify',
|
||||
} as const
|
||||
|
||||
@@ -37,6 +38,7 @@ export const SETTINGS_MENU: readonly SettingsMenuGroup[] = [
|
||||
items: [
|
||||
{ routeName: SETTINGS_ROUTES.signature, label: '서명', icon: 'edit' },
|
||||
{ routeName: SETTINGS_ROUTES.vacation, label: '부재중 자동 응답', icon: 'calendar-x' },
|
||||
{ routeName: SETTINGS_ROUTES.filters, label: '필터', icon: 'filter' },
|
||||
{ routeName: SETTINGS_ROUTES.notify, label: '알림', icon: 'bell' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -275,6 +275,65 @@ export interface MailVacation {
|
||||
textBody: string
|
||||
}
|
||||
|
||||
// ── 메일 필터 (계약 2절 "메일 필터" — 6단계) ──────────────────────
|
||||
|
||||
/** 조건이 모두 맞음 · 하나라도 맞음 · 모든 메일(조건 없음) */
|
||||
export type MailFilterMatch = 'all' | 'any' | 'always'
|
||||
|
||||
/** 보낸 사람(이름 · 주소) · 받는 사람(받는 사람 · 참조의 이름 · 주소) · 제목 · 본문(글자 부분) */
|
||||
export type MailFilterField = 'from' | 'to' | 'subject' | 'body'
|
||||
|
||||
export type MailFilterOperator = 'contains' | 'notContains'
|
||||
|
||||
export interface MailFilterCondition {
|
||||
field: MailFilterField
|
||||
operator: MailFilterOperator
|
||||
/** 1~200자(앞뒤 공백을 뺀 값) — 제어 문자 · `${` 는 안 된다 */
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 할 일 — 하나 이상 */
|
||||
export interface MailFilterActions {
|
||||
/**
|
||||
* 받은편지함 · 스팸메일함 · 휴지통 · 내 메일함 (보낸편지함 · 임시보관함은 400 VAL_001).
|
||||
* 지운 메일함을 가리킬 수 있다 — 그러면 받은편지함으로 들어온다 (목록에 빨간 안내)
|
||||
*/
|
||||
moveToMailboxId: string | null
|
||||
markAsRead: boolean
|
||||
flag: boolean
|
||||
/** 사내 주소(MAIL_DOMAIN)만, 자기 주소는 안 된다 */
|
||||
forwardTo: string | null
|
||||
}
|
||||
|
||||
export interface MailFilterRule {
|
||||
/** 클라이언트가 만든다(UUID) — 목록 안에서 겹치지 않게 */
|
||||
id: string
|
||||
/** 0~50자 — 비면 화면이 조건으로 이름을 보인다 */
|
||||
name: string
|
||||
isEnabled: boolean
|
||||
match: MailFilterMatch
|
||||
/** all · any 는 1~5개, always 는 빈 배열 */
|
||||
conditions: MailFilterCondition[]
|
||||
actions: MailFilterActions
|
||||
}
|
||||
|
||||
/** GET · PUT /mail-filters 응답 */
|
||||
export interface MailFilterSet {
|
||||
/** 위에서부터 차례로 적용한다 — 50개까지 */
|
||||
rules: MailFilterRule[]
|
||||
/** 지금 규칙의 지문 — PUT 에 그대로 돌려보낸다 (그사이 다른 곳에서 규칙이 바뀌었으면 409 MAIL_012) */
|
||||
version: string
|
||||
/** false = 엔진에서 다른 규칙이 켜져 지금은 필터 · 부재중 응답이 동작하지 않는다(드묾) — 저장하면 다시 켜진다 */
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
/** PUT /mail-filters 본문 — 전체 교체 (켜기 · 순서 · 지우기 · 고치기 모두) */
|
||||
export interface MailFilterSetPayload {
|
||||
rules: MailFilterRule[]
|
||||
/** 받은(마지막으로 저장한) MailFilterSet 의 version */
|
||||
version: string
|
||||
}
|
||||
|
||||
// ── 서명 (계약 2절 "서명") ─────────────────────────────
|
||||
|
||||
export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif'
|
||||
|
||||
@@ -1,40 +1,16 @@
|
||||
// 중복 발송 방지 키 — 계약 2절 "발송 본문"의 Idempotency-Key 헤더 값 (무작위 UUID)
|
||||
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { createUuid } from '@/shared/utils/uuid'
|
||||
|
||||
/** 보내기 한 번의 결과 — 성공했거나, 실패한 에러 코드 */
|
||||
export type SendOutcome = 'sent' | ErrorCode
|
||||
|
||||
/** UUID 한 개의 바이트 수 */
|
||||
const UUID_BYTES = 16
|
||||
/** 7번째 바이트 상위 4비트 = 버전(0100: 무작위 v4) */
|
||||
const VERSION_BYTE = 6
|
||||
/** 9번째 바이트 상위 2비트 = 변형(10: RFC 9562) */
|
||||
const VARIANT_BYTE = 8
|
||||
/** 16진 32자리 → 8-4-4-4-12 */
|
||||
const UUID_LAYOUT = /^(.{8})(.{4})(.{4})(.{4})(.{12})$/
|
||||
const HEX_RADIX = 16
|
||||
|
||||
function toHex(byte: number): string {
|
||||
return byte.toString(HEX_RADIX).padStart(2, '0')
|
||||
}
|
||||
|
||||
/** crypto.getRandomValues 로 UUID v4 를 만든다 — randomUUID 를 쓸 수 없을 때만 쓴다 */
|
||||
function randomUuidV4(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(UUID_BYTES))
|
||||
bytes[VERSION_BYTE] = ((bytes[VERSION_BYTE] ?? 0) & 0x0f) | 0x40
|
||||
bytes[VARIANT_BYTE] = ((bytes[VARIANT_BYTE] ?? 0) & 0x3f) | 0x80
|
||||
return Array.from(bytes, toHex).join('').replace(UUID_LAYOUT, '$1-$2-$3-$4-$5')
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰기 화면 하나에 쓸 새 키.
|
||||
* crypto.randomUUID 는 보안 컨텍스트(HTTPS·localhost)에서만 있다 — 사내망 IP 로 연 개발 서버처럼
|
||||
* 그렇지 않은 곳에서도 쓰기 화면이 깨지지 않게, 같은 형식(UUID v4)을 직접 만들어 쓴다.
|
||||
* 쓰기 화면 하나에 쓸 새 키 — UUID v4 (보안 컨텍스트 밖에서도 같은 형식, uuid.ts)
|
||||
*/
|
||||
export function createIdempotencyKey(): string {
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
||||
return randomUuidV4()
|
||||
return createUuid()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Mailbox, MailFilterRule } from '@/shared/types/mail'
|
||||
import {
|
||||
actionsText,
|
||||
conditionsText,
|
||||
defaultMoveTarget,
|
||||
filterDraftOf,
|
||||
filterDraftProblems,
|
||||
filterMoveTargets,
|
||||
filterRuleOf,
|
||||
hasFilterProblems,
|
||||
isMoveTargetMissing,
|
||||
mailDomainOf,
|
||||
newFilterDraft,
|
||||
quotedRuleTitle,
|
||||
ruleSummary,
|
||||
ruleTitle,
|
||||
withoutRule,
|
||||
withRuleEnabled,
|
||||
withRuleMoved,
|
||||
withRuleRestored,
|
||||
withRuleSaved,
|
||||
type MailFilterDraft,
|
||||
} from './mail-filter'
|
||||
|
||||
// 메일 필터 순수 함수 — 목록 요약 · 옮길 메일함 · 고치기 창의 값 · 검사 · 목록 바꾸기 (계약 2절 "메일 필터" · 디자인 원본 SettingsFilter)
|
||||
|
||||
const ME = 'ara.kim@arasystem.it.kr'
|
||||
|
||||
function mailbox(id: string, name: string, role: Mailbox['role'] = null, parentId: string | null = null): Mailbox {
|
||||
return { id, name, role, parentId, sortOrder: 0, totalCount: 0, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const INBOX = mailbox('inbox', '받은편지함', 'inbox')
|
||||
const SENT = mailbox('sent', '보낸편지함', 'sent')
|
||||
const DRAFTS = mailbox('drafts', '임시보관함', 'drafts')
|
||||
const JUNK = mailbox('junk', '스팸메일함', 'junk')
|
||||
const TRASH = mailbox('trash', '휴지통', 'trash')
|
||||
const ARCHIVE = mailbox('archive', '보관함', 'archive')
|
||||
const VENDOR = mailbox('vendor', '거래처')
|
||||
const NOTICE = mailbox('notice', '전체 공지')
|
||||
const ALPHA = mailbox('alpha', '알파', null, 'inbox')
|
||||
const OLD = mailbox('old', '지난 메일', null, 'archive')
|
||||
const MAILBOXES = [TRASH, VENDOR, ARCHIVE, OLD, JUNK, NOTICE, SENT, DRAFTS, ALPHA, INBOX]
|
||||
|
||||
function rule(patch: Partial<MailFilterRule> = {}): MailFilterRule {
|
||||
return {
|
||||
id: 'r1',
|
||||
name: '',
|
||||
isEnabled: true,
|
||||
match: 'all',
|
||||
conditions: [{ field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr' }],
|
||||
actions: { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: null },
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
const nameOf = (id: string) => MAILBOXES.find((box) => box.id === id)?.name ?? null
|
||||
|
||||
function draft(patch: Partial<MailFilterDraft> = {}): MailFilterDraft {
|
||||
return { ...newFilterDraft({ moveTarget: 'vendor' }), ...patch }
|
||||
}
|
||||
|
||||
describe('목록의 요약 (디자인 원본 SettingsFilter)', () => {
|
||||
it('조건은 "보낸 사람에 ‘…’ 포함", 모두는 " 그리고 ", 하나라도는 " 또는 ", 모든 메일은 "모든 메일"', () => {
|
||||
const conditions: MailFilterRule['conditions'] = [
|
||||
{ field: 'subject', operator: 'contains', value: '알파' },
|
||||
{ field: 'body', operator: 'notContains', value: 'Project Alpha' },
|
||||
]
|
||||
expect(conditionsText(rule())).toBe('보낸 사람에 ‘@hanbit-partners.co.kr’ 포함')
|
||||
expect(conditionsText(rule({ match: 'all', conditions }))).toBe(
|
||||
'제목에 ‘알파’ 포함 그리고 본문에 ‘Project Alpha’ 포함하지 않음',
|
||||
)
|
||||
expect(conditionsText(rule({ match: 'any', conditions }))).toBe(
|
||||
'제목에 ‘알파’ 포함 또는 본문에 ‘Project Alpha’ 포함하지 않음',
|
||||
)
|
||||
expect(conditionsText(rule({ match: 'always', conditions: [] }))).toBe('모든 메일')
|
||||
})
|
||||
|
||||
it('할 일은 옮기기 · 읽음 · 중요 · 전달 차례로 " · " — 지워진 메일함은 "없는 메일함"', () => {
|
||||
expect(
|
||||
actionsText({ moveToMailboxId: 'vendor', markAsRead: true, flag: true, forwardTo: 'doyun.lee@arasystem.it.kr' }, nameOf),
|
||||
).toBe('옮기기: 거래처 · 읽음으로 표시 · 중요 표시 · 전달: doyun.lee@arasystem.it.kr')
|
||||
expect(actionsText({ moveToMailboxId: 'gone', markAsRead: false, flag: false, forwardTo: null }, nameOf)).toBe(
|
||||
'옮기기: 없는 메일함',
|
||||
)
|
||||
})
|
||||
|
||||
it('이름이 있으면 이름 + "조건 → 할 일", 없으면 조건이 이름 자리에 오고 요약은 "→ 할 일"', () => {
|
||||
const named = rule({ name: '거래처 메일', actions: { moveToMailboxId: 'vendor', markAsRead: false, flag: false, forwardTo: null } })
|
||||
expect(ruleTitle(named)).toBe('거래처 메일')
|
||||
expect(ruleSummary(named, nameOf)).toBe('보낸 사람에 ‘@hanbit-partners.co.kr’ 포함 → 옮기기: 거래처')
|
||||
|
||||
const unnamed = rule({ name: ' ', actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: null } })
|
||||
expect(ruleTitle(unnamed)).toBe('보낸 사람에 ‘@hanbit-partners.co.kr’ 포함')
|
||||
expect(ruleSummary(unnamed, nameOf)).toBe('→ 읽음으로 표시')
|
||||
})
|
||||
|
||||
it('알림 · 단추 이름의 이름 — 따옴표로 묶고, 조건 글이 이름 자리에 오면 작은따옴표가 겹치지 않게 큰따옴표로', () => {
|
||||
expect(quotedRuleTitle(rule({ name: '거래처 메일' }))).toBe('‘거래처 메일’')
|
||||
expect(quotedRuleTitle(rule())).toBe('“보낸 사람에 ‘@hanbit-partners.co.kr’ 포함”')
|
||||
expect(quotedRuleTitle(rule({ name: '‘중요’ 메일' }))).toBe('“‘중요’ 메일”')
|
||||
})
|
||||
|
||||
it('알림 · 단추 이름의 이름 — 50자(이름 한도)를 넘으면 자르고 "…" (이름은 잘리지 않는다 · 이모지를 반쪽으로 자르지 않는다)', () => {
|
||||
const name = '가'.repeat(50)
|
||||
expect(quotedRuleTitle(rule({ name }))).toBe(`‘${name}’`)
|
||||
const long = rule({ conditions: [{ field: 'subject', operator: 'contains', value: 'x'.repeat(200) }] })
|
||||
const label = quotedRuleTitle(long)
|
||||
expect(label).toBe(`“제목에 ‘${'x'.repeat(45)}…”`)
|
||||
expect(Array.from(label.slice(1, -2))).toHaveLength(50)
|
||||
const emoji = rule({ conditions: [{ field: 'body', operator: 'contains', value: '😀'.repeat(60) }] })
|
||||
expect(quotedRuleTitle(emoji)).toBe(`“본문에 ‘${'😀'.repeat(45)}…”`)
|
||||
})
|
||||
|
||||
it('옮길 메일함이 목록에 없으면 지워진 것이다 — 옮기지 않는 규칙은 아니다', () => {
|
||||
const moving = (id: string | null) =>
|
||||
rule({ actions: { moveToMailboxId: id, markAsRead: true, flag: false, forwardTo: null } })
|
||||
expect(isMoveTargetMissing(moving('gone'), MAILBOXES)).toBe(true)
|
||||
expect(isMoveTargetMissing(moving('vendor'), MAILBOXES)).toBe(false)
|
||||
expect(isMoveTargetMissing(moving(null), MAILBOXES)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('옮길 메일함 고르기', () => {
|
||||
it('받은편지함(과 그 아래) → 내 메일함 → 보관함(과 그 아래) → 스팸메일함 → 휴지통, 보낸편지함 · 임시보관함은 뺀다', () => {
|
||||
expect(filterMoveTargets(MAILBOXES).map((box) => box.name)).toEqual([
|
||||
'받은편지함',
|
||||
'알파',
|
||||
'거래처',
|
||||
'전체 공지',
|
||||
'보관함',
|
||||
'지난 메일',
|
||||
'스팸메일함',
|
||||
'휴지통',
|
||||
])
|
||||
})
|
||||
|
||||
it('새 필터의 옮길 메일함은 처음 나오는 내 메일함, 없으면 받은편지함', () => {
|
||||
expect(defaultMoveTarget(filterMoveTargets(MAILBOXES))).toBe('alpha')
|
||||
expect(defaultMoveTarget(filterMoveTargets([INBOX, JUNK, TRASH]))).toBe('inbox')
|
||||
expect(defaultMoveTarget([])).toBe('')
|
||||
})
|
||||
|
||||
it('사내 도메인은 주소의 @ 뒤 (소문자)', () => {
|
||||
expect(mailDomainOf('Ara.Kim@AraSystem.it.kr')).toBe('arasystem.it.kr')
|
||||
expect(mailDomainOf('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('고치기 창의 값 ↔ 규칙', () => {
|
||||
it('새 필터 — 모두 · 보낸 사람 빈 조건 한 줄 · 할 일 없음, 읽기 화면에서 왔으면 그 주소', () => {
|
||||
expect(newFilterDraft({ moveTarget: 'vendor' })).toEqual({
|
||||
name: '',
|
||||
match: 'all',
|
||||
conditions: [{ field: 'from', operator: 'contains', value: '' }],
|
||||
move: false,
|
||||
moveTarget: 'vendor',
|
||||
markAsRead: false,
|
||||
flag: false,
|
||||
forward: false,
|
||||
forwardTo: '',
|
||||
})
|
||||
expect(newFilterDraft({ moveTarget: 'vendor', from: 'seojun.park@arasystem.it.kr' }).conditions).toEqual([
|
||||
{ field: 'from', operator: 'contains', value: 'seojun.park@arasystem.it.kr' },
|
||||
])
|
||||
})
|
||||
|
||||
it('저장할 때 이름 · 글 · 주소의 앞뒤 공백을 빼고, 모든 메일이면 조건을 비우고, 끈 할 일은 빈 값이다', () => {
|
||||
const value = draft({
|
||||
name: ' 거래처 메일 ',
|
||||
match: 'always',
|
||||
conditions: [{ field: 'subject', operator: 'contains', value: ' 견적 ' }],
|
||||
move: false,
|
||||
moveTarget: 'vendor',
|
||||
markAsRead: true,
|
||||
forward: true,
|
||||
forwardTo: ' doyun.lee@arasystem.it.kr ',
|
||||
})
|
||||
expect(filterRuleOf(value, { id: 'r9', isEnabled: false })).toEqual({
|
||||
id: 'r9',
|
||||
isEnabled: false,
|
||||
name: '거래처 메일',
|
||||
match: 'always',
|
||||
conditions: [],
|
||||
actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: 'doyun.lee@arasystem.it.kr' },
|
||||
})
|
||||
expect(filterRuleOf({ ...value, match: 'any', move: true }, { id: 'r9', isEnabled: true })).toMatchObject({
|
||||
conditions: [{ field: 'subject', operator: 'contains', value: '견적' }],
|
||||
actions: { moveToMailboxId: 'vendor' },
|
||||
})
|
||||
})
|
||||
|
||||
it('있는 규칙을 고칠 때 — 모든 메일 규칙도 조건 칸 한 줄을 남겨 두고, 옮기지 않던 규칙의 고르기는 기본 메일함', () => {
|
||||
const always = rule({
|
||||
match: 'always',
|
||||
conditions: [],
|
||||
actions: { moveToMailboxId: null, markAsRead: false, flag: false, forwardTo: 'doyun.lee@arasystem.it.kr' },
|
||||
})
|
||||
expect(filterDraftOf(always, 'vendor')).toMatchObject({
|
||||
match: 'always',
|
||||
conditions: [{ field: 'from', operator: 'contains', value: '' }],
|
||||
move: false,
|
||||
moveTarget: 'vendor',
|
||||
forward: true,
|
||||
forwardTo: 'doyun.lee@arasystem.it.kr',
|
||||
})
|
||||
const gone = rule({ actions: { moveToMailboxId: 'gone', markAsRead: false, flag: false, forwardTo: null } })
|
||||
expect(filterDraftOf(gone, 'vendor')).toMatchObject({ move: true, moveTarget: 'gone' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('검사 — 서버와 같은 규칙 (계약 2절 "메일 필터"의 "클라이언트")', () => {
|
||||
it('빈 조건 · `${` · 200자 넘음 · 제어 문자 — 줄마다 따로 (모든 메일이면 보지 않는다)', () => {
|
||||
const value = draft({
|
||||
markAsRead: true,
|
||||
conditions: [
|
||||
{ field: 'from', operator: 'contains', value: ' ' },
|
||||
{ field: 'subject', operator: 'contains', value: '가격 ${price}' },
|
||||
{ field: 'body', operator: 'contains', value: 'a'.repeat(201) },
|
||||
{ field: 'body', operator: 'contains', value: '탭\t문자' },
|
||||
{ field: 'to', operator: 'notContains', value: ` ${'b'.repeat(200)} ` },
|
||||
],
|
||||
})
|
||||
expect(filterDraftProblems(value, ME).conditions).toEqual(['empty', 'variable', 'invalid', 'invalid', null])
|
||||
expect(filterDraftProblems({ ...value, match: 'always' }, ME).conditions).toEqual([null, null, null, null, null])
|
||||
})
|
||||
|
||||
it('할 일이 하나도 없으면 잘못이다 — 전달을 켰으면 주소가 틀려도 할 일은 있다', () => {
|
||||
expect(filterDraftProblems(draft({ conditions: [{ field: 'from', operator: 'contains', value: 'x' }] }), ME)).toMatchObject({
|
||||
actions: true,
|
||||
forward: null,
|
||||
})
|
||||
expect(filterDraftProblems(draft({ forward: true, forwardTo: ' ' }), ME)).toMatchObject({
|
||||
actions: false,
|
||||
forward: 'empty',
|
||||
})
|
||||
})
|
||||
|
||||
it('전달은 사내 주소(대소문자 무시)만, 내 주소 · `${` 는 안 된다', () => {
|
||||
const forward = (forwardTo: string) => filterDraftProblems(draft({ forward: true, forwardTo }), ME).forward
|
||||
expect(forward('Doyun.Lee@ARASYSTEM.it.kr')).toBeNull()
|
||||
expect(forward(' doyun.lee@arasystem.it.kr ')).toBeNull()
|
||||
expect(forward("o'neil+filter_1@arasystem.it.kr")).toBeNull()
|
||||
expect(forward('me@gmail.com')).toBe('external')
|
||||
expect(forward('doyun.lee@arasystem.it.kr.evil.com')).toBe('external')
|
||||
expect(forward('ARA.KIM@arasystem.it.kr')).toBe('self')
|
||||
expect(forward('${x}@arasystem.it.kr')).toBe('variable')
|
||||
expect(forward('')).toBe('empty')
|
||||
// 로그인한 주소를 모르면 사내 주소를 알 수 없다 — 보내지 않는다
|
||||
expect(filterDraftProblems(draft({ forward: true, forwardTo: 'doyun.lee@arasystem.it.kr' }), '').forward).toBe('external')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['연달은 점', 'a..b@arasystem.it.kr'],
|
||||
['앞의 점', '.ab@arasystem.it.kr'],
|
||||
['뒤의 점', 'ab.@arasystem.it.kr'],
|
||||
['@ 앞 65자', `${'a'.repeat(65)}@arasystem.it.kr`],
|
||||
['쉼표', 'a,b@arasystem.it.kr'],
|
||||
['괄호', 'a(b)@arasystem.it.kr'],
|
||||
['큰따옴표', 'a"b@arasystem.it.kr'],
|
||||
['빈칸', 'doyun lee@arasystem.it.kr'],
|
||||
['@ 둘', 'a@b@arasystem.it.kr'],
|
||||
['@ 앞이 빔', '@arasystem.it.kr'],
|
||||
['254자 넘음', `${'a'.repeat(64)}@${'b'.repeat(190)}.arasystem.it.kr`],
|
||||
['한글', '김아라@arasystem.it.kr'],
|
||||
])('전달 주소의 형식 — %s 는 서버(IsEmail)가 받지 않으니 화면도 막는다 (dot-atom · @ 앞 64자)', (_label, address) => {
|
||||
expect(filterDraftProblems(draft({ forward: true, forwardTo: address }), ME).forward).toBe('invalid')
|
||||
})
|
||||
|
||||
it('전달 주소의 @ 앞은 64자까지 된다', () => {
|
||||
expect(filterDraftProblems(draft({ forward: true, forwardTo: `${'a'.repeat(64)}@arasystem.it.kr` }), ME).forward).toBeNull()
|
||||
})
|
||||
|
||||
it('이름은 50자 · 제어 문자 없음 (앞뒤 공백은 빼고 잰다)', () => {
|
||||
const name = (value: string) => filterDraftProblems(draft({ name: value, markAsRead: true }), ME).name
|
||||
expect(name(` ${'가'.repeat(50)} `)).toBe(false)
|
||||
expect(name('가'.repeat(51))).toBe(true)
|
||||
expect(name('이름\t탭')).toBe(true)
|
||||
})
|
||||
|
||||
it('문제가 없으면 hasFilterProblems 가 false', () => {
|
||||
const value = draft({ conditions: [{ field: 'from', operator: 'contains', value: '@hanbit' }], move: true })
|
||||
expect(hasFilterProblems(filterDraftProblems(value, ME))).toBe(false)
|
||||
expect(hasFilterProblems(filterDraftProblems({ ...value, move: false }, ME))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('목록 바꾸기 — 모두 PUT 전체 교체', () => {
|
||||
const A = rule({ id: 'a' })
|
||||
const B = rule({ id: 'b' })
|
||||
const C = rule({ id: 'c' })
|
||||
|
||||
it('켜기 · 끄기는 그 규칙만', () => {
|
||||
expect(withRuleEnabled([A, B], 'b', false).map((item) => [item.id, item.isEnabled])).toEqual([
|
||||
['a', true],
|
||||
['b', false],
|
||||
])
|
||||
})
|
||||
|
||||
it('한 칸 위 · 아래 — 끝이면 null', () => {
|
||||
expect(withRuleMoved([A, B, C], 'b', -1)?.map((item) => item.id)).toEqual(['b', 'a', 'c'])
|
||||
expect(withRuleMoved([A, B, C], 'b', 1)?.map((item) => item.id)).toEqual(['a', 'c', 'b'])
|
||||
expect(withRuleMoved([A, B, C], 'a', -1)).toBeNull()
|
||||
expect(withRuleMoved([A, B, C], 'c', 1)).toBeNull()
|
||||
expect(withRuleMoved([A, B, C], 'x', 1)).toBeNull()
|
||||
})
|
||||
|
||||
it('고친 규칙은 그 자리, 새 규칙은 끝', () => {
|
||||
const edited = { ...B, name: '고침' }
|
||||
expect(withRuleSaved([A, B, C], edited).map((item) => item.name || item.id)).toEqual(['a', '고침', 'c'])
|
||||
expect(withRuleSaved([A, B], C).map((item) => item.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('지우면 그 규칙과 자리(앞 · 뒤 규칙)를 돌려주고, 되돌리기는 그 자리에 다시 넣는다 (그사이 바뀐 다른 규칙은 그대로)', () => {
|
||||
const removal = withoutRule([A, B, C], 'b')
|
||||
expect(removal?.rules.map((item) => item.id)).toEqual(['a', 'c'])
|
||||
expect(removal?.point).toEqual({ rule: B, index: 1, afterId: 'a', beforeId: 'c' })
|
||||
expect(withoutRule([A], 'x')).toBeNull()
|
||||
|
||||
const changed = withRuleEnabled(removal?.rules ?? [], 'c', false)
|
||||
const restored = removal ? withRuleRestored(changed, removal.point) : null
|
||||
expect(restored?.map((item) => [item.id, item.isEnabled])).toEqual([
|
||||
['a', true],
|
||||
['b', true],
|
||||
['c', false],
|
||||
])
|
||||
// 이미 있으면 null
|
||||
expect(withRuleRestored([A, B], { rule: B, index: 0, afterId: null, beforeId: null })).toBeNull()
|
||||
})
|
||||
|
||||
it('되돌릴 자리 — 맨 위였으면 맨 위, 아니면 앞 규칙 뒤 → (앞 규칙이 없으면) 뒤 규칙 앞 → 지운 때의 차례(넘으면 끝)', () => {
|
||||
const D = rule({ id: 'd' })
|
||||
const at = (rules: MailFilterRule[], point: Omit<Parameters<typeof withRuleRestored>[1], 'rule'>) =>
|
||||
withRuleRestored(rules, { rule: D, ...point })?.map((item) => item.id)
|
||||
|
||||
expect(at([B, C], { index: 2, afterId: null, beforeId: 'b' })).toEqual(['d', 'b', 'c'])
|
||||
expect(at([C, A], { index: 1, afterId: 'a', beforeId: 'c' })).toEqual(['c', 'a', 'd'])
|
||||
expect(at([C, B], { index: 1, afterId: 'a', beforeId: 'c' })).toEqual(['d', 'c', 'b'])
|
||||
expect(at([B], { index: 5, afterId: 'a', beforeId: 'c' })).toEqual(['b', 'd'])
|
||||
expect(at([B, C], { index: 1, afterId: 'x', beforeId: null })).toEqual(['b', 'd', 'c'])
|
||||
})
|
||||
|
||||
it('지우기 여럿을 어떤 차례로 되돌려도 원래 차례로 돌아온다 (앞 · 뒤 규칙 기준)', () => {
|
||||
const D = rule({ id: 'd' })
|
||||
const first = withoutRule([A, B, C, D], 'b')
|
||||
const second = first ? withoutRule(first.rules, 'd') : null
|
||||
if (!first || !second) throw new Error('지우지 못했다')
|
||||
|
||||
const bThenD = withRuleRestored(withRuleRestored(second.rules, first.point) ?? [], second.point)
|
||||
const dThenB = withRuleRestored(withRuleRestored(second.rules, second.point) ?? [], first.point)
|
||||
expect(bThenD?.map((item) => item.id)).toEqual(['a', 'b', 'c', 'd'])
|
||||
expect(dThenB?.map((item) => item.id)).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,378 @@
|
||||
// 메일 필터 — 목록의 요약 · 옮길 메일함 · 고치기 창의 칸 · 검사 · 목록 바꾸기 (순수 함수만 둔다)
|
||||
// 계약 2절 "메일 필터" · 디자인 README "6단계 — 메일 필터" (디자인 원본 SettingsFilter · MailFilterStates)
|
||||
|
||||
import {
|
||||
MAIL_FILTER_FIELD_LABELS,
|
||||
MAIL_FILTER_FORBIDDEN_SEQUENCE,
|
||||
MAIL_FILTER_FORWARD_LOCAL_MAX_LENGTH,
|
||||
MAIL_FILTER_FORWARD_MAX_LENGTH,
|
||||
MAIL_FILTER_LABEL_MAX_LENGTH,
|
||||
MAIL_FILTER_NAME_MAX_LENGTH,
|
||||
MAIL_FILTER_OPERATOR_LABELS,
|
||||
MAIL_FILTER_SUMMARY_TEXT,
|
||||
MAIL_FILTER_VALUE_MAX_LENGTH,
|
||||
} from '@/shared/constants/mail-filter'
|
||||
import type {
|
||||
Mailbox,
|
||||
MailboxRole,
|
||||
MailFilterActions,
|
||||
MailFilterCondition,
|
||||
MailFilterField,
|
||||
MailFilterMatch,
|
||||
MailFilterRule,
|
||||
} from '@/shared/types/mail'
|
||||
import { isValidEmail, normalizeEmail } from '@/shared/utils/mail-address'
|
||||
import { hasControlChar, orderMailboxes } from '@/shared/utils/mailbox'
|
||||
import { createUuid } from '@/shared/utils/uuid'
|
||||
|
||||
// ── 목록의 요약 ────────────────────────────────────────
|
||||
|
||||
/** "보낸 사람에 ‘@hanbit-partners.co.kr’ 포함" */
|
||||
export function conditionText(condition: MailFilterCondition): string {
|
||||
return MAIL_FILTER_SUMMARY_TEXT.condition(
|
||||
MAIL_FILTER_FIELD_LABELS[condition.field],
|
||||
condition.value,
|
||||
MAIL_FILTER_OPERATOR_LABELS[condition.operator],
|
||||
)
|
||||
}
|
||||
|
||||
/** 조건 전체 — 모두는 " 그리고 ", 하나라도는 " 또는 " 로 잇는다. 모든 메일이면 "모든 메일" */
|
||||
export function conditionsText(rule: Pick<MailFilterRule, 'match' | 'conditions'>): string {
|
||||
if (rule.match === 'always') return MAIL_FILTER_SUMMARY_TEXT.always
|
||||
const joiner = rule.match === 'all' ? MAIL_FILTER_SUMMARY_TEXT.and : MAIL_FILTER_SUMMARY_TEXT.or
|
||||
return rule.conditions.map(conditionText).join(joiner)
|
||||
}
|
||||
|
||||
/**
|
||||
* 할 일 — "옮기기: 거래처 · 읽음으로 표시 · 중요 표시 · 전달: doyun.lee@arasystem.it.kr" (그 순서).
|
||||
* mailboxName 은 옮길 메일함의 이름 — 지워진 메일함이면 null ("없는 메일함")
|
||||
*/
|
||||
export function actionsText(actions: MailFilterActions, mailboxName: (id: string) => string | null): string {
|
||||
const parts: string[] = []
|
||||
if (actions.moveToMailboxId !== null) {
|
||||
const name = mailboxName(actions.moveToMailboxId) ?? MAIL_FILTER_SUMMARY_TEXT.missingMailbox
|
||||
parts.push(MAIL_FILTER_SUMMARY_TEXT.move(name))
|
||||
}
|
||||
if (actions.markAsRead) parts.push(MAIL_FILTER_SUMMARY_TEXT.markAsRead)
|
||||
if (actions.flag) parts.push(MAIL_FILTER_SUMMARY_TEXT.flag)
|
||||
if (actions.forwardTo) parts.push(MAIL_FILTER_SUMMARY_TEXT.forward(actions.forwardTo))
|
||||
return parts.join(MAIL_FILTER_SUMMARY_TEXT.actionSeparator)
|
||||
}
|
||||
|
||||
/** 목록에 보이는 이름 — 이름이 없으면 조건이 이름 자리에 온다 (길면 화면이 줄임표로 자른다) */
|
||||
export function ruleTitle(rule: Pick<MailFilterRule, 'name' | 'match' | 'conditions'>): string {
|
||||
return rule.name.trim() || conditionsText(rule)
|
||||
}
|
||||
|
||||
/** 길면 자르고 "…" — 글자(코드 포인트) 단위로 센다 (이모지를 반쪽으로 자르지 않게) */
|
||||
function truncate(text: string, max: number): string {
|
||||
const chars = Array.from(text)
|
||||
return chars.length > max ? `${chars.slice(0, max).join('')}…` : text
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 · 단추 이름에 넣는 이름 — 따옴표로 묶는다. 이름이 없어 조건 글(5개 × 200자까지)이 오면 길이를 잘라 "…"를 붙이고,
|
||||
* 작은따옴표가 들었으면(조건 글은 찾을 글을 ‘…’ 로 묶는다) 큰따옴표로 묶어 따옴표가 겹치지 않게 한다
|
||||
*/
|
||||
export function quotedRuleTitle(rule: Pick<MailFilterRule, 'name' | 'match' | 'conditions'>): string {
|
||||
const title = truncate(ruleTitle(rule), MAIL_FILTER_LABEL_MAX_LENGTH)
|
||||
return /[‘’]/.test(title) ? `“${title}”` : `‘${title}’`
|
||||
}
|
||||
|
||||
/** 이름 아래 요약 한 줄 — 이름이 있으면 "조건 → 할 일", 없으면(조건이 이미 이름 자리에 있다) "→ 할 일" */
|
||||
export function ruleSummary(rule: MailFilterRule, mailboxName: (id: string) => string | null): string {
|
||||
const actions = `${MAIL_FILTER_SUMMARY_TEXT.arrow}${actionsText(rule.actions, mailboxName)}`
|
||||
return rule.name.trim() ? `${conditionsText(rule)} ${actions}` : actions
|
||||
}
|
||||
|
||||
/** 옮길 메일함이 지워졌다 — 받은편지함으로 들어온다 (목록에 빨간 안내 · 고치기 창은 "(지워진 메일함)"이 골라진 채로) */
|
||||
export function isMoveTargetMissing(rule: MailFilterRule, mailboxes: readonly Mailbox[]): boolean {
|
||||
const target = rule.actions.moveToMailboxId
|
||||
return target !== null && !mailboxes.some((mailbox) => mailbox.id === target)
|
||||
}
|
||||
|
||||
// ── 옮길 메일함 ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 옮길 메일함 고르기의 묶음 차례 — 뿌리 메일함의 역할로 정한다(하위 메일함은 뿌리를 따라간다).
|
||||
* 목록에 없는 역할(보낸편지함 · 임시보관함)은 그 아래 메일함까지 두지 않는다 — 서버가 받지 않는다(400 VAL_001)
|
||||
*/
|
||||
const MOVE_TARGET_GROUPS: Partial<Record<MailboxRole | 'mine', number>> = {
|
||||
inbox: 0,
|
||||
mine: 1,
|
||||
archive: 2,
|
||||
junk: 3,
|
||||
trash: 4,
|
||||
}
|
||||
const MOVE_TARGET_GROUP_COUNT = 5
|
||||
|
||||
/**
|
||||
* 옮길 메일함 고르기의 목록 — 받은편지함(과 그 아래) → 내 메일함 → 보관함(과 그 아래) → 스팸메일함 → 휴지통
|
||||
* (같은 묶음 안은 메일함 패널 순서). 보관함은 서버가 받고, mailcow 에서 옮겨 온 계정에는 보관함이 있다
|
||||
*/
|
||||
export function filterMoveTargets(mailboxes: readonly Mailbox[]): Mailbox[] {
|
||||
const groups: Mailbox[][] = Array.from({ length: MOVE_TARGET_GROUP_COUNT }, () => [])
|
||||
let group: number | undefined
|
||||
for (const { mailbox, depth } of orderMailboxes(mailboxes)) {
|
||||
if (depth === 0) group = MOVE_TARGET_GROUPS[mailbox.role ?? 'mine']
|
||||
if (group !== undefined) groups[group]?.push(mailbox)
|
||||
}
|
||||
return groups.flat()
|
||||
}
|
||||
|
||||
/** 새 필터의 옮길 메일함 — 내 메일함이 있으면 그 첫째, 없으면 목록의 첫째(받은편지함). 고를 곳이 없으면 빈 값 */
|
||||
export function defaultMoveTarget(targets: readonly Mailbox[]): string {
|
||||
return (targets.find((mailbox) => mailbox.role === null) ?? targets[0])?.id ?? ''
|
||||
}
|
||||
|
||||
/** 로그인한 직원 주소의 도메인 — 전달은 이 도메인의 주소로만 (소문자) */
|
||||
export function mailDomainOf(email: string): string {
|
||||
const at = email.lastIndexOf('@')
|
||||
return at < 0 ? '' : email.slice(at + 1).trim().toLowerCase()
|
||||
}
|
||||
|
||||
// ── 고치기 창의 칸 ──────────────────────────────────────
|
||||
|
||||
/** 고치기 창이 다루는 값 — 저장할 때 규칙(filterRuleOf)으로 바꾼다 */
|
||||
export interface MailFilterDraft {
|
||||
name: string
|
||||
match: MailFilterMatch
|
||||
/** 모두 · 하나라도의 조건 — 모든 메일로 바꿔도 남겨 둔다(다시 바꾸면 그대로 돌아온다). 저장할 때 모든 메일이면 뺀다 */
|
||||
conditions: MailFilterCondition[]
|
||||
move: boolean
|
||||
/** 옮길 메일함 id — 옮기기를 끈 동안에도 고른 것을 남겨 둔다 */
|
||||
moveTarget: string
|
||||
markAsRead: boolean
|
||||
flag: boolean
|
||||
forward: boolean
|
||||
/** 끈 동안에도 쓴 것을 남겨 둔다 */
|
||||
forwardTo: string
|
||||
}
|
||||
|
||||
/** 고치기 창의 "할 일" 칸 */
|
||||
export type MailFilterActionDraft = Pick<
|
||||
MailFilterDraft,
|
||||
'move' | 'moveTarget' | 'markAsRead' | 'flag' | 'forward' | 'forwardTo'
|
||||
>
|
||||
|
||||
/** 할 일을 하나라도 골랐다 (옮기기 · 읽음 · 중요 · 전달) */
|
||||
export function hasDraftAction(draft: MailFilterActionDraft): boolean {
|
||||
return draft.move || draft.markAsRead || draft.flag || draft.forward
|
||||
}
|
||||
|
||||
/** 빈 조건 한 줄 — 처음 줄은 보낸 사람, [조건 더하기]는 제목 (디자인 원본 SettingsFilter) */
|
||||
export function emptyCondition(field: MailFilterField = 'from'): MailFilterCondition {
|
||||
return { field, operator: 'contains', value: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 필터 — 모두 맞음 · 빈 조건 한 줄 · 할 일 없음.
|
||||
* from 이 있으면(읽기 화면 ⋯ 「이런 메일로 필터 만들기」) 조건이 "보낸 사람 · 포함 · 그 주소"다
|
||||
*/
|
||||
export function newFilterDraft(options: { moveTarget: string; from?: string | null }): MailFilterDraft {
|
||||
return {
|
||||
name: '',
|
||||
match: 'all',
|
||||
conditions: [{ ...emptyCondition('from'), value: options.from ?? '' }],
|
||||
move: false,
|
||||
moveTarget: options.moveTarget,
|
||||
markAsRead: false,
|
||||
flag: false,
|
||||
forward: false,
|
||||
forwardTo: '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 있는 규칙을 고칠 때 — 모든 메일 규칙도 조건 칸 한 줄을 남겨 둔다(모두 · 하나라도로 바꾸면 보인다) */
|
||||
export function filterDraftOf(rule: MailFilterRule, moveTarget: string): MailFilterDraft {
|
||||
const conditions = rule.conditions.length > 0 ? rule.conditions.map((condition) => ({ ...condition })) : [emptyCondition()]
|
||||
return {
|
||||
name: rule.name,
|
||||
match: rule.match,
|
||||
conditions,
|
||||
move: rule.actions.moveToMailboxId !== null,
|
||||
moveTarget: rule.actions.moveToMailboxId ?? moveTarget,
|
||||
markAsRead: rule.actions.markAsRead,
|
||||
flag: rule.actions.flag,
|
||||
forward: rule.actions.forwardTo !== null,
|
||||
forwardTo: rule.actions.forwardTo ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 새 규칙의 id · 켜짐 — 새로 만든 필터는 켜진 채로 끝에 붙는다 */
|
||||
export function newFilterRuleBase(): Pick<MailFilterRule, 'id' | 'isEnabled'> {
|
||||
return { id: createUuid(), isEnabled: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 고치기 창의 값 → 보낼 규칙. 이름 · 글 · 전달 주소는 앞뒤 공백을 뺀다(서버도 뺀 값으로 잰다).
|
||||
* 모든 메일이면 조건을 비우고, 끈 할 일은 빈 값(null · false)이다
|
||||
*/
|
||||
export function filterRuleOf(draft: MailFilterDraft, base: Pick<MailFilterRule, 'id' | 'isEnabled'>): MailFilterRule {
|
||||
return {
|
||||
id: base.id,
|
||||
isEnabled: base.isEnabled,
|
||||
name: draft.name.trim(),
|
||||
match: draft.match,
|
||||
conditions:
|
||||
draft.match === 'always'
|
||||
? []
|
||||
: draft.conditions.map((condition) => ({ ...condition, value: condition.value.trim() })),
|
||||
actions: {
|
||||
moveToMailboxId: draft.move && draft.moveTarget ? draft.moveTarget : null,
|
||||
markAsRead: draft.markAsRead,
|
||||
flag: draft.flag,
|
||||
forwardTo: draft.forward ? draft.forwardTo.trim() : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── 검사 (서버와 같은 규칙 — 계약 2절 "메일 필터"의 "클라이언트") ─────────────
|
||||
|
||||
/** 조건의 문제 — 비었다 · `${` 가 들었다 · 길이 · 제어 문자 */
|
||||
export type MailFilterConditionProblem = 'empty' | 'variable' | 'invalid'
|
||||
/** 전달의 문제 — 비었다 · `${` 가 들었다 · 형식(서버의 IsEmail 이 받지 않는다) · 사내 주소가 아니다 · 내 주소다 */
|
||||
export type MailFilterForwardProblem = 'empty' | 'variable' | 'invalid' | 'external' | 'self'
|
||||
|
||||
export interface MailFilterProblems {
|
||||
/** 이름이 50자를 넘거나 제어 문자가 들었다 */
|
||||
name: boolean
|
||||
/** draft.conditions 와 같은 차례 — 문제가 없으면 null (모든 메일이면 모두 null) */
|
||||
conditions: (MailFilterConditionProblem | null)[]
|
||||
/** 할 일을 하나도 고르지 않았다 */
|
||||
actions: boolean
|
||||
forward: MailFilterForwardProblem | null
|
||||
}
|
||||
|
||||
function conditionProblem(value: string): MailFilterConditionProblem | null {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === '') return 'empty'
|
||||
if (trimmed.includes(MAIL_FILTER_FORBIDDEN_SEQUENCE)) return 'variable'
|
||||
if (trimmed.length > MAIL_FILTER_VALUE_MAX_LENGTH || hasControlChar(trimmed)) return 'invalid'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 전달 주소의 @ 앞 — RFC 5322 dot-atom: atext(영문 · 숫자 · ! # $ % & ' * + - / = ? ^ _ ` { | } ~) 덩어리를 점으로 잇는다
|
||||
* (앞 · 뒤 · 연달은 점 없음). 서버(IsEmail)가 받는 것보다 좁다 — 화면이 통과시킨 주소는 서버도 받는다
|
||||
*/
|
||||
const DOT_ATOM_LOCAL_PART = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/
|
||||
|
||||
function forwardProblem(address: string, ownEmail: string): MailFilterForwardProblem | null {
|
||||
const trimmed = address.trim()
|
||||
if (trimmed === '') return 'empty'
|
||||
if (trimmed.includes(MAIL_FILTER_FORBIDDEN_SEQUENCE)) return 'variable'
|
||||
const at = trimmed.lastIndexOf('@')
|
||||
const local = trimmed.slice(0, Math.max(at, 0))
|
||||
const wellFormed =
|
||||
at > 0 &&
|
||||
trimmed.length <= MAIL_FILTER_FORWARD_MAX_LENGTH &&
|
||||
local.length <= MAIL_FILTER_FORWARD_LOCAL_MAX_LENGTH &&
|
||||
DOT_ATOM_LOCAL_PART.test(local) &&
|
||||
isValidEmail(trimmed)
|
||||
if (!wellFormed) return 'invalid'
|
||||
const ownDomain = mailDomainOf(ownEmail)
|
||||
if (ownDomain === '' || mailDomainOf(trimmed) !== ownDomain) return 'external'
|
||||
return normalizeEmail(trimmed) === normalizeEmail(ownEmail) ? 'self' : null
|
||||
}
|
||||
|
||||
/** [저장]을 누를 때 본다 — ownEmail 은 로그인한 직원 주소 (사내 도메인 · 자기 주소 판정) */
|
||||
export function filterDraftProblems(draft: MailFilterDraft, ownEmail: string): MailFilterProblems {
|
||||
const name = draft.name.trim()
|
||||
return {
|
||||
name: name.length > MAIL_FILTER_NAME_MAX_LENGTH || hasControlChar(name),
|
||||
conditions: draft.conditions.map((condition) =>
|
||||
draft.match === 'always' ? null : conditionProblem(condition.value),
|
||||
),
|
||||
actions: !hasDraftAction(draft),
|
||||
forward: draft.forward ? forwardProblem(draft.forwardTo, ownEmail) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasFilterProblems(problems: MailFilterProblems): boolean {
|
||||
return (
|
||||
problems.name ||
|
||||
problems.actions ||
|
||||
problems.forward !== null ||
|
||||
problems.conditions.some((problem) => problem !== null)
|
||||
)
|
||||
}
|
||||
|
||||
/** 문제가 없는 상태 — 칸을 고치면 그 칸의 문제만 내린다 */
|
||||
export function noFilterProblems(conditionCount: number): MailFilterProblems {
|
||||
return { name: false, conditions: Array.from({ length: conditionCount }, () => null), actions: false, forward: null }
|
||||
}
|
||||
|
||||
// ── 목록 바꾸기 (켜기 · 순서 · 저장 · 지우기 · 되돌리기 — 모두 PUT 전체 교체) ─────────────
|
||||
|
||||
export function withRuleEnabled(rules: readonly MailFilterRule[], id: string, isEnabled: boolean): MailFilterRule[] {
|
||||
return rules.map((rule) => (rule.id === id ? { ...rule, isEnabled } : rule))
|
||||
}
|
||||
|
||||
/** 한 칸 위(-1) · 아래(1)로 — 끝이라 옮길 수 없으면 null */
|
||||
export function withRuleMoved(rules: readonly MailFilterRule[], id: string, offset: -1 | 1): MailFilterRule[] | null {
|
||||
const index = rules.findIndex((rule) => rule.id === id)
|
||||
const target = index + offset
|
||||
if (index < 0 || target < 0 || target >= rules.length) return null
|
||||
const next = [...rules]
|
||||
const [moved] = next.splice(index, 1)
|
||||
if (!moved) return null
|
||||
next.splice(target, 0, moved)
|
||||
return next
|
||||
}
|
||||
|
||||
/** 고친 규칙은 그 자리에, 새 규칙은 끝에 */
|
||||
export function withRuleSaved(rules: readonly MailFilterRule[], rule: MailFilterRule): MailFilterRule[] {
|
||||
return rules.some((existing) => existing.id === rule.id)
|
||||
? rules.map((existing) => (existing.id === rule.id ? rule : existing))
|
||||
: [...rules, rule]
|
||||
}
|
||||
|
||||
/** 지운 규칙과 그 자리 — 되돌리기가 다시 넣을 곳을 앞 · 뒤 규칙으로 기억한다 (되돌리기 여럿이 어떤 차례로 와도 원래 차례에 가깝게) */
|
||||
export interface MailFilterRestorePoint {
|
||||
rule: MailFilterRule
|
||||
/** 지운 때의 차례 — 앞 · 뒤 규칙이 모두 없을 때만 쓴다 */
|
||||
index: number
|
||||
/** 바로 앞 규칙 — 맨 위였으면 null */
|
||||
afterId: string | null
|
||||
/** 바로 뒤 규칙 — 맨 아래였으면 null */
|
||||
beforeId: string | null
|
||||
}
|
||||
|
||||
/** 지운 목록과 되돌릴 자리. 없는 id 면 null */
|
||||
export function withoutRule(
|
||||
rules: readonly MailFilterRule[],
|
||||
id: string,
|
||||
): { rules: MailFilterRule[]; point: MailFilterRestorePoint } | null {
|
||||
const index = rules.findIndex((rule) => rule.id === id)
|
||||
const removed = rules[index]
|
||||
if (!removed) return null
|
||||
return {
|
||||
rules: rules.filter((rule) => rule.id !== id),
|
||||
point: { rule: removed, index, afterId: rules[index - 1]?.id ?? null, beforeId: rules[index + 1]?.id ?? null },
|
||||
}
|
||||
}
|
||||
|
||||
/** 다시 넣을 차례 — 맨 위였으면 맨 위, 아니면 앞 규칙 바로 뒤 → (앞 규칙이 없으면) 뒤 규칙 바로 앞 → 지운 때의 차례(넘으면 끝) */
|
||||
function restoreIndex(rules: readonly MailFilterRule[], point: MailFilterRestorePoint): number {
|
||||
if (point.afterId === null) return 0
|
||||
const after = rules.findIndex((rule) => rule.id === point.afterId)
|
||||
if (after >= 0) return after + 1
|
||||
const before = point.beforeId === null ? -1 : rules.findIndex((rule) => rule.id === point.beforeId)
|
||||
if (before >= 0) return before
|
||||
return Math.min(Math.max(point.index, 0), rules.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 되돌리기 — 지운 규칙을 지운 자리에 다시 넣는다(restoreIndex). 지운 뒤 바꾼 다른 규칙은 그대로 둔다.
|
||||
* 이미 있으면(다른 되돌리기 · 다른 곳에서 되살렸다) null
|
||||
*/
|
||||
export function withRuleRestored(
|
||||
rules: readonly MailFilterRule[],
|
||||
point: MailFilterRestorePoint,
|
||||
): MailFilterRule[] | null {
|
||||
if (rules.some((existing) => existing.id === point.rule.id)) return null
|
||||
const next = [...rules]
|
||||
next.splice(restoreIndex(rules, point), 0, point.rule)
|
||||
return next
|
||||
}
|
||||
@@ -116,13 +116,13 @@ export function mailboxActions(mailbox: Mailbox): MailboxAction[] {
|
||||
return actions
|
||||
}
|
||||
|
||||
/** 제어 문자 — C0(줄바꿈 · 탭 등) · DEL · C1. 메일함 이름에 쓸 수 없다 (서버와 같은 범위) */
|
||||
/** 제어 문자 — C0(줄바꿈 · 탭 등) · DEL · C1. 메일함 이름 · 메일 필터의 글에 쓸 수 없다 (서버와 같은 범위) */
|
||||
const CONTROL_CHAR_RANGES: readonly (readonly [number, number])[] = [
|
||||
[0x00, 0x1f],
|
||||
[0x7f, 0x9f],
|
||||
]
|
||||
|
||||
function hasControlChar(text: string): boolean {
|
||||
export function hasControlChar(text: string): boolean {
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const code = text.charCodeAt(index)
|
||||
if (CONTROL_CHAR_RANGES.some(([start, end]) => code >= start && code <= end)) return true
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 무작위 UUID v4 — 발송 중복 방지 키(idempotency-key.ts) · 메일 필터 규칙 id(mail-filter.ts)가 함께 쓴다
|
||||
|
||||
/** UUID 한 개의 바이트 수 */
|
||||
const UUID_BYTES = 16
|
||||
/** 7번째 바이트 상위 4비트 = 버전(0100: 무작위 v4) */
|
||||
const VERSION_BYTE = 6
|
||||
/** 9번째 바이트 상위 2비트 = 변형(10: RFC 9562) */
|
||||
const VARIANT_BYTE = 8
|
||||
/** 16진 32자리 → 8-4-4-4-12 */
|
||||
const UUID_LAYOUT = /^(.{8})(.{4})(.{4})(.{4})(.{12})$/
|
||||
const HEX_RADIX = 16
|
||||
|
||||
function toHex(byte: number): string {
|
||||
return byte.toString(HEX_RADIX).padStart(2, '0')
|
||||
}
|
||||
|
||||
/** crypto.getRandomValues 로 UUID v4 를 만든다 — randomUUID 를 쓸 수 없을 때만 쓴다 */
|
||||
function randomUuidV4(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(UUID_BYTES))
|
||||
bytes[VERSION_BYTE] = ((bytes[VERSION_BYTE] ?? 0) & 0x0f) | 0x40
|
||||
bytes[VARIANT_BYTE] = ((bytes[VARIANT_BYTE] ?? 0) & 0x3f) | 0x80
|
||||
return Array.from(bytes, toHex).join('').replace(UUID_LAYOUT, '$1-$2-$3-$4-$5')
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 UUID v4.
|
||||
* crypto.randomUUID 는 보안 컨텍스트(HTTPS·localhost)에서만 있다 — 사내망 IP 로 연 개발 서버처럼
|
||||
* 그렇지 않은 곳에서도 화면이 깨지지 않게, 같은 형식(UUID v4)을 직접 만들어 쓴다.
|
||||
*/
|
||||
export function createUuid(): string {
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
||||
return randomUuidV4()
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
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 type { MailFilterRule, MailFilterSet, MailFilterSetPayload } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { useMailFilterStore } from './mail-filter.store'
|
||||
|
||||
// 메일 필터 스토어 — 모두 PUT 전체 교체 · 한 번에 하나 · 늦게 온 응답 · 409 · 켜기 · 순서의 되돌리기 (계약 2절 "메일 필터")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const OTHER = { email: 'minsu.park@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,
|
||||
}
|
||||
}
|
||||
|
||||
const A = rule('a')
|
||||
const B = rule('b')
|
||||
const C = rule('c')
|
||||
const SET: MailFilterSet = { rules: [A, B, C], version: 'v1', isActive: true }
|
||||
|
||||
/** 응답을 손으로 끝내는 요청 — 그 사이에 세션을 바꾸거나 다른 일을 해 본다 */
|
||||
function deferred() {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
return { promise, finish }
|
||||
}
|
||||
|
||||
/** 서버 흉내 — PUT 을 받으면 version 을 올려 그대로 돌려준다 */
|
||||
function saved(request: MockRequest, version = 'v2'): MockResult {
|
||||
const body = request.data as MailFilterSetPayload
|
||||
return ok({ rules: body.rules, version, isActive: true })
|
||||
}
|
||||
|
||||
describe('mail-filter.store', () => {
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().startSession(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function loaded(handler: (request: MockRequest) => MockResult | Promise<MockResult>) {
|
||||
requests = installMockApi((request) => (request.method === 'GET' ? ok(SET) : handler(request)))
|
||||
const store = useMailFilterStore()
|
||||
await store.load()
|
||||
return store
|
||||
}
|
||||
|
||||
const puts = () => requests.filter((request) => request.method === 'PUT').map((request) => request.data as MailFilterSetPayload)
|
||||
const ids = (rules: readonly MailFilterRule[] | undefined) => rules?.map((item) => item.id)
|
||||
|
||||
it('불러오기는 알리지 않는다 — 못 받으면 상태에 담고, 받으면 50개 여부를 안다', async () => {
|
||||
requests = installMockApi(() => fail(503, 'MAIL_001'))
|
||||
const store = useMailFilterStore()
|
||||
|
||||
await store.load()
|
||||
expect(store.status).toBe('error')
|
||||
expect(store.error?.code).toBe('MAIL_001')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
const fifty = Array.from({ length: 50 }, (_, index) => rule(`r${index}`))
|
||||
restoreApiAdapter()
|
||||
requests = installMockApi(() => ok({ rules: fifty, version: 'v1', isActive: true }))
|
||||
store.ensureLoaded()
|
||||
await flushPromises()
|
||||
expect(store.status).toBe('ready')
|
||||
expect(store.isFull).toBe(true)
|
||||
// 받아 둔 것이 있으면 다시 묻지 않는다 (읽기 화면 ⋯)
|
||||
store.ensureLoaded()
|
||||
expect(requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('불러오는 사이 로그아웃하고 다른 계정으로 들어오면, 늦게 온 응답을 새 사용자의 목록으로 쓰지 않는다', async () => {
|
||||
const reply = deferred()
|
||||
requests = installMockApi(() => reply.promise)
|
||||
const store = useMailFilterStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = store.load()
|
||||
authStore.clearAuth()
|
||||
await nextTick()
|
||||
authStore.startSession(OTHER)
|
||||
await nextTick()
|
||||
|
||||
reply.finish(ok(SET))
|
||||
await loading
|
||||
expect(store.filterSet).toBeNull()
|
||||
expect(store.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('저장하는 사이 세션이 바뀌면(같은 사람이 다시 들어와도) 결과를 쓰지 않고 보내지 않은 것으로 돌려준다', async () => {
|
||||
const reply = deferred()
|
||||
const store = await loaded(() => reply.promise)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const saving = store.saveRule({ ...A, name: '고침' })
|
||||
authStore.clearAuth()
|
||||
await nextTick()
|
||||
authStore.startSession(USER)
|
||||
await nextTick()
|
||||
|
||||
reply.finish(ok({ ...SET, version: 'v2' }))
|
||||
await expect(saving).resolves.toEqual({ ok: false, error: null })
|
||||
expect(store.filterSet).toBeNull()
|
||||
expect(store.saving).toBe(false)
|
||||
})
|
||||
|
||||
it('받은 version 으로 전체 교체하고, 응답이 새 목록 · version 이다 — 다음 저장은 새 version 으로', async () => {
|
||||
const store = await loaded((request) => saved(request, `v${puts().length + 1}`))
|
||||
|
||||
await store.saveRule({ ...B, name: '고친 이름' })
|
||||
await store.saveRule(rule('d'))
|
||||
|
||||
expect(puts().map((payload) => [payload.version, ids(payload.rules)])).toEqual([
|
||||
['v1', ['a', 'b', 'c']],
|
||||
['v2', ['a', 'b', 'c', 'd']],
|
||||
])
|
||||
expect(puts()[0]?.rules[1]?.name).toBe('고친 이름')
|
||||
expect(store.filterSet?.version).toBe('v3')
|
||||
})
|
||||
|
||||
it('저장은 한 번에 하나 — 기다리는 동안 온 켜기 · 순서 · 저장은 보내지 않는다', async () => {
|
||||
const reply = deferred()
|
||||
const store = await loaded(() => reply.promise)
|
||||
|
||||
const first = store.setRuleEnabled('a', false)
|
||||
expect(store.saving).toBe(true)
|
||||
await expect(store.moveRule('b', -1)).resolves.toBe(false)
|
||||
await expect(store.saveRule(rule('d'))).resolves.toEqual({ ok: false, error: null })
|
||||
expect(puts()).toHaveLength(1)
|
||||
|
||||
reply.finish(ok({ rules: [{ ...A, isEnabled: false }, B, C], version: 'v2', isActive: true }))
|
||||
await expect(first).resolves.toBe(true)
|
||||
expect(store.saving).toBe(false)
|
||||
})
|
||||
|
||||
it('켜기 · 순서는 화면을 먼저 바꾸고, 실패하면 되돌린다 (오류 알림은 인터셉터)', async () => {
|
||||
const reply = deferred()
|
||||
const store = await loaded(() => reply.promise)
|
||||
|
||||
const moving = store.moveRule('c', -1)
|
||||
expect(ids(store.filterSet?.rules)).toEqual(['a', 'c', 'b'])
|
||||
reply.finish(fail(503, 'MAIL_001'))
|
||||
await expect(moving).resolves.toBe(false)
|
||||
|
||||
expect(ids(store.filterSet?.rules)).toEqual(['a', 'b', 'c'])
|
||||
expect(store.filterSet?.version).toBe('v1')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
|
||||
})
|
||||
|
||||
it('켜기가 실패해도 되돌린다 — 끝 칸의 순서 바꾸기는 보내지 않는다', async () => {
|
||||
const store = await loaded(() => fail(400, 'MAIL_013'))
|
||||
|
||||
await expect(store.setRuleEnabled('b', false)).resolves.toBe(false)
|
||||
expect(store.filterSet?.rules.map((item) => item.isEnabled)).toEqual([true, true, true])
|
||||
// 엔진 한도는 목록에서는 알림으로 (창 안이 아니다)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_013])
|
||||
|
||||
await expect(store.moveRule('a', -1)).resolves.toBe(false)
|
||||
expect(puts()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('409 MAIL_012 — 알리지 않고 conflict 를 켜고 되돌린다. 다시 불러오면 내린다', async () => {
|
||||
const store = await loaded(() => fail(409, 'MAIL_012'))
|
||||
|
||||
await expect(store.setRuleEnabled('a', false)).resolves.toBe(false)
|
||||
expect(store.conflict).toBe(true)
|
||||
expect(store.filterSet?.rules[0]?.isEnabled).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
const outcome = await store.saveRule(rule('d'))
|
||||
expect(outcome.ok ? null : outcome.error?.code).toBe('MAIL_012')
|
||||
|
||||
restoreApiAdapter()
|
||||
installMockApi(() => ok({ rules: [B], version: 'v7', isActive: true }))
|
||||
await expect(store.reload()).resolves.toBe(true)
|
||||
expect(store.conflict).toBe(false)
|
||||
expect(ids(store.filterSet?.rules)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('고치기 창의 저장 거절(VAL_001 · MAIL_013)은 알리지 않는다 — 창 안에 보인다', async () => {
|
||||
const store = await loaded(() => fail(400, 'VAL_001'))
|
||||
|
||||
const outcome = await store.saveRule(rule('d'))
|
||||
expect(outcome.ok ? null : outcome.error?.code).toBe('VAL_001')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
expect(ids(store.filterSet?.rules)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('지우면 지운 규칙과 자리를 돌려준다 — 되돌리기는 그 자리에 다시 넣고, 그사이 바뀐 다른 규칙은 그대로 둔다', async () => {
|
||||
const store = await loaded((request) => saved(request, `v${puts().length + 1}`))
|
||||
|
||||
const removal = await store.removeRule('b')
|
||||
expect(removal).toMatchObject({ ok: true, point: { index: 1, afterId: 'a', beforeId: 'c', rule: { id: 'b' } } })
|
||||
await store.setRuleEnabled('c', false)
|
||||
|
||||
if (!removal.ok) throw new Error('지우지 못했다')
|
||||
await store.restoreRule(removal.point)
|
||||
expect(store.filterSet?.rules.map((item) => [item.id, item.isEnabled])).toEqual([
|
||||
['a', true],
|
||||
['b', true],
|
||||
['c', false],
|
||||
])
|
||||
expect(puts().map((payload) => payload.version)).toEqual(['v1', 'v2', 'v3'])
|
||||
// 이미 되돌렸으면 다시 보내지 않는다
|
||||
await expect(store.restoreRule(removal.point)).resolves.toMatchObject({ ok: true })
|
||||
expect(puts()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('되돌리기는 다른 저장이 끝난 뒤에 보낸다 (알림의 단추는 한 번뿐이다)', async () => {
|
||||
const reply = deferred()
|
||||
let calls = 0
|
||||
const store = await loaded((request) => {
|
||||
calls += 1
|
||||
return calls === 1 ? reply.promise : saved(request, 'v3')
|
||||
})
|
||||
|
||||
const toggling = store.setRuleEnabled('a', false)
|
||||
const restoring = store.restoreRule({ rule: rule('z'), index: 0, afterId: null, beforeId: 'a' })
|
||||
await flushPromises()
|
||||
expect(puts()).toHaveLength(1)
|
||||
|
||||
reply.finish(ok({ rules: [{ ...A, isEnabled: false }, B, C], version: 'v2', isActive: true }))
|
||||
await toggling
|
||||
await expect(restoring).resolves.toMatchObject({ ok: true })
|
||||
expect(puts()[1]).toEqual({ rules: [rule('z'), { ...A, isEnabled: false }, B, C], version: 'v2' })
|
||||
})
|
||||
|
||||
it('[다시 켜기] — 지금 목록 그대로 저장하고, 응답의 isActive 를 쓴다', async () => {
|
||||
requests = installMockApi((request) =>
|
||||
request.method === 'GET' ? ok({ ...SET, isActive: false }) : saved(request),
|
||||
)
|
||||
const store = useMailFilterStore()
|
||||
await store.load()
|
||||
expect(store.filterSet?.isActive).toBe(false)
|
||||
|
||||
await expect(store.reactivate()).resolves.toMatchObject({ ok: true })
|
||||
expect(puts()).toEqual([{ rules: [A, B, C], version: 'v1' }])
|
||||
expect(store.filterSet?.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('저장한 목록이 곧 보이는 목록이다 — 그 전에 시작한 불러오기가 늦게 와도 덮지 않는다', async () => {
|
||||
const stale = deferred()
|
||||
let gets = 0
|
||||
requests = installMockApi((request) => {
|
||||
if (request.method === 'PUT') return saved(request)
|
||||
gets += 1
|
||||
return gets === 1 ? ok(SET) : stale.promise
|
||||
})
|
||||
const store = useMailFilterStore()
|
||||
await store.load()
|
||||
|
||||
const reloading = store.reload()
|
||||
await store.saveRule(rule('d'))
|
||||
stale.finish(ok({ rules: [], version: 'v0', isActive: true }))
|
||||
await reloading
|
||||
|
||||
expect(ids(store.filterSet?.rules)).toEqual(['a', 'b', 'c', 'd'])
|
||||
expect(store.filterSet?.version).toBe('v2')
|
||||
})
|
||||
|
||||
it('저장 하나를 기다리는 되돌리기 둘 — 누른 차례대로 둘 다 보내고 둘 다 성공한다 (앞 것이 끝난 뒤 다음 것)', async () => {
|
||||
const first = deferred()
|
||||
let calls = 0
|
||||
const store = await loaded((request) => {
|
||||
calls += 1
|
||||
return calls === 1 ? first.promise : saved(request, `v${calls + 1}`)
|
||||
})
|
||||
|
||||
const toggling = store.setRuleEnabled('a', false)
|
||||
const restoreX = store.restoreRule({ rule: rule('x'), index: 1, afterId: 'a', beforeId: 'b' })
|
||||
const restoreY = store.restoreRule({ rule: rule('y'), index: 3, afterId: 'b', beforeId: 'c' })
|
||||
await flushPromises()
|
||||
expect(puts()).toHaveLength(1)
|
||||
|
||||
first.finish(ok({ rules: [{ ...A, isEnabled: false }, B, C], version: 'v2', isActive: true }))
|
||||
await toggling
|
||||
const [x, y] = await Promise.all([restoreX, restoreY])
|
||||
|
||||
expect(x.ok).toBe(true)
|
||||
expect(y.ok).toBe(true)
|
||||
expect(puts().map((payload) => [payload.version, ids(payload.rules)])).toEqual([
|
||||
['v1', ['a', 'b', 'c']],
|
||||
['v2', ['a', 'x', 'b', 'c']],
|
||||
['v3', ['a', 'x', 'b', 'y', 'c']],
|
||||
])
|
||||
expect(ids(store.filterSet?.rules)).toEqual(['a', 'x', 'b', 'y', 'c'])
|
||||
})
|
||||
|
||||
it('되돌리면 50개를 넘으면 보내지 않는다 (full)', async () => {
|
||||
const fifty = Array.from({ length: 50 }, (_, index) => rule(`r${index}`))
|
||||
requests = installMockApi((request) =>
|
||||
request.method === 'GET' ? ok({ rules: fifty, version: 'v1', isActive: true }) : saved(request),
|
||||
)
|
||||
const store = useMailFilterStore()
|
||||
await store.load()
|
||||
|
||||
await expect(store.restoreRule({ rule: rule('x'), index: 0, afterId: null, beforeId: 'r0' })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: null,
|
||||
full: true,
|
||||
})
|
||||
expect(puts()).toEqual([])
|
||||
})
|
||||
|
||||
it('켜기가 실패해 되돌려진 뒤의 고치기 저장 — 켜짐은 창을 연 때가 아니라 저장하는 순간 목록의 값을 쓴다', async () => {
|
||||
const reply = deferred()
|
||||
let calls = 0
|
||||
const store = await loaded((request) => {
|
||||
calls += 1
|
||||
return calls === 1 ? reply.promise : saved(request)
|
||||
})
|
||||
|
||||
const toggling = store.setRuleEnabled('b', false)
|
||||
// 저장하는 동안 창을 열었다 — 창이 받은 규칙은 꺼진 채다
|
||||
const opened = store.filterSet?.rules[1]
|
||||
expect(opened?.isEnabled).toBe(false)
|
||||
reply.finish(fail(503, 'MAIL_001'))
|
||||
await toggling
|
||||
expect(store.filterSet?.rules[1]?.isEnabled).toBe(true)
|
||||
|
||||
await store.saveRule({ ...(opened as MailFilterRule), name: '고친 이름' })
|
||||
expect(puts()[1]?.rules[1]).toMatchObject({ id: 'b', name: '고친 이름', isEnabled: true })
|
||||
})
|
||||
|
||||
it('다시 불러온 뒤의 고치기 저장 — 다시 받은 목록 · version 위에 넣는다. 다른 곳에서 지워졌으면 맨 아래, 그때 50개를 넘으면 보내지 않는다', async () => {
|
||||
let filters: MailFilterSet = SET
|
||||
requests = installMockApi((request) => (request.method === 'GET' ? ok(filters) : saved(request, 'v9')))
|
||||
const store = useMailFilterStore()
|
||||
await store.load()
|
||||
|
||||
filters = { rules: [C, A], version: 'v5', isActive: true }
|
||||
await store.reload()
|
||||
await store.saveRule({ ...B, name: '다른 곳에서 지워진 규칙' })
|
||||
expect(puts()[0]).toMatchObject({ version: 'v5' })
|
||||
expect(ids(puts()[0]?.rules)).toEqual(['c', 'a', 'b'])
|
||||
|
||||
filters = { rules: Array.from({ length: 50 }, (_, index) => rule(`r${index}`)), version: 'v6', isActive: true }
|
||||
await store.reload()
|
||||
await expect(store.saveRule({ ...B, name: '또' })).resolves.toEqual({ ok: false, error: null, full: true })
|
||||
// 있는 규칙을 고치는 것은 50개여도 된다
|
||||
await expect(store.saveRule({ ...rule('r3'), name: '고침' })).resolves.toMatchObject({ ok: true })
|
||||
expect(puts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('보낸 뒤 다시 불러왔으면 늦게 온 409 로 목록 위 줄(conflict)을 다시 켜지 않는다', async () => {
|
||||
const reply = deferred()
|
||||
let gets = 0
|
||||
requests = installMockApi((request) => {
|
||||
if (request.method === 'PUT') return reply.promise
|
||||
gets += 1
|
||||
return ok(gets === 1 ? SET : { ...SET, version: 'v8' })
|
||||
})
|
||||
const store = useMailFilterStore()
|
||||
await store.load()
|
||||
|
||||
const toggling = store.setRuleEnabled('a', false)
|
||||
await store.reload()
|
||||
reply.finish(fail(409, 'MAIL_012'))
|
||||
await toggling
|
||||
|
||||
expect(store.conflict).toBe(false)
|
||||
expect(store.filterSet?.version).toBe('v8')
|
||||
})
|
||||
|
||||
it('화면이 떠 있는 수를 센다 — 같은 내리기를 두 번 불러도 한 번만 뺀다', () => {
|
||||
installMockApi(() => ok(SET))
|
||||
const store = useMailFilterStore()
|
||||
expect(store.pageVisible).toBe(false)
|
||||
|
||||
const detachFirst = store.attachPage()
|
||||
const detachSecond = store.attachPage()
|
||||
detachFirst()
|
||||
detachFirst()
|
||||
expect(store.pageVisible).toBe(true)
|
||||
detachSecond()
|
||||
expect(store.pageVisible).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_FILTER_MAX_RULES } from '@/shared/constants/mail-filter'
|
||||
import type { MailFilterRule, MailFilterSet } from '@/shared/types/mail'
|
||||
import type { MailFilterRestorePoint } from '@/shared/utils/mail-filter'
|
||||
import {
|
||||
withoutRule,
|
||||
withRuleEnabled,
|
||||
withRuleMoved,
|
||||
withRuleRestored,
|
||||
withRuleSaved,
|
||||
} from '@/shared/utils/mail-filter'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LoadStatus } from '@/stores/mail.store'
|
||||
|
||||
// 메일 필터 스토어 — 계약 2절 "메일 필터" (사용자마다 규칙 목록 하나 — 엔진의 Sieve 스크립트에 부재중 응답과 함께 둔다)
|
||||
// - 설정 › 필터는 들어올 때마다 새로 받는다(load) — 다른 탭 · PC 에서 바꿨을 수 있다. 오류는 화면이 직접 그린다.
|
||||
// 읽기 화면 ⋯ 는 50개를 다 만들었는지 알려고, 받아 둔 것이 없을 때(받아 둔 목록이 50개면 열 때마다) 받는다(ensureLoaded — 알리지 않는다)
|
||||
// - 켜기 · 순서 · 지우기 · 고치기 · 되돌리기 · 다시 켜기 모두 PUT 전체 교체다 — 받은(마지막으로 저장한) version 으로 보내고, 응답이 새 목록 · version 이다.
|
||||
// 그래서 저장은 한 번에 하나다(saving) — 저장하는 중에 온 켜기 · 순서 · 저장은 보내지 않는다(화면은 그동안 aria-disabled).
|
||||
// 되돌리기만은 알림의 단추가 한 번뿐이라 누른 차례대로 줄을 세워(restoreQueue) 앞 저장이 끝난 뒤에 보낸다
|
||||
// - 켜기 · 순서 바꾸기는 화면을 먼저 바꾸고 실패하면 되돌린다 (오류 알림은 인터셉터 — 디자인 README "6단계 › 상태")
|
||||
// - 409 MAIL_012(그사이 다른 곳에서 바뀜)는 알리지 않고 conflict 를 켠다 — 화면이 목록 위 줄 + [다시 불러오기]로 알린다.
|
||||
// 보낸 뒤 다시 불러왔으면(순번이 바뀜) 늦게 온 409 로 줄을 다시 켜지 않는다
|
||||
// - 세션이 바뀐 뒤(로그아웃 · 다른 계정 — reset) 늦게 온 응답은 쓰지 않는다: 불러오기는 순번(seq — reset · 저장이 올린다),
|
||||
// 저장은 세션 세대(authStore.generation)로 가린다 (부재중 스토어와 같은 결)
|
||||
|
||||
/**
|
||||
* 저장 결과 — 실패면 그 오류(코드로 가른다). error 가 null 이면 보내지 않았다(저장하는 중 · 받은 목록이 없음 · 세션이 바뀜).
|
||||
* full 이면 50개를 넘어 보내지 않았다 — 되돌리기 · 그사이 다른 곳에서 지워진 규칙의 저장 (화면이 fullNote 로 알린다)
|
||||
*/
|
||||
export type MailFilterSaveOutcome =
|
||||
| { ok: true; filterSet: MailFilterSet }
|
||||
| { ok: false; error: ApiError | null; full?: boolean }
|
||||
|
||||
/** 지우기 결과 — 되돌리기가 지운 규칙을 지운 자리(point — 앞 · 뒤 규칙)에 다시 넣는다 */
|
||||
export type MailFilterRemoveOutcome =
|
||||
| { ok: true; point: MailFilterRestorePoint }
|
||||
| { ok: false; error: ApiError | null }
|
||||
|
||||
const NOT_SENT = { ok: false, error: null } as const
|
||||
const FULL = { ok: false, error: null, full: true } as const
|
||||
|
||||
/** 모든 저장에서 알림을 끄는 코드 — 그사이 다른 곳에서 바뀜(목록 위 줄이 알린다) */
|
||||
const SAVE_SILENT: readonly ErrorCode[] = ['MAIL_012']
|
||||
/** 고치기 창의 [저장]이 칸 안에 알리는 거절 — 형식(VAL_001) · 엔진 한도(MAIL_013) */
|
||||
const DIALOG_SILENT: readonly ErrorCode[] = ['VAL_001', 'MAIL_013']
|
||||
|
||||
export const useMailFilterStore = defineStore('mail-filter', () => {
|
||||
const mailApi = useMail()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
/** 보이는 목록 — 켜기 · 순서는 저장이 끝나기 전에 먼저 바뀐다. 늘 통째로 바꾼다(shallowRef) */
|
||||
const filterSet = shallowRef<MailFilterSet | null>(null)
|
||||
const status = ref<LoadStatus>('idle')
|
||||
const error = ref<ApiError | null>(null)
|
||||
/** 409 MAIL_012 — 다른 곳에서 바뀌어 저장하지 못했다 (목록 위 줄 + [다시 불러오기]) */
|
||||
const conflict = ref<boolean>(false)
|
||||
/** PUT 을 기다리는 중 — 한 번에 하나 */
|
||||
const saving = ref<boolean>(false)
|
||||
|
||||
/** 50개를 다 만들었다 — [새 필터] · 「이런 메일로 필터 만들기」를 끈다 */
|
||||
const isFull = computed<boolean>(() => (filterSet.value?.rules.length ?? 0) >= MAIL_FILTER_MAX_RULES)
|
||||
/**
|
||||
* 설정 › 필터 화면이 떠 있는 수 (attachPage) — 화면 밖에서 [되돌리기]가 그사이 바뀜(409)으로 실패하면
|
||||
* 목록 위 줄을 볼 곳이 없으니 알림으로 알린다 (useMailFilterActions)
|
||||
*/
|
||||
const pageCount = ref<number>(0)
|
||||
const pageVisible = computed<boolean>(() => pageCount.value > 0)
|
||||
|
||||
let seq = 0
|
||||
/** 기다리는 저장 — 되돌리기가 끝나기를 기다린다 (실패해도 거부하지 않는 약속) */
|
||||
let pending: Promise<MailFilterSaveOutcome> | null = null
|
||||
/** 되돌리기 차례 — 알림 여럿의 [되돌리기]를 누른 차례대로 하나씩 보낸다 (거부하지 않는 약속) */
|
||||
let restoreQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
/** 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다 — 화면이 사전 문구와 "다시 시도"를 그린다) */
|
||||
async function load(): Promise<MailFilterSet | null> {
|
||||
seq += 1
|
||||
const current = seq
|
||||
status.value = 'loading'
|
||||
error.value = null
|
||||
try {
|
||||
const result = await mailApi.getFilters({ silent: true })
|
||||
if (current !== seq) return filterSet.value
|
||||
filterSet.value = result
|
||||
status.value = 'ready'
|
||||
conflict.value = false
|
||||
return result
|
||||
} catch (caught) {
|
||||
if (current !== seq) return filterSet.value
|
||||
status.value = 'error'
|
||||
error.value = toApiError(caught)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 읽기 화면 ⋯ — 50개를 다 만들었는지. 아직 받지 않았거나 못 받았으면 받고, 받아 둔 목록이 50개면 다시 받아 본다
|
||||
* (다른 곳에서 지웠으면 「이런 메일로 필터 만들기」가 다시 켜지게 — 알리지 않는다)
|
||||
*/
|
||||
function ensureLoaded(): void {
|
||||
if (status.value === 'idle' || status.value === 'error') void load()
|
||||
else if (status.value === 'ready' && isFull.value) void reload({ silent: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* [다시 불러오기] — 보이는 목록은 둔 채 새로 받는다. 못 받으면 인터셉터가 알린다(목록은 그대로).
|
||||
* silent — 알리지 않고 받아 본다 (읽기 화면 ⋯ 의 다시 보기)
|
||||
*/
|
||||
async function reload(options: { silent?: boolean } = {}): Promise<boolean> {
|
||||
seq += 1
|
||||
const current = seq
|
||||
try {
|
||||
const result = await mailApi.getFilters(options.silent ? { silent: true } : undefined)
|
||||
if (current !== seq) return false
|
||||
filterSet.value = result
|
||||
status.value = 'ready'
|
||||
error.value = null
|
||||
conflict.value = false
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 목록 전체를 보낸다 — 저장하는 중이거나 받은 목록이 없으면 보내지 않는다 */
|
||||
function send(rules: MailFilterRule[], silent: readonly ErrorCode[]): Promise<MailFilterSaveOutcome> {
|
||||
const current = filterSet.value
|
||||
if (saving.value || !current) return Promise.resolve(NOT_SENT)
|
||||
const session = authStore.generation
|
||||
const sentSeq = seq
|
||||
saving.value = true
|
||||
const request: Promise<MailFilterSaveOutcome> = mailApi
|
||||
.saveFilters({ rules, version: current.version }, { silent: [...SAVE_SILENT, ...silent] })
|
||||
.then(
|
||||
(result): MailFilterSaveOutcome => {
|
||||
if (authStore.generation !== session) return NOT_SENT
|
||||
// 그 전에 시작한 불러오기가 늦게 와도 저장한 목록을 덮지 않는다
|
||||
seq += 1
|
||||
filterSet.value = result
|
||||
status.value = 'ready'
|
||||
error.value = null
|
||||
conflict.value = false
|
||||
return { ok: true, filterSet: result }
|
||||
},
|
||||
(caught: unknown): MailFilterSaveOutcome => {
|
||||
if (authStore.generation !== session) return NOT_SENT
|
||||
const apiError = toApiError(caught)
|
||||
// 그사이 다시 불러왔으면(순번이 바뀜) 늦게 온 409 로 목록 위 줄을 다시 켜지 않는다
|
||||
if (apiError.code === 'MAIL_012' && seq === sentSeq) conflict.value = true
|
||||
return { ok: false, error: apiError }
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
if (authStore.generation === session) saving.value = false
|
||||
if (pending === request) pending = null
|
||||
})
|
||||
pending = request
|
||||
return request
|
||||
}
|
||||
|
||||
/** 화면을 먼저 바꾸고 보낸다 — 실패하면(그사이 목록을 다른 것이 바꾸지 않았으면) 되돌린다 */
|
||||
async function sendOptimistic(build: (rules: readonly MailFilterRule[]) => MailFilterRule[] | null): Promise<boolean> {
|
||||
const before = filterSet.value
|
||||
if (saving.value || !before) return false
|
||||
const rules = build(before.rules)
|
||||
if (!rules) return false
|
||||
const optimistic: MailFilterSet = { ...before, rules }
|
||||
filterSet.value = optimistic
|
||||
const outcome = await send(rules, [])
|
||||
if (!outcome.ok && filterSet.value === optimistic) filterSet.value = before
|
||||
return outcome.ok
|
||||
}
|
||||
|
||||
/** 켜기 · 끄기 */
|
||||
function setRuleEnabled(id: string, isEnabled: boolean): Promise<boolean> {
|
||||
return sendOptimistic((rules) => withRuleEnabled(rules, id, isEnabled))
|
||||
}
|
||||
|
||||
/** 순서 바꾸기 — 한 칸 위(-1) · 아래(1). 끝이면 아무것도 하지 않는다 */
|
||||
function moveRule(id: string, offset: -1 | 1): Promise<boolean> {
|
||||
return sendOptimistic((rules) => withRuleMoved(rules, id, offset))
|
||||
}
|
||||
|
||||
/**
|
||||
* 고치기 창의 [저장] — 지금 목록(다시 불러왔으면 그 목록 · version) 위에 넣는다. 칸 안에 알릴 거절은 알림을 끈다.
|
||||
* 있는 규칙은 같은 id 자리에서 바꾸고, 켜짐은 저장하는 순간 목록의 값을 쓴다 — 창은 켜기를 고치지 않는다
|
||||
* (창을 연 뒤 켜기가 실패해 되돌려졌으면 그 값이 맞다). 새 규칙 · 그사이 다른 곳에서 지워진 규칙은 맨 아래 — 50개를 넘으면 보내지 않는다(full)
|
||||
*/
|
||||
function saveRule(rule: MailFilterRule): Promise<MailFilterSaveOutcome> {
|
||||
const current = filterSet.value
|
||||
if (!current) return Promise.resolve(NOT_SENT)
|
||||
const existing = current.rules.find((item) => item.id === rule.id)
|
||||
if (existing) return send(withRuleSaved(current.rules, { ...rule, isEnabled: existing.isEnabled }), DIALOG_SILENT)
|
||||
if (current.rules.length >= MAIL_FILTER_MAX_RULES) return Promise.resolve(FULL)
|
||||
return send(withRuleSaved(current.rules, rule), DIALOG_SILENT)
|
||||
}
|
||||
|
||||
/** 지우기 — 확인 없이 한다 (화면이 알림의 [되돌리기]를 준다) */
|
||||
async function removeRule(id: string): Promise<MailFilterRemoveOutcome> {
|
||||
const current = filterSet.value
|
||||
const removal = current ? withoutRule(current.rules, id) : null
|
||||
if (!removal) return NOT_SENT
|
||||
const outcome = await send(removal.rules, [])
|
||||
return outcome.ok ? { ok: true, point: removal.point } : outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* 되돌리기 — 지운 규칙을 지운 자리(앞 · 뒤 규칙 기준)에 다시 넣어 저장한다(지운 뒤 바꾼 다른 규칙은 그대로). 이미 있으면 그대로 성공이다.
|
||||
* 누른 차례대로 하나씩 보내고(restoreQueue), 다른 저장이 돌고 있으면 끝난 뒤에 보낸다. 그사이 세션이 바뀌었으면 보내지 않고,
|
||||
* 다시 넣으면 50개를 넘을 때도 보내지 않는다(full)
|
||||
*/
|
||||
function restoreRule(point: MailFilterRestorePoint): Promise<MailFilterSaveOutcome> {
|
||||
const session = authStore.generation
|
||||
const run = restoreQueue.then(() => restoreNow(point, session))
|
||||
restoreQueue = run.catch(() => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
async function restoreNow(point: MailFilterRestorePoint, session: number): Promise<MailFilterSaveOutcome> {
|
||||
// 앞 저장이 끝날 때까지 — 기다림을 마친 뒤 보내기(send)까지는 await 가 없어야 그사이 다른 저장이 끼어들지 않는다
|
||||
while (pending) await pending
|
||||
const current = filterSet.value
|
||||
if (!current || authStore.generation !== session) return NOT_SENT
|
||||
const rules = withRuleRestored(current.rules, point)
|
||||
if (!rules) return { ok: true, filterSet: current }
|
||||
if (current.rules.length >= MAIL_FILTER_MAX_RULES) return FULL
|
||||
return send(rules, [])
|
||||
}
|
||||
|
||||
/** [다시 켜기] (isActive: false) — 지금 목록 그대로 저장한다. 저장하면 엔진에서 다시 켜진다 */
|
||||
function reactivate(): Promise<MailFilterSaveOutcome> {
|
||||
const current = filterSet.value
|
||||
if (!current) return Promise.resolve(NOT_SENT)
|
||||
return send([...current.rules], [])
|
||||
}
|
||||
|
||||
/** 설정 › 필터 화면이 떴다 — 돌려받은 함수로 내린다 (두 번 불러도 한 번만 센다) */
|
||||
function attachPage(): () => void {
|
||||
pageCount.value += 1
|
||||
let attached = true
|
||||
return () => {
|
||||
if (!attached) return
|
||||
attached = false
|
||||
pageCount.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
seq += 1
|
||||
pending = null
|
||||
restoreQueue = Promise.resolve()
|
||||
filterSet.value = null
|
||||
status.value = 'idle'
|
||||
error.value = null
|
||||
conflict.value = false
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
// 로그아웃 · 다른 계정 로그인 — 이전 사용자의 필터가 남지 않게
|
||||
watch(
|
||||
() => authStore.user?.email ?? null,
|
||||
() => reset(),
|
||||
)
|
||||
|
||||
return {
|
||||
filterSet,
|
||||
status,
|
||||
error,
|
||||
conflict,
|
||||
saving,
|
||||
isFull,
|
||||
pageVisible,
|
||||
attachPage,
|
||||
load,
|
||||
ensureLoaded,
|
||||
reload,
|
||||
setRuleEnabled,
|
||||
moveRule,
|
||||
saveRule,
|
||||
removeRule,
|
||||
restoreRule,
|
||||
reactivate,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
@@ -80,6 +80,7 @@
|
||||
--color-danger-border: #fbd7d3;
|
||||
--color-warning-surface: #fff7e6;
|
||||
--color-warning-text: #7a5200; /* 임시 저장 "다른 곳에서 바뀐 저장본" 안내 */
|
||||
--color-warning-hover: #fdebc8; /* 노란 줄 안의 단추에 마우스를 올렸을 때 — 메일 필터 [다시 불러오기] · [다시 켜기] (디자인 원본 MailFilterStates) */
|
||||
--color-success-text: #067647; /* 서명 "저장했습니다" (디자인 원본 Settings) */
|
||||
--color-status-live: #12b76a; /* 실시간 연결 "연결되어 있습니다" 점 (디자인 원본 SettingsNotify) */
|
||||
--color-star: #b7791f;
|
||||
@@ -126,6 +127,7 @@
|
||||
--shadow-toast-strong: 0 0.625rem 1.75rem rgba(16, 24, 28, 0.22);
|
||||
--shadow-dialog: 0 0.875rem 2.25rem rgba(16, 24, 28, 0.14);
|
||||
--shadow-segment: 0 0.0625rem 0.125rem rgba(16, 24, 28, 0.14); /* 전환 단추의 고른 칸 (디자인 원본 Settings) */
|
||||
--shadow-switch-knob: 0 0.0625rem 0.125rem rgba(16, 24, 40, 0.24); /* 켜기 스위치의 동그라미 (디자인 원본 SettingsFilter) */
|
||||
--color-backdrop: rgba(11, 54, 64, 0.32);
|
||||
|
||||
/* ── 크기 ───────────────────────────────── */
|
||||
@@ -160,6 +162,15 @@
|
||||
* 목록 위 권하기 줄 높이 40(--size-notice-bar — 새 메일 알림 줄과 같다) · 줄 안의 [알림 켜기] 높이 36 · [✕ 닫기] 36×36
|
||||
*/
|
||||
--size-notice-action: 2.25rem; /* 36px — 알림 권하기 줄 안의 단추 · 밀도 예외 */
|
||||
/*
|
||||
* 6단계(메일 필터) 밀도 예외 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "6단계 — 메일 필터", 사용자 확정 캔버스).
|
||||
* 목록 행의 켜기 스위치 누르는 곳 40×44(--size-icon-button × --size-touch — 그림은 36×20) ·
|
||||
* 목록 행의 ▲ ▼ 32×40(--size-mailbox-more × --size-row-min — 메일함 ⋯ 와 같다) ·
|
||||
* 목록 위 노란 줄 · 고치기 창 안 [다시 불러오기] 높이 36(--size-notice-action) ·
|
||||
* 고치기 창의 조건 줄(어디에서 · 포함 · 찾을 글) · 옮길 메일함 · 전달 주소 칸과 [조건 더하기] 높이 40 · 조건 ✕ 36×40
|
||||
*/
|
||||
--size-filter-control: 2.5rem; /* 40px — 고치기 창의 조건 줄 · 옮길 메일함 · 전달 주소 칸 · [조건 더하기] 높이 · 밀도 예외 */
|
||||
--size-filter-remove: 2.25rem; /* 36px — 조건 ✕ 폭 (높이는 --size-filter-control) · 밀도 예외 */
|
||||
--size-status-dot: 0.5rem; /* 8px — 설정 › 알림의 실시간 연결 점 */
|
||||
--size-option-input: 1.125rem; /* 18px — 설정 화면의 고르기 상자 · 라디오 (줄 전체가 44px 로 눌린다) */
|
||||
--form-max-width: 26.25rem; /* 420px — 비밀번호 칸 (디자인 원본 SettingsAccount) */
|
||||
|
||||
@@ -54,6 +54,12 @@ export function createTestRouter(): Router {
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/mail/filters',
|
||||
name: 'settings-mail-filters',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings/mail/notify',
|
||||
name: 'settings-mail-notify',
|
||||
|
||||
Reference in New Issue
Block a user