generated from ttipo/project-base
feat(frontend): 새 메일 확인·메일 검색·여러 통 선택과 처리 화면
- 화면이 보이는 동안 1분마다 바뀐 것을 묻고 목록·메일함 숫자·탭 제목 "(N)" 을 맞춘다 — 늦게 온 응답은 목록 변경 세대로 버린다 - 상단 검색 칸 → /mail/search (범위 전환, 결과 없음 안내에 엔진 한계) - 표·분할 보기에서 여러 통 고르기와 도구 줄, 이동 메뉴, 휴지통 이동 + 되돌리기, 휴지통에서만 확인 뒤 영구 삭제 - 읽기 화면의 삭제도 DELETE 대신 휴지통 이동 · 확인 뒤 영구 삭제 — 다른 창이 휴지통에 넣은 메일을 확인 없이 지우지 않게 - 되돌리기 알림은 띄운 세션에서만 움직인다 — 메일 id 는 계정마다 겹칠 수 있다 - 메일 스토어를 기능별 setup 모듈로 나눴다 (공개 API 그대로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명)
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명) · MailPurgeDialog(영구 삭제 확인)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary 를 더한다)
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
@@ -168,6 +168,15 @@ onMounted(() => {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 되돌릴 수 없는 일 — 빨강 채움 (영구 삭제, 디자인 원본 BulkSelect) */
|
||||
:slotted(.modal-dialog__action--danger) {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--danger:hover) {
|
||||
background: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
/* 보조 단추 — 바탕 없이 (쓰기 화면의 "취소"와 같은 모양) */
|
||||
:slotted(.modal-dialog__action--secondary) {
|
||||
background: transparent;
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
await router.push(`/mail/inbox/${messageId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, {
|
||||
props: { mailboxId: 'inbox', messageId },
|
||||
props: { messageId },
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
return { wrapper, router }
|
||||
@@ -114,7 +114,7 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
await router.push('/mail/inbox/m2')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, {
|
||||
props: { mailboxId: 'inbox', messageId: 'm2' },
|
||||
props: { messageId: 'm2' },
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
|
||||
@@ -134,4 +134,27 @@ describe('ListReadNav (목록 보기에서 연 메일의 이동)', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(useMailViewStore().takeReturnFocus()).toBe('m2')
|
||||
})
|
||||
|
||||
it('검색 결과에서 연 메일 — 이전/다음은 결과 안의 주소로, "목록으로"는 검색 결과로 돌아간다', async () => {
|
||||
const store = useMailStore()
|
||||
store.listSource = { kind: 'search', q: '회의', scope: 'all' }
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 3 }
|
||||
const query = `q=${encodeURIComponent('회의')}&in=all`
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/search?${query}`)
|
||||
await router.push(`/mail/search/m2?${query}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ListReadNav, { props: { messageId: 'm2' }, global: { plugins: [pinia, router] } })
|
||||
|
||||
expect(wrapper.get('a.tool-button').attributes('href')).toBe(`/mail/search?${query}`)
|
||||
expect(wrapper.get('.nav-position').text()).toBe('2 / 3')
|
||||
|
||||
await wrapper.get('button[aria-label="다음 메일"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search/m3?${query}`)
|
||||
|
||||
await wrapper.get('a.tool-button').trigger('click', { button: 0 })
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?${query}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기에서 연 메일의 이동 도구 — 목록으로 · 이전/다음 · 위치 "n / 전체" (디자인 원본 MainListRead)
|
||||
// 본문 툴바의 앞쪽에 들어간다 (MessageReader 의 toolbar-start 슬롯).
|
||||
// 목록은 메일함 또는 검색 결과다 — 검색 결과에서 연 메일이면 "목록으로"는 결과로, 이전/다음은 결과 안에서 옮긴다.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -9,11 +10,11 @@ import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { listLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
messageId: string
|
||||
}
|
||||
|
||||
@@ -22,17 +23,23 @@ const props = defineProps<Props>()
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { mailboxLocation, backToList, showSibling } = useMailNavigation()
|
||||
const { backToSourceList, showSiblingIn } = useMailNavigation()
|
||||
|
||||
/** 다음 페이지를 불러오는 중 — 같은 버튼을 두 번 눌러도 한 번만 넘어간다 */
|
||||
const loadingNext = ref<boolean>(false)
|
||||
|
||||
const listHref = computed(() => router.resolve(mailboxLocation(props.mailboxId)).href)
|
||||
const listHref = computed(() => {
|
||||
const source = mailStore.listSource
|
||||
return source ? router.resolve(listLocationOf(source)).href : router.resolve({ name: 'mail' }).href
|
||||
})
|
||||
/** 불러온 목록에서의 위치 — 목록 밖의 메일(주소로 바로 연 경우 등)이면 -1 */
|
||||
const index = computed(() => mailStore.messages.findIndex((message) => message.id === props.messageId))
|
||||
/** 전체 — 서버가 준 total, 없으면 메일함 개수(메일함 목록일 때만), 그것도 없으면 불러온 수 */
|
||||
const total = computed(
|
||||
() =>
|
||||
mailStore.pageInfo?.total ?? mailStore.currentMailbox?.totalCount ?? mailStore.messages.length,
|
||||
mailStore.pageInfo?.total ??
|
||||
(mailStore.search ? null : mailStore.currentMailbox?.totalCount) ??
|
||||
mailStore.messages.length,
|
||||
)
|
||||
const position = computed(() =>
|
||||
index.value === -1 ? '' : `${formatCount(index.value + 1)} / ${formatCount(total.value)}`,
|
||||
@@ -46,7 +53,8 @@ const hasNext = computed(
|
||||
|
||||
async function goPrev() {
|
||||
const previous = mailStore.messages[index.value - 1]
|
||||
if (previous) await showSibling(props.mailboxId, previous.id)
|
||||
const source = mailStore.listSource
|
||||
if (previous && source) await showSiblingIn(source, previous.id)
|
||||
}
|
||||
|
||||
async function goNext() {
|
||||
@@ -61,7 +69,8 @@ async function goNext() {
|
||||
}
|
||||
}
|
||||
const next = mailStore.messages[current + 1]
|
||||
if (next) await showSibling(props.mailboxId, next.id)
|
||||
const source = mailStore.listSource
|
||||
if (next && source) await showSiblingIn(source, next.id)
|
||||
}
|
||||
|
||||
/** 목록으로 — 불러온 페이지·스크롤 위치는 그대로, 초점은 보던 메일의 행으로 돌려준다 */
|
||||
@@ -69,8 +78,10 @@ function onBackToList(event: MouseEvent) {
|
||||
// 새 탭·새 창으로 여는 동작(Ctrl·⌘·Shift·가운데 버튼)은 브라우저에 맡긴다
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return
|
||||
event.preventDefault()
|
||||
const source = mailStore.listSource
|
||||
if (!source) return
|
||||
viewStore.setReturnFocus(props.messageId)
|
||||
void backToList(props.mailboxId)
|
||||
void backToSourceList(source)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
// 여러 통 처리 도구 줄의 단추 — 높이 44 · 아이콘 + 이름 (디자인 원본 BulkSelect · ListStates)
|
||||
// 이름은 labelMode 로 정한다: always(늘 보인다) · wide(폭 1100 이상에서만 — 목록 보기) · never(아이콘만 40×44 — 분할 보기의 목록 칸)
|
||||
// 이름을 숨겨도 aria-label · 툴팁(title)으로 읽힌다. data-tool · tabindex(로빙) · aria-haspopup 같은 속성은 단추에 그대로 붙는다.
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
|
||||
interface Props {
|
||||
icon: IconName
|
||||
label: string
|
||||
/** 툴팁 — 주지 않으면 이름 */
|
||||
title?: string
|
||||
labelMode?: 'always' | 'wide' | 'never'
|
||||
/** danger: 삭제 · 영구 삭제 (빨강) */
|
||||
tone?: 'default' | 'danger'
|
||||
/** 메뉴를 여는 단추 — 이름 뒤 ▾ (아이콘만일 때는 빼고) */
|
||||
menu?: boolean
|
||||
disabled?: boolean
|
||||
iconStrokeWidth?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'click', event: MouseEvent): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: undefined,
|
||||
labelMode: 'wide',
|
||||
tone: 'default',
|
||||
menu: false,
|
||||
disabled: false,
|
||||
iconStrokeWidth: 1.75,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
defineExpose({
|
||||
/** 단추 요소 — 메뉴의 바깥 누름 판정 · 초점 되돌리기에 쓴다 */
|
||||
element: buttonRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
:class="['bulk-button', `bulk-button--${tone}`, `bulk-button--label-${labelMode}`, { 'bulk-button--menu': menu }]"
|
||||
:aria-label="label"
|
||||
:title="title ?? label"
|
||||
:disabled="disabled"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="15"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
/>
|
||||
<span class="bulk-button__label">{{ label }}</span>
|
||||
<AppIcon
|
||||
v-if="menu"
|
||||
name="chevron-down"
|
||||
:size="12"
|
||||
:stroke-width="2"
|
||||
class="bulk-button__chevron"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulk-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
min-width: var(--size-icon-button);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--toolbar-button-inline);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bulk-button:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.bulk-button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bulk-button--menu {
|
||||
padding: 0 var(--space-8) 0 var(--toolbar-button-inline);
|
||||
}
|
||||
|
||||
.bulk-button--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.bulk-button--danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
/* 아이콘만 40×44 (분할 보기의 목록 칸) */
|
||||
.bulk-button--label-never {
|
||||
width: var(--size-icon-button);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bulk-button--label-never .bulk-button__label,
|
||||
.bulk-button--label-never .bulk-button__chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 1100px 미만 — 아이콘만 (메뉴 단추는 ▾ 를 둔다 · tokens.css 화면 폭 기준) */
|
||||
@media (max-width: 68.6875rem) {
|
||||
.bulk-button--label-wide .bulk-button__label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
// 이동 ▾ — 옮길 메일함 메뉴 (디자인 원본 BulkSelect "이동 메뉴" · README "3단계 › 여러 통 처리")
|
||||
// 메뉴는 편집기 메뉴(EditorMenu — 항목 높이 40, 메뉴 항목 밀도 예외)를 같이 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에 초점,
|
||||
// 마우스로 열면 메뉴 자체에 초점(EditorPopup). Esc 는 메뉴만 닫고 이 단추로 돌아간다 — EditorPopup 이 Esc 를 막아 목록 칸의 "선택 풀기"까지 가지 않는다.
|
||||
// 받은 속성(data-tool · tabindex — 도구 줄의 로빙)은 단추에 붙인다.
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
|
||||
interface Props {
|
||||
/** 옮길 수 있는 메일함 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 */
|
||||
mailboxes: readonly Mailbox[]
|
||||
labelMode: 'always' | 'wide' | 'never'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'move', mailbox: Mailbox): void
|
||||
}
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const buttonRef = ref<InstanceType<typeof MailBulkButton> | null>(null)
|
||||
const open = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value?.element ?? null)
|
||||
const items = computed<EditorMenuItem[]>(() => props.mailboxes.map((mailbox) => ({ id: mailbox.id, label: mailbox.name })))
|
||||
|
||||
function openMenu(keyboard: boolean) {
|
||||
if (props.disabled || items.value.length === 0) return
|
||||
byKeyboard.value = keyboard
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
// detail 0 — Enter · Space 로 눌렀다
|
||||
openMenu(event.detail === 0)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' || open.value) return
|
||||
event.preventDefault()
|
||||
openMenu(true)
|
||||
}
|
||||
|
||||
function onClose(restoreFocus: boolean) {
|
||||
open.value = false
|
||||
if (restoreFocus) trigger.value?.focus()
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
open.value = false
|
||||
trigger.value?.focus()
|
||||
const target = props.mailboxes.find((mailbox) => mailbox.id === id)
|
||||
if (target) emit('move', target)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bulk-move">
|
||||
<MailBulkButton
|
||||
v-bind="$attrs"
|
||||
ref="buttonRef"
|
||||
icon="folder-move"
|
||||
:label="MAIL_BULK_TEXT.move"
|
||||
:label-mode="labelMode"
|
||||
menu
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<EditorMenu
|
||||
v-if="open"
|
||||
:label="MAIL_BULK_TEXT.moveMenu"
|
||||
:items="items"
|
||||
:trigger="trigger"
|
||||
:focus-content="!byKeyboard"
|
||||
@select="onSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메뉴는 이 단추 바로 아래 — 도구 줄 아래 선 밑에서 연다 (EditorPopup) */
|
||||
.bulk-move {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
// 고르기 상자 — 여러 통 처리 (디자인 원본 BulkSelect · ListStates)
|
||||
// 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크, 일부만 골랐으면(표 머리) – 표시.
|
||||
// 누르는 영역(폭 · 높이)은 쓰는 쪽이 class 로 정한다 — 표의 고르기 칸 32 · 분할 보기 행의 고르기 칸 36 (밀도 예외, tokens.css).
|
||||
// 수정 글쇠(Shift — 범위)는 쓰는 쪽이 판단하도록 click 이벤트를 그대로 넘긴다.
|
||||
interface Props {
|
||||
checked: boolean
|
||||
/** 일부만 골랐다 — 표 머리의 상자 (aria-checked="mixed") */
|
||||
mixed?: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle', event: MouseEvent): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
class="mail-checkbox"
|
||||
:aria-checked="mixed ? 'mixed' : checked"
|
||||
:aria-label="label"
|
||||
@click="emit('toggle', $event)"
|
||||
>
|
||||
<svg
|
||||
class="mail-checkbox__box"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
v-if="!checked && !mixed"
|
||||
class="mail-checkbox__empty"
|
||||
x="1"
|
||||
y="1"
|
||||
width="14"
|
||||
height="14"
|
||||
rx="3.5"
|
||||
/>
|
||||
<template v-else>
|
||||
<rect
|
||||
class="mail-checkbox__fill"
|
||||
x="0.25"
|
||||
y="0.25"
|
||||
width="15.5"
|
||||
height="15.5"
|
||||
rx="4"
|
||||
/>
|
||||
<line
|
||||
v-if="mixed"
|
||||
class="mail-checkbox__mark"
|
||||
x1="4.5"
|
||||
y1="8"
|
||||
x2="11.5"
|
||||
y2="8"
|
||||
/>
|
||||
<polyline
|
||||
v-else
|
||||
class="mail-checkbox__mark"
|
||||
points="4.2 8.3 6.9 10.8 11.8 5.4"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mail-checkbox__box {
|
||||
flex: 0 0 auto;
|
||||
width: var(--size-checkbox);
|
||||
height: var(--size-checkbox);
|
||||
}
|
||||
|
||||
.mail-checkbox__empty {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-border-input);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.mail-checkbox__fill {
|
||||
fill: var(--color-brand);
|
||||
}
|
||||
|
||||
.mail-checkbox__mark {
|
||||
fill: none;
|
||||
stroke: var(--color-on-brand);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageList from './MessageList.vue'
|
||||
|
||||
// 목록 틀 — 새 메일 알림 줄 · Esc (디자인 README "3단계 › 새 메일" · "여러 통 처리")
|
||||
|
||||
const INBOX: Mailbox = {
|
||||
id: 'inbox',
|
||||
name: '받은편지함',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 0,
|
||||
}
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailListFrame (목록 칸의 틀)', () => {
|
||||
let pinia: Pinia
|
||||
let fresh: MessageSummary[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
fresh = []
|
||||
installMockApi(() => ok({ items: fresh, pageInfo: { hasNext: false, nextCursor: null, total: fresh.length } }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountList(messages: MessageSummary[]) {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [INBOX]
|
||||
store.currentMailboxId = 'inbox'
|
||||
store.messages = messages
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageList, { global: { plugins: [pinia, router] }, attachTo: document.body })
|
||||
return { wrapper, store, section: wrapper.get('section.message-list').element as HTMLElement }
|
||||
}
|
||||
|
||||
it('목록을 내려 본 채 새 메일이 위에 생기면 머리 아래에 "새 메일 N통 · 맨 위로" — 누르면 맨 위로 가고 사라진다', async () => {
|
||||
const old = [summary('m1'), summary('m2'), summary('m3')]
|
||||
const { wrapper, store, section } = await mountList(old)
|
||||
section.scrollTop = 240
|
||||
fresh = [summary('new1', { isRead: false }), summary('new2', { isRead: false }), ...old]
|
||||
|
||||
await store.syncVisibleList()
|
||||
await flushPromises()
|
||||
|
||||
const notice = wrapper.get('.new-mail-notice')
|
||||
expect(notice.text()).toBe('새 메일 2통 · 맨 위로')
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(5)
|
||||
expect(section.scrollTop).toBe(240)
|
||||
|
||||
await notice.trigger('click')
|
||||
expect(section.scrollTop).toBe(0)
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.findAll('.message-row__main')[0]?.element)
|
||||
})
|
||||
|
||||
it('맨 위를 보고 있으면 알림 줄 없이 새 메일이 그냥 위에 생긴다', async () => {
|
||||
const old = [summary('m1')]
|
||||
const { wrapper, store } = await mountList(old)
|
||||
fresh = [summary('new1', { isRead: false }), ...old]
|
||||
|
||||
await store.syncVisibleList()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.message-row')[0]?.classes()).toContain('is-unread')
|
||||
})
|
||||
|
||||
it('알림 줄은 스스로 맨 위로 올라가도 사라지고, 다른 메일함으로 바꿔도 사라진다', async () => {
|
||||
const old = [summary('m1')]
|
||||
const { wrapper, store, section } = await mountList(old)
|
||||
section.scrollTop = 300
|
||||
fresh = [summary('new1'), ...old]
|
||||
await store.syncVisibleList()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(true)
|
||||
|
||||
section.scrollTop = 0
|
||||
section.dispatchEvent(new Event('scroll'))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
|
||||
section.scrollTop = 300
|
||||
fresh = [summary('new2'), summary('new1'), ...old]
|
||||
await store.syncVisibleList()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.new-mail-notice').text()).toBe('새 메일 1통 · 맨 위로')
|
||||
|
||||
await store.openMailbox('sent')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.new-mail-notice').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('Esc 는 고른 것을 푼다 — 도구 줄에 있던 초점은 목록 칸으로 옮긴다', async () => {
|
||||
const { wrapper, store, section } = await mountList([summary('m1'), summary('m2')])
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const clear = wrapper.get('[data-tool="markRead"]')
|
||||
;(clear.element as HTMLElement).focus()
|
||||
|
||||
await clear.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
|
||||
expect(store.checkedCount).toBe(0)
|
||||
expect(document.activeElement).toBe(section)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 칸의 틀 — 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다 (디자인 원본 Main · MainList · Search · BulkSelect · ListStates)
|
||||
// - 머리 묶음(스크롤해도 위에 남는다): 메일함 머리 · 검색 결과 머리 + 범위 줄 — 하나라도 고르면 머리 자리가 도구 줄로 바뀐다 —
|
||||
// 그 아래 "새 메일 N통 · 맨 위로"(useListArrivalNotice)
|
||||
// - 상태: 불러오는 중(뼈대) · 못 불러옴 · 행(slot) + 메일 더 보기 · 빈 메일함 · 검색 결과 없음
|
||||
// - Esc 는 고른 것을 푼다 — 이 칸 안의 키만 받는다 (전역 단축키가 아니다 · 메뉴 · 대화상자 안의 Esc 는 그쪽 것이다)
|
||||
// - 목록 보기는 메일을 열면 이 칸이 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살리고 보던 메일로 초점을 돌려준다
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailNewNotice from '@/components/mail/MailNewNotice.vue'
|
||||
import MailSearchEmpty from '@/components/mail/MailSearchEmpty.vue'
|
||||
import MailSearchHeader from '@/components/mail/MailSearchHeader.vue'
|
||||
import MailSearchScope from '@/components/mail/MailSearchScope.vue'
|
||||
import MailSelectionToolbar from '@/components/mail/MailSelectionToolbar.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useListArrivalNotice } from '@/composables/useListArrivalNotice'
|
||||
import { useListPositionMemory } from '@/composables/useListPositionMemory'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_TEXT, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
variant: 'split' | 'list'
|
||||
/** 목록 칸 제목의 id — 머리 · 검색 결과 머리 · 도구 줄 가운데 보이는 것이 이 id 를 단다 */
|
||||
titleId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { replaceWithMailbox } = useMailNavigation()
|
||||
const { setScope } = useMailSearchNavigation()
|
||||
|
||||
const sectionRef = ref<HTMLElement | null>(null)
|
||||
const topRef = ref<HTMLElement | null>(null)
|
||||
const { noticeCount, goTop } = useListArrivalNotice(sectionRef, topRef)
|
||||
// 분할 보기의 목록 칸은 메일을 열어도 사라지지 않는다 — 목록 보기의 표만 위치를 기억한다
|
||||
useListPositionMemory(sectionRef, () => props.variant === 'list')
|
||||
|
||||
const search = computed(() => mailStore.search)
|
||||
const selecting = computed(() => mailStore.checkedCount > 0)
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
/** 없는 주소(RES_001)일 때 — 메일함은 받은편지함으로, 없는 메일함 안의 검색은 전체 메일함에서 */
|
||||
const leaveLabel = computed(() => (search.value ? MAIL_SEARCH_TEXT.searchAll : '받은편지함으로'))
|
||||
|
||||
function retry() {
|
||||
const current = search.value
|
||||
if (current) void mailStore.openSearch(current.q, current.scope, { force: true })
|
||||
else if (mailStore.currentMailboxId) void mailStore.openMailbox(mailStore.currentMailboxId)
|
||||
}
|
||||
|
||||
function leave() {
|
||||
if (search.value) void setScope(SEARCH_SCOPE_ALL)
|
||||
else void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
}
|
||||
|
||||
/** 초점이 사라진 도구 줄 대신 목록 칸에 둔다 — 키보드 사용자가 자리를 잃지 않게 */
|
||||
function focusSection() {
|
||||
sectionRef.value?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || !selecting.value || event.defaultPrevented || isImeComposing(event)) return
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('dialog, [data-editor-popup]')) return
|
||||
event.preventDefault()
|
||||
mailStore.clearChecked()
|
||||
}
|
||||
|
||||
// 고른 것을 다 풀면 도구 줄이 사라진다 — 초점이 그 안에 있었으면(갈 곳을 잃으면) 목록 칸으로
|
||||
watch(
|
||||
() => mailStore.checkedCount,
|
||||
(count, previous) => {
|
||||
if (count > 0 || previous === 0) return
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body) focusSection()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
:class="['mail-list-frame', `mail-list-frame--${variant}`]"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-busy="busy"
|
||||
tabindex="-1"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<div
|
||||
ref="topRef"
|
||||
class="mail-list-frame__top"
|
||||
>
|
||||
<MailSelectionToolbar
|
||||
v-if="selecting"
|
||||
:title-id="titleId"
|
||||
:variant="variant"
|
||||
/>
|
||||
<MailSearchHeader
|
||||
v-else-if="search"
|
||||
:title-id="titleId"
|
||||
:variant="variant"
|
||||
/>
|
||||
<MailListHeader
|
||||
v-else
|
||||
:title-id="titleId"
|
||||
:variant="variant"
|
||||
/>
|
||||
<MailSearchScope v-if="search" />
|
||||
<div
|
||||
class="mail-list-frame__notice"
|
||||
role="status"
|
||||
>
|
||||
<MailNewNotice
|
||||
v-if="noticeCount > 0"
|
||||
:count="noticeCount"
|
||||
@go-top="goTop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageListSkeleton
|
||||
v-if="mailStore.listStatus === 'loading'"
|
||||
:label="search ? MAIL_SEARCH_TEXT.loading : undefined"
|
||||
/>
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
:leave-label="leaveLabel"
|
||||
@retry="retry"
|
||||
@leave="leave"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<slot />
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
:class="['mail-list-frame__more', `mail-list-frame__more--${variant}`]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="mail-list-frame__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MailSearchEmpty
|
||||
v-else-if="mailStore.listStatus === 'ready' && search"
|
||||
:query="search.q"
|
||||
:offer-all="search.scope !== SEARCH_SCOPE_ALL"
|
||||
@search-all="setScope(SEARCH_SCOPE_ALL)"
|
||||
/>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
:class="{ 'mail-list-frame__empty': variant === 'list' }"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-list-frame {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 프로그램이 준 초점(도구 줄이 사라진 뒤)에는 고리를 그리지 않는다 — 칸 전체에 고리가 생기면 어지럽다 */
|
||||
.mail-list-frame:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 머리 묶음 — 스크롤해도 위에 남는다 (넓은 화면은 칸, 좁은 화면은 창 기준) */
|
||||
.mail-list-frame__top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.mail-list-frame__more--split {
|
||||
padding: var(--space-8) var(--space-12) var(--space-16);
|
||||
}
|
||||
|
||||
.mail-list-frame__more--list {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-10) var(--space-20) var(--space-20);
|
||||
}
|
||||
|
||||
.mail-list-frame__more-button {
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-list-frame__more--split .mail-list-frame__more-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mail-list-frame__more--list .mail-list-frame__more-button {
|
||||
padding: 0 var(--space-24);
|
||||
}
|
||||
|
||||
.mail-list-frame__more-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.mail-list-frame__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-list-frame__empty {
|
||||
padding-block: calc(var(--space-80) + var(--space-16));
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.mail-list-frame {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · 보기 전환 · 새로고침
|
||||
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
|
||||
// 스크롤해도 위에 남는 것은 목록 틀(MailListFrame)의 머리 묶음이 맡는다 — 메일을 고르면 이 자리가 도구 줄로 바뀐다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -54,9 +55,6 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
|
||||
|
||||
<style scoped>
|
||||
.list-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
// "새 메일 N통 · 맨 위로" — 목록을 내려 본 채 새 메일이 위에 생겼을 때 머리 아래에 (디자인 원본 ListStates · README "3단계 › 새 메일")
|
||||
// 높이 40(밀도 예외) · 바탕 #F3F8F8 · #0A5F67 굵게 · 위 화살표. 누르면 맨 위로 가고 줄은 사라진다 (목록 틀이 처리한다)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { NEW_MAIL_NOTICE_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
count: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'go-top'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="new-mail-notice"
|
||||
@click="emit('go-top')"
|
||||
>
|
||||
<AppIcon
|
||||
name="arrow-up"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ NEW_MAIL_NOTICE_TEXT(formatCount(count)) }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.new-mail-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
width: 100%;
|
||||
min-height: var(--size-notice-bar);
|
||||
padding: 0 var(--space-12);
|
||||
border: 0;
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.new-mail-notice:hover {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.new-mail-notice:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
// 영구 삭제 확인 — 휴지통에서만 (디자인 원본 BulkSelect · ListStates "휴지통 · 영구 삭제 확인")
|
||||
// 제목 "메일 N통을 영구 삭제할까요?" · 글 "휴지통에서 지운 메일은 되돌릴 수 없습니다." · [영구 삭제](빨강 채움) [취소]
|
||||
// 열리면 「취소」에 초점이 간다 — 실수로 Enter · Space 를 눌러도 지워지지 않게. Esc 는 취소와 같다.
|
||||
// 쓰는 곳: 여러 통 처리 도구 줄 · 읽기 화면(한 통) — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
count: number
|
||||
/** 제목 · 글의 id 앞부분 — 화면에 하나뿐인 값 */
|
||||
idPrefix: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="MAIL_BULK_TEXT.purgeTitle(formatCount(count))"
|
||||
:message="MAIL_BULK_TEXT.purgeMessage"
|
||||
:title-id="`${idPrefix}-title`"
|
||||
:message-id="`${idPrefix}-message`"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
data-purge-confirm
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ MAIL_BULK_TEXT.purgeConfirm }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
data-purge-cancel
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAIL_BULK_TEXT.purgeCancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailSearchBox from './MailSearchBox.vue'
|
||||
|
||||
// 상단 검색 칸 (디자인 README "3단계 › 검색" · 계약 2절 "검색 › 클라이언트")
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 1, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [mailbox('inbox', 'inbox', '받은편지함'), mailbox('trash', 'trash', '휴지통')]
|
||||
const Q = encodeURIComponent('회의')
|
||||
|
||||
describe('MailSearchBox (메일 검색)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailSearchBox, { global: { plugins: [pinia, router] } })
|
||||
return { wrapper, router, input: wrapper.get('input') }
|
||||
}
|
||||
|
||||
it('이름 · 안내는 "메일 검색" — 검색 중이 아니면 ✕ 가 없다', async () => {
|
||||
const { wrapper, input } = await mountAt('/mail/inbox')
|
||||
|
||||
expect(input.attributes('aria-label')).toBe('메일 검색')
|
||||
expect(input.attributes('placeholder')).toBe('메일 검색')
|
||||
expect(wrapper.get('form').attributes('role')).toBe('search')
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
expect(wrapper.get('form').classes()).not.toContain('is-active')
|
||||
})
|
||||
|
||||
it('치는 동안에는 찾지 않고, Enter 로 보던 메일함에서 찾는다 (기록을 쌓는다 — 뒤로 가기로 메일함)', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox/m7')
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
await input.setValue(' 회의 ')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m7')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(push).toHaveBeenCalled()
|
||||
expect(useMailStore().searchOrigin).toEqual({ mailboxId: 'inbox', location: '/mail/inbox/m7' })
|
||||
})
|
||||
|
||||
it('휴지통에서 시작하면 그 안에서 찾는다', async () => {
|
||||
const { router, input } = await mountAt('/mail/trash')
|
||||
|
||||
await input.setValue('회의')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=trash`)
|
||||
})
|
||||
|
||||
it('한글 조합 중의 Enter 는 글자 확정이다 — 찾지 않는다 (isComposing · Safari 의 keyCode 229)', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox')
|
||||
await input.setValue('회의')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter', isComposing: true })
|
||||
const safari = new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, cancelable: true })
|
||||
input.element.dispatchEvent(safari)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(safari.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('낱말이 8개를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 친 글자는 그대로', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox')
|
||||
const words = '하나 둘 셋 넷 다섯 여섯 일곱 여덟 아홉'
|
||||
await input.setValue(words)
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
|
||||
expect((input.element as HTMLInputElement).value).toBe(words)
|
||||
})
|
||||
|
||||
it('빈 검색어의 Enter 는 아무것도 하지 않는다', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox')
|
||||
await input.setValue(' ')
|
||||
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('검색 화면 — 칸에 검색어를 보이고 테두리가 강조색, 끝에 ✕("검색어 지우기") — ✕ 는 검색 전 메일함으로', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox/m7' })
|
||||
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
|
||||
expect((input.element as HTMLInputElement).value).toBe('회의')
|
||||
expect(wrapper.get('form').classes()).toContain('is-active')
|
||||
const clear = wrapper.get('button')
|
||||
expect(clear.attributes('aria-label')).toBe('검색어 지우기')
|
||||
expect(clear.attributes('title')).toBe('검색어 지우기 (Esc)')
|
||||
|
||||
await clear.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m7')
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('검색 화면의 Esc 는 ✕ 와 같다 — 시작한 곳을 모르면(주소로 바로 들어옴) 그 범위의 메일함으로', async () => {
|
||||
const { router, input } = await mountAt(`/mail/search?q=${Q}&in=trash`)
|
||||
|
||||
await input.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/trash')
|
||||
})
|
||||
|
||||
it('검색 화면이 아니면 Esc 는 친 글자만 지운다', async () => {
|
||||
const { router, input } = await mountAt('/mail/inbox')
|
||||
await input.setValue('회의')
|
||||
|
||||
await input.trigger('keydown', { key: 'Escape' })
|
||||
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('검색 화면에서 새로 찾으면 지금 범위 그대로 바꿔 끼운다 (기록을 쌓지 않는다)', async () => {
|
||||
const { router, input } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
await input.setValue('일정')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${encodeURIComponent('일정')}&in=all`)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 검색 칸 — 메일 화면에서는 "메일 검색" (디자인 원본 Search · BulkSelect 머리 · README "3단계 › 검색")
|
||||
// - Enter 로 찾는다. 치는 동안에는 찾지 않는다 — 한글 조합 중의 Enter 는 글자 확정이다(isImeComposing, Safari · macOS 의 keyCode 229 포함)
|
||||
// - 검색 중(검색 화면)에는 테두리가 강조색(1px 테두리 + 1px 그림자)이고 끝에 ✕(40×38 · "검색어 지우기" — 밀도 예외).
|
||||
// ✕ · Esc 는 검색을 닫고 검색 전 메일함으로 돌아간다. 검색 화면이 아니면 Esc 는 친 글자만 지운다
|
||||
// - 검색 화면에서는 칸에 지금 검색어를 보인다 (뒤로 가기로 결과에 돌아와도)
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
|
||||
const { isSearching, routeSearch, startSearch, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
const text = ref<string>('')
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (isImeComposing(event)) return
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void startSearch(text.value)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Escape') return
|
||||
if (isSearching.value) {
|
||||
event.preventDefault()
|
||||
void closeSearch()
|
||||
} else if (text.value !== '') {
|
||||
event.preventDefault()
|
||||
text.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 화면에서는 주소의 검색어를 보이고, 검색을 닫으면(검색 화면을 떠나면) 비운다
|
||||
watch(
|
||||
() => routeSearch.value?.q ?? null,
|
||||
(query, previous) => {
|
||||
if (query !== null) text.value = query
|
||||
else if (previous) text.value = ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
role="search"
|
||||
:class="['mail-search', { 'is-active': isSearching }]"
|
||||
@submit.prevent
|
||||
>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="mail-search__icon"
|
||||
/>
|
||||
<input
|
||||
v-model="text"
|
||||
type="search"
|
||||
class="mail-search__input"
|
||||
:aria-label="MAIL_SEARCH_TEXT.box"
|
||||
:placeholder="MAIL_SEARCH_TEXT.box"
|
||||
:maxlength="MAIL_SEARCH_MAX_LENGTH"
|
||||
autocomplete="off"
|
||||
enterkeyhint="search"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<button
|
||||
v-if="isSearching"
|
||||
type="button"
|
||||
class="mail-search__clear"
|
||||
:aria-label="MAIL_SEARCH_TEXT.clear"
|
||||
:title="MAIL_SEARCH_TEXT.clearTitle"
|
||||
@click="closeSearch"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-search {
|
||||
flex: 1 1 17.5rem;
|
||||
max-width: 32.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
height: var(--size-search-box);
|
||||
margin: 0;
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 검색 중 · 칸에 초점 — 강조색 1px 테두리 + 1px 그림자 (입력칸 자체의 고리 대신 칸 테두리로 초점을 보인다) */
|
||||
.mail-search.is-active,
|
||||
.mail-search:focus-within {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
|
||||
}
|
||||
|
||||
/* 끝의 ✕ 가 칸 오른쪽 끝까지 닿는다 */
|
||||
.mail-search.is-active {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.mail-search__icon {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-search__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-search__input:focus,
|
||||
.mail-search__input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 브라우저의 지우기 단추 대신 ✕ 하나만 */
|
||||
.mail-search__input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* 40×38 — 칸(40) 안쪽 높이를 채운다 · 밀도 예외 (tokens.css) */
|
||||
.mail-search__clear {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-search-clear-width);
|
||||
height: var(--size-search-clear-height);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0 calc(var(--radius-md) - var(--border-width)) calc(var(--radius-md) - var(--border-width)) 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-search__clear:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-search__clear:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 결과 없음 (디자인 원본 Search "결과 없음") — 검색 아이콘 · "‘…’에 맞는 메일이 없습니다" · 찾는 방법의 한계 한 줄
|
||||
// 한 메일함에서 찾았으면 [전체 메일함에서 찾기]
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
|
||||
interface Props {
|
||||
query: string
|
||||
/** 한 메일함에서 찾았다 — 전체 메일함에서 찾기를 권한다 */
|
||||
offerAll: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'search-all'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StateMessage
|
||||
class="search-empty"
|
||||
icon="search"
|
||||
:title="MAIL_SEARCH_TEXT.emptyTitle(query)"
|
||||
:hint="MAIL_SEARCH_TEXT.emptyHint"
|
||||
>
|
||||
<button
|
||||
v-if="offerAll"
|
||||
type="button"
|
||||
class="search-empty__all"
|
||||
@click="emit('search-all')"
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.searchAll }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-empty {
|
||||
padding: var(--space-72) var(--space-28);
|
||||
}
|
||||
|
||||
/* 한계 안내는 좁게 두 줄로 (디자인 원본 300px) */
|
||||
.search-empty :deep(.state-message__hint) {
|
||||
max-width: 18.75rem;
|
||||
}
|
||||
|
||||
.search-empty__all {
|
||||
margin-top: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-empty__all:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 결과 머리(52) — [←] 검색 닫기(40×44) · "검색 결과"(15px 굵게) · "‘회의’ · 6통"(12px 흐린 글자) · 보기 전환
|
||||
// 디자인 원본 Search(분할 보기) · ListStates(목록 보기 표). 안쪽 여백만 보기마다 다르다.
|
||||
// 검색 닫기는 검색 전 메일함으로 돌아간다 — 툴팁에 돌아갈 메일함 이름("검색 닫기 — 받은편지함으로")
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { directionParticle } from '@/shared/utils/korean'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** 제목(h1)의 id — 목록 칸이 aria-labelledby 로 가리킨다 */
|
||||
titleId: string
|
||||
variant?: 'split' | 'list'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
variant: 'split',
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { originMailbox, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
const countText = computed(() => {
|
||||
const query = mailStore.search?.q
|
||||
if (!query) return ''
|
||||
if (mailStore.listStatus === 'loading') return MAIL_SEARCH_TEXT.searching(query)
|
||||
if (mailStore.listStatus !== 'ready') return MAIL_SEARCH_TEXT.quoted(query)
|
||||
const total = mailStore.pageInfo?.total ?? mailStore.messages.length
|
||||
return MAIL_SEARCH_TEXT.count(query, formatCount(total))
|
||||
})
|
||||
|
||||
const closeTitle = computed(() => {
|
||||
const name = originMailbox.value?.name
|
||||
return name ? MAIL_SEARCH_TEXT.closeTitle(`${name}${directionParticle(name)}`) : MAIL_SEARCH_TEXT.close
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['search-header', `search-header--${variant}`]">
|
||||
<button
|
||||
type="button"
|
||||
class="search-header__close"
|
||||
:aria-label="MAIL_SEARCH_TEXT.close"
|
||||
:title="closeTitle"
|
||||
@click="closeSearch"
|
||||
>
|
||||
<AppIcon
|
||||
name="arrow-left"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="search-header__title"
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.title }}
|
||||
</h1>
|
||||
<span class="search-header__count">{{ countText }}</span>
|
||||
<ViewModeSwitch class="search-header__switch" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 목록 보기 — 오른쪽 여백을 표 머리와 맞춘다 (디자인 원본 ListStates) */
|
||||
.search-header--list {
|
||||
padding: var(--space-4) var(--space-8);
|
||||
}
|
||||
|
||||
.search-header__close {
|
||||
flex: 0 0 auto;
|
||||
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-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.search-header__close:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-header__title {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.search-header__count {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.search-header__switch {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
// 검색 범위 줄(48 · 바탕 #FBFBFC) — "찾을 곳" + [그 메일함 | 전체 메일함] + 전체일 때 "휴지통 · 스팸메일함은 빼고 찾습니다"
|
||||
// 디자인 원본 Search · README "3단계 › 검색". 전환 단추는 설정 화면과 같은 .segmented(36 — 밀도 예외, base.css)
|
||||
// "그 메일함"은 검색을 시작한 메일함이다 (휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다) — 바꾸면 기록을 쌓지 않고 다시 찾는다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { MAIL_SEARCH_TEXT, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { originMailbox, setScope } = useMailSearchNavigation()
|
||||
|
||||
const isAll = computed(() => mailStore.search?.scope === SEARCH_SCOPE_ALL)
|
||||
/** 그 메일함 — 한 메일함에서 찾는 중이면 그 메일함, 전체에서 찾는 중이면 검색을 시작한 메일함 */
|
||||
const boxMailbox = computed<Mailbox | null>(() => (isAll.value ? originMailbox.value : mailStore.currentMailbox))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-scope">
|
||||
<span
|
||||
id="search-scope-label"
|
||||
class="search-scope__label"
|
||||
>{{ MAIL_SEARCH_TEXT.scopeLabel }}</span>
|
||||
<div
|
||||
class="segmented"
|
||||
role="group"
|
||||
aria-labelledby="search-scope-label"
|
||||
>
|
||||
<button
|
||||
v-if="boxMailbox"
|
||||
type="button"
|
||||
class="segmented__item"
|
||||
:aria-pressed="!isAll"
|
||||
@click="setScope(boxMailbox.id)"
|
||||
>
|
||||
{{ boxMailbox.name }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="segmented__item"
|
||||
:aria-pressed="isAll"
|
||||
@click="setScope(SEARCH_SCOPE_ALL)"
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.scopeAll }}
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
v-if="isAll"
|
||||
class="search-scope__hint"
|
||||
>{{ MAIL_SEARCH_TEXT.scopeAllHint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-scope {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-6) var(--space-10);
|
||||
min-height: var(--space-48);
|
||||
padding: var(--space-4) var(--space-12) var(--space-4) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.search-scope__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.search-scope__hint {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,379 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageSummary, MessagesPatch } 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 MessageList from './MessageList.vue'
|
||||
import MessageTable from './MessageTable.vue'
|
||||
|
||||
// 여러 통 처리 — 도구 줄 · 이동 메뉴 · 알림 · 되돌리기 · 영구 삭제 확인 (디자인 원본 BulkSelect · ListStates)
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [
|
||||
mailbox('inbox', 'inbox', '받은편지함'),
|
||||
mailbox('sent', 'sent', '보낸편지함'),
|
||||
mailbox('drafts', 'drafts', '임시보관함'),
|
||||
mailbox('junk', 'junk', '스팸메일함'),
|
||||
mailbox('trash', 'trash', '휴지통'),
|
||||
mailbox('archive', 'archive', '보관함'),
|
||||
]
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
type Handler = (request: MockRequest) => MockResult | Promise<MockResult> | undefined
|
||||
|
||||
describe('여러 통 처리 도구 줄', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountView(
|
||||
options: { view?: 'split' | 'list'; boxId?: string; messages?: MessageSummary[]; path?: string; handler?: Handler } = {},
|
||||
) {
|
||||
const boxId = options.boxId ?? 'inbox'
|
||||
const messages = options.messages ?? [summary('m1'), summary('m2'), summary('m3')]
|
||||
requests = installMockApi((request) => {
|
||||
const custom = options.handler?.(request)
|
||||
if (custom) return custom
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 6 } })
|
||||
if (request.url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
if (request.url === '/messages' && request.method === 'GET') {
|
||||
return ok({ items: messages, pageInfo: { hasNext: false, nextCursor: null, total: messages.length } })
|
||||
}
|
||||
if (request.method === 'PATCH') {
|
||||
const patch = request.data as MessagesPatch
|
||||
return ok({ updatedIds: patch.ids, failedIds: [] })
|
||||
}
|
||||
return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES
|
||||
store.currentMailboxId = boxId
|
||||
store.messages = messages
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(options.path ?? `/mail/${boxId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(options.view === 'split' ? MessageList : MessageTable, {
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const bulkPatches = () =>
|
||||
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
|
||||
|
||||
it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.bulk-bar__count').text()).toBe('2통 선택됨')
|
||||
const labels = wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))
|
||||
expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '삭제', '선택 해제'])
|
||||
expect(wrapper.get('[data-tool="clear"]').attributes('title')).toBe('선택 해제 (Esc)')
|
||||
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--list')
|
||||
expect(wrapper.get('[data-tool="markRead"]').text()).toBe('읽음 표시')
|
||||
})
|
||||
|
||||
it('읽음 · 안 읽음 · 중요 표시 — PATCH /messages 한 번, 목록이 바로 바뀌고 알리지 않는다 · 모두 중요면 "중요 해제"', async () => {
|
||||
const { wrapper, store } = await mountView({
|
||||
messages: [summary('m1', { isRead: false }), summary('m2', { isRead: false }), summary('m3')],
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="markRead"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(bulkPatches()).toEqual([{ ids: ['m1', 'm2'], isRead: true }])
|
||||
expect(wrapper.findAll('tbody tr.is-unread')).toHaveLength(0)
|
||||
|
||||
await wrapper.get('[data-tool="flag"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm2'], isFlagged: true })
|
||||
expect(wrapper.get('[data-tool="flag"]').attributes('aria-label')).toBe('중요 해제')
|
||||
expect(toastTexts()).toEqual([])
|
||||
})
|
||||
|
||||
it('이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함, 아래 화살표로 열면 첫 항목에 초점 · Esc 는 메뉴만 닫는다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const move = wrapper.get('[data-tool="move"]')
|
||||
|
||||
await move.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
|
||||
expect(wrapper.get('[role="menu"]').attributes('aria-label')).toBe('옮길 메일함')
|
||||
expect(items.map((item) => item.text())).toEqual(['보낸편지함', '스팸메일함', '보관함'])
|
||||
expect(document.activeElement).toBe(items[0]?.element)
|
||||
expect(move.attributes('aria-expanded')).toBe('true')
|
||||
|
||||
await items[0]?.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-tool="move"]').element)
|
||||
expect(store.checkedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('옮기면 옮긴 메일만 빼고 "메일 N통을 보관함으로 옮겼습니다." + [되돌리기] — 되돌리면 원래 메일함으로 옮기고 알린다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m3')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="move"]').trigger('click')
|
||||
await flushPromises()
|
||||
const archive = wrapper.findAll('[role="menuitem"]').find((item) => item.text() === '보관함')
|
||||
await archive?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['m1', 'm3'], mailboxId: 'archive' }])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(store.checkedCount).toBe(0)
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 2통을 보관함으로 옮겼습니다.')
|
||||
expect(toast?.action?.label).toBe('되돌리기')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm3'], mailboxId: 'inbox' })
|
||||
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('일부만 옮겼으면 알림 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다."', async () => {
|
||||
const { wrapper, store } = await mountView({
|
||||
handler: (request) =>
|
||||
request.method === 'PATCH' ? ok({ updatedIds: ['m1', 'm2'], failedIds: ['m3'] }) : undefined,
|
||||
})
|
||||
store.toggleCheckAll()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="move"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('[role="menuitem"]').find((item) => item.text() === '보낸편지함')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toastTexts()).toEqual([
|
||||
'메일 2통을 보낸편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
])
|
||||
})
|
||||
|
||||
it('휴지통 밖의 삭제는 휴지통으로 옮긴다 — DELETE 가 아니라 PATCH { mailboxId: 휴지통 }', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['m2'], mailboxId: 'trash' }])
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
expect(toastTexts()).toEqual(['메일 1통을 휴지통으로 옮겼습니다.'])
|
||||
})
|
||||
|
||||
it('요청이 실패하면 인터셉터 알림만 — 고른 것을 그대로 둔다', async () => {
|
||||
const { wrapper, store } = await mountView({
|
||||
handler: (request) => (request.method === 'PATCH' ? fail(503, 'MAIL_001') : undefined),
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.messages).toHaveLength(3)
|
||||
expect(store.checkedCount).toBe(1)
|
||||
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
|
||||
})
|
||||
|
||||
describe('휴지통 — 영구 삭제 확인', () => {
|
||||
const trashed = () => [summary('t1', { mailboxIds: ['trash'] }), summary('t2', { mailboxIds: ['trash'] })]
|
||||
|
||||
it('"영구 삭제"를 누르면 먼저 묻는다 — 제목 · 글, 처음 초점은 「취소」 · 취소하면 지우지 않는다', async () => {
|
||||
const { wrapper, store } = await mountView({ boxId: 'trash', messages: trashed() })
|
||||
store.toggleCheckAll()
|
||||
await flushPromises()
|
||||
const purge = wrapper.get('[data-tool="delete"]')
|
||||
expect(purge.attributes('aria-label')).toBe('영구 삭제')
|
||||
|
||||
await purge.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.get('dialog')
|
||||
expect(dialog.attributes('open')).toBeDefined()
|
||||
expect(dialog.get('#bulk-purge-title').text()).toBe('메일 2통을 영구 삭제할까요?')
|
||||
expect(dialog.get('#bulk-purge-message').text()).toBe('휴지통에서 지운 메일은 되돌릴 수 없습니다.')
|
||||
expect(document.activeElement).toBe(dialog.get('[data-purge-cancel]').element)
|
||||
|
||||
// 대화상자 안의 Esc 는 고른 것을 풀지 않는다 (목록 칸의 Esc 와 겹치지 않게)
|
||||
await dialog.get('[data-purge-cancel]').trigger('keydown', { key: 'Escape' })
|
||||
await dialog.get('[data-purge-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('dialog').attributes('open')).toBeUndefined()
|
||||
expect(store.checkedCount).toBe(2)
|
||||
expect(requests.some((request) => request.url === '/message-deletions')).toBe(false)
|
||||
})
|
||||
|
||||
it('「영구 삭제」를 누르면 POST /message-deletions — 지운 메일을 빼고 "메일 N통을 영구 삭제했습니다."', async () => {
|
||||
const { wrapper, store } = await mountView({ boxId: 'trash', messages: trashed() })
|
||||
store.toggleCheckAll()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-purge-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const deletion = requests.find((request) => request.url === '/message-deletions')
|
||||
expect(deletion).toMatchObject({ method: 'POST', data: { ids: ['t1', 't2'] } })
|
||||
expect(store.messages).toEqual([])
|
||||
expect(toastTexts()).toEqual(['메일 2통을 영구 삭제했습니다.'])
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('분할 보기 — 열어 둔 메일을 옮기면 목록 주소로 바꿔 끼운다', async () => {
|
||||
const { wrapper, router, store } = await mountView({ view: 'split', path: '/mail/inbox/m2' })
|
||||
store.selectedId = 'm2'
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--split')
|
||||
|
||||
await wrapper.get('[data-tool="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('도구 줄 안에서는 화살표로 단추를 옮긴다 (로빙 tabindex) · 선택 해제는 고른 것을 푼다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const first = wrapper.get('[data-tool="markRead"]')
|
||||
expect(first.attributes('tabindex')).toBe('0')
|
||||
expect(wrapper.get('[data-tool="markUnread"]').attributes('tabindex')).toBe('-1')
|
||||
|
||||
await first.trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-tool="markUnread"]').element)
|
||||
await wrapper.get('[data-tool="markUnread"]').trigger('keydown', { key: 'End' })
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-tool="clear"]').element)
|
||||
|
||||
await wrapper.get('[data-tool="clear"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(store.checkedCount).toBe(0)
|
||||
expect(wrapper.find('.bulk-bar').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('옮기는 동안 눌리지 않는 단추(이동 · 삭제)는 Tab 자리 · 화살표에서 건너뛰고, 초점이 그 단추에 있었으면 다음 단추로 옮긴다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const { wrapper, store } = await mountView({
|
||||
handler: (request) =>
|
||||
request.method === 'PATCH'
|
||||
? new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const tool = (id: string) => wrapper.get(`[data-tool="${id}"]`)
|
||||
;(tool('delete').element as HTMLElement).focus()
|
||||
await flushPromises()
|
||||
expect(tool('delete').attributes('tabindex')).toBe('0')
|
||||
|
||||
await tool('delete').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(tool('delete').attributes('disabled')).toBeDefined()
|
||||
expect(tool('move').attributes('disabled')).toBeDefined()
|
||||
expect(tool('delete').attributes('tabindex')).toBe('-1')
|
||||
expect(tool('clear').attributes('tabindex')).toBe('0')
|
||||
expect(document.activeElement).toBe(tool('clear').element)
|
||||
|
||||
await tool('flag').trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(tool('clear').element)
|
||||
await tool('clear').trigger('keydown', { key: 'ArrowLeft' })
|
||||
expect(document.activeElement).toBe(tool('flag').element)
|
||||
|
||||
release(ok({ updatedIds: ['m1'], failedIds: [] }))
|
||||
await flushPromises()
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('초점이 도구 줄 밖에 있어도 Tab 자리는 누를 수 있는 단추로 옮긴다 — 초점은 빼앗지 않는다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
const { wrapper, store } = await mountView({
|
||||
handler: (request) =>
|
||||
request.method === 'PATCH'
|
||||
? new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
const tool = (id: string) => wrapper.get(`[data-tool="${id}"]`)
|
||||
;(tool('delete').element as HTMLElement).focus()
|
||||
const outside = document.createElement('button')
|
||||
document.body.append(outside)
|
||||
outside.focus()
|
||||
await flushPromises()
|
||||
|
||||
await tool('delete').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(tool('delete').attributes('tabindex')).toBe('-1')
|
||||
expect(tool('clear').attributes('tabindex')).toBe('0')
|
||||
expect(document.activeElement).toBe(outside)
|
||||
release(ok({ updatedIds: ['m1'], failedIds: [] }))
|
||||
await flushPromises()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
// 여러 통 처리 도구 줄 — 하나라도 고르면 목록 머리(52) 자리에 (디자인 원본 BulkSelect · ListStates, 바탕 #F3F8F8)
|
||||
// "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · 중요 표시(모두 중요면 "중요 해제") | 이동 ▾ · 삭제(휴지통에서는 "영구 삭제", 빨강)
|
||||
// … 오른쪽 끝 선택 해제(✕). 하는 일은 useBulkMailActions 가 맡는다.
|
||||
// - 단추(MailBulkButton)는 높이 44 · 아이콘 + 이름. 폭 1100 미만이거나 분할 보기의 목록 칸에서는 아이콘만(40×44 — 이름은 툴팁 · aria-label).
|
||||
// "선택 해제"는 목록 보기에서 이름을 늘 보인다 (캔버스 BulkSelect)
|
||||
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
|
||||
// 옮기기 · 지우기를 하는 동안 눌리지 않는 단추(이동 · 삭제)는 건너뛰고, Tab 자리 · 초점이 그 단추에 있었으면 다음 누를 수 있는 단추로 옮긴다
|
||||
// - 영구 삭제는 확인 대화상자(MailPurgeDialog — 처음 초점은 「취소」)를 거친다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import MailBulkButton from '@/components/mail/MailBulkButton.vue'
|
||||
import MailBulkMoveButton from '@/components/mail/MailBulkMoveButton.vue'
|
||||
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import { useBulkMailActions } from '@/composables/useBulkMailActions'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** "N통 선택됨"(h1)의 id — 목록 칸이 aria-labelledby 로 가리킨다 */
|
||||
titleId: string
|
||||
variant: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const bulk = useBulkMailActions()
|
||||
|
||||
type ToolId = 'markRead' | 'markUnread' | 'flag' | 'move' | 'delete' | 'clear'
|
||||
const TOOL_ORDER: readonly ToolId[] = ['markRead', 'markUnread', 'flag', 'move', 'delete', 'clear']
|
||||
|
||||
interface LeadingTool {
|
||||
id: Extract<ToolId, 'markRead' | 'markUnread' | 'flag'>
|
||||
icon: IconName
|
||||
label: string
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const toolsRef = ref<HTMLElement | null>(null)
|
||||
/** 로빙 tabindex — 마지막으로 초점이 간 단추 (Tab 으로 들어올 단추는 rovingTool) */
|
||||
const activeTool = ref<ToolId>('markRead')
|
||||
const purgeOpen = ref<boolean>(false)
|
||||
/** 초점이 도구 줄 안의 어느 단추에 있었는지 — 그 단추가 눌리지 않게 되어 초점을 잃었을 때 되돌려 줄 곳을 찾는다 */
|
||||
let lastFocusedTool: ToolId | null = null
|
||||
|
||||
/** 지금 누를 수 없는 단추 — 옮기기 · 지우기를 하는 동안 */
|
||||
const disabledTools = computed<ReadonlySet<ToolId>>(() => new Set<ToolId>(bulk.busy.value ? ['move', 'delete'] : []))
|
||||
/** 화살표 · Home · End 로 옮겨 다닐 단추 — 누를 수 없는 단추는 건너뛴다 */
|
||||
const enabledTools = computed<ToolId[]>(() => TOOL_ORDER.filter((id) => !disabledTools.value.has(id)))
|
||||
/** Tab 으로 들어올 단추 — 마지막 단추를 누를 수 없게 되면 그다음(끝이면 처음부터) 누를 수 있는 단추 */
|
||||
const rovingTool = computed<ToolId>(() => {
|
||||
if (!disabledTools.value.has(activeTool.value)) return activeTool.value
|
||||
const start = TOOL_ORDER.indexOf(activeTool.value)
|
||||
for (let step = 1; step < TOOL_ORDER.length; step += 1) {
|
||||
const candidate = TOOL_ORDER[(start + step) % TOOL_ORDER.length]
|
||||
if (candidate && !disabledTools.value.has(candidate)) return candidate
|
||||
}
|
||||
return 'clear'
|
||||
})
|
||||
|
||||
/** 분할 보기의 목록 칸은 아이콘만, 목록 보기는 폭 1100 이상에서 이름까지 */
|
||||
const labelMode = computed(() => (props.variant === 'split' ? 'never' : 'wide'))
|
||||
const countText = computed(() => MAIL_BULK_TEXT.selected(formatCount(mailStore.checkedCount)))
|
||||
const deleteLabel = computed(() => (bulk.inTrash.value ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete))
|
||||
const leadingTools = computed<LeadingTool[]>(() => [
|
||||
{ id: 'markRead', icon: 'mail-open', label: MAIL_BULK_TEXT.markRead, run: () => void bulk.markRead(true) },
|
||||
{ id: 'markUnread', icon: 'mail', label: MAIL_BULK_TEXT.markUnread, run: () => void bulk.markRead(false) },
|
||||
{
|
||||
id: 'flag',
|
||||
icon: 'star',
|
||||
label: bulk.allFlagged.value ? MAIL_BULK_TEXT.unflag : MAIL_BULK_TEXT.flag,
|
||||
run: () => void bulk.toggleFlagged(),
|
||||
},
|
||||
])
|
||||
|
||||
function tabindexOf(id: ToolId): number {
|
||||
return rovingTool.value === id ? 0 : -1
|
||||
}
|
||||
|
||||
function focusTool(id: ToolId) {
|
||||
activeTool.value = id
|
||||
toolsRef.value?.querySelector<HTMLElement>(`[data-tool="${id}"]`)?.focus()
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (bulk.inTrash.value) {
|
||||
purgeOpen.value = true
|
||||
return
|
||||
}
|
||||
void bulk.moveToTrash()
|
||||
}
|
||||
|
||||
function onPurgeConfirm() {
|
||||
purgeOpen.value = false
|
||||
void bulk.purge()
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const target = event.target instanceof HTMLElement ? event.target : null
|
||||
// 메뉴 · 대화상자 안의 키는 그쪽이 처리한다
|
||||
if (!target || target.closest('[data-editor-popup], dialog')) return
|
||||
const order = enabledTools.value
|
||||
const current = order.findIndex((id) => id === target.dataset.tool)
|
||||
if (current === -1) return
|
||||
const last = order.length - 1
|
||||
const moves: Record<string, number> = {
|
||||
ArrowRight: current === last ? 0 : current + 1,
|
||||
ArrowLeft: current === 0 ? last : current - 1,
|
||||
Home: 0,
|
||||
End: last,
|
||||
}
|
||||
const next = order[moves[event.key] ?? -1]
|
||||
if (!next) return
|
||||
event.preventDefault()
|
||||
focusTool(next)
|
||||
}
|
||||
|
||||
function onFocusIn(event: FocusEvent) {
|
||||
const element = event.target instanceof HTMLElement ? event.target : null
|
||||
const tool = TOOL_ORDER.find((id) => id === element?.dataset.tool)
|
||||
if (!tool) return
|
||||
activeTool.value = tool
|
||||
lastFocusedTool = tool
|
||||
}
|
||||
|
||||
/** 초점이 도구 줄 밖의 다른 곳으로 갔다 — 갈 곳 없이 사라진 것(단추가 눌리지 않게 됨)은 기억해 둔다 */
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && toolsRef.value?.contains(next)) return
|
||||
if (next !== null) lastFocusedTool = null
|
||||
}
|
||||
|
||||
// 초점이 있던 단추를 누를 수 없게 되면(옮기기 · 지우기 시작) 다음 누를 수 있는 단추로 초점을 옮긴다 — 키보드 사용자가 자리를 잃지 않게
|
||||
watch(disabledTools, (disabled) => {
|
||||
const focused = lastFocusedTool
|
||||
if (!focused || !disabled.has(focused)) return
|
||||
void nextTick(() => {
|
||||
const active = document.activeElement
|
||||
const lost =
|
||||
active === null ||
|
||||
active === document.body ||
|
||||
(active instanceof HTMLElement && active.dataset.tool === focused)
|
||||
if (lost) focusTool(rovingTool.value)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['bulk-bar', `bulk-bar--${variant}`]">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="bulk-bar__count"
|
||||
>
|
||||
{{ countText }}
|
||||
</h1>
|
||||
<div
|
||||
ref="toolsRef"
|
||||
class="bulk-bar__tools"
|
||||
role="toolbar"
|
||||
:aria-label="MAIL_BULK_TEXT.toolbar"
|
||||
@keydown="onKeydown"
|
||||
@focusin="onFocusIn"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<MailBulkButton
|
||||
v-for="tool in leadingTools"
|
||||
:key="tool.id"
|
||||
:icon="tool.icon"
|
||||
:label="tool.label"
|
||||
:label-mode="labelMode"
|
||||
:data-tool="tool.id"
|
||||
:tabindex="tabindexOf(tool.id)"
|
||||
@click="tool.run"
|
||||
/>
|
||||
<span
|
||||
v-if="variant === 'list'"
|
||||
class="bulk-bar__divider"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<MailBulkMoveButton
|
||||
:mailboxes="bulk.moveTargets.value"
|
||||
:label-mode="labelMode"
|
||||
:disabled="bulk.busy.value"
|
||||
data-tool="move"
|
||||
:tabindex="tabindexOf('move')"
|
||||
@move="bulk.moveTo"
|
||||
/>
|
||||
<MailBulkButton
|
||||
icon="trash"
|
||||
:label="deleteLabel"
|
||||
:label-mode="labelMode"
|
||||
tone="danger"
|
||||
:disabled="bulk.busy.value"
|
||||
data-tool="delete"
|
||||
:tabindex="tabindexOf('delete')"
|
||||
@click="onDelete"
|
||||
/>
|
||||
<MailBulkButton
|
||||
class="bulk-bar__clear"
|
||||
icon="x"
|
||||
:label="MAIL_BULK_TEXT.clear"
|
||||
:title="MAIL_BULK_TEXT.clearTitle"
|
||||
:label-mode="variant === 'split' ? 'never' : 'always'"
|
||||
:icon-stroke-width="1.9"
|
||||
data-tool="clear"
|
||||
:tabindex="tabindexOf('clear')"
|
||||
@click="mailStore.clearChecked()"
|
||||
/>
|
||||
</div>
|
||||
<MailPurgeDialog
|
||||
:open="purgeOpen"
|
||||
:count="mailStore.checkedCount"
|
||||
id-prefix="bulk-purge"
|
||||
@confirm="onPurgeConfirm"
|
||||
@cancel="purgeOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-20);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
/* 분할 보기의 목록 칸(384) — 촘촘하게 (디자인 원본 ListStates) */
|
||||
.bulk-bar--split {
|
||||
padding: var(--space-4) var(--space-4) var(--space-4) var(--space-12);
|
||||
}
|
||||
|
||||
.bulk-bar__count {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 var(--space-10) 0 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bulk-bar--split .bulk-bar__count {
|
||||
margin-right: var(--space-4);
|
||||
}
|
||||
|
||||
.bulk-bar__tools {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.bulk-bar--split .bulk-bar__tools {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bulk-bar__divider {
|
||||
width: var(--space-1);
|
||||
height: var(--space-20);
|
||||
margin: 0 var(--space-6);
|
||||
background: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.bulk-bar__clear {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 — 열린 메일 한 통 (디자인 README "3단계 › 여러 통 처리" · useMessageRemoval)
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함으로 정한다 — 휴지통 목록에서는 휴지통에 든 메일, 그 밖에서는 휴지통에만 든 메일 (useMessageRemoval)
|
||||
// · 「삭제」: 휴지통으로 옮긴다 — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// · 「영구 삭제」: 확인 대화상자(처음 초점은 「취소」)를 거쳐 영구 삭제 — "메일 1통을 영구 삭제했습니다."
|
||||
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
|
||||
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { useMessageRemoval } from '@/composables/useMessageRemoval'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { listLocationOf, messageLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
/** split: 지우면 다음 메일을 연다 · list: 지우면 목록으로 */
|
||||
layout: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const removal = useMessageRemoval()
|
||||
const { backToSourceList } = useMailNavigation()
|
||||
|
||||
const working = ref<boolean>(false)
|
||||
const confirmOpen = ref<boolean>(false)
|
||||
|
||||
/** 「영구 삭제」할 메일인가 — 휴지통 목록 밖에서는 휴지통에만 든 메일만 (useMessageRemoval) */
|
||||
const permanent = computed(() => removal.deletesPermanently(props.message))
|
||||
|
||||
async function run(kind: 'trash' | 'purge') {
|
||||
const target = props.message
|
||||
const source = mailStore.listSource
|
||||
working.value = true
|
||||
try {
|
||||
const outcome = kind === 'trash' ? await removal.moveToTrash(target) : await removal.purge(target)
|
||||
if (!outcome.removed || !source) return
|
||||
if (props.layout === 'list') {
|
||||
// 목록 보기에서는 지우면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToSourceList(source)
|
||||
return
|
||||
}
|
||||
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
if (working.value) return
|
||||
if (permanent.value) confirmOpen.value = true
|
||||
else void run('trash')
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
confirmOpen.value = false
|
||||
void run('purge')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MailToolButton
|
||||
icon="trash"
|
||||
:label="permanent ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete"
|
||||
tone="danger"
|
||||
:disabled="working"
|
||||
data-reader-delete
|
||||
@click="onClick"
|
||||
/>
|
||||
<MailPurgeDialog
|
||||
:open="confirmOpen"
|
||||
:count="1"
|
||||
id-prefix="reader-purge"
|
||||
@confirm="onConfirm"
|
||||
@cancel="confirmOpen = false"
|
||||
/>
|
||||
</template>
|
||||
@@ -2,22 +2,25 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { flushPromises, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageList from './MessageList.vue'
|
||||
|
||||
const DRAFTS: Mailbox = {
|
||||
id: 'drafts',
|
||||
name: '임시보관함',
|
||||
role: 'drafts',
|
||||
const INBOX: Mailbox = {
|
||||
id: 'inbox',
|
||||
name: '받은편지함',
|
||||
role: 'inbox',
|
||||
parentId: null,
|
||||
sortOrder: 3,
|
||||
totalCount: 2,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 0,
|
||||
}
|
||||
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', sortOrder: 3, totalCount: 2 }
|
||||
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent' }
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
@@ -50,21 +53,22 @@ describe('MessageList (분할 보기 목록)', () => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountList(mailbox: Mailbox, messages: MessageSummary[]) {
|
||||
async function mountList(mailbox: Mailbox, messages: MessageSummary[], path = `/mail/${mailbox.id}`) {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [mailbox]
|
||||
store.mailboxes = [INBOX, SENT, DRAFTS]
|
||||
store.currentMailboxId = mailbox.id
|
||||
store.messages = messages
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${mailbox.id}`)
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
return mount(MessageList, { props: { mailboxId: mailbox.id }, global: { plugins: [pinia, router] } })
|
||||
const wrapper = mount(MessageList, { global: { plugins: [pinia, router] } })
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
it('임시보관함 — 보낸 사람 자리에 받는 사람, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
|
||||
const wrapper = await mountList(DRAFTS, [
|
||||
const { wrapper } = await mountList(DRAFTS, [
|
||||
summary('d1', { isRead: false, to: [{ name: '박준호', email: 'junho.park@arasystem.test' }, { name: '인프라팀', email: 'infra@arasystem.test' }] }),
|
||||
summary('d2', { to: [], subject: '' }),
|
||||
])
|
||||
@@ -80,11 +84,76 @@ describe('MessageList (분할 보기 목록)', () => {
|
||||
})
|
||||
|
||||
it('보낸편지함은 그대로 "받는 사람: …" 이고 읽기 화면으로 간다', async () => {
|
||||
const sent: Mailbox = { ...DRAFTS, id: 'sent', name: '보낸편지함', role: 'sent' }
|
||||
const wrapper = await mountList(sent, [summary('s1', { mailboxIds: ['sent'] })])
|
||||
const { wrapper } = await mountList(SENT, [summary('s1', { mailboxIds: ['sent'] })])
|
||||
const row = wrapper.get('.message-row')
|
||||
|
||||
expect(row.get('.message-row__who').text()).toBe('받는 사람: 이도윤')
|
||||
expect(row.get('.message-row__main').attributes('href')).toBe('/mail/sent/s1')
|
||||
})
|
||||
|
||||
describe('여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기")', () => {
|
||||
const inboxRows = () => [
|
||||
summary('m1', { mailboxIds: ['inbox'] }),
|
||||
summary('m2', { mailboxIds: ['inbox'] }),
|
||||
summary('m3', { mailboxIds: ['inbox'] }),
|
||||
]
|
||||
|
||||
it('행마다 고르기 상자("고르기: 제목")가 있고, 하나라도 고르면 모든 행이 고르기 칸을 보인다', async () => {
|
||||
const { wrapper, store } = await mountList(INBOX, inboxRows())
|
||||
|
||||
expect(wrapper.findAll('.message-row.is-checking')).toHaveLength(0)
|
||||
const box = wrapper.findAll('.message-row [role="checkbox"]')[1]
|
||||
expect(box?.attributes('aria-label')).toBe('고르기: 제목 m2')
|
||||
|
||||
await box?.trigger('click')
|
||||
|
||||
expect(store.checkedIds.has('m2')).toBe(true)
|
||||
expect(wrapper.findAll('.message-row.is-checking')).toHaveLength(3)
|
||||
expect(wrapper.findAll('.message-row')[1]?.classes()).toContain('is-checked')
|
||||
// 머리 자리가 도구 줄로 — 분할 보기의 목록 칸에서는 아이콘만(이름은 aria-label · 툴팁)
|
||||
expect(wrapper.get('#message-list-title').text()).toBe('1통 선택됨')
|
||||
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--split')
|
||||
expect(wrapper.get('[data-tool="markRead"]').attributes('aria-label')).toBe('읽음 표시')
|
||||
})
|
||||
|
||||
it('행을 누르면 지금처럼 메일을 열고, Ctrl(⌘)+클릭 · Shift+클릭은 열지 않고 고른다', async () => {
|
||||
const { wrapper, router, store } = await mountList(INBOX, inboxRows())
|
||||
const main = (index: number) => wrapper.findAll('.message-row__main')[index]
|
||||
|
||||
await main(0)?.trigger('click', { metaKey: true })
|
||||
await main(2)?.trigger('click', { shiftKey: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2', 'm3'])
|
||||
|
||||
await main(1)?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
expect(store.checkedCount).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
it('전체 메일함 검색 결과 — 시각 앞에 "보낸편지함 ·", 보낸 메일은 "받는 사람: 이름", 누르면 검색 결과 안의 주소', async () => {
|
||||
const q = encodeURIComponent('회의')
|
||||
const { wrapper, store } = await mountList(
|
||||
INBOX,
|
||||
[
|
||||
summary('r1', { mailboxIds: ['inbox'], from: { name: '이도윤', email: 'doyun.lee@arasystem.test' } }),
|
||||
summary('r2', { mailboxIds: ['sent'], to: [{ name: '정하늘', email: 'haneul@arasystem.test' }] }),
|
||||
],
|
||||
`/mail/search?q=${q}&in=all`,
|
||||
)
|
||||
store.listSource = { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL }
|
||||
await flushPromises()
|
||||
const rows = wrapper.findAll('.message-row')
|
||||
|
||||
expect(rows.map((row) => row.get('.message-row__who').text())).toEqual(['이도윤', '받는 사람: 정하늘'])
|
||||
expect(rows.map((row) => row.get('.message-row__mailbox').text())).toEqual(['받은편지함 ·', '보낸편지함 ·'])
|
||||
expect(rows[0]?.get('.message-row__main').attributes('href')).toBe(`/mail/search/r1?q=${q}&in=all`)
|
||||
// 머리 — 검색 결과 · ‘회의’ · 2통 · 범위 줄(전체일 때 안내)
|
||||
expect(wrapper.get('#message-list-title').text()).toBe('검색 결과')
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’ · 2통')
|
||||
expect(wrapper.get('.search-scope').text()).toContain('휴지통 · 스팸메일함은 빼고 찾습니다')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,111 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기의 목록 영역 — 머리(메일함 이름·개수·보기 전환·새로고침) + 2줄 행 + 메일 더 보기 (디자인 원본 Main 가운데)
|
||||
// 상태: 불러오는 중 · 메일 서버 장애(다시 시도) · 없는 메일함(받은편지함으로) · 빈 메일함 (디자인 원본 States)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
// 분할 보기의 목록 칸 — 2줄 행 (디자인 원본 Main 가운데 · Search 왼쪽 · ListStates "분할 보기에서 여러 통 고르기")
|
||||
// 머리 · 상태(불러오는 중 · 못 불러옴 · 빈 메일함 · 검색 결과 없음) · 메일 더 보기는 목록 틀(MailListFrame)이 맡는다.
|
||||
// 목록은 메일함 또는 검색 결과다 — 행 규칙은 useMailListRows.
|
||||
import MailListFrame from '@/components/mail/MailListFrame.vue'
|
||||
import MessageListItem from '@/components/mail/MessageListItem.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { useMailListRows } from '@/composables/useMailListRows'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { replaceWithMailbox } = useMailNavigation()
|
||||
const rows = useMailListRows()
|
||||
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const showRecipients = computed(() => {
|
||||
const role = mailbox.value?.role
|
||||
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
|
||||
})
|
||||
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
|
||||
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
|
||||
function retry() {
|
||||
void mailStore.openMailbox(props.mailboxId)
|
||||
}
|
||||
|
||||
/** 없는 메일함(RES_001) — 받은편지함으로 바꿔 끼운다 */
|
||||
function goToInbox() {
|
||||
void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
/** Shift 면 범위, 아니면 하나 */
|
||||
function onCheck(id: string, range: boolean) {
|
||||
if (range) mailStore.checkRangeTo(id)
|
||||
else mailStore.toggleCheck(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
<MailListFrame
|
||||
class="message-list"
|
||||
aria-labelledby="message-list-title"
|
||||
:aria-busy="busy"
|
||||
variant="split"
|
||||
title-id="message-list-title"
|
||||
>
|
||||
<MailListHeader title-id="message-list-title" />
|
||||
|
||||
<MessageListSkeleton v-if="mailStore.listStatus === 'loading'" />
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
leave-label="받은편지함으로"
|
||||
@retry="retry"
|
||||
@leave="goToInbox"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<ul class="message-list__items">
|
||||
<MessageListItem
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:mailbox-id="mailboxId"
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
:show-recipients="showRecipients"
|
||||
:draft="isDrafts"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
class="message-list__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="message-list__text-button message-list__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
<ul class="message-list__items">
|
||||
<MessageListItem
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:to="rows.locationOf(message)"
|
||||
:role="rows.roleOf(message)"
|
||||
:mixed="rows.mixed.value"
|
||||
:mailbox-name="rows.mailboxNameOf(message)"
|
||||
:selected="message.id === mailStore.selectedId"
|
||||
:checked="mailStore.checkedIds.has(message.id)"
|
||||
:checking="mailStore.checkedCount > 0"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</ul>
|
||||
</MailListFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-list {
|
||||
flex: 1 1 24rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -114,38 +53,4 @@ function goToInbox() {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.message-list__more {
|
||||
padding: var(--space-8) var(--space-12) var(--space-16);
|
||||
}
|
||||
|
||||
.message-list__text-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-list__text-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.message-list__more-button {
|
||||
width: 100%;
|
||||
min-height: var(--size-row-min);
|
||||
}
|
||||
|
||||
.message-list__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.message-list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,73 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
// 분할 보기 목록의 2줄 행 (디자인 원본 Main) — 안 읽음은 뱃지 없이 굵은 글자로만 구분한다
|
||||
// 임시보관함(draft)의 행은 보낸 사람 자리에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 누르면 쓰기 화면에서 이어 쓴다
|
||||
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다)
|
||||
// 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts — 임시보관함은 받는 사람 · 쓰기 화면에서 이어 쓰기)
|
||||
// 전체 메일함 검색 결과면 첫 줄 시각 앞에 "보낸편지함 ·" (디자인 원본 Search)
|
||||
// 여러 통 고르기 (디자인 원본 ListStates "분할 보기에서 여러 통 고르기"):
|
||||
// - 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다 (숨긴 동안에도 Tab 으로 닿는다)
|
||||
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { formatMailListTime } from '@/shared/utils/format'
|
||||
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
mailboxId: string
|
||||
/** 누르면 갈 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
|
||||
to: RouteLocationRaw
|
||||
/** 이 행의 메일함 역할 — 보낸 사람 자리 · 읽음 표시 규칙 */
|
||||
role: MailboxRole | null
|
||||
/** 전체 메일함 검색 결과 — 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" */
|
||||
mixed?: boolean
|
||||
/** 시각 앞에 보일 메일함 이름 (전체 메일함 검색 결과) */
|
||||
mailboxName?: string | null
|
||||
/** 지금 열려 있는 메일 */
|
||||
selected?: boolean
|
||||
/** 보낸편지함처럼 받는 사람을 보여 줄 메일함인지 */
|
||||
showRecipients?: boolean
|
||||
/** 임시보관함의 행 — 누르면 쓰기 화면에서 이어 쓴다 */
|
||||
draft?: boolean
|
||||
/** 고른 메일 (여러 통 처리) */
|
||||
checked?: boolean
|
||||
/** 하나라도 골랐다 — 모든 행에 고르기 상자를 보인다 */
|
||||
checking?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle-flag', id: string): void
|
||||
/** 고르기 · 풀기 — range 면 Shift(범위) */
|
||||
(e: 'check', id: string, range: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
mailboxName: null,
|
||||
selected: false,
|
||||
showRecipients: false,
|
||||
draft: false,
|
||||
checked: false,
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 임시 저장본의 받는 사람 — 없으면 빈 문자열 */
|
||||
const draftRecipients = computed(() => (props.draft ? draftRecipientNames(props.message.to) : ''))
|
||||
const who = computed(() => {
|
||||
if (props.draft) return draftRecipients.value || DRAFT_NO_RECIPIENTS
|
||||
if (props.showRecipients) {
|
||||
const recipients = summarizeRecipients(props.message.to)
|
||||
return recipients ? `받는 사람: ${recipients}` : '받는 사람 없음'
|
||||
}
|
||||
return displayName(props.message.from) || '(보낸 사람 없음)'
|
||||
})
|
||||
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'split' }))
|
||||
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
|
||||
const time = computed(() => formatMailListTime(props.message.receivedAt))
|
||||
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
|
||||
const unread = computed(() => !props.draft && !props.message.isRead)
|
||||
const location = computed<RouteLocationRaw>(() =>
|
||||
props.draft
|
||||
? { name: 'mail-compose', query: { draft: props.message.id } }
|
||||
: { name: 'mail-message', params: { mailboxId: props.mailboxId, messageId: props.message.id } },
|
||||
)
|
||||
const unread = computed(() => isRowUnread(props.message, props.role))
|
||||
const draft = computed(() => isDraftRole(props.role))
|
||||
|
||||
/** Ctrl(⌘) · Shift + 클릭은 메일을 열지 않고 고른다 (RouterLink 는 수정 글쇠가 있으면 이동하지 않는다 — 브라우저의 새 탭도 막는다) */
|
||||
function onMainClick(event: MouseEvent) {
|
||||
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) return
|
||||
event.preventDefault()
|
||||
emit('check', props.message.id, event.shiftKey)
|
||||
}
|
||||
|
||||
/** Shift + 누름이 글자를 고르지 않게 한다 */
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (event.shiftKey) event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['message-row', { 'is-selected': selected, 'is-unread': unread }]">
|
||||
<li
|
||||
:class="[
|
||||
'message-row',
|
||||
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
|
||||
]"
|
||||
:data-row-id="message.id"
|
||||
@mousedown="onMouseDown"
|
||||
>
|
||||
<MailCheckbox
|
||||
class="message-row__check"
|
||||
:checked="checked"
|
||||
:label="MAIL_BULK_TEXT.selectOne(subject)"
|
||||
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
|
||||
/>
|
||||
<RouterLink
|
||||
class="message-row__main"
|
||||
:to="location"
|
||||
:to="to"
|
||||
:data-message-id="message.id"
|
||||
@click="onMainClick"
|
||||
>
|
||||
<span class="message-row__top">
|
||||
<span :class="['message-row__who', { 'is-placeholder': draft && draftRecipients === '' }]">
|
||||
<span :class="['message-row__who', { 'is-placeholder': who.placeholder }]">
|
||||
<span
|
||||
v-if="unread"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ who }}
|
||||
>안 읽음, </span>{{ who.text }}
|
||||
</span>
|
||||
<span
|
||||
v-if="mixed && mailboxName"
|
||||
class="message-row__mailbox"
|
||||
>{{ mailboxName }} ·</span>
|
||||
<time
|
||||
class="message-row__time"
|
||||
:datetime="message.receivedAt"
|
||||
@@ -104,6 +136,7 @@ const location = computed<RouteLocationRaw>(() =>
|
||||
|
||||
<style scoped>
|
||||
.message-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
@@ -118,6 +151,45 @@ const location = computed<RouteLocationRaw>(() =>
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 고른 행 (디자인 원본 ListStates · BulkSelect) */
|
||||
.message-row.is-checked {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.message-row.is-checked:hover {
|
||||
background: var(--color-row-checked-hover);
|
||||
}
|
||||
|
||||
/*
|
||||
* 고르기 칸 — 고르지 않았고 마우스 · 초점도 없으면 칸 없이 지금 행 그대로다 (디자인 원본 ListStates).
|
||||
* 숨긴 동안에도 화면 밖에 두어 Tab 으로 닿게 한다 — 초점이 오면 행에 초점이 있으므로 나타난다
|
||||
*/
|
||||
.message-row__check {
|
||||
position: absolute;
|
||||
width: var(--space-1);
|
||||
height: var(--space-1);
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 마우스를 올리거나 초점이 가면 · 하나라도 고르면 — 왼쪽 36px(밀도 예외)에 16px 상자, 상자는 칸 오른쪽에 붙는다 */
|
||||
.message-row:hover .message-row__check,
|
||||
.message-row:focus-within .message-row__check,
|
||||
.message-row.is-checking .message-row__check {
|
||||
position: static;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
width: var(--size-row-check);
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
clip-path: none;
|
||||
}
|
||||
|
||||
.message-row__check:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
/* 2줄 행 약 50px — 보낸 사람·시간 / 제목 + 첨부 클립 (디자인 원본 Main) */
|
||||
.message-row__main {
|
||||
flex: 1 1 auto;
|
||||
@@ -133,6 +205,13 @@ const location = computed<RouteLocationRaw>(() =>
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 고르기 칸이 보이면 칸 뒤 10px 에서 글이 시작한다 */
|
||||
.message-row:hover .message-row__main,
|
||||
.message-row:focus-within .message-row__main,
|
||||
.message-row.is-checking .message-row__main {
|
||||
padding-left: var(--space-10);
|
||||
}
|
||||
|
||||
.message-row__main:hover {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -161,6 +240,13 @@ const location = computed<RouteLocationRaw>(() =>
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 전체 메일함 검색 결과 — 시각 앞의 메일함 이름 (디자인 원본 Search) */
|
||||
.message-row__mailbox {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-row__time {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 불러오는 중 — 행 자리를 미리 잡아 화면이 튀지 않게 한다 (디자인 원본 States "불러오는 중")
|
||||
// 목록 불러오는 중 — 행 자리를 미리 잡아 화면이 튀지 않게 한다 (디자인 원본 States "불러오는 중" · Search "찾는 중")
|
||||
interface Props {
|
||||
rows?: number
|
||||
/** 스크린리더에 읽힐 이름 — 검색 중이면 "찾는 중" */
|
||||
label?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
rows: 7,
|
||||
label: '메일 목록 불러오는 중',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
class="list-skeleton"
|
||||
aria-label="메일 목록 불러오는 중"
|
||||
:aria-label="label"
|
||||
aria-busy="true"
|
||||
>
|
||||
<li
|
||||
|
||||
@@ -5,10 +5,19 @@ import { h } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import type { Mailbox, 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'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import {
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageReader from './MessageReader.vue'
|
||||
|
||||
@@ -270,3 +279,181 @@ describe('MessageReader 불러오기 실패', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/missing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를 쓰지 않는다', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
|
||||
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 0 }
|
||||
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash' }
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().startSession({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountWith(
|
||||
options: {
|
||||
mailboxIds?: string[]
|
||||
layout?: 'split' | 'list'
|
||||
handler?: (request: MockRequest) => MockResult | undefined
|
||||
source?: MailListSource
|
||||
} = {},
|
||||
) {
|
||||
requests = installMockApi((request) => {
|
||||
const custom = options.handler?.(request)
|
||||
if (custom) return custom
|
||||
if (request.url === '/mailboxes') return ok({ items: [INBOX, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
if (request.url === '/messages' && request.method === 'GET') {
|
||||
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
}
|
||||
if (request.url === '/messages') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
|
||||
if (request.url === '/message-deletions') {
|
||||
return ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
|
||||
}
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [INBOX, TRASH]
|
||||
store.listSource = options.source ?? { kind: 'mailbox', mailboxId: 'inbox' }
|
||||
const open = { ...DETAIL, mailboxIds: options.mailboxIds ?? ['inbox'] }
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', subject: '다음 메일' }]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = open
|
||||
store.selectedId = open.id
|
||||
store.selectedStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox')
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, {
|
||||
props: { layout: options.layout ?? 'split' },
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const deleteButton = (wrapper: Awaited<ReturnType<typeof mountWith>>['wrapper']) => wrapper.get('[data-reader-delete]')
|
||||
const usedDelete = () => requests.some((request) => request.method === 'DELETE')
|
||||
|
||||
it('휴지통 밖 — 휴지통으로 옮기고(PATCH) 다음 메일을 연다, "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]', async () => {
|
||||
const { wrapper, router, store } = await mountWith()
|
||||
expect(deleteButton(wrapper).text()).toBe('삭제')
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.find((request) => request.method === 'PATCH')).toMatchObject({
|
||||
url: '/messages',
|
||||
data: { ids: ['m1'], mailboxId: 'trash' },
|
||||
})
|
||||
expect(usedDelete()).toBe(false)
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 휴지통으로 옮겼습니다.')
|
||||
|
||||
// 되돌리기 — 원래 메일함으로 (여러 통 처리와 같은 규칙)
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.method === 'PATCH').at(-1)?.data).toEqual({ ids: ['m1'], mailboxId: 'inbox' })
|
||||
expect(useToastStore().toasts.map((item) => item.message)).toContain('메일 1통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('목록 보기에서는 지우면 목록으로 돌아간다', async () => {
|
||||
const { wrapper, router } = await mountWith({ layout: 'list' })
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(usedDelete()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통인지는 목록이 아니라 그 메일의 메일함으로 정한다 — 「영구 삭제」는 확인(처음 초점 「취소」)을 거쳐 POST /message-deletions', async () => {
|
||||
const { wrapper, router, store } = await mountWith({ mailboxIds: ['trash'] })
|
||||
expect(deleteButton(wrapper).text()).toBe('영구 삭제')
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.get('dialog')
|
||||
expect(dialog.attributes('open')).toBeDefined()
|
||||
expect(dialog.get('#reader-purge-title').text()).toBe('메일 1통을 영구 삭제할까요?')
|
||||
expect(document.activeElement).toBe(dialog.get('[data-purge-cancel]').element)
|
||||
|
||||
await dialog.get('[data-purge-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requests.some((request) => request.url === '/message-deletions')).toBe(false)
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-purge-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.find((request) => request.url === '/message-deletions')).toMatchObject({
|
||||
method: 'POST',
|
||||
data: { ids: ['m1'] },
|
||||
})
|
||||
expect(usedDelete()).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['메일 1통을 영구 삭제했습니다.'])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
})
|
||||
|
||||
it('그사이 휴지통을 떠난 메일이면(skippedIds) 지우지 않았다고 알리고 목록을 다시 맞춘다 — 넘어가지 않는다', async () => {
|
||||
const { wrapper, router } = await mountWith({
|
||||
mailboxIds: ['trash'],
|
||||
handler: (request) =>
|
||||
request.url === '/message-deletions' ? ok({ deletedIds: [], skippedIds: ['m1'] }) : undefined,
|
||||
})
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-purge-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
'1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
])
|
||||
expect(requests.filter((request) => request.method === 'GET' && request.url === '/messages')).toHaveLength(1)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
expect(usedDelete()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통 목록 밖에서 휴지통과 다른 메일함에 함께 든 메일은 「삭제」 — 휴지통으로 옮긴다(영구 삭제를 묻지 않는다)', async () => {
|
||||
const { wrapper, store } = await mountWith({
|
||||
mailboxIds: ['inbox', 'trash'],
|
||||
source: { kind: 'search', q: '회의', scope: 'all' },
|
||||
})
|
||||
expect(deleteButton(wrapper).text()).toBe('삭제')
|
||||
|
||||
await deleteButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('dialog').attributes('open')).toBeUndefined()
|
||||
expect(requests.some((request) => request.url === '/message-deletions')).toBe(false)
|
||||
expect(requests.find((request) => request.method === 'PATCH')?.data).toEqual({ ids: ['m1'], mailboxId: 'trash' })
|
||||
// 전체 메일함 검색에는 휴지통이 빠진다 — 결과에서 빠지고 알림은 "옮겼습니다"
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual(['메일 1통을 휴지통으로 옮겼습니다.'])
|
||||
expect(usedDelete()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통 목록 밖에서 휴지통에만 든 메일은 「영구 삭제」, 휴지통 목록에서는 다른 메일함에 함께 들어도 「영구 삭제」 (지금처럼)', async () => {
|
||||
const onlyTrash = await mountWith({ mailboxIds: ['trash'], source: { kind: 'search', q: '회의', scope: 'all' } })
|
||||
expect(deleteButton(onlyTrash.wrapper).text()).toBe('영구 삭제')
|
||||
onlyTrash.wrapper.unmount()
|
||||
|
||||
const inTrashList = await mountWith({ mailboxIds: ['trash', 'inbox'], source: { kind: 'mailbox', mailboxId: 'trash' } })
|
||||
expect(deleteButton(inTrashList.wrapper).text()).toBe('영구 삭제')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// 본문 영역 — 선택한 메일 (디자인 원본 Main 오른쪽 · MainListRead)
|
||||
// 본문 영역 — 선택한 메일 (디자인 원본 Main 오른쪽 · MainListRead · Search 오른쪽)
|
||||
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
|
||||
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다.
|
||||
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
@@ -12,11 +14,14 @@ import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import MessageAttachments from '@/components/mail/MessageAttachments.vue'
|
||||
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { listedAttachments } from '@/shared/utils/mail-attachment'
|
||||
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
|
||||
import { mailboxOfMessage } from '@/shared/utils/mail-list-row'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -27,18 +32,19 @@ interface Props {
|
||||
layout?: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
layout: 'split',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const { mailboxLocation, messageLocation, backToList, replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
const deleting = ref<boolean>(false)
|
||||
const { backToSourceList, replaceWithMailbox } = useMailNavigation()
|
||||
|
||||
const message = computed(() => mailStore.selected)
|
||||
const isTrash = computed(() => mailStore.currentMailbox?.role === 'trash')
|
||||
/** 검색 결과에서 연 메일의 메일함 이름 — 메일함 목록에서 열었으면 null */
|
||||
const searchMailboxName = computed(() => {
|
||||
if (!mailStore.search || !message.value) return null
|
||||
return mailboxOfMessage(message.value, mailStore.mailboxes, mailStore.currentMailboxId)?.name ?? null
|
||||
})
|
||||
const fromName = computed(() => displayName(message.value?.from) || '(보낸 사람 없음)')
|
||||
const fromEmail = computed(() => message.value?.from?.email ?? '')
|
||||
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
|
||||
@@ -49,35 +55,14 @@ const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.valu
|
||||
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
|
||||
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
|
||||
|
||||
async function onDelete() {
|
||||
const current = message.value
|
||||
const mailboxId = mailStore.currentMailboxId
|
||||
if (!current || !mailboxId || deleting.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
const nextId = await mailStore.removeMessage(current.id)
|
||||
if (props.layout === 'list') {
|
||||
// 목록 보기에서는 삭제하면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToList(mailboxId)
|
||||
return
|
||||
}
|
||||
await router.replace(nextId ? messageLocation(mailboxId, nextId) : mailboxLocation(mailboxId))
|
||||
} catch {
|
||||
// 알림은 인터셉터가 띄웠다 — 메일은 그대로 둔다
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (mailStore.selectedId) void mailStore.openMessage(mailStore.selectedId)
|
||||
}
|
||||
|
||||
/** 없는 메일(RES_001 — 지워졌거나 주소가 틀림) — 목록으로 돌아간다 (뒤로 가기 또는 바꿔 끼우기) */
|
||||
function leaveToList() {
|
||||
const mailboxId = mailStore.currentMailboxId
|
||||
void (mailboxId ? backToList(mailboxId) : replaceWithMailbox(null))
|
||||
const source = mailStore.listSource
|
||||
void (source ? backToSourceList(source) : replaceWithMailbox(null))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -114,12 +99,9 @@ function leaveToList() {
|
||||
:to="{ name: 'mail-compose', query: { forward: message.id } }"
|
||||
/>
|
||||
<MailToolbarDivider v-if="layout === 'split'" />
|
||||
<MailToolButton
|
||||
icon="trash"
|
||||
:label="isTrash ? '영구 삭제' : '삭제'"
|
||||
tone="danger"
|
||||
:disabled="deleting"
|
||||
@click="onDelete"
|
||||
<MessageDeleteButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -206,6 +188,13 @@ function leaveToList() {
|
||||
<span class="reader__recipients-label">참조</span>
|
||||
{{ formatAddressList(message.cc) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="searchMailboxName"
|
||||
class="reader__mailbox"
|
||||
>
|
||||
<span class="reader__recipients-label">{{ MAIL_SEARCH_TEXT.mailboxLine }}</span>
|
||||
{{ searchMailboxName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +244,11 @@ function leaveToList() {
|
||||
v-else
|
||||
icon="mail"
|
||||
title="메일을 선택하세요"
|
||||
hint="목록에서 메일을 고르면 내용이 여기에 표시됩니다."
|
||||
:hint="
|
||||
mailStore.search
|
||||
? '검색 결과에서 메일을 고르면 내용이 여기에 표시됩니다.'
|
||||
: '목록에서 메일을 고르면 내용이 여기에 표시됩니다.'
|
||||
"
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
@@ -400,6 +393,13 @@ function leaveToList() {
|
||||
margin-right: var(--space-6);
|
||||
}
|
||||
|
||||
/* 검색 결과에서 연 메일 — "메일함 받은편지함" (12.5px #667085, 디자인 원본 Search) */
|
||||
.reader__mailbox {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reader__notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -20,6 +21,8 @@ const INBOX: Mailbox = {
|
||||
totalCount: 15,
|
||||
unreadCount: 2,
|
||||
}
|
||||
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
|
||||
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
@@ -55,37 +58,38 @@ describe('MessageTable (목록 보기)', () => {
|
||||
})
|
||||
|
||||
async function mountTable(
|
||||
options: { mailbox?: Mailbox; messages?: MessageSummary[]; hasNext?: boolean } = {},
|
||||
options: { mailbox?: Mailbox; messages?: MessageSummary[]; hasNext?: boolean; path?: string } = {},
|
||||
) {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [options.mailbox ?? INBOX]
|
||||
store.mailboxes = [INBOX, SENT, DRAFTS]
|
||||
store.currentMailboxId = (options.mailbox ?? INBOX).id
|
||||
store.messages = options.messages ?? [summary('m1', { isRead: false, hasAttachment: true }), summary('m2')]
|
||||
store.pageInfo = { hasNext: options.hasNext ?? false, nextCursor: options.hasNext ? 'c2' : null, total: 15 }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${store.currentMailboxId}`)
|
||||
await router.push(options.path ?? `/mail/${store.currentMailboxId}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageTable, {
|
||||
props: { mailboxId: store.currentMailboxId ?? 'inbox' },
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
it('진짜 <table> 이다 — 캡션 · 다섯 칸 colgroup · 머리 칸', async () => {
|
||||
it('진짜 <table> 이다 — 캡션 · 여섯 칸 colgroup(고르기 칸 포함) · 머리 칸', async () => {
|
||||
const { wrapper } = await mountTable()
|
||||
|
||||
expect(wrapper.get('table caption').text()).toBe('받은편지함 메일 목록')
|
||||
expect(wrapper.findAll('colgroup col')).toHaveLength(5)
|
||||
expect(wrapper.findAll('colgroup col')).toHaveLength(6)
|
||||
expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([
|
||||
'',
|
||||
'중요 표시',
|
||||
'보낸 사람',
|
||||
'제목',
|
||||
'날짜',
|
||||
'크기',
|
||||
])
|
||||
expect(wrapper.get('thead th [role="checkbox"]').attributes('aria-label')).toBe('불러온 메일 모두 고르기')
|
||||
expect(wrapper.findAll('thead th').every((th) => th.attributes('scope') === 'col')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -144,26 +148,25 @@ describe('MessageTable (목록 보기)', () => {
|
||||
})
|
||||
|
||||
it('보낸편지함에서는 받는 사람 칸이 된다', async () => {
|
||||
const sent: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
|
||||
const { wrapper } = await mountTable({ mailbox: sent, messages: [summary('s1')] })
|
||||
const { wrapper } = await mountTable({ mailbox: SENT, messages: [summary('s1', { mailboxIds: ['sent'] })] })
|
||||
|
||||
expect(wrapper.findAll('thead th')[1]?.text()).toBe('받는 사람')
|
||||
expect(wrapper.findAll('thead th')[2]?.text()).toBe('받는 사람')
|
||||
expect(wrapper.findAll('tbody .mail-table__who')[0]?.text()).toBe('박준호')
|
||||
})
|
||||
|
||||
it('임시보관함 — 받는 사람 칸에 받는 사람을 쉼표로, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
|
||||
const drafts: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
|
||||
const { wrapper, router } = await mountTable({
|
||||
mailbox: drafts,
|
||||
mailbox: DRAFTS,
|
||||
messages: [
|
||||
summary('d1', {
|
||||
mailboxIds: ['drafts'],
|
||||
isRead: false,
|
||||
to: [
|
||||
{ name: '박준호', email: 'junho.park@arasystem.test' },
|
||||
{ name: null, email: 'infra@arasystem.test' },
|
||||
],
|
||||
}),
|
||||
summary('d2', { to: [], subject: '' }),
|
||||
summary('d2', { mailboxIds: ['drafts'], to: [], subject: '' }),
|
||||
],
|
||||
})
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
@@ -188,7 +191,7 @@ describe('MessageTable (목록 보기)', () => {
|
||||
)
|
||||
const { wrapper } = await mountTable({ hasNext: true })
|
||||
|
||||
await wrapper.get('.message-table__more-button').trigger('click')
|
||||
await wrapper.get('.mail-list-frame__more-button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests[0]?.params).toMatchObject({ mailboxId: 'inbox', cursor: 'c2' })
|
||||
@@ -204,4 +207,103 @@ describe('MessageTable (목록 보기)', () => {
|
||||
expect(document.activeElement).toBe(wrapper.findAll('a.mail-table__link')[1]?.element)
|
||||
scrollTo.mockRestore()
|
||||
})
|
||||
|
||||
describe('여러 통 고르기 (디자인 원본 BulkSelect)', () => {
|
||||
const three = () => [summary('m1'), summary('m2'), summary('m3'), summary('m4')]
|
||||
|
||||
it('행의 고르기 상자 — 이름 "고르기: 제목", 고르면 행이 고른 바탕이 되고 머리가 도구 줄 "N통 선택됨"으로 바뀐다', async () => {
|
||||
const { wrapper, store } = await mountTable({ messages: three() })
|
||||
const box = wrapper.findAll('tbody [role="checkbox"]')[1]
|
||||
|
||||
expect(box?.attributes('aria-label')).toBe('고르기: 제목 m2')
|
||||
expect(box?.attributes('aria-checked')).toBe('false')
|
||||
await box?.trigger('click')
|
||||
|
||||
expect(store.checkedIds.has('m2')).toBe(true)
|
||||
expect(wrapper.findAll('tbody tr')[1]?.classes()).toContain('is-checked')
|
||||
expect(wrapper.findAll('tbody [role="checkbox"]')[1]?.attributes('aria-checked')).toBe('true')
|
||||
expect(wrapper.find('.list-header').exists()).toBe(false)
|
||||
expect(wrapper.get('#message-table-title').text()).toBe('1통 선택됨')
|
||||
expect(wrapper.get('[role="toolbar"]').attributes('aria-label')).toBe('고른 메일')
|
||||
})
|
||||
|
||||
it('머리의 상자 — 하나도 · 일부만(mixed) · 모두, 누르면 불러온 메일을 모두 고르고 모두 골랐으면 푼다', async () => {
|
||||
const { wrapper, store } = await mountTable({ messages: three() })
|
||||
const head = () => wrapper.get('thead [role="checkbox"]')
|
||||
|
||||
expect(head().attributes('aria-checked')).toBe('false')
|
||||
await wrapper.findAll('tbody [role="checkbox"]')[0]?.trigger('click')
|
||||
expect(head().attributes('aria-checked')).toBe('mixed')
|
||||
|
||||
await head().trigger('click')
|
||||
expect([...store.checkedIds]).toEqual(['m1', 'm2', 'm3', 'm4'])
|
||||
expect(head().attributes('aria-checked')).toBe('true')
|
||||
|
||||
await head().trigger('click')
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('Ctrl(⌘)+클릭은 메일을 열지 않고 하나씩, Shift+클릭은 범위로 고른다', async () => {
|
||||
const { wrapper, router, store } = await mountTable({ messages: three() })
|
||||
const rows = () => wrapper.findAll('tbody tr')
|
||||
|
||||
await rows()[0]?.get('.mail-table__who').trigger('click', { ctrlKey: true })
|
||||
await rows()[2]?.get('.mail-table__link').trigger('click', { shiftKey: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2', 'm3'])
|
||||
|
||||
await rows()[1]?.get('.mail-table__date').trigger('click', { metaKey: true })
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm3'])
|
||||
})
|
||||
|
||||
it('Esc 는 목록 칸 안에서 고른 것을 푼다', async () => {
|
||||
const { wrapper, store } = await mountTable({ messages: three() })
|
||||
await wrapper.findAll('tbody [role="checkbox"]')[0]?.trigger('click')
|
||||
|
||||
await wrapper.get('section.message-table').trigger('keydown', { key: 'Escape' })
|
||||
|
||||
expect(store.checkedCount).toBe(0)
|
||||
expect(wrapper.find('.list-header').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('전체 메일함 검색 결과 — 보낸 사람 뒤에 메일함 칸(96), 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름"', async () => {
|
||||
const store = useMailStore()
|
||||
const { wrapper } = await mountTable({
|
||||
path: `/mail/search?q=${encodeURIComponent('회의')}&in=all`,
|
||||
messages: [
|
||||
summary('r1', { mailboxIds: ['inbox'] }),
|
||||
summary('r2', { mailboxIds: ['sent'], to: [{ name: '정하늘', email: 'haneul@arasystem.test' }] }),
|
||||
summary('r3', { mailboxIds: ['drafts'], to: [{ name: '운영팀', email: 'ops@arasystem.test' }] }),
|
||||
],
|
||||
})
|
||||
store.listSource = { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL }
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('colgroup col')).toHaveLength(7)
|
||||
expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([
|
||||
'',
|
||||
'중요 표시',
|
||||
'보낸 사람',
|
||||
'메일함',
|
||||
'제목',
|
||||
'날짜',
|
||||
'크기',
|
||||
])
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.map((row) => row.get('.mail-table__who').text())).toEqual([
|
||||
'이도윤',
|
||||
'받는 사람: 정하늘',
|
||||
'받는 사람: 운영팀',
|
||||
])
|
||||
expect(rows.map((row) => row.get('.mail-table__mailbox').text())).toEqual(['받은편지함', '보낸편지함', '임시보관함'])
|
||||
// 검색 결과에서 연 메일의 주소 · 임시 저장본은 지금처럼 쓰기 화면
|
||||
expect(rows[0]?.get('.mail-table__link').attributes('href')).toBe(
|
||||
`/mail/search/r1?q=${encodeURIComponent('회의')}&in=all`,
|
||||
)
|
||||
expect(rows[2]?.get('.mail-table__link').attributes('href')).toBe('/mail/compose?draft=r3')
|
||||
expect(wrapper.get('table caption').text()).toBe('검색 결과')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,279 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 — 게시판형 표 (디자인 원본 MainList). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
|
||||
// 칸: 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — tokens.css 의 --table-* 값.
|
||||
// 행 어디를 눌러도 메일이 열리고, 제목은 키보드로 갈 수 있는 링크다. 별 버튼은 따로 동작한다.
|
||||
// 임시보관함은 받는 사람 칸에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 행을 누르면 쓰기 화면에서 이어 쓴다
|
||||
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다).
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
// 목록 보기 — 게시판형 표 (디자인 원본 MainList · BulkSelect · ListStates). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
|
||||
// 칸: 고르기 32 · 별 32 · 보낸 사람 168 · [메일함 96 — 전체 메일함 검색 결과] · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12)
|
||||
// — tokens.css 의 --table-* 값. 머리 줄은 MessageTableHead, 행은 MessageTableRow.
|
||||
// 머리 · 상태 · 메일 더 보기 · 표 위치 기억(메일을 열었다 돌아올 때)은 목록 틀(MailListFrame)이 맡는다.
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailListHeader from '@/components/mail/MailListHeader.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT, OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
|
||||
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import MailListFrame from '@/components/mail/MailListFrame.vue'
|
||||
import MessageTableHead from '@/components/mail/MessageTableHead.vue'
|
||||
import MessageTableRow from '@/components/mail/MessageTableRow.vue'
|
||||
import { useMailListRows } from '@/composables/useMailListRows'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
interface Props {
|
||||
mailboxId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { messageLocation, replaceWithMailbox } = useMailNavigation()
|
||||
const rows = useMailListRows()
|
||||
|
||||
/** 넓은 화면에서 표 칸 자체가 스크롤된다 — 메일을 열었다 돌아올 때 위치를 되살린다 */
|
||||
const sectionRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const mailbox = computed(() => mailStore.currentMailbox)
|
||||
const isOutgoing = computed(() => {
|
||||
const role = mailbox.value?.role
|
||||
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
|
||||
})
|
||||
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
|
||||
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
|
||||
const whoHeader = computed(() => (isOutgoing.value ? '받는 사람' : '보낸 사람'))
|
||||
const caption = computed(() => `${mailbox.value?.name ?? '메일함'} 메일 목록`)
|
||||
const empty = computed(() =>
|
||||
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
|
||||
const caption = computed(() =>
|
||||
mailStore.search ? MAIL_SEARCH_TEXT.title : `${mailStore.currentMailbox?.name ?? '메일함'} 메일 목록`,
|
||||
)
|
||||
const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null }))
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
|
||||
function whoOf(message: MessageSummary): string {
|
||||
if (isDrafts.value) return draftRecipientNames(message.to) || DRAFT_NO_RECIPIENTS
|
||||
if (isOutgoing.value) return summarizeRecipients(message.to) || '받는 사람 없음'
|
||||
return displayName(message.from) || '(보낸 사람 없음)'
|
||||
/** Shift 면 범위, 아니면 하나 */
|
||||
function onCheck(id: string, range: boolean) {
|
||||
if (range) mailStore.checkRangeTo(id)
|
||||
else mailStore.toggleCheck(id)
|
||||
}
|
||||
|
||||
/** 임시 저장본의 받는 사람이 없다 — 흐린 글자로 */
|
||||
function hasNoDraftRecipients(message: MessageSummary): boolean {
|
||||
return isDrafts.value && draftRecipientNames(message.to) === ''
|
||||
function open(location: RouteLocationRaw) {
|
||||
void router.push(location)
|
||||
}
|
||||
|
||||
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
|
||||
function isUnread(message: MessageSummary): boolean {
|
||||
return !isDrafts.value && !message.isRead
|
||||
}
|
||||
|
||||
/** 행이 여는 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
|
||||
function rowLocation(message: MessageSummary): RouteLocationRaw {
|
||||
return isDrafts.value
|
||||
? { name: 'mail-compose', query: { draft: message.id } }
|
||||
: messageLocation(props.mailboxId, message.id)
|
||||
}
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 링크·별 버튼을 누른 경우와 글자를 고르는 중이면 그대로 둔다 */
|
||||
function onRowClick(event: MouseEvent, message: MessageSummary) {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('a, button')) return
|
||||
if (window.getSelection()?.toString()) return
|
||||
void router.push(rowLocation(message))
|
||||
}
|
||||
|
||||
function retry() {
|
||||
void mailStore.openMailbox(props.mailboxId)
|
||||
}
|
||||
|
||||
/** 없는 메일함(RES_001) — 받은편지함으로 바꿔 끼운다 */
|
||||
function goToInbox() {
|
||||
void replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
}
|
||||
|
||||
// 메일을 열면 이 표가 사라진다 — 스크롤을 기억해 "목록으로" 돌아오면 되살린다 (불러온 페이지는 스토어에 그대로 있다)
|
||||
onBeforeUnmount(() => {
|
||||
viewStore.rememberListPosition({
|
||||
mailboxId: props.mailboxId,
|
||||
scrollTop: sectionRef.value?.scrollTop ?? 0,
|
||||
windowY: window.scrollY,
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = viewStore.takeListPosition(props.mailboxId)
|
||||
const focusId = viewStore.takeReturnFocus()
|
||||
if (!saved && !focusId) return
|
||||
await nextTick()
|
||||
if (saved) {
|
||||
if (sectionRef.value) sectionRef.value.scrollTop = saved.scrollTop
|
||||
if (saved.windowY > 0) window.scrollTo({ top: saved.windowY })
|
||||
}
|
||||
if (focusId) {
|
||||
// 보던 메일의 제목 링크로 초점을 돌려준다 (키보드 사용자가 자리를 잃지 않게)
|
||||
const links = sectionRef.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? []
|
||||
const link = Array.from(links).find((element) => element.dataset.messageId === focusId)
|
||||
link?.focus({ preventScroll: Boolean(saved) })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
<MailListFrame
|
||||
class="message-table"
|
||||
aria-labelledby="message-table-title"
|
||||
:aria-busy="busy"
|
||||
variant="list"
|
||||
title-id="message-table-title"
|
||||
>
|
||||
<MailListHeader
|
||||
title-id="message-table-title"
|
||||
variant="list"
|
||||
/>
|
||||
|
||||
<MessageListSkeleton v-if="mailStore.listStatus === 'loading'" />
|
||||
|
||||
<MailLoadError
|
||||
v-else-if="mailStore.listStatus === 'error' && mailStore.listError"
|
||||
:error="mailStore.listError"
|
||||
leave-label="받은편지함으로"
|
||||
@retry="retry"
|
||||
@leave="goToInbox"
|
||||
/>
|
||||
|
||||
<template v-else-if="mailStore.messages.length > 0">
|
||||
<!-- 표 최소 폭(400px)보다 좁으면 가로로 스크롤한다 -->
|
||||
<div class="message-table__scroll">
|
||||
<table class="mail-table">
|
||||
<caption class="visually-hidden">
|
||||
{{ caption }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="mail-table__col-star">
|
||||
<col class="mail-table__col-who">
|
||||
<col class="mail-table__col-subject">
|
||||
<col class="mail-table__col-date">
|
||||
<col class="mail-table__col-size">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__star"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
/>
|
||||
<span class="visually-hidden">중요 표시</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ whoHeader }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
제목
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__date"
|
||||
>
|
||||
날짜
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__size"
|
||||
>
|
||||
크기
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:class="['mail-table__row', { 'is-unread': isUnread(message) }]"
|
||||
@click="onRowClick($event, message)"
|
||||
>
|
||||
<td class="mail-table__star">
|
||||
<!-- 폭 32px · 행 높이를 채우는 별 — 44px 기준의 의도된 예외 (디자인 README, 사용자 확정) -->
|
||||
<button
|
||||
type="button"
|
||||
:class="['mail-table__star-button', { 'is-flagged': message.isFlagged }]"
|
||||
aria-label="중요 표시"
|
||||
:aria-pressed="message.isFlagged"
|
||||
@click="mailStore.toggleFlag(message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
:filled="message.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td :class="['mail-table__who', { 'is-placeholder': hasNoDraftRecipients(message) }]">
|
||||
<span
|
||||
v-if="isUnread(message)"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ whoOf(message) }}
|
||||
</td>
|
||||
<td class="mail-table__subject">
|
||||
<div class="mail-table__subject-line">
|
||||
<RouterLink
|
||||
:class="['mail-table__link', { 'is-placeholder': isDrafts && !message.subject }]"
|
||||
:to="rowLocation(message)"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
{{ message.subject || MAIL_NO_SUBJECT }}
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
label="첨부 있음"
|
||||
class="mail-table__clip"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mail-table__date">
|
||||
<time :datetime="message.receivedAt">{{ formatMailTableDate(message.receivedAt) }}</time>
|
||||
</td>
|
||||
<td class="mail-table__size">
|
||||
{{ formatFileSize(message.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="mailStore.hasMore"
|
||||
class="message-table__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="message-table__text-button message-table__more-button"
|
||||
:disabled="mailStore.isLoadingMore"
|
||||
@click="mailStore.loadMore()"
|
||||
>
|
||||
{{ mailStore.isLoadingMore ? '불러오는 중…' : '메일 더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="mailStore.listStatus === 'ready'"
|
||||
class="message-table__empty"
|
||||
:icon="emptyIcon"
|
||||
:title="empty.title"
|
||||
:hint="empty.hint"
|
||||
/>
|
||||
</section>
|
||||
<!-- 표 최소 폭(440px · 메일함 칸이 있으면 더 넓다)보다 좁으면 가로로 스크롤한다 -->
|
||||
<div class="message-table__scroll">
|
||||
<table :class="['mail-table', { 'mail-table--mixed': rows.mixed.value }]">
|
||||
<caption class="visually-hidden">
|
||||
{{ caption }}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col class="mail-table__col-check">
|
||||
<col class="mail-table__col-star">
|
||||
<col class="mail-table__col-who">
|
||||
<col
|
||||
v-if="rows.mixed.value"
|
||||
class="mail-table__col-mailbox"
|
||||
>
|
||||
<col class="mail-table__col-subject">
|
||||
<col class="mail-table__col-date">
|
||||
<col class="mail-table__col-size">
|
||||
</colgroup>
|
||||
<MessageTableHead
|
||||
:who-header="rows.whoHeader.value"
|
||||
:mixed="rows.mixed.value"
|
||||
/>
|
||||
<tbody>
|
||||
<MessageTableRow
|
||||
v-for="message in mailStore.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:to="rows.locationOf(message)"
|
||||
:role="rows.roleOf(message)"
|
||||
:mixed="rows.mixed.value"
|
||||
:mailbox-name="rows.mailboxNameOf(message)"
|
||||
:checked="mailStore.checkedIds.has(message.id)"
|
||||
@open="open"
|
||||
@toggle-flag="mailStore.toggleFlag"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</MailListFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-table {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -293,18 +102,31 @@ onMounted(async () => {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
/* 전체 메일함 검색 결과 — 메일함 칸만큼 더 넓어야 칸이 찌그러지지 않는다 */
|
||||
.mail-table--mixed {
|
||||
min-width: calc(var(--table-min-width) + var(--table-col-mailbox) + var(--table-gap));
|
||||
}
|
||||
|
||||
/*
|
||||
* 칸 폭 = 디자인 칸 폭 + 칸 사이(12px). 첫 칸에는 행 왼쪽 여백(20px), 마지막 칸에는 행 오른쪽 여백(8px)을 더한다.
|
||||
* 칸 사이는 각 칸의 오른쪽 안쪽 여백으로 만든다.
|
||||
*/
|
||||
.mail-table__col-check {
|
||||
width: calc(var(--space-20) + var(--table-col-check) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-star {
|
||||
width: calc(var(--space-20) + var(--table-col-star) + var(--table-gap));
|
||||
width: calc(var(--table-col-star) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-who {
|
||||
width: calc(var(--table-col-who) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-mailbox {
|
||||
width: calc(var(--table-col-mailbox) + var(--table-gap));
|
||||
}
|
||||
|
||||
.mail-table__col-date {
|
||||
width: calc(var(--table-col-date) + var(--table-gap));
|
||||
}
|
||||
@@ -313,8 +135,9 @@ onMounted(async () => {
|
||||
width: calc(var(--table-col-size) + var(--space-8));
|
||||
}
|
||||
|
||||
.mail-table th,
|
||||
.mail-table td {
|
||||
/* 칸 공통 — 머리 줄(MessageTableHead)과 행(MessageTableRow)의 칸에 함께 쓴다 */
|
||||
.mail-table :deep(th),
|
||||
.mail-table :deep(td) {
|
||||
padding: 0 var(--table-gap) 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -323,164 +146,35 @@ onMounted(async () => {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mail-table th:first-child,
|
||||
.mail-table td:first-child {
|
||||
.mail-table :deep(th:first-child),
|
||||
.mail-table :deep(td:first-child) {
|
||||
padding-left: var(--space-20);
|
||||
}
|
||||
|
||||
.mail-table th:last-child,
|
||||
.mail-table td:last-child {
|
||||
.mail-table :deep(th:last-child),
|
||||
.mail-table :deep(td:last-child) {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
.mail-table thead th {
|
||||
height: var(--size-table-head);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-table thead .mail-table__star {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-table__row {
|
||||
height: var(--size-table-row);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mail-table__row td {
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mail-table__row:hover {
|
||||
background: var(--color-row-hover);
|
||||
}
|
||||
|
||||
.mail-table__row:focus-within {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.mail-table__star-button {
|
||||
width: var(--size-compact-control);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.mail-table__star-button.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.mail-table__who {
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.mail-table__subject-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.mail-table__link {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mail-table__link:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mail-table__clip {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 날짜·크기는 머리와 행 모두 오른쪽 정렬 — 위의 `.mail-table th/td { text-align: left }` 보다
|
||||
우선하도록 th·td 를 함께 적는다 (클래스만 쓰면 기본 규칙에 져서 행만 왼쪽으로 어긋난다) */
|
||||
.mail-table th.mail-table__date,
|
||||
.mail-table th.mail-table__size,
|
||||
.mail-table td.mail-table__date,
|
||||
.mail-table td.mail-table__size {
|
||||
/* 날짜·크기는 머리와 행 모두 오른쪽 정렬 — 위의 칸 공통 규칙보다 우선하도록 th·td 를 함께 적는다 */
|
||||
.mail-table :deep(th.mail-table__date),
|
||||
.mail-table :deep(th.mail-table__size),
|
||||
.mail-table :deep(td.mail-table__date),
|
||||
.mail-table :deep(td.mail-table__size) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mail-table__date,
|
||||
.mail-table__size {
|
||||
.mail-table :deep(.mail-table__date),
|
||||
.mail-table :deep(.mail-table__size) {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
|
||||
.mail-table__who.is-placeholder,
|
||||
.mail-table__link.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.mail-table__row.is-unread .mail-table__who,
|
||||
.mail-table__row.is-unread .mail-table__link {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-table__row.is-unread .mail-table__date {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.message-table__more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-10) var(--space-20) var(--space-20);
|
||||
}
|
||||
|
||||
.message-table__text-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-table__text-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.message-table__more-button {
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-24);
|
||||
}
|
||||
|
||||
.message-table__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-table__empty {
|
||||
padding-block: calc(var(--space-80) + var(--space-16));
|
||||
}
|
||||
|
||||
/* 1100px 미만 — 크기 칸을 숨기고 날짜 칸이 마지막 칸이 된다 (tokens.css 화면 폭 기준) */
|
||||
@media (max-width: 68.6875rem) {
|
||||
.mail-table__col-size,
|
||||
.mail-table__size {
|
||||
.mail-table :deep(.mail-table__size) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -488,13 +182,13 @@ onMounted(async () => {
|
||||
width: calc(var(--table-col-date) + var(--space-8));
|
||||
}
|
||||
|
||||
.mail-table th.mail-table__date,
|
||||
.mail-table td.mail-table__date {
|
||||
.mail-table :deep(th.mail-table__date),
|
||||
.mail-table :deep(td.mail-table__date) {
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
/* 600px 미만 — 보낸 사람 · 날짜 칸을 줄인다. 표 최소 폭(400px)보다 좁으면 가로 스크롤 */
|
||||
/* 600px 미만 — 보낸 사람 · 날짜 칸을 줄인다. 표 최소 폭보다 좁으면 가로 스크롤 */
|
||||
@media (max-width: 37.4375rem) {
|
||||
.mail-table__col-who {
|
||||
width: calc(var(--table-col-who-narrow) + var(--table-gap));
|
||||
@@ -504,11 +198,4 @@ onMounted(async () => {
|
||||
width: calc(var(--table-col-date-narrow) + var(--space-8));
|
||||
}
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 표 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.message-table {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 표의 머리 줄(32) — 고르기 · 별 · 보낸 사람(받는 사람) · [메일함] · 제목 · 날짜 · 크기 (디자인 원본 BulkSelect · ListStates)
|
||||
// 머리의 고르기 상자는 불러온 메일 모두 고르기 · 풀기 — 일부만 골랐으면 – 표시 (aria-checked="mixed")
|
||||
// 칸 폭 · 칸 사이 여백은 표(MessageTable)가 정한다.
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
/** 보낸 사람 칸 이름 — 보낸편지함 · 임시보관함은 "받는 사람" */
|
||||
whoHeader: string
|
||||
/** 전체 메일함 검색 결과 — 메일함 칸이 생긴다 */
|
||||
mixed?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<thead class="mail-table-head">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__check"
|
||||
>
|
||||
<!-- 폭 32 · 머리 줄 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (디자인 README "3단계", 별 칸과 같다) -->
|
||||
<MailCheckbox
|
||||
class="mail-table-head__check"
|
||||
:checked="mailStore.checkState === 'all'"
|
||||
:mixed="mailStore.checkState === 'some'"
|
||||
:label="MAIL_BULK_TEXT.selectAll"
|
||||
@toggle="mailStore.toggleCheckAll()"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__star"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
/>
|
||||
<span class="visually-hidden">중요 표시</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ whoHeader }}
|
||||
</th>
|
||||
<th
|
||||
v-if="mixed"
|
||||
scope="col"
|
||||
>
|
||||
{{ MAIL_SEARCH_TEXT.mailboxColumn }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
제목
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__date"
|
||||
>
|
||||
날짜
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="mail-table__size"
|
||||
>
|
||||
크기
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-table-head th {
|
||||
height: var(--size-table-head);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mail-table-head__check {
|
||||
width: var(--table-col-check);
|
||||
height: calc(var(--size-table-head) - var(--border-width));
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 보기 표의 한 행(38) — 고르기 · 별 · 보낸 사람 · [메일함] · 제목(링크 + 첨부 클립) · 날짜 · 크기 (디자인 원본 MainList · BulkSelect)
|
||||
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다
|
||||
// - Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 — 메일을 열지 않고, 제목 링크를 눌러도 새 탭 · 새 창으로 열지 않는다
|
||||
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
|
||||
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import type { MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
|
||||
import { isDraftRole, isRowUnread, rowWho } from '@/shared/utils/mail-list-row'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
to: RouteLocationRaw
|
||||
role: MailboxRole | null
|
||||
/** 전체 메일함 검색 결과 — 메일함 칸이 생기고, 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" */
|
||||
mixed?: boolean
|
||||
mailboxName?: string | null
|
||||
checked?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'open', location: RouteLocationRaw): void
|
||||
(e: 'toggle-flag', id: string): void
|
||||
/** 고르기 · 풀기 — range 면 Shift(범위) */
|
||||
(e: 'check', id: string, range: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
mixed: false,
|
||||
mailboxName: null,
|
||||
checked: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'table' }))
|
||||
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
|
||||
const unread = computed(() => isRowUnread(props.message, props.role))
|
||||
const draft = computed(() => isDraftRole(props.role))
|
||||
|
||||
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
|
||||
function onRowClick(event: MouseEvent) {
|
||||
const target = event.target instanceof Element ? event.target : null
|
||||
if (target?.closest('button')) return
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
event.preventDefault()
|
||||
emit('check', props.message.id, event.shiftKey)
|
||||
return
|
||||
}
|
||||
if (target?.closest('a')) return
|
||||
if (window.getSelection()?.toString()) return
|
||||
emit('open', props.to)
|
||||
}
|
||||
|
||||
/** Shift + 누름이 글자를 고르지 않게 한다 */
|
||||
function onMouseDown(event: MouseEvent) {
|
||||
if (event.shiftKey) event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
:class="['mail-table__row', { 'is-unread': unread, 'is-checked': checked }]"
|
||||
:data-row-id="message.id"
|
||||
@click="onRowClick"
|
||||
@mousedown="onMouseDown"
|
||||
>
|
||||
<td class="mail-table__check">
|
||||
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (디자인 README "3단계", 별 칸과 같다) -->
|
||||
<MailCheckbox
|
||||
class="mail-table__check-button"
|
||||
:checked="checked"
|
||||
:label="MAIL_BULK_TEXT.selectOne(subject)"
|
||||
@toggle="(event: MouseEvent) => emit('check', message.id, event.shiftKey)"
|
||||
/>
|
||||
</td>
|
||||
<td class="mail-table__star">
|
||||
<!-- 폭 32px · 행 높이를 채우는 별 — 44px 기준의 의도된 예외 (디자인 README, 사용자 확정) -->
|
||||
<button
|
||||
type="button"
|
||||
:class="['mail-table__star-button', { 'is-flagged': message.isFlagged }]"
|
||||
aria-label="중요 표시"
|
||||
:aria-pressed="message.isFlagged"
|
||||
@click="emit('toggle-flag', message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
name="star"
|
||||
:size="14"
|
||||
:filled="message.isFlagged"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td :class="['mail-table__who', { 'is-placeholder': who.placeholder }]">
|
||||
<span
|
||||
v-if="unread"
|
||||
class="visually-hidden"
|
||||
>안 읽음, </span>{{ who.text }}
|
||||
</td>
|
||||
<td
|
||||
v-if="mixed"
|
||||
class="mail-table__mailbox"
|
||||
>
|
||||
{{ mailboxName ?? '' }}
|
||||
</td>
|
||||
<td class="mail-table__subject">
|
||||
<div class="mail-table__subject-line">
|
||||
<RouterLink
|
||||
:class="['mail-table__link', { 'is-placeholder': draft && !message.subject }]"
|
||||
:to="to"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
{{ subject }}
|
||||
</RouterLink>
|
||||
<AppIcon
|
||||
v-if="message.hasAttachment"
|
||||
name="paperclip"
|
||||
:size="13"
|
||||
:stroke-width="2"
|
||||
label="첨부 있음"
|
||||
class="mail-table__clip"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mail-table__date">
|
||||
<time :datetime="message.receivedAt">{{ formatMailTableDate(message.receivedAt) }}</time>
|
||||
</td>
|
||||
<td class="mail-table__size">
|
||||
{{ formatFileSize(message.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-table__row {
|
||||
height: var(--size-table-row);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mail-table__row td {
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.mail-table__row:hover {
|
||||
background: var(--color-row-hover);
|
||||
}
|
||||
|
||||
.mail-table__row:focus-within {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 고른 행 (디자인 원본 BulkSelect) */
|
||||
.mail-table__row.is-checked {
|
||||
background: var(--color-row-checked);
|
||||
}
|
||||
|
||||
.mail-table__row.is-checked:hover {
|
||||
background: var(--color-row-checked-hover);
|
||||
}
|
||||
|
||||
.mail-table__check-button {
|
||||
width: var(--table-col-check);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mail-table__star-button {
|
||||
width: var(--size-compact-control);
|
||||
height: calc(var(--size-table-row) - var(--border-width));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.mail-table__star-button.is-flagged {
|
||||
color: var(--color-star);
|
||||
}
|
||||
|
||||
.mail-table__who {
|
||||
font-weight: 450;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
/* 전체 메일함 검색 결과의 메일함 칸 (디자인 원본 ListStates) */
|
||||
.mail-table__mailbox {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mail-table__subject-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.mail-table__link {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mail-table__link:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mail-table__clip {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
|
||||
.mail-table__who.is-placeholder,
|
||||
.mail-table__link.is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
|
||||
.mail-table__row.is-unread .mail-table__who,
|
||||
.mail-table__row.is-unread .mail-table__link {
|
||||
font-weight: 650;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mail-table__row.is-unread .mail-table__date {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 전역 셸 — 왼쪽 앱 레일 + 상단 바 + 모듈 영역(slot).
|
||||
// 모든 모듈 레이아웃은 이 셸로 감싼다: <AppShell module-id="mail"> … </AppShell>
|
||||
// 모듈의 검색 칸은 search 슬롯으로 상단 바에 넣는다: <template #search><MailSearchBox /></template> (없으면 비활성 통합 검색)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppRail from './AppRail.vue'
|
||||
@@ -20,7 +21,14 @@ const moduleLabel = computed(() => findAppModule(props.moduleId).label)
|
||||
<div class="app-shell">
|
||||
<AppRail :active-module="moduleId" />
|
||||
<div class="app-shell__main">
|
||||
<AppTopBar :title="moduleLabel" />
|
||||
<AppTopBar :title="moduleLabel">
|
||||
<template
|
||||
v-if="$slots.search"
|
||||
#search
|
||||
>
|
||||
<slot name="search" />
|
||||
</template>
|
||||
</AppTopBar>
|
||||
<div class="app-shell__module">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 바 — 모듈 이름 · 통합 검색 · 로그인한 직원 · 로그아웃
|
||||
// 상단 바 — 모듈 이름 · 검색 칸 · 로그인한 직원 · 로그아웃
|
||||
// 검색 칸은 모듈이 search 슬롯으로 넣는다(메일: "메일 검색" — MailSearchBox). 넣지 않은 모듈은 비활성 "통합 검색"(준비 중)이다.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -39,23 +40,25 @@ async function onLogout() {
|
||||
<header class="top-bar">
|
||||
<span class="top-bar__title">{{ title }}</span>
|
||||
|
||||
<!-- 통합 검색은 1단계 범위 밖이다 (docs/mail/api-contract.md "이 단계에서 하지 않는 것") -->
|
||||
<div
|
||||
class="top-bar__search"
|
||||
:title="MODULE_NOT_READY_LABEL"
|
||||
>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="top-bar__search-icon"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
class="top-bar__search-input"
|
||||
:aria-label="`통합 검색 (${MODULE_NOT_READY_LABEL})`"
|
||||
placeholder="통합 검색"
|
||||
disabled
|
||||
<slot name="search">
|
||||
<!-- 모듈을 가로지르는 통합 검색은 아직 없다 (docs/mail/api-contract.md "이 단계에서 하지 않는 것") -->
|
||||
<div
|
||||
class="top-bar__search"
|
||||
:title="MODULE_NOT_READY_LABEL"
|
||||
>
|
||||
</div>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="top-bar__search-icon"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
class="top-bar__search-input"
|
||||
:aria-label="`통합 검색 (${MODULE_NOT_READY_LABEL})`"
|
||||
placeholder="통합 검색"
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
v-if="authStore.user"
|
||||
@@ -109,7 +112,7 @@ async function onLogout() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
height: 2.5rem;
|
||||
height: var(--size-search-box);
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { listLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore, type MailBulkResult } from '@/stores/mail.store'
|
||||
|
||||
// 여러 통 처리 — 도구 줄의 단추가 하는 일 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리")
|
||||
// - 읽음 · 안 읽음 · 중요 표시: 목록이 바로 바뀌므로 알리지 않는다 (서버가 바꾸지 못한 메일만 되돌린다)
|
||||
// - 이동 · 삭제(휴지통 밖 — 휴지통으로 옮긴다) · 영구 삭제(휴지통에서만, 확인 대화상자를 거친 뒤): 알림은 useMailMoveFeedback
|
||||
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
|
||||
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
|
||||
|
||||
export function useBulkMailActions() {
|
||||
const mailStore = useMailStore()
|
||||
const router = useRouter()
|
||||
const feedback = useMailMoveFeedback()
|
||||
|
||||
/** 옮기기 · 지우기를 하는 중 — 끝날 때까지 다시 누르지 못한다 (읽음 · 중요 표시는 막지 않는다) */
|
||||
const busy = ref<boolean>(false)
|
||||
|
||||
/** 고른 메일이 모두 중요 표시 — 단추가 "중요 해제"가 된다 */
|
||||
const allFlagged = computed<boolean>(
|
||||
() => mailStore.checkedMessages.length > 0 && mailStore.checkedMessages.every((message) => message.isFlagged),
|
||||
)
|
||||
/** 휴지통을 보는 중 — "삭제"가 "영구 삭제"가 된다 (휴지통 안의 검색 포함) */
|
||||
const inTrash = computed<boolean>(() => mailStore.currentMailbox?.role === 'trash')
|
||||
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
|
||||
const moveTargets = computed<Mailbox[]>(() =>
|
||||
mailStore.orderedMailboxes
|
||||
.map((node) => node.mailbox)
|
||||
.filter(
|
||||
(mailbox) =>
|
||||
mailbox.id !== mailStore.currentMailboxId && mailbox.role !== 'drafts' && mailbox.role !== 'trash',
|
||||
),
|
||||
)
|
||||
|
||||
async function markRead(isRead: boolean): Promise<void> {
|
||||
await mailStore.patchChecked({ isRead })
|
||||
}
|
||||
|
||||
/** 중요 표시 — 모두 중요면 해제, 아니면 모두 중요로 */
|
||||
async function toggleFlagged(): Promise<void> {
|
||||
await mailStore.patchChecked({ isFlagged: !allFlagged.value })
|
||||
}
|
||||
|
||||
/** 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 (기록을 쌓지 않는다) */
|
||||
async function leaveRemovedMessage(result: MailBulkResult): Promise<void> {
|
||||
const openId = mailStore.selectedId
|
||||
const source = mailStore.listSource
|
||||
if (result.stale || !openId || !source || !result.doneIds.includes(openId)) return
|
||||
if (mailStore.messages.some((message) => message.id === openId)) return
|
||||
await router.replace(listLocationOf(source))
|
||||
}
|
||||
|
||||
async function moveTo(mailbox: Mailbox): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.moveChecked(mailbox.id)
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announceMove(result, mailbox.name)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 휴지통 밖의 삭제 — 휴지통으로 옮긴다 (DELETE 가 아니다 — 그사이 다른 창이 휴지통으로 옮긴 메일을 영구 삭제하지 않게) */
|
||||
async function moveToTrash(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
const trash = await mailStore.resolveMailboxByRole('trash')
|
||||
if ('error' in trash) {
|
||||
notifyApiError(trash.error)
|
||||
return
|
||||
}
|
||||
await moveTo(trash.mailbox)
|
||||
}
|
||||
|
||||
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
|
||||
async function purge(): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.purgeChecked()
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announcePurge(result)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { busy, allFlagged, inTrash, moveTargets, markRead, toggleFlagged, moveTo, moveToTrash, purge }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, nextTick, type EffectScope } from 'vue'
|
||||
|
||||
import { useDraftTrash } from '@/composables/useDraftTrash'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore, type ToastAction } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
|
||||
// 쓰기 화면의 휴지통 · 되돌리기 (디자인 README "2단계 › 임시 저장") — 되돌리기는 알림을 띄운 세션에서만 움직인다
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 1, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [mailbox('drafts', 'drafts', '임시보관함'), mailbox('trash', 'trash', '휴지통')]
|
||||
|
||||
function summary(id: string): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['drafts'],
|
||||
from: null,
|
||||
to: [],
|
||||
subject: '',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 1,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useDraftTrash (쓰기 화면의 휴지통)', () => {
|
||||
let scope: EffectScope
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().startSession(USER)
|
||||
scope = effectScope()
|
||||
requests = installMockApi(({ method, url, data }) => {
|
||||
if (url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
if (method === 'PATCH') return ok({ ...summary('d1'), mailboxIds: [(data as { mailboxId: string }).mailboxId] })
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function discard(): Promise<ToastAction> {
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
const trash = scope.run(() => useDraftTrash())
|
||||
if (!trash) throw new Error('scope')
|
||||
await trash.discard('d1')
|
||||
const action = useToastStore().toasts[0]?.action
|
||||
if (!action || !('run' in action)) throw new Error('되돌리기 단추가 없다')
|
||||
return action
|
||||
}
|
||||
|
||||
const patchesTo = (mailboxId: string) =>
|
||||
requests.filter(
|
||||
(request) => request.method === 'PATCH' && (request.data as { mailboxId?: string }).mailboxId === mailboxId,
|
||||
)
|
||||
|
||||
it('휴지통으로 옮기고(PATCH) [되돌리기] — 누르면 임시보관함으로 되돌린다', async () => {
|
||||
const action = await discard()
|
||||
expect(patchesTo('trash')).toHaveLength(1)
|
||||
expect(useToastStore().toasts[0]?.message).toBe('임시 저장한 메일을 휴지통으로 옮겼습니다.')
|
||||
|
||||
if ('run' in action) action.run()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchesTo('drafts')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('그사이 로그아웃했으면(다시 로그인했어도) 되돌리기는 아무것도 하지 않고, 알림도 닫힌다', async () => {
|
||||
const action = await discard()
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
useAuthStore().startSession(USER)
|
||||
await nextTick()
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
if ('run' in action) action.run()
|
||||
await flushPromises()
|
||||
expect(patchesTo('drafts')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage, notifyApiError, toApiError } from './useApi'
|
||||
import { notifyApiError, toApiError } from './useApi'
|
||||
import { useMail } from './useMail'
|
||||
import { useToast } from './useToast'
|
||||
import { DRAFT_DISCARDED_MESSAGE, DRAFT_RESTORED_MESSAGE, DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
|
||||
@@ -21,18 +21,14 @@ export function useDraftTrash() {
|
||||
const toast = useToast()
|
||||
|
||||
async function moveToTrash(draftId: string): Promise<DraftTrashResult> {
|
||||
let trash = mailStore.mailboxByRole('trash')
|
||||
if (!trash) {
|
||||
// 메일함을 아직 못 받았다(받는 중 · 실패) — 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
|
||||
const loadError = await mailStore.loadMailboxes({ background: true })
|
||||
trash = mailStore.mailboxByRole('trash')
|
||||
if (!trash) {
|
||||
notifyApiError(loadError ?? { code: 'BIZ_001', message: getErrorMessage('BIZ_001') })
|
||||
return 'failed'
|
||||
}
|
||||
// 메일함을 아직 못 받았으면(받는 중 · 실패) 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
|
||||
const trash = await mailStore.resolveMailboxByRole('trash')
|
||||
if ('error' in trash) {
|
||||
notifyApiError(trash.error)
|
||||
return 'failed'
|
||||
}
|
||||
try {
|
||||
await updateMessage(draftId, { mailboxId: trash.id }, { silent: ['RES_001'] })
|
||||
await updateMessage(draftId, { mailboxId: trash.mailbox.id }, { silent: ['RES_001'] })
|
||||
return 'moved'
|
||||
} catch (error) {
|
||||
// 그 밖의 실패는 인터셉터가 알렸다
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||
|
||||
import { listKeyOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 새 메일 확인으로 목록을 맞출 때의 스크롤 · 알림 줄 (디자인 README "3단계 › 새 메일")
|
||||
// - 맨 위를 보고 있으면 새 메일이 그냥 위에 생긴다
|
||||
// - 목록을 내려 본 채면 보던 행을 같은 자리에 두고(위에 행이 생겨도 화면이 밀리지 않게), 머리 아래에 "새 메일 N통 · 맨 위로"를 띄운다
|
||||
// — 누르거나 스스로 맨 위로 올라가면 사라진다. 다른 목록으로 바꾸면 사라진다
|
||||
// 넓은 화면은 목록 칸이, 좁은 화면은 창 전체가 스크롤된다 (AppShell) — 둘 다 같은 규칙이다.
|
||||
// 행은 data-row-id 를 단다 (분할 보기 행 · 표 행).
|
||||
|
||||
/** 이만큼(px)보다 덜 내려갔으면 맨 위로 본다 */
|
||||
const SCROLL_TOP_TOLERANCE = 1
|
||||
|
||||
interface RowAnchor {
|
||||
id: string
|
||||
/** 화면에서의 위쪽 위치 */
|
||||
top: number
|
||||
}
|
||||
|
||||
export function useListArrivalNotice(sectionRef: Ref<HTMLElement | null>, topRef: Ref<HTMLElement | null>) {
|
||||
const mailStore = useMailStore()
|
||||
/** 알림 줄의 "N통" — 0 이면 줄이 없다 */
|
||||
const noticeCount = ref<number>(0)
|
||||
|
||||
function sectionScrolled(): boolean {
|
||||
return (sectionRef.value?.scrollTop ?? 0) > SCROLL_TOP_TOLERANCE
|
||||
}
|
||||
|
||||
/** 목록을 내려 본 채인지 — 목록 칸이 스크롤되었거나(넓은 화면) 창이 목록 위쪽을 지나 스크롤되었다(좁은 화면) */
|
||||
function isScrolledDown(): boolean {
|
||||
const section = sectionRef.value
|
||||
if (!section) return false
|
||||
return sectionScrolled() || section.getBoundingClientRect().top < -SCROLL_TOP_TOLERANCE
|
||||
}
|
||||
|
||||
function rows(): HTMLElement[] {
|
||||
return Array.from(sectionRef.value?.querySelectorAll<HTMLElement>('[data-row-id]') ?? [])
|
||||
}
|
||||
|
||||
/** 머리 묶음 바로 아래에 보이는 첫 행 — 새 행이 위에 생겨도 이 행이 같은 자리에 남게 한다 */
|
||||
function firstVisibleRow(): RowAnchor | null {
|
||||
const line = topRef.value?.getBoundingClientRect().bottom ?? 0
|
||||
for (const row of rows()) {
|
||||
const rect = row.getBoundingClientRect()
|
||||
if (rect.bottom > line && row.dataset.rowId) return { id: row.dataset.rowId, top: rect.top }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function keepAnchor(anchor: RowAnchor, viaSection: boolean) {
|
||||
const row = rows().find((element) => element.dataset.rowId === anchor.id)
|
||||
if (!row) return
|
||||
const delta = row.getBoundingClientRect().top - anchor.top
|
||||
if (Math.abs(delta) < SCROLL_TOP_TOLERANCE) return
|
||||
const section = sectionRef.value
|
||||
if (viaSection && section) section.scrollTop += delta
|
||||
else window.scrollBy(0, delta)
|
||||
}
|
||||
|
||||
/** 맨 위로 — 알림 줄을 치우고 맨 위의 메일에 초점을 준다 (키보드 사용자가 새 메일부터 보게) */
|
||||
function goTop() {
|
||||
const section = sectionRef.value
|
||||
noticeCount.value = 0
|
||||
if (!section) return
|
||||
section.scrollTop = 0
|
||||
if (section.getBoundingClientRect().top < 0 && typeof section.scrollIntoView === 'function') {
|
||||
section.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
section.querySelector<HTMLElement>('[data-row-id] [data-message-id]')?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (noticeCount.value > 0 && !isScrolledDown()) noticeCount.value = 0
|
||||
}
|
||||
|
||||
// 목록을 바꾸기 직전(sync — 화면은 아직 옛 목록)에 보던 행을 잡아 두고, 바뀐 뒤 그 행을 같은 자리에 되돌린다
|
||||
watch(
|
||||
() => mailStore.listSync,
|
||||
(sync) => {
|
||||
if (!sync) return
|
||||
const scrolled = isScrolledDown()
|
||||
const viaSection = sectionScrolled()
|
||||
const anchor = scrolled ? firstVisibleRow() : null
|
||||
void nextTick(() => {
|
||||
if (anchor) keepAnchor(anchor, viaSection)
|
||||
else if (sectionRef.value && !scrolled) sectionRef.value.scrollTop = 0
|
||||
if (scrolled && sync.addedAbove > 0) noticeCount.value += sync.addedAbove
|
||||
})
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
// 다른 목록(메일함 · 검색)으로 바꾸면 알림 줄을 치운다
|
||||
watch(
|
||||
() => (mailStore.listSource ? listKeyOf(mailStore.listSource) : null),
|
||||
() => {
|
||||
noticeCount.value = 0
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
sectionRef.value?.addEventListener('scroll', onScroll, { passive: true })
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sectionRef.value?.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
})
|
||||
|
||||
return { noticeCount, goTop }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, type Ref } from 'vue'
|
||||
|
||||
import { listKeyOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
/**
|
||||
* 목록 보기의 표 위치 기억 — 메일을 열면 표가 사라지므로, 사라질 때 스크롤을 기억했다가 "목록으로" 돌아오면 되살리고
|
||||
* 보던 메일의 제목 링크로 초점을 돌려준다(키보드 사용자가 자리를 잃지 않게). 메일함 · 검색 결과마다 따로 기억한다.
|
||||
* 불러온 페이지는 스토어에 그대로 있다. 넓은 화면은 칸(section)이, 좁은 화면은 창이 스크롤된다.
|
||||
*/
|
||||
export function useListPositionMemory(sectionRef: Ref<HTMLElement | null>, enabled: () => boolean) {
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const source = mailStore.listSource
|
||||
if (!enabled() || !source) return
|
||||
viewStore.rememberListPosition({
|
||||
listKey: listKeyOf(source),
|
||||
scrollTop: sectionRef.value?.scrollTop ?? 0,
|
||||
windowY: window.scrollY,
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const source = mailStore.listSource
|
||||
if (!enabled() || !source) return
|
||||
const saved = viewStore.takeListPosition(listKeyOf(source))
|
||||
const focusId = viewStore.takeReturnFocus()
|
||||
if (!saved && !focusId) return
|
||||
await nextTick()
|
||||
if (saved) {
|
||||
if (sectionRef.value) sectionRef.value.scrollTop = saved.scrollTop
|
||||
if (saved.windowY > 0) window.scrollTo({ top: saved.windowY })
|
||||
}
|
||||
if (focusId) {
|
||||
const links = sectionRef.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? []
|
||||
const link = Array.from(links).find((element) => element.dataset.messageId === focusId)
|
||||
link?.focus({ preventScroll: Boolean(saved) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -13,10 +13,15 @@ import type {
|
||||
DraftSaved,
|
||||
MailQuota,
|
||||
MailSignature,
|
||||
MailState,
|
||||
Mailbox,
|
||||
MessageDeletionRequest,
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
MessageListQuery,
|
||||
MessageSummary,
|
||||
MessagesPatch,
|
||||
MessagesPatched,
|
||||
RecipientSuggestion,
|
||||
RecipientSuggestionQuery,
|
||||
SaveSignaturePayload,
|
||||
@@ -64,7 +69,7 @@ function startBrowserDownload(url: string): void {
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
// 메일 API Composable — docs/mail/api-contract.md 2절의 모든 엔드포인트
|
||||
// 메일 API Composable — docs/mail/api-contract.md 2절의 엔드포인트 (DELETE /messages/{id} 만 웹은 쓰지 않는다 — 아래 updateMessage 뒤 설명)
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
|
||||
export function useMail() {
|
||||
const api = useApi()
|
||||
@@ -78,7 +83,18 @@ export function useMail() {
|
||||
return api.get<MailQuota, MailQuota>('/mail-quota', options)
|
||||
}
|
||||
|
||||
/** 받은 시각 내림차순. cursor 는 pageInfo.nextCursor 를 해석하지 말고 그대로 넘긴다 */
|
||||
/**
|
||||
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
|
||||
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
|
||||
*/
|
||||
function getMailState(options?: MailRequestOptions): Promise<MailState> {
|
||||
return api.get<MailState, MailState>('/mail-state', options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 받은 시각 내림차순. cursor 는 pageInfo.nextCursor 를 해석하지 말고 그대로 넘긴다.
|
||||
* 검색(q)은 mailboxId 가 있으면 그 메일함, 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다
|
||||
*/
|
||||
function listMessages(
|
||||
query: MessageListQuery,
|
||||
options?: MailRequestOptions,
|
||||
@@ -102,9 +118,26 @@ export function useMail() {
|
||||
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes, options)
|
||||
}
|
||||
|
||||
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 (204) */
|
||||
async function deleteMessage(id: string, options?: MailRequestOptions): Promise<void> {
|
||||
await api.delete(`/messages/${segment(id)}`, options)
|
||||
// DELETE /messages/{id} 는 부르지 않는다 — 휴지통이면 확인 없이 영구 삭제하므로, 그사이 다른 창이 휴지통으로 옮긴 메일을
|
||||
// 지울 수 있다. 삭제는 휴지통으로 옮기기(updateMessages { mailboxId }), 영구 삭제는 휴지통에서 확인한 뒤 deleteMessages 다.
|
||||
|
||||
/**
|
||||
* 여러 통의 읽음 · 중요 표시 · 이동 (PATCH /messages) — ids 는 100개까지 (부르는 쪽이 나눠 보낸다).
|
||||
* 503 MAIL_001 이면 일부만 바뀌었을 수 있다 — 같은 요청을 다시 보내도 결과가 같다(멱등)
|
||||
*/
|
||||
function updateMessages(patch: MessagesPatch, options?: MailRequestOptions): Promise<MessagesPatched> {
|
||||
return api.patch<MessagesPatched, MessagesPatched>('/messages', patch, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴지통에서 영구 삭제 (POST /message-deletions) — 휴지통에 있는 메일만 다룬다. ids 는 100개까지.
|
||||
* 확인한 상태 그대로일 때만 지우고, 계속 어긋나면 아무것도 지우지 않고 503 MAIL_001
|
||||
*/
|
||||
function deleteMessages(
|
||||
request: MessageDeletionRequest,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<MessageDeletionResult> {
|
||||
return api.post<MessageDeletionResult, MessageDeletionResult>('/message-deletions', request, options)
|
||||
}
|
||||
|
||||
// ── 임시 저장 (계약 2절 "임시 저장") ─────────────────────
|
||||
@@ -218,10 +251,12 @@ export function useMail() {
|
||||
return {
|
||||
listMailboxes,
|
||||
getQuota,
|
||||
getMailState,
|
||||
listMessages,
|
||||
getMessage,
|
||||
updateMessage,
|
||||
deleteMessage,
|
||||
updateMessages,
|
||||
deleteMessages,
|
||||
sendMessage,
|
||||
uploadAttachment,
|
||||
attachmentDownloadUrl,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import type { Mailbox, MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { isDraftRole, mailboxOfMessage, whoColumnLabel } from '@/shared/utils/mail-list-row'
|
||||
import { isAllMailboxSearch, messageLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 보이는 목록의 행마다 필요한 값 — 분할 보기 행(MessageListItem)과 목록 보기 표(MessageTable)가 함께 쓴다.
|
||||
* - 메일함 목록 · 한 메일함 안의 검색: 모든 행이 그 메일함의 규칙(임시보관함 · 보낸편지함)을 따른다
|
||||
* - 전체 메일함 검색: 행마다 그 메일의 메일함 — 메일함 이름을 함께 보인다 (디자인 README "3단계 › 검색")
|
||||
* - 누르면 갈 곳: 임시 저장본은 쓰기 화면에서 이어 쓰고, 그 밖은 지금 목록 안의 메일 주소 (검색 결과면 /mail/search/<id>?…)
|
||||
*/
|
||||
export function useMailListRows() {
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/** 전체 메일함에서 찾은 결과 — 행마다 메일함이 다르다 */
|
||||
const mixed = computed<boolean>(() => isAllMailboxSearch(mailStore.listSource))
|
||||
/** 보낸 사람 칸 머리 — 보낸편지함 · 임시보관함(한 메일함일 때)은 받는 사람 */
|
||||
const whoHeader = computed<string>(() => whoColumnLabel(mailStore.currentMailbox?.role ?? null, mixed.value))
|
||||
|
||||
function mailboxOf(message: MessageSummary): Mailbox | null {
|
||||
return mixed.value ? mailboxOfMessage(message, mailStore.mailboxes) : mailStore.currentMailbox
|
||||
}
|
||||
|
||||
function roleOf(message: MessageSummary): MailboxRole | null {
|
||||
return mailboxOf(message)?.role ?? null
|
||||
}
|
||||
|
||||
/** 행에 보일 메일함 이름 — 전체 메일함 검색 결과에서만 */
|
||||
function mailboxNameOf(message: MessageSummary): string | null {
|
||||
return mixed.value ? (mailboxOf(message)?.name ?? null) : null
|
||||
}
|
||||
|
||||
function locationOf(message: MessageSummary): RouteLocationRaw {
|
||||
if (isDraftRole(roleOf(message))) return { name: 'mail-compose', query: { draft: message.id } }
|
||||
const source = mailStore.listSource
|
||||
return source ? messageLocationOf(source, message.id) : { name: 'mail' }
|
||||
}
|
||||
|
||||
return { mixed, whoHeader, roleOf, mailboxNameOf, locationOf }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useToast } from './useToast'
|
||||
import { DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
|
||||
import { movedMessageText, purgedMessageText, restoredMessageText, type BulkOrigin } from '@/shared/utils/mail-bulk'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리") — 도구 줄과 읽기 화면의 「삭제」가 함께 쓴다.
|
||||
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고 "메일 N통을 되돌렸습니다.")
|
||||
* - "메일 N통을 영구 삭제했습니다."
|
||||
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
|
||||
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
|
||||
* 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast)
|
||||
*/
|
||||
export function useMailMoveFeedback() {
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
|
||||
async function undo(origins: readonly BulkOrigin[]): Promise<void> {
|
||||
const result = await mailStore.restoreMoved(origins)
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped))
|
||||
}
|
||||
|
||||
function announceMove(result: BulkMoveResult, mailboxName: string) {
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done === 0 && skipped === 0) return
|
||||
const origins = result.origins
|
||||
toast.success(
|
||||
movedMessageText(done, mailboxName, skipped),
|
||||
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins) } : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
function announcePurge(result: MailBulkResult) {
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done > 0 || skipped > 0) toast.success(purgedMessageText(done, skipped))
|
||||
}
|
||||
|
||||
return { announceMove, announcePurge }
|
||||
}
|
||||
@@ -1,34 +1,55 @@
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
listLocationOf,
|
||||
messageLocationOf,
|
||||
type MailListSource,
|
||||
} from '@/shared/utils/mail-list-source'
|
||||
|
||||
/**
|
||||
* 메일 화면 사이 이동 규칙 — 브라우저 기록(history)을 깔끔하게 유지한다.
|
||||
* - 이전·다음 메일은 기록을 바꿔 끼운다: 몇 통을 넘겨 봐도 "뒤로"는 목록으로 간다
|
||||
* - "목록으로"는 바로 앞 기록이 그 목록이면 뒤로 가기, 아니면 목록으로 바꿔 끼운다
|
||||
* - 없는 주소(404 RES_001)에서 다른 메일함으로 빠져나갈 때도 바꿔 끼운다: 뒤로 가기로 그 주소에 다시 오지 않게
|
||||
* 목록은 메일함 또는 검색 결과다 (mail-list-source.ts) — 검색 결과에서 연 메일은 /mail/search/<메일 id>?q=…&in=…
|
||||
*/
|
||||
export function useMailNavigation() {
|
||||
const router = useRouter()
|
||||
|
||||
function mailboxLocation(mailboxId: string): RouteLocationRaw {
|
||||
return { name: 'mail-mailbox', params: { mailboxId } }
|
||||
return listLocationOf({ kind: 'mailbox', mailboxId })
|
||||
}
|
||||
|
||||
function messageLocation(mailboxId: string, messageId: string): RouteLocationRaw {
|
||||
return { name: 'mail-message', params: { mailboxId, messageId } }
|
||||
return messageLocationOf({ kind: 'mailbox', mailboxId }, messageId)
|
||||
}
|
||||
|
||||
async function backToList(mailboxId: string): Promise<void> {
|
||||
const listPath = router.resolve(mailboxLocation(mailboxId)).fullPath
|
||||
if (router.options.history.state.back === listPath) {
|
||||
/** 바로 앞 기록이 그곳이면 뒤로 가기, 아니면 바꿔 끼운다 (기록을 쌓지 않는다) */
|
||||
async function backOrReplace(location: RouteLocationRaw): Promise<void> {
|
||||
const path = router.resolve(location).fullPath
|
||||
if (router.options.history.state.back === path) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
await router.replace(mailboxLocation(mailboxId))
|
||||
await router.replace(location)
|
||||
}
|
||||
|
||||
/** 목록으로 — 메일함 목록 또는 검색 결과 */
|
||||
async function backToSourceList(source: MailListSource): Promise<void> {
|
||||
await backOrReplace(listLocationOf(source))
|
||||
}
|
||||
|
||||
async function backToList(mailboxId: string): Promise<void> {
|
||||
await backToSourceList({ kind: 'mailbox', mailboxId })
|
||||
}
|
||||
|
||||
async function showSiblingIn(source: MailListSource, messageId: string): Promise<void> {
|
||||
await router.replace(messageLocationOf(source, messageId))
|
||||
}
|
||||
|
||||
async function showSibling(mailboxId: string, messageId: string): Promise<void> {
|
||||
await router.replace(messageLocation(mailboxId, messageId))
|
||||
await showSiblingIn({ kind: 'mailbox', mailboxId }, messageId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,5 +60,14 @@ export function useMailNavigation() {
|
||||
await router.replace(mailboxId ? mailboxLocation(mailboxId) : { name: 'mail' })
|
||||
}
|
||||
|
||||
return { mailboxLocation, messageLocation, backToList, showSibling, replaceWithMailbox }
|
||||
return {
|
||||
mailboxLocation,
|
||||
messageLocation,
|
||||
backOrReplace,
|
||||
backToList,
|
||||
backToSourceList,
|
||||
showSibling,
|
||||
showSiblingIn,
|
||||
replaceWithMailbox,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { getErrorMessage, notifyApiError } from './useApi'
|
||||
import { useMailNavigation } from './useMailNavigation'
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import {
|
||||
exceedsSearchWords,
|
||||
isSearchRouteName,
|
||||
listLocationOf,
|
||||
normalizeSearchText,
|
||||
scopeMailboxIdOf,
|
||||
searchSourceFromQuery,
|
||||
type SearchListSource,
|
||||
} from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore, type SearchOrigin } from '@/stores/mail.store'
|
||||
|
||||
/** 메일함 화면(목록 · 연 메일)의 라우트 */
|
||||
const MAILBOX_ROUTES: readonly string[] = ['mail-mailbox', 'mail-message']
|
||||
|
||||
function paramOf(value: unknown): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 화면 오가기 (계약 2절 "검색" · 디자인 README "3단계 › 검색")
|
||||
* - 상단 검색 칸의 Enter: 메일함에서 시작하면 그 메일함에서 찾고(휴지통 · 스팸메일함에서 시작해도 그 안에서), 검색 화면에서는 지금 범위 그대로
|
||||
* - 결과 머리의 범위 전환: "그 메일함 | 전체 메일함" — 기록을 쌓지 않고 바꿔 끼운다
|
||||
* - ✕ · Esc · "검색 닫기": 검색 전 메일함(화면)으로 — 바로 앞 기록이 그곳이면 뒤로 가기 (뒤로 가기: 메일 → 결과 → 검색 전 메일함)
|
||||
*/
|
||||
export function useMailSearchNavigation() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const { backOrReplace } = useMailNavigation()
|
||||
|
||||
const isSearching = computed<boolean>(() => isSearchRouteName(route.name))
|
||||
/** 지금 주소의 검색 — 검색 화면이 아니거나 검색어가 없으면 null */
|
||||
const routeSearch = computed<SearchListSource | null>(() =>
|
||||
isSearching.value ? searchSourceFromQuery(route.query) : null,
|
||||
)
|
||||
|
||||
/** 검색을 시작한 메일함 — 범위 줄의 "그 메일함". 모르면(주소로 바로 들어옴) 지금 범위, 그것도 없으면 기본 메일함 */
|
||||
const originMailbox = computed<Mailbox | null>(() => {
|
||||
const id = mailStore.searchOrigin?.mailboxId ?? scopeMailboxIdOf(routeSearch.value)
|
||||
const known = id ? mailStore.mailboxes.find((mailbox) => mailbox.id === id) : undefined
|
||||
return known ?? mailStore.defaultMailbox
|
||||
})
|
||||
|
||||
/** 지금 화면을 검색을 시작한 곳으로 — 쓰기 화면처럼 메일함 화면이 아니면 마지막으로 보던 메일함 화면 */
|
||||
function originOfCurrentScreen(): SearchOrigin {
|
||||
if (MAILBOX_ROUTES.includes(String(route.name))) {
|
||||
return { mailboxId: paramOf(route.params.mailboxId), location: route.fullPath }
|
||||
}
|
||||
const last = mailStore.lastBrowseLocation ? router.resolve(mailStore.lastBrowseLocation) : null
|
||||
if (last && MAILBOX_ROUTES.includes(String(last.name))) {
|
||||
return { mailboxId: paramOf(last.params.mailboxId), location: last.fullPath }
|
||||
}
|
||||
// 마지막으로 본 것이 검색 결과였다 — 그 검색을 시작한 곳
|
||||
return mailStore.searchOrigin ?? { mailboxId: mailStore.defaultMailbox?.id ?? null, location: null }
|
||||
}
|
||||
|
||||
/** 검색을 닫으면 갈 곳 */
|
||||
function returnLocation(): RouteLocationRaw {
|
||||
const saved = mailStore.searchOrigin?.location
|
||||
if (saved) return saved
|
||||
const id = originMailbox.value?.id
|
||||
return id ? { name: 'mail-mailbox', params: { mailboxId: id } } : { name: 'mail' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter — 빈 검색어는 아무것도 하지 않는다. 같은 검색어로 다시 누르면 보이는 채로 다시 찾는다.
|
||||
* 낱말이 서버 한도(8개)를 넘으면 찾지 않고 서버와 같은 코드(VAL_001)의 사전 문구로 알린다 — 칸에 친 글자는 그대로 둔다
|
||||
*/
|
||||
async function startSearch(text: string): Promise<void> {
|
||||
const q = normalizeSearchText(text)
|
||||
if (!q) return
|
||||
if (exceedsSearchWords(q)) {
|
||||
notifyApiError({ code: 'VAL_001', message: getErrorMessage('VAL_001') })
|
||||
return
|
||||
}
|
||||
const current = routeSearch.value
|
||||
if (current) {
|
||||
if (current.q === q) {
|
||||
await mailStore.openSearch(q, current.scope, { force: true })
|
||||
return
|
||||
}
|
||||
await router.replace(listLocationOf({ ...current, q }))
|
||||
return
|
||||
}
|
||||
const origin = originOfCurrentScreen()
|
||||
mailStore.rememberSearchOrigin(origin)
|
||||
await router.push(listLocationOf({ kind: 'search', q, scope: origin.mailboxId ?? SEARCH_SCOPE_ALL }))
|
||||
}
|
||||
|
||||
/** 범위 전환 — 결과에서 연 메일이 있었으면 결과로 돌아간다 */
|
||||
async function setScope(scope: string): Promise<void> {
|
||||
const current = routeSearch.value
|
||||
if (!current) return
|
||||
if (current.scope === scope && route.name !== 'mail-search-message') return
|
||||
await router.replace(listLocationOf({ ...current, scope }))
|
||||
}
|
||||
|
||||
async function closeSearch(): Promise<void> {
|
||||
await backOrReplace(returnLocation())
|
||||
}
|
||||
|
||||
return { isSearching, routeSearch, originMailbox, startSearch, setScope, closeSearch }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, type EffectScope } from 'vue'
|
||||
|
||||
import { useMailStatePolling } from '@/composables/useMailStatePolling'
|
||||
import { MAIL_STATE_POLL_INTERVAL_MS, MAIL_STATE_RECHECK_GAP_MS } from '@/shared/constants/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
|
||||
// 새 메일 확인 (계약 2절 "새 메일 확인") — 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
|
||||
|
||||
describe('useMailStatePolling (새 메일 확인)', () => {
|
||||
let scope: EffectScope
|
||||
let clock: ManualClock
|
||||
let visibility: DocumentVisibilityState
|
||||
let states: MockResult[]
|
||||
let requests: MockRequest[]
|
||||
const onChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
scope = effectScope()
|
||||
clock = createManualClock()
|
||||
visibility = 'visible'
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
|
||||
onChange.mockReset()
|
||||
states = []
|
||||
requests = installMockApi(() => states.shift() ?? ok({ state: 'same' }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
Reflect.deleteProperty(document, 'visibilityState')
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
function start() {
|
||||
scope.run(() => useMailStatePolling({ onChange, clock }))
|
||||
}
|
||||
|
||||
function setVisibility(next: DocumentVisibilityState) {
|
||||
visibility = next
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
const asked = () => requests.filter((request) => request.url === '/mail-state').length
|
||||
|
||||
async function tick(ms: number) {
|
||||
clock.advance(ms)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('바로 한 번 물어 기준을 잡고(알리지 않는다), 1분마다 다시 묻다가 값이 바뀌면 알린다', async () => {
|
||||
states = [ok({ state: 's1' }), ok({ state: 's1' }), ok({ state: 's2' })]
|
||||
start()
|
||||
await flushPromises()
|
||||
|
||||
expect(asked()).toBe(1)
|
||||
expect(requests[0]?.config.silent).toBe(true)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS - 1)
|
||||
expect(asked()).toBe(1)
|
||||
await tick(1)
|
||||
expect(asked()).toBe(2)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
expect(asked()).toBe(3)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('화면이 안 보이는 동안은 묻지 않는다 — 돌아왔을 때 마지막으로 물은 지 10초가 지났으면 바로 한 번', async () => {
|
||||
start()
|
||||
await flushPromises()
|
||||
setVisibility('hidden')
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS * 3)
|
||||
expect(asked()).toBe(1)
|
||||
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(2)
|
||||
})
|
||||
|
||||
it('돌아왔을 때 마지막으로 물은 지 10초가 안 됐으면 원래 차례(1분)에 묻는다', async () => {
|
||||
start()
|
||||
await flushPromises()
|
||||
await tick(MAIL_STATE_RECHECK_GAP_MS - 4_000)
|
||||
setVisibility('hidden')
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(1)
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS - (MAIL_STATE_RECHECK_GAP_MS - 4_000) - 1)
|
||||
expect(asked()).toBe(1)
|
||||
await tick(1)
|
||||
expect(asked()).toBe(2)
|
||||
})
|
||||
|
||||
it('한 번에 하나씩 — 묻는 중에 탭으로 돌아와도 또 묻지 않는다', async () => {
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
requests = installMockApi(
|
||||
() =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
start()
|
||||
await flushPromises()
|
||||
await tick(MAIL_STATE_RECHECK_GAP_MS * 2)
|
||||
setVisibility('hidden')
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
|
||||
expect(asked()).toBe(1)
|
||||
release(ok({ state: 's1' }))
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(1)
|
||||
})
|
||||
|
||||
it('실패(MAIL_001 · 네트워크)는 알리지 않고 다음 차례에 다시 묻는다 — 지난 값은 그대로 기준이다', async () => {
|
||||
states = [ok({ state: 's1' }), fail(503, 'MAIL_001'), 'network', ok({ state: 's1' }), ok({ state: 's2' })]
|
||||
start()
|
||||
await flushPromises()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
expect(asked()).toBe(4)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('로그인하지 않았으면 묻지 않는다 — 세션이 끝나면 그만 묻는다', async () => {
|
||||
start()
|
||||
await flushPromises()
|
||||
useAuthStore().clearAuth()
|
||||
await flushPromises()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS * 2)
|
||||
expect(asked()).toBe(1)
|
||||
})
|
||||
|
||||
it('화면이 사라지면(scope 종료) 그만 묻는다', async () => {
|
||||
start()
|
||||
await flushPromises()
|
||||
scope.stop()
|
||||
|
||||
await tick(MAIL_STATE_POLL_INTERVAL_MS * 2)
|
||||
setVisibility('hidden')
|
||||
setVisibility('visible')
|
||||
await flushPromises()
|
||||
expect(asked()).toBe(1)
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { onScopeDispose, watch } from 'vue'
|
||||
|
||||
import { systemClock, type Clock, type ClockTimer } from './useClock'
|
||||
import { useMail } from './useMail'
|
||||
import { MAIL_STATE_POLL_INTERVAL_MS, MAIL_STATE_RECHECK_GAP_MS } from '@/shared/constants/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일")
|
||||
// - 로그인한 동안, 화면이 보이는 동안(visibilityState === 'visible')만 60초마다 GET /mail-state 를 묻는다
|
||||
// - 탭이 다시 보이면 마지막으로 물은 지 10초가 지났을 때 바로 한 번 (안 지났으면 원래 차례에)
|
||||
// - 한 번에 하나씩 — 묻는 중이면 새로 묻지 않는다
|
||||
// - 처음 받은 값은 기준으로만 쓰고, 값이 바뀌면 onChange 를 부른다 (메일함 개수 · 보이는 목록 맞추기는 부르는 쪽이)
|
||||
// - 실패(MAIL_001 · 네트워크)는 알리지 않고(silent) 다음 차례에 다시 묻는다. 401 은 지금의 세션 흐름(갱신 1회 → 만료 대화상자)을 탄다
|
||||
// 실시간 푸시가 아니라 1분 확인이다 — 모자라면 백엔드가 SSE 로 중계하는 방식으로 바꾼다 (계약 같은 절)
|
||||
|
||||
export interface MailStatePollingOptions {
|
||||
/** 지난 값과 달라졌을 때 */
|
||||
onChange: () => void
|
||||
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
export interface MailStatePollingHandle {
|
||||
/** 그만 묻는다 — 화면이 사라지면 저절로 부른다 */
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
function isPageVisible(): boolean {
|
||||
return typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||
}
|
||||
|
||||
export function useMailStatePolling(options: MailStatePollingOptions): MailStatePollingHandle {
|
||||
const { getMailState } = useMail()
|
||||
const authStore = useAuthStore()
|
||||
const clock = options.clock ?? systemClock
|
||||
|
||||
/** 지난 값 — 처음 받은 값은 기준으로만 쓴다 */
|
||||
let baseline: string | null = null
|
||||
let lastAskedAt: number | null = null
|
||||
let timer: ClockTimer | null = null
|
||||
let inFlight = false
|
||||
let stopped = false
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clock.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
function schedule(delay: number) {
|
||||
cancelTimer()
|
||||
if (stopped || !isPageVisible() || !authStore.isLoggedIn) return
|
||||
timer = clock.setTimeout(
|
||||
() => {
|
||||
timer = null
|
||||
void ask()
|
||||
},
|
||||
Math.max(0, delay),
|
||||
)
|
||||
}
|
||||
|
||||
async function ask(): Promise<void> {
|
||||
if (stopped || inFlight || !isPageVisible() || !authStore.isLoggedIn) return
|
||||
inFlight = true
|
||||
lastAskedAt = clock.now()
|
||||
const session = authStore.generation
|
||||
try {
|
||||
const { state } = await getMailState({ silent: true })
|
||||
// 묻는 사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면 버린다
|
||||
if (stopped || authStore.generation !== session) return
|
||||
const changed = baseline !== null && state !== baseline
|
||||
baseline = state
|
||||
if (changed) options.onChange()
|
||||
} catch {
|
||||
// 알리지 않는다 — 다음 차례에 다시 묻는다
|
||||
} finally {
|
||||
inFlight = false
|
||||
schedule(MAIL_STATE_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (stopped) return
|
||||
if (!isPageVisible()) {
|
||||
cancelTimer()
|
||||
return
|
||||
}
|
||||
if (inFlight) return
|
||||
const elapsed = lastAskedAt === null ? Number.POSITIVE_INFINITY : clock.now() - lastAskedAt
|
||||
if (elapsed >= MAIL_STATE_RECHECK_GAP_MS) void ask()
|
||||
else schedule(MAIL_STATE_POLL_INTERVAL_MS - elapsed)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopped = true
|
||||
cancelTimer()
|
||||
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
|
||||
// 세션이 바뀌면(다시 로그인 등) 지난 값을 버리고 새로 기준을 잡는다
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
baseline = null
|
||||
if (authStore.isLoggedIn) void ask()
|
||||
else cancelTimer()
|
||||
},
|
||||
)
|
||||
|
||||
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
// 바로 한 번 물어 기준을 잡는다 — 1분 뒤에 처음 물으면 그 사이에 온 메일은 2분 뒤에야 알아챈다
|
||||
void ask()
|
||||
onScopeDispose(stop)
|
||||
|
||||
return { stop }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { defineComponent, h, type FunctionalComponent } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import { useMailTabTitle } from '@/composables/useMailTabTitle'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { documentTitle } from '@/shared/utils/document-title'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { flushPromises } from '@/test-utils/mock-api'
|
||||
|
||||
// 탭 제목 — 받은편지함에 안 읽은 메일이 있으면 "(N) " (디자인 README "3단계 › 새 메일" · 디자인 원본 ListStates)
|
||||
|
||||
const Empty: FunctionalComponent = () => h('div')
|
||||
|
||||
/** 메일 모듈 레이아웃(MailLayout) 대신 — 이 composable 만 부른다 */
|
||||
const TitleHost = defineComponent({
|
||||
setup() {
|
||||
useMailTabTitle()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
function inbox(unreadCount: number): Mailbox {
|
||||
return { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 9, unreadCount }
|
||||
}
|
||||
|
||||
describe('useMailTabTitle (탭 제목)', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
async function mountHost() {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/mail/compose', name: 'mail-compose', component: Empty, meta: { title: '메일 쓰기' } },
|
||||
{ path: '/mail/:mailboxId', name: 'mail-mailbox', component: Empty, meta: { title: '메일' } },
|
||||
{ path: '/settings', name: 'settings', component: Empty, meta: { title: '설정' } },
|
||||
],
|
||||
})
|
||||
// 앱 라우터와 같다 — 이동할 때마다 기본 제목을 쓴다
|
||||
router.afterEach((to) => {
|
||||
document.title = documentTitle(to.meta.title)
|
||||
})
|
||||
await router.push('/mail/inbox')
|
||||
await router.isReady()
|
||||
mount(TitleHost, { global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return router
|
||||
}
|
||||
|
||||
it('"(3) 메일 · ARA SYSTEM" — 모두 읽으면 "메일 · ARA SYSTEM"', async () => {
|
||||
useMailStore().mailboxes = [inbox(3)]
|
||||
await mountHost()
|
||||
expect(document.title).toBe('(3) 메일 · ARA SYSTEM')
|
||||
|
||||
useMailStore().mailboxes = [inbox(0)]
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('메일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('쓰기 화면에서도 — "(3) 메일 쓰기 · ARA SYSTEM", 이동이 기본 제목을 써도 다시 붙인다', async () => {
|
||||
useMailStore().mailboxes = [inbox(3)]
|
||||
const router = await mountHost()
|
||||
|
||||
await router.push('/mail/compose')
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('(3) 메일 쓰기 · ARA SYSTEM')
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('(3) 메일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('메일 모듈 밖에서는 붙이지 않는다', async () => {
|
||||
useMailStore().mailboxes = [inbox(3)]
|
||||
const router = await mountHost()
|
||||
|
||||
await router.push('/settings')
|
||||
await flushPromises()
|
||||
expect(document.title).toBe('설정 · ARA SYSTEM')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { documentTitle } from '@/shared/utils/document-title'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/** 메일 모듈의 라우트 — 이름이 모두 mail 로 시작한다 (router/index.ts) */
|
||||
function isMailRouteName(name: unknown): boolean {
|
||||
return typeof name === 'string' && (name === 'mail' || name.startsWith('mail-'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 화면의 탭 제목 — 받은편지함에 안 읽은 메일이 있으면 앞에 "(N) " ("(3) 메일 · ARA SYSTEM" · "(3) 메일 쓰기 · ARA SYSTEM").
|
||||
* 디자인 README "3단계 › 새 메일". 이동할 때마다 router.afterEach 가 기본 제목을 쓰므로, 이동이 끝난 뒤(post)에 다시 맞춘다.
|
||||
* 메일 모듈 레이아웃(MailLayout)에서 부른다 — 다른 모듈로 가면 그 화면의 제목이 그대로 남는다
|
||||
*/
|
||||
export function useMailTabTitle() {
|
||||
const route = useRoute()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
watch(
|
||||
[() => route.fullPath, () => route.meta.title, () => mailStore.inboxUnreadCount],
|
||||
() => {
|
||||
if (!isMailRouteName(route.name)) return
|
||||
document.title = documentTitle(route.meta.title, mailStore.inboxUnreadCount)
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 — 열린 메일 한 통 (디자인 README "3단계 › 여러 통 처리" · 계약 2절 "여러 통 한꺼번에")
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함(mailboxIds)으로 정한다 — 검색 결과 · 다른 창에서 옮긴 메일도 맞게 (deletesPermanently)
|
||||
// · 휴지통 목록(휴지통 안의 검색 포함)에서는 휴지통에 든 메일이면 「영구 삭제」 (지금까지와 같다)
|
||||
// · 그 밖의 목록에서는 휴지통에만 든 메일만 「영구 삭제」 — 휴지통과 다른 메일함에 함께 든 메일(다른 메일 프로그램이 복사)은
|
||||
// 영구 삭제가 휴지통에서만 빼고 끝나 목록 · 알림이 사실과 달라지므로 「삭제」(휴지통으로 옮기기)다
|
||||
// - 「삭제」: 휴지통으로 옮긴다(PATCH /messages { mailboxId: 휴지통 }) — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// - 「영구 삭제」: 확인 대화상자를 거친 뒤 POST /message-deletions — "메일 1통을 영구 삭제했습니다."
|
||||
// - DELETE /messages/{id} 는 쓰지 않는다 — 메일함 정보가 낡았으면(그사이 다른 창이 휴지통으로 옮겼으면) 확인 없이 영구 삭제하기 때문이다.
|
||||
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
|
||||
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
|
||||
|
||||
export interface MessageRemovalOutcome {
|
||||
/** 서버가 처리했고 지금 목록에서도 빠졌다 — 다음 메일(또는 목록)로 넘어간다 */
|
||||
removed: boolean
|
||||
/** 이어서 보여 줄 메일 — 다음 메일, 없으면 이전 메일 (목록 밖의 메일이었으면 null) */
|
||||
nextId: string | null
|
||||
}
|
||||
|
||||
const NOTHING: MessageRemovalOutcome = { removed: false, nextId: null }
|
||||
|
||||
export function useMessageRemoval() {
|
||||
const mailStore = useMailStore()
|
||||
const feedback = useMailMoveFeedback()
|
||||
|
||||
/**
|
||||
* 「영구 삭제」할 메일인가 (위 규칙) — 휴지통 메일함을 아직 모르면 아니라고 본다 (옮기기 쪽이 해롭지 않다)
|
||||
*/
|
||||
function deletesPermanently(message: Pick<MessageSummary, 'mailboxIds'>): boolean {
|
||||
const trashId = mailStore.mailboxByRole('trash')?.id
|
||||
if (!trashId || !message.mailboxIds.includes(trashId)) return false
|
||||
if (mailStore.currentMailbox?.role === 'trash') return true
|
||||
return message.mailboxIds.every((id) => id === trashId)
|
||||
}
|
||||
|
||||
function outcomeOf(id: string, nextId: string | null, done: boolean, stale: boolean): MessageRemovalOutcome {
|
||||
const removed = done && !stale && !mailStore.messages.some((message) => message.id === id)
|
||||
return { removed, nextId: removed ? nextId : null }
|
||||
}
|
||||
|
||||
/** 휴지통으로 옮긴다 */
|
||||
async function moveToTrash(message: MessageSummary): Promise<MessageRemovalOutcome> {
|
||||
const trash = await mailStore.resolveMailboxByRole('trash')
|
||||
if ('error' in trash) {
|
||||
notifyApiError(trash.error)
|
||||
return NOTHING
|
||||
}
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const result = await mailStore.moveMessages([message], trash.mailbox.id)
|
||||
feedback.announceMove(result, trash.mailbox.name)
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
/** 영구 삭제 — 확인 대화상자에서 「영구 삭제」를 누른 뒤에만 부른다 */
|
||||
async function purge(message: MessageSummary): Promise<MessageRemovalOutcome> {
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const result = await mailStore.purgeMessages([message.id])
|
||||
feedback.announcePurge(result)
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
return { deletesPermanently, moveToTrash, purge }
|
||||
}
|
||||
@@ -1,14 +1,30 @@
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore, type ToastAction } from '@/stores/toast.store'
|
||||
|
||||
/**
|
||||
* 화면 알림(토스트) — 성공 안내처럼 화면이 직접 띄우는 알림에 쓴다.
|
||||
* API 오류 알림은 useApi 인터셉터가 이미 띄우므로 호출부에서 다시 띄우지 않는다.
|
||||
* 그 자리의 단추(되돌리기 등 — run)는 알림을 띄운 세션에서만 움직인다: 그사이 로그아웃 · 세션 만료 · 다른 계정 로그인이 있었으면
|
||||
* 눌러도 아무것도 하지 않는다 (세션이 바뀌면 알림도 닫힌다 — toast.store)
|
||||
*/
|
||||
export function useToast() {
|
||||
const store = useToastStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
/** 띄운 세션에서만 움직이는 단추 */
|
||||
function sessionBound(action: ToastAction): ToastAction {
|
||||
if (!('run' in action)) return action
|
||||
const session = authStore.generation
|
||||
return {
|
||||
label: action.label,
|
||||
run: () => {
|
||||
if (authStore.generation === session) action.run()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function success(message: string, action?: ToastAction): number {
|
||||
return store.show({ kind: 'success', message, action })
|
||||
return store.show({ kind: 'success', message, action: action ? sessionBound(action) : undefined })
|
||||
}
|
||||
|
||||
return { success, dismiss: store.dismiss }
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 쓰기)
|
||||
// 메일 모듈 레이아웃 — 전역 셸 + 메일함 패널 + 하위 화면(목록·본문 / 검색 결과 / 쓰기)
|
||||
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
|
||||
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 1분마다 새 메일 확인, 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계")
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
|
||||
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useMailStatePolling } from '@/composables/useMailStatePolling'
|
||||
import { useMailTabTitle } from '@/composables/useMailTabTitle'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -14,10 +19,23 @@ void mailStore.ensureMailboxes()
|
||||
void mailStore.ensureQuota()
|
||||
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
|
||||
void useMailSignatureStore().ensureSignature()
|
||||
|
||||
// 새 메일 확인 — 바뀌었으면 메일함 개수 · 사용량과 보이는 메일함 목록을 맞춘다 (검색 결과는 저절로 바꾸지 않는다 — 스토어가 거른다)
|
||||
useMailStatePolling({
|
||||
clock: useClock(),
|
||||
onChange: () => {
|
||||
mailStore.syncCounts()
|
||||
void mailStore.syncVisibleList()
|
||||
},
|
||||
})
|
||||
useMailTabTitle()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell module-id="mail">
|
||||
<template #search>
|
||||
<MailSearchBox />
|
||||
</template>
|
||||
<MailboxPanel />
|
||||
<RouterView />
|
||||
</AppShell>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { mailViewModeStorageKey } from '@/stores/mail-view.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailboxPage from './MailboxPage.vue'
|
||||
|
||||
// 검색 결과 화면 (디자인 원본 Search · ListStates · 계약 2절 "검색")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const Q = encodeURIComponent('회의')
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 5, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES = [mailbox('inbox', 'inbox', '받은편지함'), mailbox('sent', 'sent', '보낸편지함')]
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: `주간 회의 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function detail(id: string): MessageDetail {
|
||||
return {
|
||||
...summary(id),
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '본문',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailboxPage — 검색 결과', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let results: MessageSummary[]
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
useMailStore().mailboxes = MAILBOXES
|
||||
results = [summary('r1'), summary('r2')]
|
||||
requests = installMockApi(({ url }) => {
|
||||
if (url === '/messages') return ok({ items: results, pageInfo: { hasNext: false, nextCursor: null, total: results.length } })
|
||||
return ok(detail(url.split('/').pop() ?? 'r1'))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MailboxPage, { global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
const listRequests = () => requests.filter((request) => request.url === '/messages')
|
||||
|
||||
it('주소의 검색어 · 범위로 찾는다 — 머리 "검색 결과" · "‘회의’ · 2통", 범위 줄 [받은편지함 | 전체 메일함]', async () => {
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
|
||||
expect(listRequests()[0]?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
|
||||
expect(wrapper.get('#message-list-title').text()).toBe('검색 결과')
|
||||
expect(wrapper.get('.search-header__count').text()).toBe('‘회의’ · 2통')
|
||||
expect(wrapper.get('.search-header__close').attributes('aria-label')).toBe('검색 닫기')
|
||||
const scope = wrapper.findAll('.search-scope .segmented__item')
|
||||
expect(scope.map((button) => [button.text(), button.attributes('aria-pressed')])).toEqual([
|
||||
['받은편지함', 'true'],
|
||||
['전체 메일함', 'false'],
|
||||
])
|
||||
expect(wrapper.find('.search-scope__hint').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.message-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('결과가 없으면 한계 안내 한 줄과 [전체 메일함에서 찾기] — 누르면 전체에서 다시 찾는다(기록을 쌓지 않는다)', async () => {
|
||||
results = []
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
expect(wrapper.text()).toContain('‘회의’에 맞는 메일이 없습니다')
|
||||
expect(wrapper.text()).toContain(
|
||||
'낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.',
|
||||
)
|
||||
await wrapper.get('.search-empty__all').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=all`)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', limit: 20 })
|
||||
// 전체에서 찾았으면 더 넓힐 곳이 없다 — 안내 줄만
|
||||
expect(wrapper.find('.search-empty__all').exists()).toBe(false)
|
||||
expect(wrapper.get('.search-scope__hint').text()).toBe('휴지통 · 스팸메일함은 빼고 찾습니다')
|
||||
})
|
||||
|
||||
it('범위 줄에서 "전체 메일함"을 누르면 기록을 쌓지 않고 전체에서 다시 찾고, "받은편지함"을 누르면 돌아온다', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
|
||||
const push = vi.spyOn(router, 'push')
|
||||
const scopeButton = (label: string) =>
|
||||
wrapper.findAll('.search-scope .segmented__item').find((button) => button.text() === label)
|
||||
|
||||
await scopeButton('전체 메일함')?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=all`)
|
||||
expect(listRequests().at(-1)?.params).toEqual({ q: '회의', limit: 20 })
|
||||
expect(scopeButton('전체 메일함')?.attributes('aria-pressed')).toBe('true')
|
||||
// 메일함 패널은 전체 메일함에서 찾는 동안 현재 메일함을 표시하지 않는다
|
||||
expect(useMailStore().currentMailboxId).toBeNull()
|
||||
|
||||
await scopeButton('받은편지함')?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
|
||||
expect(listRequests()).toHaveLength(3)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('결과에서 연 메일은 /mail/search/<id>?… — 다시 찾지 않고, 읽기 화면에 "메일함 받은편지함" 을 더한다', async () => {
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
const row = wrapper.findAll('.message-row__main')[1]
|
||||
expect(row?.attributes('href')).toBe(`/mail/search/r2?q=${Q}&in=all`)
|
||||
|
||||
await router.push(row?.attributes('href') ?? '')
|
||||
await flushPromises()
|
||||
|
||||
expect(listRequests()).toHaveLength(1)
|
||||
expect(wrapper.get('#reader-subject').text()).toBe('주간 회의 r2')
|
||||
expect(wrapper.get('.reader__mailbox').text()).toBe('메일함 받은편지함')
|
||||
expect(wrapper.findAll('.message-row')[1]?.classes()).toContain('is-selected')
|
||||
})
|
||||
|
||||
it('검색 닫기(←)는 검색을 시작한 화면으로 돌아간다', async () => {
|
||||
useMailStore().rememberSearchOrigin({ mailboxId: 'sent', location: '/mail/sent' })
|
||||
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=sent`)
|
||||
|
||||
expect(wrapper.get('.search-header__close').attributes('title')).toBe('검색 닫기 — 보낸편지함으로')
|
||||
await wrapper.get('.search-header__close').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
expect(useMailStore().search).toBeNull()
|
||||
})
|
||||
|
||||
it('검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다', async () => {
|
||||
const { router } = await mountAt('/mail/search')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(listRequests().some((request) => request.params.q !== undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('목록 보기 — 검색 결과도 표로, 전체 메일함이면 메일함 칸이 생긴다', async () => {
|
||||
window.localStorage.setItem(mailViewModeStorageKey(USER.email), 'list')
|
||||
results = [summary('r1'), summary('r2', { mailboxIds: ['sent'] })]
|
||||
const { wrapper } = await mountAt(`/mail/search?q=${Q}&in=all`)
|
||||
|
||||
expect(wrapper.find('table.mail-table').exists()).toBe(true)
|
||||
expect(wrapper.get('#message-table-title').text()).toBe('검색 결과')
|
||||
expect(wrapper.findAll('tbody .mail-table__mailbox').map((cell) => cell.text())).toEqual([
|
||||
'받은편지함',
|
||||
'보낸편지함',
|
||||
])
|
||||
})
|
||||
|
||||
it('검색하면 고른 것을 푼다', async () => {
|
||||
const store = useMailStore()
|
||||
const { router } = await mountAt('/mail/inbox')
|
||||
store.toggleCheck('r1')
|
||||
|
||||
await router.push(`/mail/search?q=${Q}&in=inbox`)
|
||||
await flushPromises()
|
||||
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId)
|
||||
// 화면 상태는 주소가 기준이다: 주소의 메일함·메일이 바뀌면 스토어가 그에 맞춰 불러온다.
|
||||
// 메일함 · 검색 결과 화면 (/mail · /mail/:mailboxId · /mail/:mailboxId/:messageId · /mail/search?q=…&in=… · /mail/search/:messageId?…)
|
||||
// 화면 상태는 주소가 기준이다: 주소의 메일함·검색·메일이 바뀌면 스토어가 그에 맞춰 불러온다.
|
||||
// 보기 방식(사용자별 기억)에 따라 같은 주소를 다르게 그린다:
|
||||
// - 분할: 목록(2줄 행) + 본문 (디자인 원본 Main)
|
||||
// - 분할: 목록(2줄 행) + 본문 (디자인 원본 Main · Search)
|
||||
// - 목록: 메일을 열지 않았으면 게시판형 표(MainList), 열었으면 표 자리에 본문을 넓게(MainListRead)
|
||||
// 검색 결과는 메일함 목록과 같은 목록 · 표 · 본문을 쓴다 — 목록의 출처만 다르다 (mail-list-source.ts)
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -13,6 +14,7 @@ import MessageList from '@/components/mail/MessageList.vue'
|
||||
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
|
||||
import MessageReader from '@/components/mail/MessageReader.vue'
|
||||
import MessageTable from '@/components/mail/MessageTable.vue'
|
||||
import { useMailSearchNavigation } from '@/composables/useMailSearchNavigation'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailViewStore } from '@/stores/mail-view.store'
|
||||
|
||||
@@ -20,14 +22,17 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const viewStore = useMailViewStore()
|
||||
const { isSearching, routeSearch, closeSearch } = useMailSearchNavigation()
|
||||
|
||||
function paramOf(value: string | string[] | undefined): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
}
|
||||
|
||||
const mailboxId = computed(() => paramOf(route.params.mailboxId))
|
||||
const mailboxId = computed(() => (isSearching.value ? null : paramOf(route.params.mailboxId)))
|
||||
const messageId = computed(() => paramOf(route.params.messageId))
|
||||
const isListView = computed(() => viewStore.mode === 'list')
|
||||
/** 목록이 있는 화면 — 메일함 또는 검색 결과 */
|
||||
const hasList = computed(() => Boolean(mailboxId.value) || isSearching.value)
|
||||
|
||||
watch(
|
||||
mailboxId,
|
||||
@@ -37,6 +42,21 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 검색어 · 범위가 바뀔 때만 다시 찾는다 — 결과에서 메일을 열고 닫는 것으로는 다시 찾지 않는다.
|
||||
// 검색어가 없는 주소(/mail/search)는 검색을 닫은 것으로 본다
|
||||
watch(
|
||||
[isSearching, () => routeSearch.value?.q ?? null, () => routeSearch.value?.scope ?? null],
|
||||
([searching, q, scope]) => {
|
||||
if (!searching) return
|
||||
if (q === null || scope === null) {
|
||||
void closeSearch()
|
||||
return
|
||||
}
|
||||
void mailStore.openSearch(q, scope)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
messageId,
|
||||
(id) => {
|
||||
@@ -46,20 +66,20 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 쓰기를 마치거나 취소하면 이 화면으로 돌아온다 (보던 메일함·메일)
|
||||
// 쓰기를 마치거나 취소하면 이 화면으로 돌아온다 (보던 메일함·메일·검색 결과)
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
(fullPath) => {
|
||||
if (mailboxId.value) mailStore.rememberBrowseLocation(fullPath)
|
||||
if (hasList.value) mailStore.rememberBrowseLocation(fullPath)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// /mail 로 들어오면 메일함을 불러온 뒤 받은편지함으로 보낸다
|
||||
watch(
|
||||
[mailboxId, () => mailStore.defaultMailbox],
|
||||
([id, fallback]) => {
|
||||
if (!id && fallback) {
|
||||
[hasList, () => mailStore.defaultMailbox],
|
||||
([listed, fallback]) => {
|
||||
if (!listed && fallback) {
|
||||
void router.replace({ name: 'mail-mailbox', params: { mailboxId: fallback.id } })
|
||||
}
|
||||
},
|
||||
@@ -69,26 +89,20 @@ watch(
|
||||
|
||||
<template>
|
||||
<main :class="['mail-view', { 'mail-view--list': isListView }]">
|
||||
<template v-if="mailboxId && isListView">
|
||||
<template v-if="hasList && isListView">
|
||||
<MessageReader
|
||||
v-if="messageId"
|
||||
layout="list"
|
||||
>
|
||||
<template #toolbar-start>
|
||||
<ListReadNav
|
||||
:mailbox-id="mailboxId"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
<ListReadNav :message-id="messageId" />
|
||||
</template>
|
||||
</MessageReader>
|
||||
<MessageTable
|
||||
v-else
|
||||
:mailbox-id="mailboxId"
|
||||
/>
|
||||
<MessageTable v-else />
|
||||
</template>
|
||||
|
||||
<template v-else-if="mailboxId">
|
||||
<MessageList :mailbox-id="mailboxId" />
|
||||
<template v-else-if="hasList">
|
||||
<MessageList />
|
||||
<MessageReader />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { APP_NAME } from '@/shared/constants/brand'
|
||||
import { documentTitle } from '@/shared/utils/document-title'
|
||||
import { MAIL_SEARCH_MESSAGE_ROUTE, MAIL_SEARCH_ROUTE } from '@/shared/utils/mail-list-source'
|
||||
import { toSafeRedirect } from '@/shared/utils/redirect'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
@@ -44,6 +45,15 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/mail/ComposePage.vue'),
|
||||
meta: { title: '메일 쓰기' },
|
||||
},
|
||||
// 검색 결과 /mail/search?q=…&in=<메일함 id | all> · 결과에서 연 메일 /mail/search/<메일 id>?q=…&in=… (계약 2절 "검색")
|
||||
// 고정 경로라 :mailboxId 보다 앞에 둔다 — 같은 화면(MailboxPage)이 목록 출처만 바꿔 그린다
|
||||
{ path: 'search', name: MAIL_SEARCH_ROUTE, component: MailboxPage, meta: { title: '메일 검색' } },
|
||||
{
|
||||
path: 'search/:messageId',
|
||||
name: MAIL_SEARCH_MESSAGE_ROUTE,
|
||||
component: MailboxPage,
|
||||
meta: { title: '메일 검색' },
|
||||
},
|
||||
{ path: ':mailboxId', name: 'mail-mailbox', component: MailboxPage },
|
||||
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
|
||||
],
|
||||
@@ -92,8 +102,9 @@ router.beforeEach(async (to) => {
|
||||
return true
|
||||
})
|
||||
|
||||
// 메일 화면에서는 MailLayout(useMailTabTitle)이 받은편지함의 안 읽은 수 "(N) " 를 앞에 다시 붙인다 — 이동이 끝난 뒤에 돈다
|
||||
router.afterEach((to) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} · ${APP_NAME}` : APP_NAME
|
||||
document.title = documentTitle(to.meta.title)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -143,6 +143,19 @@ export const ICONS = {
|
||||
line(4, 18, 4.01, 18),
|
||||
],
|
||||
'arrow-left': [line(19, 12, 5, 12), polyline('12 19 5 12 12 5')],
|
||||
/** 새 메일 알림 줄 "맨 위로" (디자인 원본 ListStates) */
|
||||
'arrow-up': [line(12, 19, 12, 5), polyline('5 12 12 5 19 12')],
|
||||
/** 여러 통 처리 — 읽음 표시 (열린 봉투, 디자인 원본 BulkSelect) */
|
||||
'mail-open': [
|
||||
path('M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0l8 6Z'),
|
||||
path('m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10'),
|
||||
],
|
||||
/** 여러 통 처리 — 이동 (디자인 원본 BulkSelect) */
|
||||
'folder-move': [
|
||||
path('M2 9V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-1.5'),
|
||||
path('M2 13h10'),
|
||||
path('m9 16 3-3-3-3'),
|
||||
],
|
||||
'chevron-left': [polyline('15 18 9 12 15 6')],
|
||||
'chevron-right': [polyline('9 18 15 12 9 6')],
|
||||
'chevron-down': [polyline('6 9 12 15 18 9')],
|
||||
|
||||
@@ -190,6 +190,85 @@ export const DRAFT_RESTORED_MESSAGE = '임시보관함으로 되돌렸습니다.
|
||||
export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
|
||||
export const MAIL_NO_SUBJECT = '(제목 없음)'
|
||||
|
||||
// ── 새 메일 확인 (계약 2절 "새 메일 확인" · 디자인 README "3단계 › 새 메일") ─────────────
|
||||
/** 화면이 보이는 동안 이 간격으로 바뀐 것이 있는지 묻는다 */
|
||||
export const MAIL_STATE_POLL_INTERVAL_MS = 60_000
|
||||
/** 탭이 다시 보일 때 마지막으로 물은 지 이만큼 지났으면 바로 한 번 묻는다 */
|
||||
export const MAIL_STATE_RECHECK_GAP_MS = 10_000
|
||||
/** 바뀌었을 때 보이는 목록을 다시 받는 최대 개수 — 지금까지 불러온 만큼, 그보다 많이 불러왔으면 이만큼만 맞추고 그 뒤는 둔다 */
|
||||
export const MAIL_LIST_SYNC_MAX = 100
|
||||
/** 목록을 내려 본 채 새 메일이 위에 생겼을 때 머리 아래의 알림 줄 */
|
||||
export const NEW_MAIL_NOTICE_TEXT = (count: string) => `새 메일 ${count}통 · 맨 위로`
|
||||
|
||||
// ── 검색 (계약 2절 "검색" · 디자인 README "3단계 › 검색") ─────────────
|
||||
/** 검색 범위 "전체 메일함" — 주소의 in 값 (휴지통 · 스팸메일함은 서버가 뺀다) */
|
||||
export const SEARCH_SCOPE_ALL = 'all'
|
||||
/** 검색어 길이 — 앞뒤 공백을 뺀 1~200자, JS 문자열 길이(UTF-16) 기준 (넘으면 400 VAL_001) */
|
||||
export const MAIL_SEARCH_MAX_LENGTH = 200
|
||||
/** 검색어 낱말 수 — 띄어쓰기로 나눈 조각 8개까지 (넘으면 400 VAL_001) */
|
||||
export const MAIL_SEARCH_MAX_WORDS = 8
|
||||
|
||||
export const MAIL_SEARCH_TEXT = {
|
||||
/** 메일 화면에서 상단 검색 칸의 이름 · 안내 */
|
||||
box: '메일 검색',
|
||||
clear: '검색어 지우기',
|
||||
clearTitle: '검색어 지우기 (Esc)',
|
||||
close: '검색 닫기',
|
||||
/** "검색 닫기 — 받은편지함으로" — 돌아갈 메일함 이름과 조사는 부르는 쪽이 붙인다 */
|
||||
closeTitle: (place: string) => `검색 닫기 — ${place}`,
|
||||
title: '검색 결과',
|
||||
quoted: (query: string) => `‘${query}’`,
|
||||
count: (query: string, count: string) => `‘${query}’ · ${count}통`,
|
||||
searching: (query: string) => `‘${query}’ · 찾는 중`,
|
||||
scopeLabel: '찾을 곳',
|
||||
scopeAll: '전체 메일함',
|
||||
scopeAllHint: '휴지통 · 스팸메일함은 빼고 찾습니다',
|
||||
emptyTitle: (query: string) => `‘${query}’에 맞는 메일이 없습니다`,
|
||||
emptyHint:
|
||||
'낱말 단위로 찾습니다. 붙여 쓴 말의 일부(‘서버이전’의 ‘서버’)나 제목 맨 앞의 말머리([공지] · Re:)로는 찾지 못합니다.',
|
||||
searchAll: '전체 메일함에서 찾기',
|
||||
/** 결과 목록의 보이지 않는 이름 (불러오는 동안) */
|
||||
loading: '찾는 중',
|
||||
/** 검색 결과에서 연 메일의 읽기 화면 — "메일함 받은편지함" */
|
||||
mailboxLine: '메일함',
|
||||
/** 전체 메일함 결과에서 보낸편지함 · 임시보관함의 메일 — 보낸 사람 자리에 받는 사람 */
|
||||
recipientPrefix: '받는 사람: ',
|
||||
/** 결과 표의 메일함 칸 머리 */
|
||||
mailboxColumn: '메일함',
|
||||
} as const
|
||||
|
||||
// ── 여러 통 한꺼번에 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리") ─────────────
|
||||
/** 요청 하나의 ids 한도 — 넘으면 이만큼씩 차례로 보낸다 */
|
||||
export const MAIL_BULK_MAX_IDS = 100
|
||||
|
||||
export const MAIL_BULK_TEXT = {
|
||||
toolbar: '고른 메일',
|
||||
selected: (count: string) => `${count}통 선택됨`,
|
||||
markRead: '읽음 표시',
|
||||
markUnread: '안 읽음 표시',
|
||||
flag: '중요 표시',
|
||||
unflag: '중요 해제',
|
||||
move: '이동',
|
||||
moveMenu: '옮길 메일함',
|
||||
delete: '삭제',
|
||||
purge: '영구 삭제',
|
||||
clear: '선택 해제',
|
||||
clearTitle: '선택 해제 (Esc)',
|
||||
/** 표 머리의 고르기 상자 */
|
||||
selectAll: '불러온 메일 모두 고르기',
|
||||
/** 행의 고르기 상자 */
|
||||
selectOne: (subject: string) => `고르기: ${subject}`,
|
||||
/** place 에는 메일함 이름과 조사를 붙여 넘긴다 ("보낸편지함으로") */
|
||||
moved: (count: string, place: string) => `메일 ${count}통을 ${place} 옮겼습니다.`,
|
||||
purged: (count: string) => `메일 ${count}통을 영구 삭제했습니다.`,
|
||||
restored: (count: string) => `메일 ${count}통을 되돌렸습니다.`,
|
||||
skipped: (count: string) => `${count}통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.`,
|
||||
purgeTitle: (count: string) => `메일 ${count}통을 영구 삭제할까요?`,
|
||||
purgeMessage: '휴지통에서 지운 메일은 되돌릴 수 없습니다.',
|
||||
purgeConfirm: '영구 삭제',
|
||||
purgeCancel: '취소',
|
||||
} as const
|
||||
|
||||
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
|
||||
/**
|
||||
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
|
||||
|
||||
@@ -95,9 +95,13 @@ export interface MailQuota {
|
||||
attachmentTotalMaxBytes: number
|
||||
}
|
||||
|
||||
/** GET /messages 쿼리 */
|
||||
/**
|
||||
* GET /messages 쿼리 — mailboxId 와 q 중 하나는 있어야 한다 (계약 2절 "검색").
|
||||
* q 만 있으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다
|
||||
*/
|
||||
export interface MessageListQuery extends CursorPaginationQuery {
|
||||
mailboxId: string
|
||||
mailboxId?: string
|
||||
/** 검색어 — 앞뒤 공백을 뺀 1~200자, 낱말 8개까지 */
|
||||
q?: string
|
||||
}
|
||||
|
||||
@@ -108,6 +112,37 @@ export interface UpdateMessagePayload {
|
||||
mailboxId?: string
|
||||
}
|
||||
|
||||
// ── 새 메일 확인 (계약 2절 "새 메일 확인" — 3단계) ──────────────────
|
||||
|
||||
/** GET /mail-state — 불투명 값. 해석하지 말고 지난 값과 같은지만 본다 */
|
||||
export interface MailState {
|
||||
state: string
|
||||
}
|
||||
|
||||
// ── 여러 통 한꺼번에 (계약 2절 "여러 통 한꺼번에" — 3단계) ─────────────
|
||||
|
||||
/** PATCH /messages 본문 — ids 1~100(같은 id 를 두 번 넣지 않는다), 바꿀 것이 하나 이상 */
|
||||
export interface MessagesPatch extends UpdateMessagePayload {
|
||||
ids: string[]
|
||||
}
|
||||
|
||||
/** PATCH /messages 응답 — failedIds: 없음 · 그사이 지워짐 · 엔진이 거절함 (나머지는 그대로 처리했다) */
|
||||
export interface MessagesPatched {
|
||||
updatedIds: string[]
|
||||
failedIds: string[]
|
||||
}
|
||||
|
||||
/** POST /message-deletions 본문 — 휴지통에서 영구 삭제, ids 1~100 */
|
||||
export interface MessageDeletionRequest {
|
||||
ids: string[]
|
||||
}
|
||||
|
||||
/** POST /message-deletions 응답 — skippedIds: 휴지통에 없음 · 없음 · 엔진이 거절 (지우지 않았다) */
|
||||
export interface MessageDeletionResult {
|
||||
deletedIds: string[]
|
||||
skippedIds: string[]
|
||||
}
|
||||
|
||||
/** 발송 본문에 싣는 첨부 — POST /mail-attachments 응답의 id */
|
||||
export interface AttachmentRef {
|
||||
id: string
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { documentTitle } from './document-title'
|
||||
|
||||
describe('documentTitle (탭 제목)', () => {
|
||||
it('받은편지함에 안 읽은 메일이 있으면 앞에 "(N) " (디자인 원본 ListStates)', () => {
|
||||
expect(documentTitle('메일', 3)).toBe('(3) 메일 · ARA SYSTEM')
|
||||
expect(documentTitle('메일 쓰기', 3)).toBe('(3) 메일 쓰기 · ARA SYSTEM')
|
||||
expect(documentTitle('메일', 1_204)).toBe('(1,204) 메일 · ARA SYSTEM')
|
||||
})
|
||||
|
||||
it('모두 읽었으면 그대로 — 제목이 없으면 앱 이름만', () => {
|
||||
expect(documentTitle('메일', 0)).toBe('메일 · ARA SYSTEM')
|
||||
expect(documentTitle('설정')).toBe('설정 · ARA SYSTEM')
|
||||
expect(documentTitle(undefined)).toBe('ARA SYSTEM')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
// 브라우저 탭 제목 — "메일 · ARA SYSTEM", 받은편지함에 안 읽은 메일이 있으면 "(3) 메일 · ARA SYSTEM" (순수 함수만 둔다)
|
||||
// 디자인 README "3단계 › 새 메일" — 다른 탭에서 일할 때 알아보게. 소리 · 데스크톱 알림은 하지 않는다.
|
||||
|
||||
import { APP_NAME } from '@/shared/constants/brand'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
export function documentTitle(pageTitle: string | undefined, unreadCount = 0): string {
|
||||
const base = pageTitle ? `${pageTitle} · ${APP_NAME}` : APP_NAME
|
||||
return unreadCount > 0 ? `(${formatCount(unreadCount)}) ${base}` : base
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { subjectParticle } from './korean'
|
||||
import { directionParticle, subjectParticle } from './korean'
|
||||
|
||||
describe('subjectParticle (주격 조사 이/가)', () => {
|
||||
it.each([
|
||||
@@ -13,3 +13,25 @@ describe('subjectParticle (주격 조사 이/가)', () => {
|
||||
expect(subjectParticle(word)).toBe(particle)
|
||||
})
|
||||
})
|
||||
|
||||
describe('directionParticle (방향 조사 으로/로)', () => {
|
||||
it.each([
|
||||
// 받침 있음 → 으로
|
||||
['보낸편지함', '으로'],
|
||||
['휴지통', '으로'],
|
||||
['받은편지함', '으로'],
|
||||
['보관함', '으로'],
|
||||
// 받침 없음 → 로
|
||||
['프로젝트', '로'],
|
||||
['스팸메일함 ', '으로'],
|
||||
// ㄹ 받침 → 로
|
||||
['서울', '로'],
|
||||
['메일', '로'],
|
||||
// 한글로 끝나지 않으면 발음을 알 수 없다
|
||||
['Work', '(으)로'],
|
||||
['2026', '(으)로'],
|
||||
['', '(으)로'],
|
||||
])('%s → %s', (word, particle) => {
|
||||
expect(directionParticle(word)).toBe(particle)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,16 +4,36 @@ const HANGUL_SYLLABLE_START = 0xac00
|
||||
const HANGUL_SYLLABLE_END = 0xd7a3
|
||||
/** 한글 음절 하나당 받침 경우의 수 (받침 없음 포함) */
|
||||
const JONGSEONG_COUNT = 28
|
||||
/** 받침 번호 — 0 은 받침 없음, 8 은 ㄹ */
|
||||
const NO_JONGSEONG = 0
|
||||
const JONGSEONG_RIEUL = 8
|
||||
|
||||
/** 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글자) null */
|
||||
function lastJongseong(word: string): number | null {
|
||||
const last = Array.from(word.trim()).pop()
|
||||
if (!last) return null
|
||||
|
||||
const code = last.charCodeAt(0)
|
||||
if (code < HANGUL_SYLLABLE_START || code > HANGUL_SYLLABLE_END) return null
|
||||
return (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT
|
||||
}
|
||||
|
||||
/**
|
||||
* 주격 조사 "이/가" — 마지막 글자에 받침이 있으면 "이", 없으면 "가".
|
||||
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "이(가)" 를 쓴다.
|
||||
*/
|
||||
export function subjectParticle(word: string): '이' | '가' | '이(가)' {
|
||||
const last = Array.from(word.trim()).pop()
|
||||
if (!last) return '이(가)'
|
||||
|
||||
const code = last.charCodeAt(0)
|
||||
if (code < HANGUL_SYLLABLE_START || code > HANGUL_SYLLABLE_END) return '이(가)'
|
||||
return (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT === 0 ? '가' : '이'
|
||||
const jongseong = lastJongseong(word)
|
||||
if (jongseong === null) return '이(가)'
|
||||
return jongseong === NO_JONGSEONG ? '가' : '이'
|
||||
}
|
||||
|
||||
/**
|
||||
* 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로).
|
||||
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "(으)로" 를 쓴다.
|
||||
*/
|
||||
export function directionParticle(word: string): '으로' | '로' | '(으)로' {
|
||||
const jongseong = lastJongseong(word)
|
||||
if (jongseong === null) return '(으)로'
|
||||
return jongseong === NO_JONGSEONG || jongseong === JONGSEONG_RIEUL ? '로' : '으로'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
chunk,
|
||||
groupByOrigin,
|
||||
movedMessageText,
|
||||
purgedMessageText,
|
||||
restoredMessageText,
|
||||
sendInChunks,
|
||||
} from './mail-bulk'
|
||||
|
||||
describe('mail-bulk (여러 통 처리 보조)', () => {
|
||||
it('100통씩 나눈다 — 마지막 묶음은 남은 만큼', () => {
|
||||
const ids = Array.from({ length: 250 }, (_, index) => `m${index}`)
|
||||
const chunks = chunk(ids, 100)
|
||||
|
||||
expect(chunks.map((part) => part.length)).toEqual([100, 100, 50])
|
||||
expect(chunks.flat()).toEqual(ids)
|
||||
expect(chunk([], 100)).toEqual([])
|
||||
})
|
||||
|
||||
it('나눠 차례로 보낸다 — 처리한 메일 · 그대로 둔 메일을 모으고, 요청이 실패하면 그 묶음부터 보내지 않은 것으로 두고 멈춘다', async () => {
|
||||
const ids = Array.from({ length: 7 }, (_, index) => `m${index}`)
|
||||
const sent: string[][] = []
|
||||
|
||||
const result = await sendInChunks(ids, 3, async (chunkIds) => {
|
||||
sent.push(chunkIds)
|
||||
if (sent.length === 2) throw new Error('503')
|
||||
return { done: chunkIds.slice(1), skipped: chunkIds.slice(0, 1) }
|
||||
})
|
||||
|
||||
expect(sent).toEqual([
|
||||
['m0', 'm1', 'm2'],
|
||||
['m3', 'm4', 'm5'],
|
||||
])
|
||||
expect(result).toEqual({ doneIds: ['m1', 'm2'], skippedIds: ['m0'], unsentIds: ['m3', 'm4', 'm5', 'm6'] })
|
||||
})
|
||||
|
||||
it('되돌리기 — 원래 메일함별로 묶는다, 원래 메일함을 모르는 메일은 뺀다', () => {
|
||||
const origins = new Map([
|
||||
['m1', 'inbox'],
|
||||
['m2', 'project'],
|
||||
['m3', 'inbox'],
|
||||
])
|
||||
|
||||
expect(groupByOrigin(['m1', 'm2', 'm3', 'm4'], origins)).toEqual([
|
||||
{ mailboxId: 'inbox', ids: ['m1', 'm3'] },
|
||||
{ mailboxId: 'project', ids: ['m2'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('옮긴 알림 — 메일함 이름의 받침에 맞춰 으로/로 (디자인 README "3단계 › 여러 통 처리")', () => {
|
||||
expect(movedMessageText(3, '보낸편지함')).toBe('메일 3통을 보낸편지함으로 옮겼습니다.')
|
||||
expect(movedMessageText(2, '휴지통')).toBe('메일 2통을 휴지통으로 옮겼습니다.')
|
||||
expect(movedMessageText(1, '프로젝트')).toBe('메일 1통을 프로젝트로 옮겼습니다.')
|
||||
expect(movedMessageText(1, '메일')).toBe('메일 1통을 메일로 옮겼습니다.')
|
||||
})
|
||||
|
||||
it('일부만 처리했으면 뒤에 덧붙이고, 하나도 못 했으면 그 문장만', () => {
|
||||
expect(movedMessageText(2, '보낸편지함', 1)).toBe(
|
||||
'메일 2통을 보낸편지함으로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
)
|
||||
expect(purgedMessageText(3)).toBe('메일 3통을 영구 삭제했습니다.')
|
||||
expect(purgedMessageText(0, 2)).toBe('2통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.')
|
||||
expect(restoredMessageText(1_234)).toBe('메일 1,234통을 되돌렸습니다.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
// 여러 통 처리 보조 — 나눠 보내기 · 되돌리기 묶음 · 알림 문구 (계약 2절 "여러 통 한꺼번에" — 순수 함수만 둔다)
|
||||
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { directionParticle } from '@/shared/utils/korean'
|
||||
|
||||
/** size 개씩 나눈다 — 요청 하나의 ids 는 100개까지다 */
|
||||
export function chunk<T>(items: readonly T[], size: number): T[][] {
|
||||
const step = Math.max(1, Math.floor(size))
|
||||
const result: T[][] = []
|
||||
for (let start = 0; start < items.length; start += step) result.push(items.slice(start, start + step))
|
||||
return result
|
||||
}
|
||||
|
||||
/** 여러 통 처리의 결과 */
|
||||
export interface BulkResult {
|
||||
/** 처리한 메일 */
|
||||
doneIds: string[]
|
||||
/** 그사이 옮겨졌거나 지워져 그대로 둔 메일 — 서버의 failedIds · skippedIds */
|
||||
skippedIds: string[]
|
||||
/** 요청이 실패해 보내지 못한 메일 — 알림은 인터셉터가 띄웠다 */
|
||||
unsentIds: string[]
|
||||
}
|
||||
|
||||
export function emptyBulkResult(): BulkResult {
|
||||
return { doneIds: [], skippedIds: [], unsentIds: [] }
|
||||
}
|
||||
|
||||
/** 한 묶음을 보낸 결과 — 처리한 메일 · 그대로 둔 메일 */
|
||||
export type BulkChunkSender = (chunkIds: string[]) => Promise<{ done: readonly string[]; skipped: readonly string[] }>
|
||||
|
||||
/**
|
||||
* ids 를 size 통씩 차례로 보낸다 — 요청이 실패하면(알림은 인터셉터) 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다
|
||||
* (같은 오류가 이어질 것이고, 503 은 일부만 바뀌었을 수 있어 부르는 쪽이 목록을 다시 맞춘다)
|
||||
*/
|
||||
export async function sendInChunks(ids: readonly string[], size: number, send: BulkChunkSender): Promise<BulkResult> {
|
||||
const result = emptyBulkResult()
|
||||
const chunks = chunk(ids, size)
|
||||
for (const [index, chunkIds] of chunks.entries()) {
|
||||
try {
|
||||
const { done, skipped } = await send(chunkIds)
|
||||
result.doneIds.push(...done)
|
||||
result.skippedIds.push(...skipped)
|
||||
} catch {
|
||||
result.unsentIds.push(...chunks.slice(index).flat())
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 되돌리기 한 묶음 — 같은 메일함으로 돌아갈 메일 */
|
||||
export interface BulkOrigin {
|
||||
mailboxId: string
|
||||
ids: string[]
|
||||
}
|
||||
|
||||
/** 옮긴 메일을 원래 메일함별로 묶는다 (옮긴 순서대로). 원래 메일함을 모르는 메일은 뺀다 */
|
||||
export function groupByOrigin(ids: readonly string[], originOf: ReadonlyMap<string, string>): BulkOrigin[] {
|
||||
const groups = new Map<string, string[]>()
|
||||
for (const id of ids) {
|
||||
const origin = originOf.get(id)
|
||||
if (!origin) continue
|
||||
const group = groups.get(origin) ?? []
|
||||
group.push(id)
|
||||
groups.set(origin, group)
|
||||
}
|
||||
return [...groups].map(([mailboxId, groupIds]) => ({ mailboxId, ids: groupIds }))
|
||||
}
|
||||
|
||||
/** 일부를 처리하지 못했으면 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." 를 덧붙인다 — 하나도 못 했으면 그 문장만 */
|
||||
function withSkipped(text: string, done: number, skipped: number): string {
|
||||
if (skipped <= 0) return text
|
||||
const suffix = MAIL_BULK_TEXT.skipped(formatCount(skipped))
|
||||
return done > 0 ? `${text} ${suffix}` : suffix
|
||||
}
|
||||
|
||||
/** "메일 3통을 보낸편지함으로 옮겼습니다." — 메일함 이름의 받침에 맞춰 '으로/로' */
|
||||
export function movedMessageText(done: number, mailboxName: string, skipped = 0): string {
|
||||
const place = `${mailboxName}${directionParticle(mailboxName)}`
|
||||
return withSkipped(MAIL_BULK_TEXT.moved(formatCount(done), place), done, skipped)
|
||||
}
|
||||
|
||||
export function purgedMessageText(done: number, skipped = 0): string {
|
||||
return withSkipped(MAIL_BULK_TEXT.purged(formatCount(done)), done, skipped)
|
||||
}
|
||||
|
||||
export function restoredMessageText(done: number, skipped = 0): string {
|
||||
return withSkipped(MAIL_BULK_TEXT.restored(formatCount(done)), done, skipped)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import type { PageInfo } from '@/shared/types/pagination'
|
||||
import { mergeRefreshedList } from './mail-list-merge'
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: null,
|
||||
to: [],
|
||||
subject: id,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 1,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
const ids = (list: readonly MessageSummary[]) => list.map((message) => message.id)
|
||||
const range = (prefix: string, from: number, to: number) =>
|
||||
Array.from({ length: to - from + 1 }, (_, index) => summary(`${prefix}${from + index}`))
|
||||
const page = (hasNext: boolean, nextCursor: string | null, total: number | null): PageInfo => ({
|
||||
hasNext,
|
||||
nextCursor,
|
||||
total,
|
||||
})
|
||||
|
||||
describe('mergeRefreshedList (새 메일 확인으로 목록 맞추기)', () => {
|
||||
it('새 메일이 위에 생기면 그 수를 센다 — 다시 받은 범위는 서버 기준(읽음 · 다른 창에서 지운 메일)', () => {
|
||||
const current = [summary('a'), summary('b', { isRead: false }), summary('c'), summary('d')]
|
||||
const fetched = [summary('new1'), summary('new2'), summary('a'), summary('b', { isRead: true }), summary('d')]
|
||||
|
||||
const result = mergeRefreshedList({
|
||||
current,
|
||||
currentPageInfo: page(true, 'old-cursor', 40),
|
||||
fetched,
|
||||
fetchedPageInfo: page(true, 'new-cursor', 41),
|
||||
})
|
||||
|
||||
expect(ids(result.messages)).toEqual(['new1', 'new2', 'a', 'b', 'd'])
|
||||
expect(result.messages[3]?.isRead).toBe(true)
|
||||
expect(result.addedAbove).toBe(2)
|
||||
expect(result.pageInfo).toEqual(page(true, 'new-cursor', 41))
|
||||
})
|
||||
|
||||
it('새 메일이 와서 다시 받은 범위 밖으로 밀려난 옛 메일은 그대로 두고, "더 보기"는 그 뒤부터(옛 커서) 잇는다', () => {
|
||||
const current = range('m', 1, 20)
|
||||
const fetched = [summary('new1'), ...range('m', 1, 19)]
|
||||
|
||||
const result = mergeRefreshedList({
|
||||
current,
|
||||
currentPageInfo: page(true, 'after-m20', 50),
|
||||
fetched,
|
||||
fetchedPageInfo: page(true, 'after-m19', 51),
|
||||
})
|
||||
|
||||
expect(ids(result.messages)).toEqual(['new1', ...ids(range('m', 1, 20))])
|
||||
expect(result.pageInfo).toEqual(page(true, 'after-m20', 51))
|
||||
})
|
||||
|
||||
it('100통보다 많이 불러왔으면 첫 100통만 맞추고 그 뒤는 둔다', () => {
|
||||
const current = range('m', 1, 140)
|
||||
const fetched = [summary('new1'), ...range('m', 1, 99)]
|
||||
|
||||
const result = mergeRefreshedList({
|
||||
current,
|
||||
currentPageInfo: page(true, 'after-m140', 300),
|
||||
fetched,
|
||||
fetchedPageInfo: page(true, 'after-m99', 301),
|
||||
})
|
||||
|
||||
expect(result.messages).toHaveLength(141)
|
||||
expect(ids(result.messages).slice(0, 3)).toEqual(['new1', 'm1', 'm2'])
|
||||
expect(ids(result.messages).slice(-1)).toEqual(['m140'])
|
||||
expect(result.pageInfo.nextCursor).toBe('after-m140')
|
||||
expect(result.addedAbove).toBe(1)
|
||||
})
|
||||
|
||||
it('겹치는 메일이 없으면(새 메일이 다시 받은 범위보다 많다) 다시 받은 것만 쓴다 — 사이가 빈 목록을 만들지 않는다', () => {
|
||||
const result = mergeRefreshedList({
|
||||
current: range('m', 1, 20),
|
||||
currentPageInfo: page(true, 'after-m20', 50),
|
||||
fetched: range('new', 1, 20),
|
||||
fetchedPageInfo: page(true, 'after-new20', 70),
|
||||
})
|
||||
|
||||
expect(ids(result.messages)).toEqual(ids(range('new', 1, 20)))
|
||||
expect(result.pageInfo.nextCursor).toBe('after-new20')
|
||||
expect(result.addedAbove).toBe(20)
|
||||
})
|
||||
|
||||
it('달라진 것이 없으면 그대로 — 새 메일 0', () => {
|
||||
const current = range('m', 1, 3)
|
||||
const result = mergeRefreshedList({
|
||||
current,
|
||||
currentPageInfo: page(false, null, 3),
|
||||
fetched: range('m', 1, 3),
|
||||
fetchedPageInfo: page(false, null, 3),
|
||||
})
|
||||
|
||||
expect(ids(result.messages)).toEqual(['m1', 'm2', 'm3'])
|
||||
expect(result.addedAbove).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
// 새 메일 확인으로 보이는 목록을 다시 받아 맞출 때의 병합 (계약 2절 "새 메일 확인" — 순수 함수만 둔다)
|
||||
// - 지금까지 불러온 만큼(최대 100통)을 다시 받아 그 범위는 서버 기준으로 바꾼다 (새 메일 · 다른 창의 읽음 · 이동 · 삭제)
|
||||
// - 다시 받은 범위 밖의 옛 메일은 그대로 둔다 — 100통보다 많이 불러왔거나, 새 메일이 와서 범위 밖으로 밀려난 메일
|
||||
// (밀려난 메일을 버리면 보던 행이 사라지고 스크롤이 튄다). 겹치는 메일이 없으면(새 메일이 범위보다 많이 옴) 다시 받은 것만 쓴다
|
||||
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import type { PageInfo } from '@/shared/types/pagination'
|
||||
|
||||
export interface ListMergeInput {
|
||||
current: readonly MessageSummary[]
|
||||
currentPageInfo: PageInfo | null
|
||||
fetched: readonly MessageSummary[]
|
||||
fetchedPageInfo: PageInfo
|
||||
}
|
||||
|
||||
export interface ListMergeResult {
|
||||
messages: MessageSummary[]
|
||||
pageInfo: PageInfo
|
||||
/** 목록 맨 위에 새로 생긴 메일 수 — 전에 없던 메일이 맨 앞에 몇 통 이어지는지 ("새 메일 N통") */
|
||||
addedAbove: number
|
||||
}
|
||||
|
||||
export function mergeRefreshedList(input: ListMergeInput): ListMergeResult {
|
||||
const { current, fetched } = input
|
||||
const fetchedIds = new Set(fetched.map((message) => message.id))
|
||||
const currentIds = new Set(current.map((message) => message.id))
|
||||
|
||||
// 다시 받은 범위와 겹치는 마지막 옛 메일 — 그 뒤의 옛 메일은 다시 받지 않았다
|
||||
let lastOverlap = -1
|
||||
current.forEach((message, index) => {
|
||||
if (fetchedIds.has(message.id)) lastOverlap = index
|
||||
})
|
||||
const tail =
|
||||
lastOverlap === -1 ? [] : current.slice(lastOverlap + 1).filter((message) => !fetchedIds.has(message.id))
|
||||
|
||||
// 남겨 둔 옛 메일이 있으면 "더 보기"는 그 뒤부터 이어야 한다 — 옛 커서(마지막으로 받은 메일 다음)를 쓴다. 전체 개수는 새 값
|
||||
const pageInfo =
|
||||
tail.length > 0 && input.currentPageInfo
|
||||
? { ...input.currentPageInfo, total: input.fetchedPageInfo.total }
|
||||
: input.fetchedPageInfo
|
||||
|
||||
let addedAbove = 0
|
||||
for (const message of fetched) {
|
||||
if (currentIds.has(message.id)) break
|
||||
addedAbove += 1
|
||||
}
|
||||
|
||||
return { messages: [...fetched, ...tail], pageInfo, addedAbove }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { isRowUnread, mailboxOfMessage, rowWho, whoColumnLabel } from './mail-list-row'
|
||||
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
{ id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0 },
|
||||
{ id: 'sent', name: '보낸편지함', role: 'sent', parentId: null, sortOrder: 1, totalCount: 0, unreadCount: 0 },
|
||||
{ id: 'project', name: '프로젝트', role: null, parentId: null, sortOrder: 9, totalCount: 0, unreadCount: 0 },
|
||||
]
|
||||
|
||||
const MESSAGE: Pick<MessageSummary, 'from' | 'to' | 'isRead' | 'mailboxIds'> = {
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [
|
||||
{ name: '정하늘', email: 'haneul@arasystem.test' },
|
||||
{ name: null, email: 'ops@arasystem.test' },
|
||||
],
|
||||
isRead: false,
|
||||
mailboxIds: ['inbox'],
|
||||
}
|
||||
|
||||
describe('mail-list-row (목록 행의 표시)', () => {
|
||||
it('받은 메일 — 보낸 사람 (이름이 없으면 주소, 없으면 "(보낸 사람 없음)")', () => {
|
||||
expect(rowWho(MESSAGE, { role: 'inbox', mixed: false, view: 'split' })).toEqual({ text: '이도윤', placeholder: false })
|
||||
expect(rowWho({ ...MESSAGE, from: null }, { role: 'inbox', mixed: false, view: 'table' }).text).toBe(
|
||||
'(보낸 사람 없음)',
|
||||
)
|
||||
})
|
||||
|
||||
it('보낸편지함 — 분할 보기는 "받는 사람: …", 표는 머리가 받는 사람이라 이름만, 전체 검색 결과의 표는 "받는 사람: …"', () => {
|
||||
expect(rowWho(MESSAGE, { role: 'sent', mixed: false, view: 'split' }).text).toBe('받는 사람: 정하늘 외 1명')
|
||||
expect(rowWho(MESSAGE, { role: 'sent', mixed: false, view: 'table' }).text).toBe('정하늘 외 1명')
|
||||
expect(rowWho(MESSAGE, { role: 'sent', mixed: true, view: 'table' }).text).toBe('받는 사람: 정하늘 외 1명')
|
||||
expect(rowWho({ ...MESSAGE, to: [] }, { role: 'sent', mixed: false, view: 'split' }).text).toBe('받는 사람 없음')
|
||||
})
|
||||
|
||||
it('임시보관함 — 받는 사람을 쉼표로(전체 검색 결과는 "받는 사람: "), 없으면 흐린 "(받는 사람 없음)"', () => {
|
||||
expect(rowWho(MESSAGE, { role: 'drafts', mixed: false, view: 'split' })).toEqual({
|
||||
text: '정하늘, ops@arasystem.test',
|
||||
placeholder: false,
|
||||
})
|
||||
expect(rowWho(MESSAGE, { role: 'drafts', mixed: true, view: 'split' }).text).toBe(
|
||||
'받는 사람: 정하늘, ops@arasystem.test',
|
||||
)
|
||||
expect(rowWho({ ...MESSAGE, to: [] }, { role: 'drafts', mixed: true, view: 'table' })).toEqual({
|
||||
text: '(받는 사람 없음)',
|
||||
placeholder: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('임시 저장본은 읽음 여부를 보이지 않는다', () => {
|
||||
expect(isRowUnread(MESSAGE, 'inbox')).toBe(true)
|
||||
expect(isRowUnread(MESSAGE, 'drafts')).toBe(false)
|
||||
expect(isRowUnread({ isRead: true }, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('표 머리의 보낸 사람 칸 — 한 메일함의 보낸편지함 · 임시보관함만 "받는 사람"', () => {
|
||||
expect(whoColumnLabel('sent', false)).toBe('받는 사람')
|
||||
expect(whoColumnLabel('drafts', false)).toBe('받는 사람')
|
||||
expect(whoColumnLabel('sent', true)).toBe('보낸 사람')
|
||||
expect(whoColumnLabel('inbox', false)).toBe('보낸 사람')
|
||||
expect(whoColumnLabel(null, false)).toBe('보낸 사람')
|
||||
})
|
||||
|
||||
it('메일의 메일함 — 목록이 가리키는 메일함에 있으면 그것, 아니면 아는 첫 메일함, 모르면 null', () => {
|
||||
expect(mailboxOfMessage({ mailboxIds: ['unknown', 'project', 'inbox'] }, MAILBOXES)?.name).toBe('프로젝트')
|
||||
expect(mailboxOfMessage({ mailboxIds: ['project', 'inbox'] }, MAILBOXES, 'inbox')?.name).toBe('받은편지함')
|
||||
expect(mailboxOfMessage({ mailboxIds: ['project'] }, MAILBOXES, 'inbox')?.name).toBe('프로젝트')
|
||||
expect(mailboxOfMessage({ mailboxIds: ['unknown'] }, MAILBOXES)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
// 목록 행의 표시 — 분할 보기(2줄 행)와 목록 보기(표)가 같은 규칙을 쓴다 (순수 함수만 둔다)
|
||||
// - 임시보관함: 보낸 사람 자리에 받는 사람(없으면 흐린 "(받는 사람 없음)"), 읽음 여부를 보이지 않고, 누르면 쓰기 화면에서 이어 쓴다
|
||||
// - 보낸편지함: 받는 사람 — 분할 보기는 "받는 사람: …", 표는 머리가 "받는 사람"이라 이름만
|
||||
// - 전체 메일함 검색 결과는 행마다 메일함이 다르다 — 보낸편지함 · 임시보관함의 메일은 "받는 사람: 이름" (디자인 README "3단계 › 검색")
|
||||
|
||||
import {
|
||||
DRAFT_NO_RECIPIENTS,
|
||||
MAIL_SEARCH_TEXT,
|
||||
OUTGOING_MAILBOX_ROLES,
|
||||
} from '@/shared/constants/mail'
|
||||
import type { Mailbox, MailboxRole, MessageSummary } from '@/shared/types/mail'
|
||||
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
|
||||
|
||||
const NO_RECIPIENTS = '받는 사람 없음'
|
||||
const NO_SENDER = '(보낸 사람 없음)'
|
||||
|
||||
export interface RowContext {
|
||||
/** 행의 메일함 역할 — 메일함 목록 · 한 메일함 안의 검색은 그 메일함, 전체 메일함 검색은 그 메일의 메일함 */
|
||||
role: MailboxRole | null
|
||||
/** 전체 메일함 검색 결과 — 행마다 메일함이 다르다 */
|
||||
mixed: boolean
|
||||
view: 'split' | 'table'
|
||||
}
|
||||
|
||||
export interface RowWho {
|
||||
text: string
|
||||
/** 비어 있는 값 — 흐린 글자로 */
|
||||
placeholder: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일이 속한 메일함 — 목록이 가리키는 메일함(preferredId)에 있으면 그것, 아니면 메일의 메일함 가운데 아는 첫 메일함.
|
||||
* 메일함 목록을 아직 못 받았거나 모르는 메일함뿐이면 null
|
||||
*/
|
||||
export function mailboxOfMessage(
|
||||
message: Pick<MessageSummary, 'mailboxIds'>,
|
||||
mailboxes: readonly Mailbox[],
|
||||
preferredId: string | null = null,
|
||||
): Mailbox | null {
|
||||
const ids = preferredId && message.mailboxIds.includes(preferredId) ? [preferredId] : message.mailboxIds
|
||||
for (const id of ids) {
|
||||
const found = mailboxes.find((mailbox) => mailbox.id === id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function isDraftRole(role: MailboxRole | null): boolean {
|
||||
return role === 'drafts'
|
||||
}
|
||||
|
||||
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
|
||||
export function isRowUnread(message: Pick<MessageSummary, 'isRead'>, role: MailboxRole | null): boolean {
|
||||
return !isDraftRole(role) && !message.isRead
|
||||
}
|
||||
|
||||
/** 보낸 사람 자리 */
|
||||
export function rowWho(message: Pick<MessageSummary, 'from' | 'to'>, context: RowContext): RowWho {
|
||||
const { role, mixed, view } = context
|
||||
if (isDraftRole(role)) {
|
||||
const names = draftRecipientNames(message.to)
|
||||
if (!names) return { text: DRAFT_NO_RECIPIENTS, placeholder: true }
|
||||
return { text: mixed ? `${MAIL_SEARCH_TEXT.recipientPrefix}${names}` : names, placeholder: false }
|
||||
}
|
||||
if (role && OUTGOING_MAILBOX_ROLES.includes(role)) {
|
||||
const recipients = summarizeRecipients(message.to)
|
||||
if (!recipients) return { text: NO_RECIPIENTS, placeholder: false }
|
||||
const prefixed = view === 'split' || mixed
|
||||
return { text: prefixed ? `${MAIL_SEARCH_TEXT.recipientPrefix}${recipients}` : recipients, placeholder: false }
|
||||
}
|
||||
return { text: displayName(message.from) || NO_SENDER, placeholder: false }
|
||||
}
|
||||
|
||||
/** 표 머리의 보낸 사람 칸 이름 — 보낸편지함 · 임시보관함(한 메일함일 때)은 받는 사람 */
|
||||
export function whoColumnLabel(role: MailboxRole | null, mixed: boolean): string {
|
||||
return !mixed && role && OUTGOING_MAILBOX_ROLES.includes(role) ? '받는 사람' : '보낸 사람'
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import {
|
||||
exceedsSearchWords,
|
||||
isAllMailboxSearch,
|
||||
isSearchRouteName,
|
||||
listKeyOf,
|
||||
listLocationOf,
|
||||
listQueryOf,
|
||||
messageLocationOf,
|
||||
normalizeSearchText,
|
||||
sameListSource,
|
||||
scopeMailboxIdOf,
|
||||
searchSourceFromQuery,
|
||||
type MailListSource,
|
||||
} from './mail-list-source'
|
||||
|
||||
const INBOX: MailListSource = { kind: 'mailbox', mailboxId: 'inbox' }
|
||||
const SEARCH_INBOX: MailListSource = { kind: 'search', q: '회의', scope: 'inbox' }
|
||||
const SEARCH_ALL: MailListSource = { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL }
|
||||
|
||||
describe('mail-list-source (보이는 목록의 출처)', () => {
|
||||
it('GET /messages 쿼리 — 메일함은 mailboxId, 한 메일함 검색은 q + mailboxId, 전체 검색은 q 만', () => {
|
||||
expect(listQueryOf(INBOX)).toEqual({ mailboxId: 'inbox' })
|
||||
expect(listQueryOf(SEARCH_INBOX)).toEqual({ q: '회의', mailboxId: 'inbox' })
|
||||
expect(listQueryOf(SEARCH_ALL)).toEqual({ q: '회의' })
|
||||
})
|
||||
|
||||
it('가리키는 메일함 — 전체 메일함 검색이면 없다', () => {
|
||||
expect(scopeMailboxIdOf(INBOX)).toBe('inbox')
|
||||
expect(scopeMailboxIdOf(SEARCH_INBOX)).toBe('inbox')
|
||||
expect(scopeMailboxIdOf(SEARCH_ALL)).toBeNull()
|
||||
expect(scopeMailboxIdOf(null)).toBeNull()
|
||||
expect(isAllMailboxSearch(SEARCH_ALL)).toBe(true)
|
||||
expect(isAllMailboxSearch(SEARCH_INBOX)).toBe(false)
|
||||
})
|
||||
|
||||
it('같은 목록인지 — 종류 · 메일함 · 검색어 · 범위가 모두 같아야 한다', () => {
|
||||
expect(sameListSource(INBOX, { kind: 'mailbox', mailboxId: 'inbox' })).toBe(true)
|
||||
expect(sameListSource(INBOX, SEARCH_INBOX)).toBe(false)
|
||||
expect(sameListSource(SEARCH_INBOX, SEARCH_ALL)).toBe(false)
|
||||
expect(sameListSource(SEARCH_ALL, { kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL })).toBe(true)
|
||||
expect(sameListSource(null, null)).toBe(true)
|
||||
expect(sameListSource(null, INBOX)).toBe(false)
|
||||
expect(listKeyOf(SEARCH_INBOX)).not.toBe(listKeyOf(SEARCH_ALL))
|
||||
})
|
||||
|
||||
it('주소 — 검색 결과는 /mail/search?q=…&in=…, 결과에서 연 메일은 /mail/search/<id>?q=…&in=…', () => {
|
||||
expect(listLocationOf(INBOX)).toEqual({ name: 'mail-mailbox', params: { mailboxId: 'inbox' } })
|
||||
expect(messageLocationOf(INBOX, 'm1')).toEqual({
|
||||
name: 'mail-message',
|
||||
params: { mailboxId: 'inbox', messageId: 'm1' },
|
||||
})
|
||||
expect(listLocationOf(SEARCH_ALL)).toEqual({ name: 'mail-search', query: { q: '회의', in: 'all' } })
|
||||
expect(messageLocationOf(SEARCH_INBOX, 'm1')).toEqual({
|
||||
name: 'mail-search-message',
|
||||
params: { messageId: 'm1' },
|
||||
query: { q: '회의', in: 'inbox' },
|
||||
})
|
||||
expect(isSearchRouteName('mail-search')).toBe(true)
|
||||
expect(isSearchRouteName('mail-search-message')).toBe(true)
|
||||
expect(isSearchRouteName('mail-mailbox')).toBe(false)
|
||||
})
|
||||
|
||||
it('주소의 검색 쿼리 — 앞뒤 공백을 빼고, 비었으면 검색이 아니다. 범위가 없으면 전체', () => {
|
||||
expect(searchSourceFromQuery({ q: ' 회의 일정 ', in: 'inbox' })).toEqual({
|
||||
kind: 'search',
|
||||
q: '회의 일정',
|
||||
scope: 'inbox',
|
||||
})
|
||||
expect(searchSourceFromQuery({ q: '회의' })).toEqual({ kind: 'search', q: '회의', scope: SEARCH_SCOPE_ALL })
|
||||
expect(searchSourceFromQuery({ q: ['회의', '무시'], in: ['sent'] })).toEqual({
|
||||
kind: 'search',
|
||||
q: '회의',
|
||||
scope: 'sent',
|
||||
})
|
||||
expect(searchSourceFromQuery({ q: ' ' })).toBeNull()
|
||||
expect(searchSourceFromQuery({})).toBeNull()
|
||||
})
|
||||
|
||||
it('검색어는 서버 한도(200 — JS 문자열 길이)에서 자르고, 이모지를 가운데서 자르면 그 글자를 뺀다', () => {
|
||||
expect(normalizeSearchText('가'.repeat(250))).toHaveLength(200)
|
||||
const emoji = normalizeSearchText(`${'가'.repeat(199)}😀`)
|
||||
expect(emoji).toBe('가'.repeat(199))
|
||||
expect(() => encodeURIComponent(emoji)).not.toThrow()
|
||||
expect(normalizeSearchText(' ')).toBe('')
|
||||
})
|
||||
|
||||
it('낱말은 8개까지 — 띄어쓰기로 나눈 조각을 센다', () => {
|
||||
expect(exceedsSearchWords('하나 둘 셋 넷 다섯 여섯 일곱 여덟')).toBe(false)
|
||||
expect(exceedsSearchWords('하나 둘 셋 넷 다섯 여섯 일곱 여덟 아홉')).toBe(true)
|
||||
expect(exceedsSearchWords(' "회의를 잡겠습니다" ')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// 보이는 메일 목록의 출처 — 메일함 하나 또는 검색 결과 (순수 함수만 둔다)
|
||||
// 목록 · 표 · 읽기 화면 · 여러 통 처리는 출처를 몰라도 되게 이 함수들로 쿼리 · 주소를 만든다 (계약 2절 "검색").
|
||||
|
||||
import type { LocationQuery, LocationQueryValue, RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { MAIL_SEARCH_MAX_LENGTH, MAIL_SEARCH_MAX_WORDS, SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { MessageListQuery } from '@/shared/types/mail'
|
||||
|
||||
export interface MailboxListSource {
|
||||
kind: 'mailbox'
|
||||
mailboxId: string
|
||||
}
|
||||
|
||||
export interface SearchListSource {
|
||||
kind: 'search'
|
||||
/** 앞뒤 공백을 뺀 검색어 */
|
||||
q: string
|
||||
/** 찾을 곳 — 메일함 id 또는 전체(SEARCH_SCOPE_ALL) */
|
||||
scope: string
|
||||
}
|
||||
|
||||
export type MailListSource = MailboxListSource | SearchListSource
|
||||
|
||||
/** 검색 결과 · 검색 결과에서 연 메일의 라우트 이름 (router/index.ts) */
|
||||
export const MAIL_SEARCH_ROUTE = 'mail-search'
|
||||
export const MAIL_SEARCH_MESSAGE_ROUTE = 'mail-search-message'
|
||||
|
||||
export function isSearchRouteName(name: unknown): boolean {
|
||||
return name === MAIL_SEARCH_ROUTE || name === MAIL_SEARCH_MESSAGE_ROUTE
|
||||
}
|
||||
|
||||
export function sameListSource(a: MailListSource | null, b: MailListSource | null): boolean {
|
||||
if (!a || !b) return a === b
|
||||
if (a.kind === 'mailbox' && b.kind === 'mailbox') return a.mailboxId === b.mailboxId
|
||||
if (a.kind === 'search' && b.kind === 'search') return a.q === b.q && a.scope === b.scope
|
||||
return false
|
||||
}
|
||||
|
||||
/** GET /messages 쿼리 — 메일함은 mailboxId, 검색은 q (+ 한 메일함에서 찾으면 mailboxId) */
|
||||
export function listQueryOf(source: MailListSource): Pick<MessageListQuery, 'mailboxId' | 'q'> {
|
||||
if (source.kind === 'mailbox') return { mailboxId: source.mailboxId }
|
||||
return source.scope === SEARCH_SCOPE_ALL ? { q: source.q } : { q: source.q, mailboxId: source.scope }
|
||||
}
|
||||
|
||||
/** 출처가 가리키는 메일함 하나 — 메일함 목록이거나 한 메일함 안의 검색. 전체 메일함 검색이면 null */
|
||||
export function scopeMailboxIdOf(source: MailListSource | null): string | null {
|
||||
if (!source) return null
|
||||
if (source.kind === 'mailbox') return source.mailboxId
|
||||
return source.scope === SEARCH_SCOPE_ALL ? null : source.scope
|
||||
}
|
||||
|
||||
/** 전체 메일함에서 찾은 결과 — 행마다 메일함이 다르다 (행에 메일함 이름을 보인다) */
|
||||
export function isAllMailboxSearch(source: MailListSource | null): boolean {
|
||||
return source?.kind === 'search' && source.scope === SEARCH_SCOPE_ALL
|
||||
}
|
||||
|
||||
/** 목록마다 다른 키 — 표 위치 기억 등 */
|
||||
export function listKeyOf(source: MailListSource): string {
|
||||
return source.kind === 'mailbox' ? `mailbox:${source.mailboxId}` : `search:${source.scope}:${source.q}`
|
||||
}
|
||||
|
||||
/** 검색 주소의 쿼리 — /mail/search?q=…&in=<메일함 id | all> */
|
||||
export function searchRouteQuery(source: SearchListSource): Record<string, string> {
|
||||
return { q: source.q, in: source.scope }
|
||||
}
|
||||
|
||||
/** 목록 화면 주소 */
|
||||
export function listLocationOf(source: MailListSource): RouteLocationRaw {
|
||||
if (source.kind === 'mailbox') return { name: 'mail-mailbox', params: { mailboxId: source.mailboxId } }
|
||||
return { name: MAIL_SEARCH_ROUTE, query: searchRouteQuery(source) }
|
||||
}
|
||||
|
||||
/** 목록에서 연 메일의 주소 — 검색 결과에서 열면 /mail/search/<메일 id>?q=…&in=… */
|
||||
export function messageLocationOf(source: MailListSource, messageId: string): RouteLocationRaw {
|
||||
if (source.kind === 'mailbox') {
|
||||
return { name: 'mail-message', params: { mailboxId: source.mailboxId, messageId } }
|
||||
}
|
||||
return { name: MAIL_SEARCH_MESSAGE_ROUTE, params: { messageId }, query: searchRouteQuery(source) }
|
||||
}
|
||||
|
||||
function firstValue(value: LocationQueryValue | LocationQueryValue[] | undefined): string {
|
||||
const first = Array.isArray(value) ? value[0] : value
|
||||
return typeof first === 'string' ? first : ''
|
||||
}
|
||||
|
||||
/** 앞 반쪽만 남은 서로게이트(이모지를 가운데서 자름) — 주소에 넣을 수 없다 (encodeURIComponent 가 실패한다) */
|
||||
const TRAILING_HIGH_SURROGATE = /[\uD800-\uDBFF]$/
|
||||
|
||||
/**
|
||||
* 검색어 다듬기 — 앞뒤 공백을 빼고 서버 한도(200자 — JS 문자열 길이, 입력칸 maxlength 와 같다)에서 자른다.
|
||||
* 이모지를 가운데서 자르게 되면 그 글자를 뺀다
|
||||
*/
|
||||
export function normalizeSearchText(text: string): string {
|
||||
const cut = text.trim().slice(0, MAIL_SEARCH_MAX_LENGTH)
|
||||
return (TRAILING_HIGH_SURROGATE.test(cut) ? cut.slice(0, -1) : cut).trim()
|
||||
}
|
||||
|
||||
/** 서버가 받는 낱말 수를 넘는가 — 띄어쓰기로 나눈 조각을 센다 (따옴표와 상관없이, 서버와 같다) */
|
||||
export function exceedsSearchWords(text: string): boolean {
|
||||
return text.split(/\s+/u).filter((word) => word !== '').length > MAIL_SEARCH_MAX_WORDS
|
||||
}
|
||||
|
||||
/** 주소의 검색 쿼리 → 출처. 검색어가 없으면 null (범위가 비었으면 전체) */
|
||||
export function searchSourceFromQuery(query: LocationQuery): SearchListSource | null {
|
||||
const q = normalizeSearchText(firstValue(query.q))
|
||||
if (!q) return null
|
||||
const scope = firstValue(query.in).trim()
|
||||
return { kind: 'search', q, scope: scope || SEARCH_SCOPE_ALL }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { checkAllState, checkRange, pruneChecked, toggleChecked } from './mail-selection'
|
||||
|
||||
const ORDER = ['m1', 'm2', 'm3', 'm4', 'm5']
|
||||
const sorted = (set: ReadonlySet<string>) => [...set].sort()
|
||||
|
||||
describe('mail-selection (여러 통 고르기)', () => {
|
||||
it('하나 고르기 · 풀기 — 새 모음을 돌려준다', () => {
|
||||
const empty = new Set<string>()
|
||||
const one = toggleChecked(empty, 'm2')
|
||||
|
||||
expect(sorted(one)).toEqual(['m2'])
|
||||
expect(empty.size).toBe(0)
|
||||
expect(sorted(toggleChecked(one, 'm2'))).toEqual([])
|
||||
})
|
||||
|
||||
it('Shift — 기준부터 누른 메일까지, 위로도 아래로도', () => {
|
||||
expect(sorted(checkRange(new Set(['m2']), ORDER, 'm2', 'm4'))).toEqual(['m2', 'm3', 'm4'])
|
||||
expect(sorted(checkRange(new Set(['m4']), ORDER, 'm4', 'm1'))).toEqual(['m1', 'm2', 'm3', 'm4'])
|
||||
})
|
||||
|
||||
it('Shift — 누른 메일이 고른 것이었으면 범위를 푼다', () => {
|
||||
const all = new Set(ORDER)
|
||||
expect(sorted(checkRange(all, ORDER, 'm1', 'm3'))).toEqual(['m4', 'm5'])
|
||||
})
|
||||
|
||||
it('Shift — 기준이 없거나 목록에 없으면 누른 메일 하나만', () => {
|
||||
expect(sorted(checkRange(new Set(), ORDER, null, 'm3'))).toEqual(['m3'])
|
||||
expect(sorted(checkRange(new Set(['m1']), ORDER, 'gone', 'm3'))).toEqual(['m1', 'm3'])
|
||||
expect(sorted(checkRange(new Set(['m1']), ORDER, 'm1', 'gone'))).toEqual(['m1'])
|
||||
})
|
||||
|
||||
it('표 머리 상자 — 하나도 · 일부만 · 모두', () => {
|
||||
expect(checkAllState(new Set(), ORDER)).toBe('none')
|
||||
expect(checkAllState(new Set(['m1']), ORDER)).toBe('some')
|
||||
expect(checkAllState(new Set(ORDER), ORDER)).toBe('all')
|
||||
expect(checkAllState(new Set(['gone']), ORDER)).toBe('none')
|
||||
})
|
||||
|
||||
it('목록에서 빠진 메일은 뺀다 — 뺄 것이 없으면 같은 모음(다시 그리지 않게)', () => {
|
||||
const checked = new Set(['m1', 'm3'])
|
||||
expect(pruneChecked(checked, ORDER)).toBe(checked)
|
||||
expect(sorted(pruneChecked(checked, ['m3', 'm4']))).toEqual(['m3'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
// 여러 통 고르기 — 고른 메일 id 모음을 다루는 순수 함수 (디자인 README "3단계 › 여러 통 처리")
|
||||
// 고를 수 있는 것은 불러온 메일까지다 — 목록 순서(orderedIds)는 지금 보이는 목록 그대로 넘긴다 (계약 2절 "여러 통 한꺼번에")
|
||||
|
||||
export type CheckAllState = 'none' | 'some' | 'all'
|
||||
|
||||
/** 하나 고르기 · 풀기 (Ctrl · ⌘ + 클릭, 고르기 상자) */
|
||||
export function toggleChecked(checked: ReadonlySet<string>, id: string): Set<string> {
|
||||
const next = new Set(checked)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift + 클릭 — 기준(마지막으로 누른 메일)부터 누른 메일까지를 누른 메일의 새 상태로 맞춘다
|
||||
* (누른 메일이 고른 것이었으면 범위를 풀고, 아니면 범위를 고른다). 기준이 없거나 목록에 없으면 누른 메일 하나만 바꾼다
|
||||
*/
|
||||
export function checkRange(
|
||||
checked: ReadonlySet<string>,
|
||||
orderedIds: readonly string[],
|
||||
anchorId: string | null,
|
||||
targetId: string,
|
||||
): Set<string> {
|
||||
const target = orderedIds.indexOf(targetId)
|
||||
const anchor = anchorId === null ? -1 : orderedIds.indexOf(anchorId)
|
||||
if (target === -1) return new Set(checked)
|
||||
if (anchor === -1) return toggleChecked(checked, targetId)
|
||||
|
||||
const select = !checked.has(targetId)
|
||||
const [from, to] = anchor <= target ? [anchor, target] : [target, anchor]
|
||||
const next = new Set(checked)
|
||||
for (const id of orderedIds.slice(from, to + 1)) {
|
||||
if (select) next.add(id)
|
||||
else next.delete(id)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** 표 머리 상자의 상태 — 하나도 · 일부만(– 표시) · 모두 */
|
||||
export function checkAllState(checked: ReadonlySet<string>, orderedIds: readonly string[]): CheckAllState {
|
||||
const count = orderedIds.filter((id) => checked.has(id)).length
|
||||
if (count === 0) return 'none'
|
||||
return count === orderedIds.length ? 'all' : 'some'
|
||||
}
|
||||
|
||||
/** 목록에 없는 id 를 뺀다 — 뺄 것이 없으면 같은 모음을 돌려준다 (다시 그리지 않게) */
|
||||
export function pruneChecked(checked: ReadonlySet<string>, orderedIds: readonly string[]): ReadonlySet<string> {
|
||||
if (checked.size === 0) return checked
|
||||
const present = new Set(orderedIds)
|
||||
const kept = [...checked].filter((id) => present.has(id))
|
||||
return kept.length === checked.size ? checked : new Set(kept)
|
||||
}
|
||||
@@ -78,14 +78,17 @@ describe('mail-view.store (보기 방식 — 사용자별 기억)', () => {
|
||||
expect(parseMailViewMode('list')).toBe('list')
|
||||
})
|
||||
|
||||
it('표 위치는 같은 메일함으로 돌아왔을 때만 한 번 돌려준다', () => {
|
||||
it('표 위치는 같은 목록(메일함 · 검색 결과)으로 돌아왔을 때만 한 번 돌려준다', () => {
|
||||
const store = useMailViewStore()
|
||||
store.rememberListPosition({ mailboxId: 'inbox', scrollTop: 420, windowY: 0 })
|
||||
store.rememberListPosition({ listKey: 'mailbox:inbox', scrollTop: 420, windowY: 0 })
|
||||
|
||||
expect(store.takeListPosition('sent')).toBeNull()
|
||||
expect(store.takeListPosition('mailbox:sent')).toBeNull()
|
||||
|
||||
store.rememberListPosition({ mailboxId: 'inbox', scrollTop: 420, windowY: 0 })
|
||||
expect(store.takeListPosition('inbox')).toEqual({ mailboxId: 'inbox', scrollTop: 420, windowY: 0 })
|
||||
expect(store.takeListPosition('inbox')).toBeNull()
|
||||
store.rememberListPosition({ listKey: 'mailbox:inbox', scrollTop: 420, windowY: 0 })
|
||||
expect(store.takeListPosition('mailbox:inbox')).toEqual({ listKey: 'mailbox:inbox', scrollTop: 420, windowY: 0 })
|
||||
expect(store.takeListPosition('mailbox:inbox')).toBeNull()
|
||||
|
||||
store.rememberListPosition({ listKey: 'search:all:회의', scrollTop: 120, windowY: 0 })
|
||||
expect(store.takeListPosition('search:inbox:회의')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,7 +41,8 @@ function writeStored(key: string, value: string) {
|
||||
|
||||
/** 목록 보기에서 메일을 열 때 기억해 둔 표 위치 — "목록으로" 돌아오면 되살린다 */
|
||||
export interface ListPosition {
|
||||
mailboxId: string
|
||||
/** 어느 목록의 위치인지 — 메일함 · 검색 결과마다 다르다 (mail-list-source.ts 의 listKeyOf) */
|
||||
listKey: string
|
||||
/** 표 칸의 스크롤 (넓은 화면 — 칸마다 따로 스크롤) */
|
||||
scrollTop: number
|
||||
/** 화면 전체 스크롤 (좁은 화면 — 칸이 접히고 화면이 스크롤) */
|
||||
@@ -74,11 +75,11 @@ export const useMailViewStore = defineStore('mail-view', () => {
|
||||
listPosition.value = position
|
||||
}
|
||||
|
||||
/** 같은 메일함으로 돌아왔을 때만 위치를 돌려주고 비운다 */
|
||||
function takeListPosition(mailboxId: string): ListPosition | null {
|
||||
/** 같은 목록(메일함 · 검색 결과)으로 돌아왔을 때만 위치를 돌려주고 비운다 */
|
||||
function takeListPosition(listKey: string): ListPosition | null {
|
||||
const saved = listPosition.value
|
||||
listPosition.value = null
|
||||
return saved && saved.mailboxId === mailboxId ? saved : null
|
||||
return saved && saved.listKey === listKey ? saved : null
|
||||
}
|
||||
|
||||
function setReturnFocus(messageId: string | null) {
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MessageSummary, MessagesPatch } 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 { useMailStore } from './mail.store'
|
||||
|
||||
// 3단계 — 검색 결과 · 새 메일 확인으로 목록 맞추기 · 여러 통 처리 (계약 2절 "새 메일 확인" · "검색" · "여러 통 한꺼번에")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
function mailbox(id: string, role: Mailbox['role'], name: string, unreadCount = 0): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 10, unreadCount }
|
||||
}
|
||||
|
||||
const MAILBOXES = [
|
||||
mailbox('inbox', 'inbox', '받은편지함', 2),
|
||||
mailbox('sent', 'sent', '보낸편지함'),
|
||||
mailbox('archive', 'archive', '보관함'),
|
||||
mailbox('junk', 'junk', '스팸메일함'),
|
||||
mailbox('trash', 'trash', '휴지통'),
|
||||
]
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-27T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function page(items: MessageSummary[], nextCursor: string | null = null, total: number | null = items.length) {
|
||||
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total } })
|
||||
}
|
||||
|
||||
const ids = (list: readonly { id: string }[]) => list.map((item) => item.id)
|
||||
const patches = (requests: MockRequest[]) =>
|
||||
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data as MessagesPatch)
|
||||
|
||||
describe('mail.store — 3단계 목록', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
/** 메일함 목록과 첫 페이지를 받은 상태로 */
|
||||
async function openInbox(
|
||||
items: MessageSummary[],
|
||||
handler?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined,
|
||||
) {
|
||||
const requests = installMockApi((request) => {
|
||||
const custom = handler?.(request)
|
||||
if (custom) return custom
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 5 } })
|
||||
if (request.url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
if (request.url === '/messages' && request.method === 'GET') return page(items)
|
||||
return ok({ updatedIds: [], failedIds: [] })
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.loadMailboxes()
|
||||
await store.openMailbox('inbox')
|
||||
return { store, requests }
|
||||
}
|
||||
|
||||
describe('검색 (계약 2절 "검색")', () => {
|
||||
it('한 메일함에서 찾으면 q + mailboxId, 전체 메일함이면 q 만 — 가리키는 메일함은 그 범위다', async () => {
|
||||
const requests = installMockApi(() => page([summary('r1')]))
|
||||
const store = useMailStore()
|
||||
|
||||
await store.openSearch('회의', 'inbox')
|
||||
expect(requests[0]?.params).toEqual({ q: '회의', mailboxId: 'inbox', limit: 20 })
|
||||
expect(store.search).toEqual({ kind: 'search', q: '회의', scope: 'inbox' })
|
||||
expect(store.currentMailboxId).toBe('inbox')
|
||||
|
||||
await store.openSearch('회의', SEARCH_SCOPE_ALL)
|
||||
expect(requests[1]?.params).toEqual({ q: '회의', limit: 20 })
|
||||
expect(store.currentMailboxId).toBeNull()
|
||||
expect(ids(store.messages)).toEqual(['r1'])
|
||||
})
|
||||
|
||||
it('같은 검색 결과가 이미 보이면 다시 찾지 않는다 — 같은 검색어로 다시 Enter(force)면 보이는 채로 다시 찾는다', async () => {
|
||||
const requests = installMockApi(() => page([summary('r1')]))
|
||||
const store = useMailStore()
|
||||
|
||||
await store.openSearch('회의', SEARCH_SCOPE_ALL)
|
||||
await store.openSearch('회의', SEARCH_SCOPE_ALL)
|
||||
expect(requests).toHaveLength(1)
|
||||
|
||||
await store.openSearch('회의', SEARCH_SCOPE_ALL, { force: true })
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(store.listStatus).toBe('ready')
|
||||
})
|
||||
|
||||
it('더 보기는 검색어 · 범위와 커서를 함께 보낸다', async () => {
|
||||
const requests = installMockApi(({ params }) =>
|
||||
params.cursor ? page([summary('r2')]) : page([summary('r1')], 'c2', 2),
|
||||
)
|
||||
const store = useMailStore()
|
||||
await store.openSearch('회의', 'sent')
|
||||
|
||||
await store.loadMore()
|
||||
|
||||
expect(requests[1]?.params).toEqual({ q: '회의', mailboxId: 'sent', cursor: 'c2', limit: 20 })
|
||||
expect(ids(store.messages)).toEqual(['r1', 'r2'])
|
||||
})
|
||||
|
||||
it('메일함으로 돌아가면 보이던 결과를 버리고 그 메일함을 새로 연다', async () => {
|
||||
const requests = installMockApi(({ params }) => (params.q ? page([summary('r1')]) : page([summary('m1')])))
|
||||
const store = useMailStore()
|
||||
await store.openMailbox('inbox')
|
||||
await store.openSearch('회의', 'inbox')
|
||||
|
||||
const opening = store.openMailbox('inbox')
|
||||
expect(store.listStatus).toBe('loading')
|
||||
await opening
|
||||
|
||||
expect(store.search).toBeNull()
|
||||
expect(ids(store.messages)).toEqual(['m1'])
|
||||
expect(requests.at(-1)?.params).toEqual({ mailboxId: 'inbox', limit: 20 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('새 메일 확인으로 목록 맞추기 (계약 2절 "새 메일 확인")', () => {
|
||||
it('지금까지 불러온 만큼(적어도 20) 다시 받아 맞추고, 새로 생긴 메일 수를 알린다 — 고른 메일은 그대로', async () => {
|
||||
const first = Array.from({ length: 30 }, (_, index) => summary(`m${index + 1}`))
|
||||
let fresh: MessageSummary[] = first
|
||||
const { store, requests } = await openInbox(first, (request) =>
|
||||
request.url === '/messages' && request.params.limit !== 20 ? page(fresh, 'c-next', 99) : undefined,
|
||||
)
|
||||
store.toggleCheck('m3')
|
||||
fresh = [summary('new1'), ...first.slice(0, 29)]
|
||||
|
||||
await store.syncVisibleList()
|
||||
|
||||
const sync = requests.at(-1)
|
||||
expect(sync?.params).toEqual({ mailboxId: 'inbox', limit: 30 })
|
||||
expect(sync?.config.silent).toBe(true)
|
||||
expect(ids(store.messages).slice(0, 2)).toEqual(['new1', 'm1'])
|
||||
expect(store.messages).toHaveLength(31)
|
||||
expect(store.listSync?.addedAbove).toBe(1)
|
||||
expect([...store.checkedIds]).toEqual(['m3'])
|
||||
})
|
||||
|
||||
it('100통보다 많이 불러왔으면 100통만 다시 받는다', async () => {
|
||||
const loaded = Array.from({ length: 140 }, (_, index) => summary(`m${index + 1}`))
|
||||
const { store, requests } = await openInbox([], (request) =>
|
||||
request.url === '/messages' && request.params.limit === 100 ? page(loaded.slice(0, 100), 'c', 140) : undefined,
|
||||
)
|
||||
store.messages = loaded
|
||||
|
||||
await store.syncVisibleList()
|
||||
|
||||
expect(requests.at(-1)?.params).toEqual({ mailboxId: 'inbox', limit: 100 })
|
||||
expect(store.messages).toHaveLength(140)
|
||||
})
|
||||
|
||||
it('검색 결과는 저절로 바꾸지 않는다 — 되돌리기처럼 사용자가 한 일 뒤에만(includeSearch) 맞춘다', async () => {
|
||||
const requests = installMockApi(() => page([summary('r1')]))
|
||||
const store = useMailStore()
|
||||
await store.openSearch('회의', SEARCH_SCOPE_ALL)
|
||||
|
||||
await store.syncVisibleList()
|
||||
expect(requests).toHaveLength(1)
|
||||
|
||||
await store.syncVisibleList({ includeSearch: true })
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.params).toEqual({ q: '회의', limit: 20 })
|
||||
})
|
||||
|
||||
it('실패해도 알리지 않고 목록을 그대로 둔다', async () => {
|
||||
let broken = false
|
||||
const { store } = await openInbox([summary('m1')], (request) =>
|
||||
broken && request.url === '/messages' ? fail(503, 'MAIL_001') : undefined,
|
||||
)
|
||||
broken = true
|
||||
|
||||
await store.syncVisibleList()
|
||||
|
||||
expect(ids(store.messages)).toEqual(['m1'])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('보내는 중인 중요 표시를 옛 값으로 되돌려 보이지 않는다', async () => {
|
||||
let releasePatch: (result: MockResult) => void = () => undefined
|
||||
const { store } = await openInbox([summary('m1')], (request) => {
|
||||
if (request.method === 'PATCH') {
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
releasePatch = resolve
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const toggling = store.toggleFlag('m1')
|
||||
await store.syncVisibleList()
|
||||
expect(store.messages[0]?.isFlagged).toBe(true)
|
||||
|
||||
releasePatch(ok(summary('m1', { isFlagged: true })))
|
||||
await toggling
|
||||
expect(store.messages[0]?.isFlagged).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('고르기', () => {
|
||||
it('하나씩 · 범위 · 모두 — 메일함을 바꾸면 풀고, 목록에서 빠진 메일은 뺀다', async () => {
|
||||
const { store } = await openInbox([summary('m1'), summary('m2'), summary('m3'), summary('m4')])
|
||||
|
||||
store.toggleCheck('m1')
|
||||
store.checkRangeTo('m3')
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm2', 'm3'])
|
||||
expect(store.checkState).toBe('some')
|
||||
|
||||
store.toggleCheckAll()
|
||||
expect(store.checkState).toBe('all')
|
||||
|
||||
store.messages = store.messages.filter((message) => message.id !== 'm2')
|
||||
expect([...store.checkedIds].sort()).toEqual(['m1', 'm3', 'm4'])
|
||||
|
||||
await store.openMailbox('sent')
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('여러 통 처리 (계약 2절 "여러 통 한꺼번에")', () => {
|
||||
it('읽음 표시 — 화면을 먼저 바꾸고 failedIds 만 되돌린다 · 안 읽은 개수도 · 고른 것은 그대로', async () => {
|
||||
const { store, requests } = await openInbox(
|
||||
[summary('m1', { isRead: false }), summary('m2', { isRead: false }), summary('m3')],
|
||||
(request) => (request.method === 'PATCH' ? ok({ updatedIds: ['m1', 'm3'], failedIds: ['m2'] }) : undefined),
|
||||
)
|
||||
store.toggleCheckAll()
|
||||
|
||||
const marking = store.patchChecked({ isRead: true })
|
||||
expect(store.messages.every((message) => message.isRead)).toBe(true)
|
||||
expect(store.mailboxByRole('inbox')?.unreadCount).toBe(0)
|
||||
await marking
|
||||
|
||||
expect(patches(requests)).toEqual([{ ids: ['m1', 'm2', 'm3'], isRead: true }])
|
||||
expect(store.messages.map((message) => message.isRead)).toEqual([true, false, true])
|
||||
expect(store.checkedCount).toBe(3)
|
||||
})
|
||||
|
||||
it('100통이 넘으면 100통씩 차례로 보낸다 — 요청이 실패한 묶음은 되돌린다', async () => {
|
||||
const many = Array.from({ length: 230 }, (_, index) => summary(`m${index + 1}`))
|
||||
let calls = 0
|
||||
const { store, requests } = await openInbox(many, (request) => {
|
||||
if (request.method !== 'PATCH') return undefined
|
||||
calls += 1
|
||||
const patch = request.data as MessagesPatch
|
||||
return calls === 3 ? fail(503, 'MAIL_001') : ok({ updatedIds: patch.ids, failedIds: [] })
|
||||
})
|
||||
store.toggleCheckAll()
|
||||
|
||||
const result = await store.patchChecked({ isFlagged: true })
|
||||
|
||||
expect(patches(requests).map((patch) => patch.ids.length)).toEqual([100, 100, 30])
|
||||
expect(result.doneIds).toHaveLength(200)
|
||||
expect(result.unsentIds).toHaveLength(30)
|
||||
expect(store.messages.filter((message) => message.isFlagged)).toHaveLength(200)
|
||||
})
|
||||
|
||||
it('이동 — updatedIds 만 목록에서 빼고 전체 개수를 줄인다 · 고른 것을 푼다 · 되돌리기는 원래 메일함으로', async () => {
|
||||
const { store, requests } = await openInbox(
|
||||
[summary('m1'), summary('m2'), summary('m3')],
|
||||
(request) => (request.method === 'PATCH' ? ok({ updatedIds: ['m1', 'm3'], failedIds: ['m2'] }) : undefined),
|
||||
)
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 3 }
|
||||
store.toggleCheckAll()
|
||||
|
||||
const result = await store.moveChecked('archive')
|
||||
|
||||
expect(patches(requests)).toEqual([{ ids: ['m1', 'm2', 'm3'], mailboxId: 'archive' }])
|
||||
expect(ids(store.messages)).toEqual(['m2'])
|
||||
expect(store.pageInfo?.total).toBe(1)
|
||||
expect(store.checkedCount).toBe(0)
|
||||
expect(result.skippedIds).toEqual(['m2'])
|
||||
expect(result.origins).toEqual([{ mailboxId: 'inbox', ids: ['m1', 'm3'] }])
|
||||
})
|
||||
|
||||
it('전체 메일함 검색 결과 — 다른 메일함으로 옮기면 결과에 남고(메일함만 바뀐다), 휴지통 · 스팸메일함으로 옮기면 빠진다', async () => {
|
||||
const requests = installMockApi((request) => {
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 5 } })
|
||||
if (request.method === 'GET') {
|
||||
return page([summary('r1'), summary('r2', { mailboxIds: ['sent'] }), summary('r3', { mailboxIds: ['archive'] })])
|
||||
}
|
||||
const patch = request.data as MessagesPatch
|
||||
return ok({ updatedIds: patch.ids, failedIds: [] })
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.loadMailboxes()
|
||||
await store.openSearch('회의', SEARCH_SCOPE_ALL)
|
||||
|
||||
store.toggleCheck('r1')
|
||||
store.toggleCheck('r2')
|
||||
const moved = await store.moveChecked('archive')
|
||||
expect(ids(store.messages)).toEqual(['r1', 'r2', 'r3'])
|
||||
expect(store.messages.map((message) => message.mailboxIds)).toEqual([['archive'], ['archive'], ['archive']])
|
||||
// 되돌리기 — 원래 메일함별로 묶는다 (전체 검색은 메일마다 다르다)
|
||||
expect(moved.origins).toEqual([
|
||||
{ mailboxId: 'inbox', ids: ['r1'] },
|
||||
{ mailboxId: 'sent', ids: ['r2'] },
|
||||
])
|
||||
|
||||
store.toggleCheck('r3')
|
||||
await store.moveChecked('trash')
|
||||
expect(ids(store.messages)).toEqual(['r1', 'r2'])
|
||||
expect(patches(requests).at(-1)).toEqual({ ids: ['r3'], mailboxId: 'trash' })
|
||||
})
|
||||
|
||||
it('되돌리기 — 원래 메일함별로 묶어 다시 옮긴 뒤 보이는 목록을 맞춘다', async () => {
|
||||
let restored = false
|
||||
const { store, requests } = await openInbox([summary('m1'), summary('m2')], (request) => {
|
||||
if (request.method === 'PATCH') {
|
||||
const patch = request.data as MessagesPatch
|
||||
restored = true
|
||||
return ok({ updatedIds: patch.ids, failedIds: [] })
|
||||
}
|
||||
if (request.url === '/messages' && restored) return page([summary('m1'), summary('m2'), summary('p1')])
|
||||
return undefined
|
||||
})
|
||||
|
||||
const result = await store.restoreMoved([
|
||||
{ mailboxId: 'inbox', ids: ['m1'] },
|
||||
{ mailboxId: 'project', ids: ['p1'] },
|
||||
])
|
||||
await flushPromises()
|
||||
|
||||
expect(patches(requests)).toEqual([
|
||||
{ ids: ['m1'], mailboxId: 'inbox' },
|
||||
{ ids: ['p1'], mailboxId: 'project' },
|
||||
])
|
||||
expect(result.doneIds).toEqual(['m1', 'p1'])
|
||||
expect(ids(store.messages)).toEqual(['m1', 'm2', 'p1'])
|
||||
})
|
||||
|
||||
it('영구 삭제 — POST /message-deletions 로 100통씩, deletedIds 만 빼고 skippedIds 는 남긴다 · DELETE 는 쓰지 않는다', async () => {
|
||||
const trashItems = Array.from({ length: 120 }, (_, index) => summary(`t${index + 1}`, { mailboxIds: ['trash'] }))
|
||||
const requests = installMockApi((request) => {
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 5 } })
|
||||
if (request.url === '/message-deletions') {
|
||||
const body = request.data as { ids: string[] }
|
||||
return ok({ deletedIds: body.ids.filter((id) => id !== 't2'), skippedIds: body.ids.filter((id) => id === 't2') })
|
||||
}
|
||||
if (request.url === '/messages') return page(trashItems)
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.loadMailboxes()
|
||||
await store.openMailbox('trash')
|
||||
store.toggleCheckAll()
|
||||
|
||||
const result = await store.purgeChecked()
|
||||
|
||||
const deletions = requests.filter((request) => request.url === '/message-deletions')
|
||||
expect(deletions.map((request) => request.method)).toEqual(['POST', 'POST'])
|
||||
expect(deletions.map((request) => (request.data as { ids: string[] }).ids.length)).toEqual([100, 20])
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
expect(result.doneIds).toHaveLength(119)
|
||||
expect(result.skippedIds).toEqual(['t2'])
|
||||
expect(ids(store.messages)).toEqual(['t2'])
|
||||
})
|
||||
|
||||
it('영구 삭제 중 한 묶음이 실패하면(503 — 일부를 지운 뒤일 수 있다) 보이는 목록을 다시 맞춘다 — 휴지통 안의 검색 결과도', async () => {
|
||||
const trashItems = Array.from({ length: 120 }, (_, index) => summary(`t${index + 1}`, { mailboxIds: ['trash'] }))
|
||||
let deletions = 0
|
||||
const requests = installMockApi((request) => {
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 5 } })
|
||||
if (request.url === '/message-deletions') {
|
||||
deletions += 1
|
||||
return deletions === 2 ? fail(503, 'MAIL_001') : ok({ deletedIds: (request.data as { ids: string[] }).ids, skippedIds: [] })
|
||||
}
|
||||
if (request.url === '/messages') return page(trashItems.slice(100))
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.loadMailboxes()
|
||||
store.listSource = { kind: 'search', q: '백업', scope: 'trash' }
|
||||
store.messages = trashItems
|
||||
store.listStatus = 'ready'
|
||||
store.toggleCheckAll()
|
||||
|
||||
const result = await store.purgeChecked()
|
||||
await flushPromises()
|
||||
|
||||
expect(result.doneIds).toHaveLength(100)
|
||||
expect(result.unsentIds).toHaveLength(20)
|
||||
const resync = requests.filter((request) => request.url === '/messages').at(-1)
|
||||
expect(resync?.params).toEqual({ q: '백업', mailboxId: 'trash', limit: 20 })
|
||||
expect(resync?.config.silent).toBe(true)
|
||||
expect(ids(store.messages)).toEqual(ids(trashItems.slice(100)))
|
||||
})
|
||||
})
|
||||
|
||||
describe('경합 — 늦게 온 응답 · 그사이 바뀐 목록', () => {
|
||||
it('목록 맞추기 응답이 이 탭의 이동보다 늦게 오면 버리고 한 번 더 맞춘다 — 옮긴 메일이 되살아나지 않는다', async () => {
|
||||
let hold = false
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
let server = [summary('m1'), summary('m2'), summary('m3')]
|
||||
const { store, requests } = await openInbox(server, (request) => {
|
||||
if (request.method === 'PATCH') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
|
||||
if (request.url !== '/messages') return undefined
|
||||
if (hold) {
|
||||
hold = false
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
}
|
||||
return page(server)
|
||||
})
|
||||
const listsBefore = requests.filter((request) => request.method === 'GET' && request.url === '/messages').length
|
||||
|
||||
hold = true
|
||||
const syncing = store.syncVisibleList()
|
||||
store.toggleCheck('m1')
|
||||
await store.moveChecked('archive')
|
||||
server = [summary('m2'), summary('m3')]
|
||||
// 이동 전에 떠난 응답 — 옮긴 메일(m1)이 아직 들어 있다
|
||||
release(page([summary('m1'), summary('m2'), summary('m3')]))
|
||||
await syncing
|
||||
await flushPromises()
|
||||
|
||||
expect(ids(store.messages)).toEqual(['m2', 'm3'])
|
||||
expect(requests.filter((request) => request.method === 'GET' && request.url === '/messages').length).toBe(listsBefore + 2)
|
||||
})
|
||||
|
||||
it('목록 맞추기 응답이 읽음 표시보다 늦게 오면 버린다 — 목록과 열린 메일의 읽음이 되돌아가지 않는다', async () => {
|
||||
let hold = false
|
||||
let release: (result: MockResult) => void = () => undefined
|
||||
let serverRead = false
|
||||
const { store } = await openInbox([summary('m1', { isRead: false })], (request) => {
|
||||
if (request.method === 'PATCH') return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
|
||||
if (request.url !== '/messages') return undefined
|
||||
if (hold) {
|
||||
hold = false
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
}
|
||||
return page([summary('m1', { isRead: serverRead })])
|
||||
})
|
||||
store.selected = { ...summary('m1', { isRead: false }), cc: [], bcc: [], replyTo: [], sentAt: null, messageId: null, references: [], htmlBody: null, textBody: '본문', hasBlockedImages: false, attachments: [] }
|
||||
store.selectedId = 'm1'
|
||||
|
||||
hold = true
|
||||
const syncing = store.syncVisibleList()
|
||||
store.toggleCheck('m1')
|
||||
await store.patchChecked({ isRead: true })
|
||||
serverRead = true
|
||||
release(page([summary('m1', { isRead: false })]))
|
||||
await syncing
|
||||
await flushPromises()
|
||||
|
||||
expect(store.messages[0]?.isRead).toBe(true)
|
||||
expect(store.selected?.isRead).toBe(true)
|
||||
})
|
||||
|
||||
it('여러 통 이동 중에 다른 메일함을 열면 — 끝난 이동은 새 목록과 그 목록에서 고른 것을 건드리지 않는다', async () => {
|
||||
let releasePatch: (result: MockResult) => void = () => undefined
|
||||
const { store, requests } = await openInbox([summary('m1'), summary('m2')], (request) => {
|
||||
if (request.method === 'PATCH') {
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
releasePatch = resolve
|
||||
})
|
||||
}
|
||||
if (request.url === '/messages' && request.params.mailboxId === 'archive') {
|
||||
return page([summary('m1', { mailboxIds: ['archive'] }), summary('a1', { mailboxIds: ['archive'] })])
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
|
||||
const moving = store.moveChecked('archive')
|
||||
await store.openMailbox('archive')
|
||||
store.toggleCheck('a1')
|
||||
releasePatch(ok({ updatedIds: ['m1'], failedIds: [] }))
|
||||
const result = await moving
|
||||
await flushPromises()
|
||||
|
||||
expect(result.stale).toBe(true)
|
||||
expect(ids(store.messages)).toEqual(['m1', 'a1'])
|
||||
expect([...store.checkedIds]).toEqual(['a1'])
|
||||
// 개수는 서버 기준으로 다시 맞춘다
|
||||
expect(requests.filter((request) => request.url === '/mailboxes').length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('읽기 화면의 한 통 삭제 중에 검색 결과로 옮겨 가면 — 결과에서 빼지 않고 다시 찾지도 않는다', async () => {
|
||||
let releasePatch: (result: MockResult) => void = () => undefined
|
||||
const { store, requests } = await openInbox([summary('m1'), summary('m2')], (request) => {
|
||||
if (request.method === 'PATCH') {
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
releasePatch = resolve
|
||||
})
|
||||
}
|
||||
if (request.url === '/messages' && request.params.q === '회의') return page([summary('m1')])
|
||||
return undefined
|
||||
})
|
||||
const open = store.messages[0]
|
||||
if (!open) throw new Error('메일이 없다')
|
||||
|
||||
const trashing = store.moveMessages([open], 'trash')
|
||||
await store.openSearch('회의', SEARCH_SCOPE_ALL)
|
||||
releasePatch(ok({ updatedIds: ['m1'], failedIds: [] }))
|
||||
const result = await trashing
|
||||
await flushPromises()
|
||||
|
||||
expect(result.stale).toBe(true)
|
||||
expect(ids(store.messages)).toEqual(['m1'])
|
||||
// 검색 결과는 저절로 다시 찾지 않는다
|
||||
expect(requests.filter((request) => request.method === 'GET' && request.params.q === '회의')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('이동 중에 다른 메일함에 갔다 돌아오면 — 이동이 닿기 전에 다시 받은 목록을 끝난 뒤 서버 기준으로 맞춘다 (옮긴 메일이 남지 않는다)', async () => {
|
||||
let releasePatch: (result: MockResult) => void = () => undefined
|
||||
let inbox = [summary('m1'), summary('m2')]
|
||||
const { store, requests } = await openInbox(inbox, (request) => {
|
||||
if (request.method === 'PATCH') {
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
releasePatch = resolve
|
||||
})
|
||||
}
|
||||
if (request.url === '/messages' && request.params.mailboxId === 'inbox') return page(inbox)
|
||||
return undefined
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
|
||||
const moving = store.moveChecked('archive')
|
||||
await store.openMailbox('sent')
|
||||
await store.openMailbox('inbox')
|
||||
expect(ids(store.messages)).toEqual(['m1', 'm2'])
|
||||
inbox = [summary('m2')]
|
||||
releasePatch(ok({ updatedIds: ['m1'], failedIds: [] }))
|
||||
const result = await moving
|
||||
await flushPromises()
|
||||
|
||||
expect(result.stale).toBe(true)
|
||||
expect(ids(store.messages)).toEqual(['m2'])
|
||||
const inboxLists = requests.filter((request) => request.method === 'GET' && request.params.mailboxId === 'inbox')
|
||||
expect(inboxLists.at(-1)?.config.silent).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -230,23 +230,31 @@ describe('mail.store', () => {
|
||||
expect(store.messages[0]?.isFlagged).toBe(true)
|
||||
})
|
||||
|
||||
it('삭제하면 목록에서 빼고 다음 메일(없으면 이전 메일) id 를 돌려준다', async () => {
|
||||
const requests = installMockApi(({ method, url }) => {
|
||||
if (method === 'DELETE') return { status: 204 }
|
||||
it('한 통 삭제 — 휴지통으로 옮기고(PATCH /messages) 목록에서 뺀다, 이어서 볼 메일은 다음 메일(없으면 이전 메일) · DELETE 는 쓰지 않는다', async () => {
|
||||
const requests = installMockApi(({ method, url, data }) => {
|
||||
if (method === 'PATCH') return ok({ updatedIds: (data as { ids: string[] }).ids, failedIds: [] })
|
||||
if (url === '/mailboxes') return ok({ items: [INBOX, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
return page([summary('m1'), summary('m2'), summary('m3')])
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.openMailbox('inbox')
|
||||
|
||||
await expect(store.removeMessage('m2')).resolves.toBe('m3')
|
||||
await expect(store.removeMessage('m3')).resolves.toBe('m1')
|
||||
expect(store.neighborAfterRemoval('m2')).toBe('m3')
|
||||
const second = store.messages[1]
|
||||
if (second) await store.moveMessages([second], 'trash')
|
||||
expect(store.neighborAfterRemoval('m3')).toBe('m1')
|
||||
const third = store.messages[1]
|
||||
if (third) await store.moveMessages([third], 'trash')
|
||||
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m1'])
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/messages/m2',
|
||||
'/messages/m3',
|
||||
expect(
|
||||
requests.filter((request) => request.method === 'PATCH').map((request) => [request.url, request.data]),
|
||||
).toEqual([
|
||||
['/messages', { ids: ['m2'], mailboxId: 'trash' }],
|
||||
['/messages', { ids: ['m3'], mailboxId: 'trash' }],
|
||||
])
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
expect(store.neighborAfterRemoval('gone')).toBeNull()
|
||||
})
|
||||
|
||||
it('다른 메일함으로 옮긴다(되돌리기) — 개수를 다시 맞추고, 보이는 목록이 옮긴 곳 · 휴지통이면 새로 고친다', async () => {
|
||||
@@ -323,22 +331,24 @@ describe('mail.store', () => {
|
||||
expect(store.attachmentTotalMaxBytes).toBe(MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES)
|
||||
})
|
||||
|
||||
it('삭제한 뒤에는 메일함 개수와 사용량을 함께 다시 맞춘다', async () => {
|
||||
const requests = installMockApi(({ method, url }) => {
|
||||
if (method === 'DELETE') return { status: 204 }
|
||||
it('영구 삭제한 뒤에는 메일함 개수와 사용량을 함께 다시 맞춘다 — POST /message-deletions, DELETE 는 쓰지 않는다', async () => {
|
||||
const requests = installMockApi(({ url, data }) => {
|
||||
if (url === '/message-deletions') return ok({ deletedIds: (data as { ids: string[] }).ids, skippedIds: [] })
|
||||
if (url === '/mail-quota') return ok(QUOTA)
|
||||
if (url === '/mailboxes') return ok({ items: [INBOX, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
return page([summary('m1'), summary('m2')])
|
||||
return page([summary('m1', { mailboxIds: ['trash'] }), summary('m2', { mailboxIds: ['trash'] })])
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.openMailbox('trash')
|
||||
|
||||
await store.removeMessage('m1')
|
||||
await store.purgeMessages(['m1'])
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual(
|
||||
expect.arrayContaining(['/messages/m1', '/mailboxes', '/mail-quota']),
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(
|
||||
expect.arrayContaining(['POST /message-deletions', 'GET /mailboxes', 'GET /mail-quota']),
|
||||
)
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,66 +1,42 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import {
|
||||
MAIL_ATTACHMENT_FALLBACK_MAX_BYTES,
|
||||
MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES,
|
||||
} from '@/shared/constants/mail'
|
||||
import type {
|
||||
MailQuota,
|
||||
Mailbox,
|
||||
MailboxRole,
|
||||
MessageDetail,
|
||||
MessageSummary,
|
||||
} from '@/shared/types/mail'
|
||||
import { DEFAULT_PAGE_SIZE, type PageInfo } from '@/shared/types/pagination'
|
||||
import { findMailboxByRole, orderMailboxes } from '@/shared/utils/mailbox'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createBulkActions } from './mail/bulk'
|
||||
import { createListState } from './mail/list'
|
||||
import { createMailboxesState } from './mail/mailboxes'
|
||||
import { createMessageActions, createOpenedMessage } from './mail/message'
|
||||
import { createSelection } from './mail/selection'
|
||||
|
||||
export type LoadStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
export type { BulkResult } from '@/shared/utils/mail-bulk'
|
||||
export type { BulkMoveResult, FlagChanges, LoadStatus, MailBulkResult, SearchOrigin } from './mail/types'
|
||||
|
||||
/** 서버가 준 한도가 양수면 그 값, 없거나 이상하면 대체값 */
|
||||
function positiveOr(value: number | undefined, fallback: number): number {
|
||||
return typeof value === 'number' && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
// 메일 도메인 스토어 — 메일함 · 사용량 · 목록 · 선택한 메일
|
||||
// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 또는 검색 결과) · 고른 메일 · 열린 메일
|
||||
// 한 스토어를 기능별 모듈(stores/mail/*)로 나눠 조립한다 — 화면은 이 스토어 하나만 본다.
|
||||
// mailboxes.ts 메일함 · 사용량 | list.ts 보이는 목록 · 이 탭이 바꾼 것 | selection.ts 고른 메일
|
||||
// message.ts 열린 메일 · 읽음 · 중요 표시 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 포함)
|
||||
//
|
||||
// 오류 처리 원칙:
|
||||
// - 화면 영역이 오류 상태를 직접 그리는 첫 조회(메일함·목록·본문)는 silent 로 부르고 상태에 담는다
|
||||
// - 사용량은 보조 정보라 silent 로 부르고, 못 받으면 패널 하단을 숨긴다 (첨부 한도는 대체값)
|
||||
// - 그 밖의 동작(더 보기·읽음·중요·삭제)은 인터셉터 알림에 맡기고 화면 상태만 되돌린다
|
||||
// - 메일함을 빠르게 바꿀 때 늦게 온 응답이 덮어쓰지 않도록 요청마다 순번을 매긴다
|
||||
// - 새 메일 확인으로 목록을 맞추는 조회는 알리지 않는다 (다음 확인에서 다시 맞춘다)
|
||||
// - 그 밖의 동작(더 보기·읽음·중요·삭제·여러 통 처리)은 인터셉터 알림에 맡기고 화면 상태만 되돌린다
|
||||
// - 늦게 온 응답이 덮어쓰지 않도록 순번을 둔다 (list.ts 머리말)
|
||||
//
|
||||
// "고른 메일"(checkedIds — 여러 통 처리의 체크)과 "열린 메일"(selectedId — 읽기 화면)은 다르다.
|
||||
export const useMailStore = defineStore('mail', () => {
|
||||
const mailApi = useMail()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── 메일함 ─────────────────────────────────────────
|
||||
const mailboxes = ref<Mailbox[]>([])
|
||||
const mailboxesStatus = ref<LoadStatus>('idle')
|
||||
const mailboxesError = ref<ApiError | null>(null)
|
||||
const mailboxState = createMailboxesState(mailApi)
|
||||
const opened = createOpenedMessage()
|
||||
const list = createListState({ mailApi, mailboxes: mailboxState, opened })
|
||||
const selection = createSelection(list)
|
||||
const message = createMessageActions({ mailApi, mailboxes: mailboxState, list, opened })
|
||||
const bulk = createBulkActions({ mailApi, mailboxes: mailboxState, list, selection })
|
||||
|
||||
// ── 사용량 (GET /mail-quota) ──────────────────────────
|
||||
const quota = ref<MailQuota | null>(null)
|
||||
|
||||
// ── 목록 ──────────────────────────────────────────
|
||||
const currentMailboxId = ref<string | null>(null)
|
||||
const messages = ref<MessageSummary[]>([])
|
||||
const pageInfo = ref<PageInfo | null>(null)
|
||||
const listStatus = ref<LoadStatus>('idle')
|
||||
const listError = ref<ApiError | null>(null)
|
||||
/** 목록을 보이는 채로 다시 불러오는 중 (새로고침) */
|
||||
const isRefreshing = ref<boolean>(false)
|
||||
const isLoadingMore = ref<boolean>(false)
|
||||
|
||||
// ── 선택한 메일 ────────────────────────────────────
|
||||
const selectedId = ref<string | null>(null)
|
||||
const selected = ref<MessageDetail | null>(null)
|
||||
const selectedStatus = ref<LoadStatus>('idle')
|
||||
const selectedError = ref<ApiError | null>(null)
|
||||
|
||||
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일) */
|
||||
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일·검색 결과) */
|
||||
const lastBrowseLocation = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
@@ -70,305 +46,6 @@ export const useMailStore = defineStore('mail', () => {
|
||||
*/
|
||||
const draftSuccessors = new Map<string, string>()
|
||||
|
||||
let mailboxSeq = 0
|
||||
let quotaSeq = 0
|
||||
let listSeq = 0
|
||||
let detailSeq = 0
|
||||
|
||||
/**
|
||||
* 첨부 파일당 한도(바이트) — 서버 값(attachmentMaxBytes).
|
||||
* 사용량을 아직 못 받았거나 값이 이상하면 대체값(20MB)으로 사전 검사·안내한다.
|
||||
*/
|
||||
const attachmentMaxBytes = computed<number>(() =>
|
||||
positiveOr(quota.value?.attachmentMaxBytes, MAIL_ATTACHMENT_FALLBACK_MAX_BYTES),
|
||||
)
|
||||
|
||||
/**
|
||||
* 메일 한 통의 첨부 합계 한도(바이트) — 서버 값(attachmentTotalMaxBytes). 발송 때 서버가 검사하는 값과 같다.
|
||||
* 못 받았거나(이 값을 아직 주지 않는 서버 포함) 값이 이상하면 대체값(25MB)을 쓴다.
|
||||
*/
|
||||
const attachmentTotalMaxBytes = computed<number>(() =>
|
||||
positiveOr(quota.value?.attachmentTotalMaxBytes, MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES),
|
||||
)
|
||||
|
||||
const orderedMailboxes = computed(() => orderMailboxes(mailboxes.value))
|
||||
const currentMailbox = computed<Mailbox | null>(
|
||||
() => mailboxes.value.find((mailbox) => mailbox.id === currentMailboxId.value) ?? null,
|
||||
)
|
||||
/** /mail 로 들어왔을 때 열 메일함 — 받은편지함, 없으면 첫 메일함 */
|
||||
const defaultMailbox = computed<Mailbox | null>(
|
||||
() =>
|
||||
findMailboxByRole(mailboxes.value, 'inbox') ?? orderedMailboxes.value[0]?.mailbox ?? null,
|
||||
)
|
||||
const hasMore = computed<boolean>(
|
||||
() => pageInfo.value?.hasNext === true && Boolean(pageInfo.value.nextCursor),
|
||||
)
|
||||
|
||||
function mailboxByRole(role: MailboxRole): Mailbox | null {
|
||||
return findMailboxByRole(mailboxes.value, role)
|
||||
}
|
||||
|
||||
/** 응답을 기다리는 메일함 목록 요청 수 — 그 응답이 화면에서 바꾼 개수를 덮어쓸 수 있는지 판단한다 */
|
||||
let mailboxLoadsInFlight = 0
|
||||
|
||||
/**
|
||||
* 메일함 목록 — 실패하면 그 오류를 돌려준다(알리지 않는다 — 부르는 쪽이 알릴지 정한다), 받았으면 null.
|
||||
* background 면 이미 보이는 화면 상태(불러오는 중·오류)를 건드리지 않고 개수만 서버 기준으로 다시 맞춘다.
|
||||
*/
|
||||
async function loadMailboxes(options: { background?: boolean } = {}): Promise<ApiError | null> {
|
||||
mailboxSeq += 1
|
||||
const seq = mailboxSeq
|
||||
if (!options.background) {
|
||||
mailboxesStatus.value = 'loading'
|
||||
mailboxesError.value = null
|
||||
}
|
||||
mailboxLoadsInFlight += 1
|
||||
try {
|
||||
const result = await mailApi.listMailboxes({ silent: true })
|
||||
if (seq !== mailboxSeq) return null
|
||||
mailboxes.value = result.items
|
||||
mailboxesStatus.value = 'ready'
|
||||
mailboxesError.value = null
|
||||
return null
|
||||
} catch (error) {
|
||||
const apiError = toApiError(error)
|
||||
if (seq !== mailboxSeq) return apiError
|
||||
// 이미 목록이 보이면 조용히 둔다. 첫 조회를 대신한 요청이었다면 패널에 오류를 그린다
|
||||
if (options.background && mailboxesStatus.value === 'ready') return apiError
|
||||
mailboxesStatus.value = 'error'
|
||||
mailboxesError.value = apiError
|
||||
return apiError
|
||||
} finally {
|
||||
mailboxLoadsInFlight -= 1
|
||||
}
|
||||
}
|
||||
|
||||
/** 지금 보이는 개수가 곧 도착할(또는 아직 없는) 메일함 응답에 덮어쓰일 수 있는지 */
|
||||
function mailboxCountsMayBeOverwritten(): boolean {
|
||||
return mailboxLoadsInFlight > 0 || mailboxesStatus.value !== 'ready'
|
||||
}
|
||||
|
||||
/** 아직 불러오지 않았거나 실패했을 때만 불러온다 */
|
||||
async function ensureMailboxes(): Promise<void> {
|
||||
if (mailboxesStatus.value === 'idle' || mailboxesStatus.value === 'error') {
|
||||
await loadMailboxes()
|
||||
}
|
||||
}
|
||||
|
||||
/** 사용량 · 첨부 한도 — 실패해도 알리지 않고 이전 값을 둔다 (보조 정보) */
|
||||
async function loadQuota(): Promise<void> {
|
||||
quotaSeq += 1
|
||||
const seq = quotaSeq
|
||||
try {
|
||||
const result = await mailApi.getQuota({ silent: true })
|
||||
if (seq === quotaSeq) quota.value = result
|
||||
} catch {
|
||||
// 못 받으면 패널 하단 사용량을 숨기고, 첨부 한도는 대체값을 쓴다
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureQuota(): Promise<void> {
|
||||
if (!quota.value) await loadQuota()
|
||||
}
|
||||
|
||||
/** 보내기·삭제·새로고침 뒤 — 메일함 개수와 사용량을 서버 기준으로 다시 맞춘다 (화면은 그대로 둔 채) */
|
||||
function syncCounts() {
|
||||
void loadMailboxes({ background: true })
|
||||
void loadQuota()
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 열기 — 첫 페이지를 불러온다.
|
||||
* 같은 메일함으로 돌아오면(쓰기 화면에서 복귀 등) 보이던 목록을 둔 채 새로 고친다.
|
||||
*/
|
||||
async function openMailbox(mailboxId: string): Promise<void> {
|
||||
const keepVisible =
|
||||
mailboxId === currentMailboxId.value && listStatus.value === 'ready'
|
||||
currentMailboxId.value = mailboxId
|
||||
listSeq += 1
|
||||
const seq = listSeq
|
||||
|
||||
if (keepVisible) {
|
||||
isRefreshing.value = true
|
||||
} else {
|
||||
messages.value = []
|
||||
pageInfo.value = null
|
||||
listStatus.value = 'loading'
|
||||
listError.value = null
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await mailApi.listMessages(
|
||||
{ mailboxId, limit: DEFAULT_PAGE_SIZE },
|
||||
// 처음 여는 목록은 목록 영역이 오류 상태("다시 시도")를 직접 그린다
|
||||
{ silent: keepVisible ? undefined : true },
|
||||
)
|
||||
if (seq !== listSeq) return
|
||||
messages.value = page.items
|
||||
pageInfo.value = page.pageInfo
|
||||
listStatus.value = 'ready'
|
||||
listError.value = null
|
||||
} catch (error) {
|
||||
if (seq !== listSeq || keepVisible) return
|
||||
listStatus.value = 'error'
|
||||
listError.value = toApiError(error)
|
||||
} finally {
|
||||
if (seq === listSeq) isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 새로고침 — 메일함 개수 · 사용량 · 지금 목록을 함께 다시 불러온다 */
|
||||
async function refresh(): Promise<void> {
|
||||
syncCounts()
|
||||
if (currentMailboxId.value) await openMailbox(currentMailboxId.value)
|
||||
}
|
||||
|
||||
/** 다음 페이지 — pageInfo.nextCursor 를 해석하지 않고 그대로 보낸다 */
|
||||
async function loadMore(): Promise<void> {
|
||||
const cursor = pageInfo.value?.nextCursor
|
||||
const mailboxId = currentMailboxId.value
|
||||
if (!cursor || !mailboxId || isLoadingMore.value) return
|
||||
|
||||
const seq = listSeq
|
||||
isLoadingMore.value = true
|
||||
try {
|
||||
const page = await mailApi.listMessages({ mailboxId, cursor, limit: DEFAULT_PAGE_SIZE })
|
||||
if (seq !== listSeq) return
|
||||
// 그사이 새 메일이 와서 경계가 밀렸을 수 있다 — 이미 있는 메일은 건너뛴다
|
||||
const known = new Set(messages.value.map((message) => message.id))
|
||||
messages.value = [...messages.value, ...page.items.filter((item) => !known.has(item.id))]
|
||||
pageInfo.value = page.pageInfo
|
||||
} catch {
|
||||
// 알림은 인터셉터가 띄웠다 — 버튼을 다시 누를 수 있게 그대로 둔다
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setLocalFlags(id: string, changes: Partial<Pick<MessageSummary, 'isRead' | 'isFlagged'>>) {
|
||||
messages.value = messages.value.map((message) =>
|
||||
message.id === id ? { ...message, ...changes } : message,
|
||||
)
|
||||
if (selected.value?.id === id) selected.value = { ...selected.value, ...changes }
|
||||
}
|
||||
|
||||
function adjustUnread(mailboxIds: readonly string[], delta: number) {
|
||||
mailboxes.value = mailboxes.value.map((mailbox) =>
|
||||
mailboxIds.includes(mailbox.id)
|
||||
? { ...mailbox, unreadCount: Math.max(0, mailbox.unreadCount + delta) }
|
||||
: mailbox,
|
||||
)
|
||||
}
|
||||
|
||||
/** 서버가 돌려준 요약으로 목록·선택 메일을 맞춘다 */
|
||||
function applySummary(summary: MessageSummary) {
|
||||
messages.value = messages.value.map((message) => (message.id === summary.id ? summary : message))
|
||||
if (selected.value?.id === summary.id) selected.value = { ...selected.value, ...summary }
|
||||
}
|
||||
|
||||
/** 읽음 처리 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
|
||||
async function markRead(message: Pick<MessageSummary, 'id' | 'isRead' | 'mailboxIds'>) {
|
||||
if (message.isRead) return
|
||||
// 주소로 바로 들어오면 메일함 목록보다 본문이 먼저 올 수 있다 — 그러면 아래 개수 조정이
|
||||
// 늦게 온 메일함 응답에 덮어쓰이므로, 읽음 처리가 끝난 뒤 서버 기준으로 다시 맞춘다
|
||||
const resync = mailboxCountsMayBeOverwritten()
|
||||
setLocalFlags(message.id, { isRead: true })
|
||||
adjustUnread(message.mailboxIds, -1)
|
||||
try {
|
||||
applySummary(await mailApi.updateMessage(message.id, { isRead: true }))
|
||||
if (resync || mailboxCountsMayBeOverwritten()) void loadMailboxes({ background: true })
|
||||
} catch {
|
||||
setLocalFlags(message.id, { isRead: false })
|
||||
adjustUnread(message.mailboxIds, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** 메일 열기 — 조회만으로는 읽음 처리되지 않으므로 열린 뒤 PATCH {isRead:true} 를 보낸다 */
|
||||
async function openMessage(id: string): Promise<void> {
|
||||
selectedId.value = id
|
||||
if (selected.value?.id === id && selectedStatus.value === 'ready') return
|
||||
|
||||
detailSeq += 1
|
||||
const seq = detailSeq
|
||||
selected.value = null
|
||||
selectedStatus.value = 'loading'
|
||||
selectedError.value = null
|
||||
try {
|
||||
const detail = await mailApi.getMessage(id, { silent: true })
|
||||
if (seq !== detailSeq) return
|
||||
selected.value = detail
|
||||
selectedStatus.value = 'ready'
|
||||
if (!detail.isRead) void markRead(detail)
|
||||
} catch (error) {
|
||||
if (seq !== detailSeq) return
|
||||
selectedStatus.value = 'error'
|
||||
selectedError.value = toApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function closeMessage() {
|
||||
detailSeq += 1
|
||||
selectedId.value = null
|
||||
selected.value = null
|
||||
selectedStatus.value = 'idle'
|
||||
selectedError.value = null
|
||||
}
|
||||
|
||||
/** 중요 표시 토글 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
|
||||
async function toggleFlag(id: string): Promise<void> {
|
||||
const current =
|
||||
messages.value.find((message) => message.id === id) ??
|
||||
(selected.value?.id === id ? selected.value : null)
|
||||
if (!current) return
|
||||
|
||||
const next = !current.isFlagged
|
||||
setLocalFlags(id, { isFlagged: next })
|
||||
try {
|
||||
applySummary(await mailApi.updateMessage(id, { isFlagged: next }))
|
||||
} catch {
|
||||
setLocalFlags(id, { isFlagged: !next })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 — 휴지통이 아니면 휴지통으로, 휴지통이면 영구 삭제 (서버가 판단).
|
||||
* 성공하면 이어서 보여 줄 메일 id(다음 메일, 없으면 이전 메일)를 돌려준다.
|
||||
* 실패하면 예외가 그대로 올라간다 (알림은 인터셉터가 띄웠다).
|
||||
*/
|
||||
async function removeMessage(id: string): Promise<string | null> {
|
||||
await mailApi.deleteMessage(id)
|
||||
|
||||
const index = messages.value.findIndex((message) => message.id === id)
|
||||
const rest = messages.value.filter((message) => message.id !== id)
|
||||
const next = index === -1 ? null : (rest[index] ?? rest[index - 1] ?? null)
|
||||
messages.value = rest
|
||||
if (pageInfo.value && pageInfo.value.total !== null) {
|
||||
pageInfo.value = { ...pageInfo.value, total: Math.max(0, pageInfo.value.total - 1) }
|
||||
}
|
||||
if (selectedId.value === id) closeMessage()
|
||||
// 휴지통 개수 · 사용량(영구 삭제)까지 서버 기준으로 다시 맞춘다
|
||||
syncCounts()
|
||||
return next?.id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일을 다른 메일함으로 옮긴다 (PATCH { mailboxId }) — 예: 버린 임시 저장본을 임시보관함으로 되돌리기.
|
||||
* 개수를 서버 기준으로 다시 맞추고, 지금 보이는 목록이 옮긴 곳이나 휴지통이면 보이는 채로 새로 고친다.
|
||||
* 실패하면 false (알림은 인터셉터가 띄웠다).
|
||||
*/
|
||||
async function moveMessage(id: string, mailboxId: string): Promise<boolean> {
|
||||
try {
|
||||
await mailApi.updateMessage(id, { mailboxId })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
syncCounts()
|
||||
const shown = currentMailboxId.value
|
||||
const trashId = mailboxByRole('trash')?.id ?? null
|
||||
if (shown !== null && (shown === mailboxId || shown === trashId)) void openMailbox(shown)
|
||||
return true
|
||||
}
|
||||
|
||||
function rememberBrowseLocation(fullPath: string) {
|
||||
lastBrowseLocation.value = fullPath
|
||||
}
|
||||
@@ -391,25 +68,10 @@ export const useMailStore = defineStore('mail', () => {
|
||||
}
|
||||
|
||||
function reset() {
|
||||
mailboxSeq += 1
|
||||
quotaSeq += 1
|
||||
listSeq += 1
|
||||
detailSeq += 1
|
||||
mailboxes.value = []
|
||||
mailboxesStatus.value = 'idle'
|
||||
mailboxesError.value = null
|
||||
quota.value = null
|
||||
currentMailboxId.value = null
|
||||
messages.value = []
|
||||
pageInfo.value = null
|
||||
listStatus.value = 'idle'
|
||||
listError.value = null
|
||||
isRefreshing.value = false
|
||||
isLoadingMore.value = false
|
||||
selectedId.value = null
|
||||
selected.value = null
|
||||
selectedStatus.value = 'idle'
|
||||
selectedError.value = null
|
||||
mailboxState.reset()
|
||||
list.reset()
|
||||
selection.clearChecked()
|
||||
opened.closeMessage()
|
||||
lastBrowseLocation.value = null
|
||||
draftSuccessors.clear()
|
||||
}
|
||||
@@ -421,43 +83,72 @@ export const useMailStore = defineStore('mail', () => {
|
||||
)
|
||||
|
||||
return {
|
||||
mailboxes,
|
||||
mailboxesStatus,
|
||||
mailboxesError,
|
||||
quota,
|
||||
attachmentMaxBytes,
|
||||
attachmentTotalMaxBytes,
|
||||
currentMailboxId,
|
||||
messages,
|
||||
pageInfo,
|
||||
listStatus,
|
||||
listError,
|
||||
isRefreshing,
|
||||
isLoadingMore,
|
||||
selectedId,
|
||||
selected,
|
||||
selectedStatus,
|
||||
selectedError,
|
||||
// 메일함 · 사용량
|
||||
mailboxes: mailboxState.mailboxes,
|
||||
mailboxesStatus: mailboxState.mailboxesStatus,
|
||||
mailboxesError: mailboxState.mailboxesError,
|
||||
quota: mailboxState.quota,
|
||||
attachmentMaxBytes: mailboxState.attachmentMaxBytes,
|
||||
attachmentTotalMaxBytes: mailboxState.attachmentTotalMaxBytes,
|
||||
orderedMailboxes: mailboxState.orderedMailboxes,
|
||||
defaultMailbox: mailboxState.defaultMailbox,
|
||||
inboxUnreadCount: mailboxState.inboxUnreadCount,
|
||||
mailboxByRole: mailboxState.mailboxByRole,
|
||||
resolveMailboxByRole: mailboxState.resolveMailboxByRole,
|
||||
loadMailboxes: mailboxState.loadMailboxes,
|
||||
ensureMailboxes: mailboxState.ensureMailboxes,
|
||||
loadQuota: mailboxState.loadQuota,
|
||||
ensureQuota: mailboxState.ensureQuota,
|
||||
syncCounts: mailboxState.syncCounts,
|
||||
// 보이는 목록
|
||||
listSource: list.listSource,
|
||||
currentMailboxId: list.currentMailboxId,
|
||||
currentMailbox: list.currentMailbox,
|
||||
search: list.search,
|
||||
searchOrigin: list.searchOrigin,
|
||||
messages: list.messages,
|
||||
pageInfo: list.pageInfo,
|
||||
listStatus: list.listStatus,
|
||||
listError: list.listError,
|
||||
isRefreshing: list.isRefreshing,
|
||||
isLoadingMore: list.isLoadingMore,
|
||||
listSync: list.listSync,
|
||||
hasMore: list.hasMore,
|
||||
neighborAfterRemoval: list.neighborAfterRemoval,
|
||||
openMailbox: list.openMailbox,
|
||||
openSearch: list.openSearch,
|
||||
rememberSearchOrigin: list.rememberSearchOrigin,
|
||||
refresh: list.refresh,
|
||||
loadMore: list.loadMore,
|
||||
syncVisibleList: list.syncVisibleList,
|
||||
// 고른 메일
|
||||
checkedIds: selection.checkedIds,
|
||||
checkedCount: selection.checkedCount,
|
||||
checkedMessages: selection.checkedMessages,
|
||||
checkState: selection.checkState,
|
||||
toggleCheck: selection.toggleCheck,
|
||||
checkRangeTo: selection.checkRangeTo,
|
||||
toggleCheckAll: selection.toggleCheckAll,
|
||||
clearChecked: selection.clearChecked,
|
||||
// 열린 메일
|
||||
selectedId: opened.selectedId,
|
||||
selected: opened.selected,
|
||||
selectedStatus: opened.selectedStatus,
|
||||
selectedError: opened.selectedError,
|
||||
openMessage: message.openMessage,
|
||||
closeMessage: opened.closeMessage,
|
||||
markRead: message.markRead,
|
||||
toggleFlag: message.toggleFlag,
|
||||
moveMessage: message.moveMessage,
|
||||
// 여러 통 처리 · 한 통 삭제
|
||||
patchChecked: bulk.patchChecked,
|
||||
moveMessages: bulk.moveMessages,
|
||||
moveChecked: bulk.moveChecked,
|
||||
purgeMessages: bulk.purgeMessages,
|
||||
purgeChecked: bulk.purgeChecked,
|
||||
restoreMoved: bulk.restoreMoved,
|
||||
// 쓰기 화면
|
||||
lastBrowseLocation,
|
||||
orderedMailboxes,
|
||||
currentMailbox,
|
||||
defaultMailbox,
|
||||
hasMore,
|
||||
mailboxByRole,
|
||||
loadMailboxes,
|
||||
ensureMailboxes,
|
||||
loadQuota,
|
||||
ensureQuota,
|
||||
syncCounts,
|
||||
openMailbox,
|
||||
refresh,
|
||||
loadMore,
|
||||
openMessage,
|
||||
closeMessage,
|
||||
markRead,
|
||||
toggleFlag,
|
||||
removeMessage,
|
||||
moveMessage,
|
||||
rememberBrowseLocation,
|
||||
recordDraftSuccessor,
|
||||
latestDraftId,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// 메일 스토어 모듈 — 여러 통 한꺼번에 (계약 2절 "여러 통 한꺼번에", mail.store.ts 가 조립한다)
|
||||
// 읽기 화면의 한 통 삭제 · 영구 삭제도 이 길(moveMessages · purgeMessages)을 쓴다 — DELETE /api/messages/{id} 는 쓰지 않는다
|
||||
// (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다).
|
||||
// 끝났을 때 보이는 목록이 다른 목록으로 바뀌어 있으면(sourceSeq) 그 목록 · 고른 것은 건드리지 않고, 개수와
|
||||
// (메일함 목록이면) 그 목록을 서버 기준으로 다시 맞춘다 (settleStale).
|
||||
import { MAIL_BULK_MAX_IDS } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import {
|
||||
emptyBulkResult,
|
||||
groupByOrigin,
|
||||
sendInChunks,
|
||||
type BulkChunkSender,
|
||||
type BulkOrigin,
|
||||
type BulkResult,
|
||||
} from '@/shared/utils/mail-bulk'
|
||||
import { isAllMailboxSearch, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
|
||||
import type { ListState } from './list'
|
||||
import type { MailboxesState } from './mailboxes'
|
||||
import type { SelectionState } from './selection'
|
||||
import type { BulkMoveResult, FlagChanges, MailApi, MailBulkResult } from './types'
|
||||
|
||||
interface BulkDeps {
|
||||
mailApi: MailApi
|
||||
mailboxes: MailboxesState
|
||||
list: ListState
|
||||
selection: SelectionState
|
||||
}
|
||||
|
||||
/** 요청 하나의 ids 한도(100)로 나눠 차례로 보낸다 */
|
||||
function sendBulk(ids: readonly string[], send: BulkChunkSender): Promise<BulkResult> {
|
||||
return sendInChunks(ids, MAIL_BULK_MAX_IDS, send)
|
||||
}
|
||||
|
||||
function hasLeftovers(result: BulkResult): boolean {
|
||||
return result.skippedIds.length > 0 || result.unsentIds.length > 0
|
||||
}
|
||||
|
||||
export function createBulkActions({ mailApi, mailboxes, list, selection }: BulkDeps) {
|
||||
/** 지금 목록이 시작할 때의 목록인지 — 아니면 끝난 일을 그 목록에 적용하지 않는다 */
|
||||
function captureList(): () => boolean {
|
||||
const identity = list.sourceSeq.value
|
||||
return () => list.sourceSeq.value !== identity
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝났을 때 다른 목록으로 바뀌어 있었다 — 그 목록은 고치지 않고 개수를 맞춘다. 메일함 목록이면 서버 기준으로 다시 맞춘다:
|
||||
* 이 일이 서버에 닿기 전에 새로 받은 목록에는 옮기거나 지운 메일 · 옛 표시가 남아 있을 수 있다 (다음 새 메일 확인까지 남지 않게).
|
||||
* 검색 결과는 저절로 바꾸지 않는다 (syncVisibleList 가 거른다)
|
||||
*/
|
||||
function settleStale() {
|
||||
mailboxes.syncCounts()
|
||||
void list.syncVisibleList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 메일의 읽음 · 중요 표시 — 화면을 먼저 바꾸고, 서버가 바꾸지 못한 메일(failedIds)과 보내지 못한 메일만 되돌린다.
|
||||
* 고른 것은 그대로 둔다. 알림은 띄우지 않는다 (목록이 바로 바뀐다 — 요청 실패는 인터셉터가 알린다)
|
||||
*/
|
||||
async function patchChecked(changes: FlagChanges): Promise<MailBulkResult> {
|
||||
const targets = selection.checkedMessages.value
|
||||
if (targets.length === 0) return { ...emptyBulkResult(), stale: false }
|
||||
const isStale = captureList()
|
||||
const ids = targets.map((message) => message.id)
|
||||
const before = new Map(targets.map((message) => [message.id, message]))
|
||||
|
||||
const flip = (message: MessageSummary, direction: 1 | -1) => {
|
||||
if (changes.isRead !== undefined && changes.isRead !== message.isRead) {
|
||||
mailboxes.adjustUnread(message.mailboxIds, (changes.isRead ? -1 : 1) * direction)
|
||||
}
|
||||
}
|
||||
for (const message of targets) {
|
||||
flip(message, 1)
|
||||
list.setLocalFlags(message.id, changes)
|
||||
}
|
||||
list.beginPending(ids, changes)
|
||||
const result = await sendBulk(ids, async (chunkIds) => {
|
||||
const patched = await mailApi.updateMessages({ ids: chunkIds, ...changes })
|
||||
return { done: patched.updatedIds, skipped: patched.failedIds }
|
||||
})
|
||||
list.endPending(ids, changes)
|
||||
|
||||
if (isStale()) {
|
||||
settleStale()
|
||||
return { ...result, stale: true }
|
||||
}
|
||||
for (const id of [...result.skippedIds, ...result.unsentIds]) {
|
||||
const original = before.get(id)
|
||||
if (!original) continue
|
||||
flip(original, -1)
|
||||
list.setLocalFlags(id, { isRead: original.isRead, isFlagged: original.isFlagged })
|
||||
}
|
||||
if (changes.isRead !== undefined) void mailboxes.loadMailboxes({ background: true })
|
||||
return { ...result, stale: false }
|
||||
}
|
||||
|
||||
/** 되돌리기에 쓸 원래 메일함 — 목록이 가리키는 메일함, 없으면(전체 검색) 옮길 곳이 아닌 첫 메일함 */
|
||||
function originMailboxOf(message: MessageSummary, targetId: string): string | null {
|
||||
const scopeId = scopeMailboxIdOf(list.listSource.value)
|
||||
if (scopeId && message.mailboxIds.includes(scopeId)) return scopeId
|
||||
return message.mailboxIds.find((id) => id !== targetId) ?? message.mailboxIds[0] ?? null
|
||||
}
|
||||
|
||||
/** 전체 메일함 검색에서 빠지는 메일함 — 휴지통 · 스팸메일함 */
|
||||
function isExcludedFromAllSearch(mailboxId: string): boolean {
|
||||
const role = mailboxes.mailboxes.value.find((mailbox) => mailbox.id === mailboxId)?.role ?? null
|
||||
return role === 'trash' || role === 'junk'
|
||||
}
|
||||
|
||||
/**
|
||||
* 옮긴 메일을 목록에 반영한다 — 메일함 목록 · 한 메일함 안의 검색에서는 뺀다.
|
||||
* 여전히 이 목록에 드는 메일은 메일함만 바꾼다: 전체 메일함 검색 결과(휴지통 · 스팸메일함으로 옮긴 것은 뺀다),
|
||||
* 이 목록의 메일함으로 옮긴 메일(열린 메일의 메일함 정보가 낡았을 때 — 이미 휴지통에 있던 메일을 휴지통으로)
|
||||
*/
|
||||
function applyMoved(ids: readonly string[], targetId: string) {
|
||||
if (ids.length === 0) return
|
||||
const moved = new Set(ids)
|
||||
const source = list.listSource.value
|
||||
const staysInAll = isAllMailboxSearch(source) && !isExcludedFromAllSearch(targetId)
|
||||
if (staysInAll || scopeMailboxIdOf(source) === targetId) list.relocate(moved, targetId)
|
||||
else list.removeFromList(moved)
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일을 옮긴다 (PATCH /messages { mailboxId }) — 휴지통 밖의 "삭제"도 휴지통으로 옮기는 것이다.
|
||||
* 응답을 받은 뒤 옮긴 메일(updatedIds)만 목록에서 빼고 개수를 다시 맞춘다. 그대로 둔 메일 · 보내지 못한 메일이 있으면
|
||||
* (503 은 일부만 바뀌었을 수 있다) 보이는 목록을 서버 기준으로 다시 맞춘다. targets 는 고른 메일이거나 열린 메일 한 통이다
|
||||
*/
|
||||
async function moveMessages(targets: readonly MessageSummary[], targetId: string): Promise<BulkMoveResult> {
|
||||
if (targets.length === 0) return { ...emptyBulkResult(), origins: [], stale: false }
|
||||
const isStale = captureList()
|
||||
const origins = new Map<string, string>()
|
||||
for (const message of targets) {
|
||||
const origin = originMailboxOf(message, targetId)
|
||||
if (origin && origin !== targetId) origins.set(message.id, origin)
|
||||
}
|
||||
|
||||
const result = await sendBulk(
|
||||
targets.map((message) => message.id),
|
||||
async (chunkIds) => {
|
||||
const patched = await mailApi.updateMessages({ ids: chunkIds, mailboxId: targetId })
|
||||
return { done: patched.updatedIds, skipped: patched.failedIds }
|
||||
},
|
||||
)
|
||||
const stale = isStale()
|
||||
if (stale) {
|
||||
settleStale()
|
||||
} else {
|
||||
applyMoved(result.doneIds, targetId)
|
||||
if (hasLeftovers(result)) void list.syncVisibleList({ includeSearch: true })
|
||||
mailboxes.syncCounts()
|
||||
}
|
||||
return { ...result, origins: groupByOrigin(result.doneIds, origins), stale }
|
||||
}
|
||||
|
||||
/** 고른 메일을 옮긴다 — 하나라도 옮겼으면 고른 것을 푼다 (요청이 통째로 실패했으면 다시 해 볼 수 있게 둔다) */
|
||||
async function moveChecked(targetId: string): Promise<BulkMoveResult> {
|
||||
const result = await moveMessages(selection.checkedMessages.value, targetId)
|
||||
if (!result.stale && result.doneIds.length > 0) selection.clearChecked()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴지통에서 영구 삭제 (POST /message-deletions) — 확인 대화상자를 거친 뒤에만 부른다.
|
||||
* 지운 메일(deletedIds)만 목록에서 빼고 개수 · 사용량을 다시 맞춘다. 그대로 둔 메일 · 보내지 못한 메일이 있으면
|
||||
* (503 은 일부를 지운 뒤일 수 있다) 보이는 목록을 서버 기준으로 다시 맞춘다
|
||||
*/
|
||||
async function purgeMessages(ids: readonly string[]): Promise<MailBulkResult> {
|
||||
if (ids.length === 0) return { ...emptyBulkResult(), stale: false }
|
||||
const isStale = captureList()
|
||||
const result = await sendBulk(ids, async (chunkIds) => {
|
||||
const deleted = await mailApi.deleteMessages({ ids: chunkIds })
|
||||
return { done: deleted.deletedIds, skipped: deleted.skippedIds }
|
||||
})
|
||||
const stale = isStale()
|
||||
if (stale) {
|
||||
settleStale()
|
||||
} else {
|
||||
list.removeFromList(new Set(result.doneIds))
|
||||
if (hasLeftovers(result)) void list.syncVisibleList({ includeSearch: true })
|
||||
mailboxes.syncCounts()
|
||||
}
|
||||
return { ...result, stale }
|
||||
}
|
||||
|
||||
/** 고른 메일을 영구 삭제한다 — 하나라도 지웠으면 고른 것을 푼다 */
|
||||
async function purgeChecked(): Promise<MailBulkResult> {
|
||||
const result = await purgeMessages(selection.checkedMessages.value.map((message) => message.id))
|
||||
if (!result.stale && result.doneIds.length > 0) selection.clearChecked()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 되돌리기 — 옮긴 메일을 원래 메일함별로 묶어 다시 옮긴 뒤, 개수와 보이는 목록(검색 결과 포함)을 서버 기준으로 맞춘다
|
||||
*/
|
||||
async function restoreMoved(origins: readonly BulkOrigin[]): Promise<BulkResult> {
|
||||
const total = emptyBulkResult()
|
||||
for (const [index, group] of origins.entries()) {
|
||||
const result = await sendBulk(group.ids, async (chunkIds) => {
|
||||
const patched = await mailApi.updateMessages({ ids: chunkIds, mailboxId: group.mailboxId })
|
||||
return { done: patched.updatedIds, skipped: patched.failedIds }
|
||||
})
|
||||
total.doneIds.push(...result.doneIds)
|
||||
total.skippedIds.push(...result.skippedIds)
|
||||
total.unsentIds.push(...result.unsentIds)
|
||||
if (result.unsentIds.length > 0) {
|
||||
total.unsentIds.push(...origins.slice(index + 1).flatMap((rest) => rest.ids))
|
||||
break
|
||||
}
|
||||
}
|
||||
mailboxes.syncCounts()
|
||||
await list.syncVisibleList({ includeSearch: true })
|
||||
return total
|
||||
}
|
||||
|
||||
return { patchChecked, moveMessages, moveChecked, purgeMessages, purgeChecked, restoreMoved }
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// 메일 스토어 모듈 — 보이는 목록(메일함 또는 검색 결과): 열기 · 더 보기 · 새 메일 확인으로 맞추기 · 이 탭이 바꾼 것 (mail.store.ts 가 조립한다)
|
||||
//
|
||||
// 늦게 온 응답 막기:
|
||||
// - listSeq: 목록을 새로 열 때마다(같은 목록을 보이는 채로 다시 불러올 때 포함) — 그 전에 보낸 목록 요청의 응답은 버린다
|
||||
// - sourceSeq: 목록의 출처(메일함 · 검색)가 바뀔 때마다 — 고른 것을 풀고(selection.ts), 끝난 여러 통 처리가
|
||||
// 아직 같은 목록인지 본다(bulk.ts — 다른 목록이면 그 목록을 건드리지 않는다)
|
||||
// - localGeneration: 이 탭이 목록 · 열린 메일을 바꿀 때마다(읽음 · 중요 표시 · 빼기 · 옮기기 · 보내던 표시가 끝남) —
|
||||
// 그 전에 떠난 새 메일 확인의 응답은 옛 상태라 버리고 한 번 더 맞춘다 (옮긴 메일이 되살아나거나 표시가 되돌아가지 않게)
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { MAIL_LIST_SYNC_MAX } from '@/shared/constants/mail'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { DEFAULT_PAGE_SIZE, type PageInfo } from '@/shared/types/pagination'
|
||||
import { mergeRefreshedList } from '@/shared/utils/mail-list-merge'
|
||||
import {
|
||||
listQueryOf,
|
||||
sameListSource,
|
||||
scopeMailboxIdOf,
|
||||
type MailListSource,
|
||||
type SearchListSource,
|
||||
} from '@/shared/utils/mail-list-source'
|
||||
import type { MailboxesState } from './mailboxes'
|
||||
import type { OpenedMessage } from './message'
|
||||
import type { FlagChanges, LoadStatus, MailApi, SearchOrigin } from './types'
|
||||
|
||||
/** 목록 · 열린 메일에 바로 적는 표시 */
|
||||
type LocalChanges = Partial<Pick<MessageSummary, 'isRead' | 'isFlagged' | 'mailboxIds'>>
|
||||
|
||||
interface ListDeps {
|
||||
mailApi: MailApi
|
||||
mailboxes: MailboxesState
|
||||
opened: OpenedMessage
|
||||
}
|
||||
|
||||
export function createListState({ mailApi, mailboxes, opened }: ListDeps) {
|
||||
/** 목록의 출처 — 메일함 하나 또는 검색 결과 (mail-list-source.ts) */
|
||||
const listSource = ref<MailListSource | null>(null)
|
||||
/** 목록의 출처가 바뀐 횟수 (위 설명) */
|
||||
const sourceSeq = ref<number>(0)
|
||||
/**
|
||||
* 목록이 가리키는 메일함 — 메일함 목록이면 그 메일함, 한 메일함 안의 검색이면 그 메일함, 전체 메일함 검색이면 null.
|
||||
* 메일함 패널의 현재 표시 · 휴지통 판단이 이 값을 본다. 값을 넣으면 그 메일함 목록을 출처로 둔다 (불러오지는 않는다)
|
||||
*/
|
||||
const currentMailboxId = computed<string | null>({
|
||||
get: () => scopeMailboxIdOf(listSource.value),
|
||||
set: (id) => {
|
||||
listSource.value = id ? { kind: 'mailbox', mailboxId: id } : null
|
||||
},
|
||||
})
|
||||
const currentMailbox = computed<Mailbox | null>(
|
||||
() => mailboxes.mailboxes.value.find((mailbox) => mailbox.id === currentMailboxId.value) ?? null,
|
||||
)
|
||||
/** 검색 결과를 보이는 중이면 그 검색 */
|
||||
const search = computed<SearchListSource | null>(() =>
|
||||
listSource.value?.kind === 'search' ? listSource.value : null,
|
||||
)
|
||||
const searchOrigin = ref<SearchOrigin | null>(null)
|
||||
const messages = ref<MessageSummary[]>([])
|
||||
const pageInfo = ref<PageInfo | null>(null)
|
||||
const listStatus = ref<LoadStatus>('idle')
|
||||
const listError = ref<ApiError | null>(null)
|
||||
/** 목록을 보이는 채로 다시 불러오는 중 (새로고침) */
|
||||
const isRefreshing = ref<boolean>(false)
|
||||
const isLoadingMore = ref<boolean>(false)
|
||||
/** 새 메일 확인으로 목록을 맞출 때마다 바뀐다 — 화면이 "새 메일 N통 · 맨 위로" 알림 줄을 정한다 */
|
||||
const listSync = ref<{ seq: number; addedAbove: number } | null>(null)
|
||||
const hasMore = computed<boolean>(
|
||||
() => pageInfo.value?.hasNext === true && Boolean(pageInfo.value.nextCursor),
|
||||
)
|
||||
|
||||
let listSeq = 0
|
||||
let listSyncSeq = 0
|
||||
let localGeneration = 0
|
||||
/**
|
||||
* 보내는 중인 읽음 · 중요 표시 — 그사이 도착한 목록(새 메일 확인)이 옛 값으로 되돌려 보이지 않게 덮어쓴다.
|
||||
* 화면에 그리지 않으므로 반응형이 아니다
|
||||
*/
|
||||
const pendingFlags = new Map<string, FlagChanges>()
|
||||
|
||||
// 출처가 바뀌면(어떻게 바꿨든) 그 수를 세고 알림 줄 정보를 비운다
|
||||
watch(
|
||||
listSource,
|
||||
(next, previous) => {
|
||||
if (sameListSource(next, previous)) return
|
||||
sourceSeq.value += 1
|
||||
listSync.value = null
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
/** 같은 출처를 새 목록으로 다시 연다 (같은 검색어로 다시 찾기) — 출처가 바뀐 것과 같게 다룬다 */
|
||||
function renewSource() {
|
||||
sourceSeq.value += 1
|
||||
listSync.value = null
|
||||
}
|
||||
|
||||
// ── 이 탭이 바꾼 것 ─────────────────────────────────
|
||||
|
||||
function bumpLocalChange() {
|
||||
localGeneration += 1
|
||||
}
|
||||
|
||||
function setLocalFlags(id: string, changes: LocalChanges) {
|
||||
messages.value = messages.value.map((message) => (message.id === id ? { ...message, ...changes } : message))
|
||||
if (opened.selected.value?.id === id) opened.selected.value = { ...opened.selected.value, ...changes }
|
||||
bumpLocalChange()
|
||||
}
|
||||
|
||||
/** 서버가 돌려준 요약으로 목록 · 열린 메일을 맞춘다 */
|
||||
function applySummary(summary: MessageSummary) {
|
||||
messages.value = messages.value.map((message) => (message.id === summary.id ? summary : message))
|
||||
if (opened.selected.value?.id === summary.id) opened.selected.value = { ...opened.selected.value, ...summary }
|
||||
bumpLocalChange()
|
||||
}
|
||||
|
||||
/** 목록에서 뺀다 — 전체 개수도 그만큼 줄인다 */
|
||||
function removeFromList(ids: ReadonlySet<string>) {
|
||||
if (ids.size === 0) return
|
||||
const before = messages.value.length
|
||||
messages.value = messages.value.filter((message) => !ids.has(message.id))
|
||||
const removed = before - messages.value.length
|
||||
if (removed > 0 && pageInfo.value && pageInfo.value.total !== null) {
|
||||
pageInfo.value = { ...pageInfo.value, total: Math.max(0, pageInfo.value.total - removed) }
|
||||
}
|
||||
bumpLocalChange()
|
||||
}
|
||||
|
||||
/** 목록에 남긴 채 메일함만 바꾼다 (전체 메일함 검색 결과에서 옮긴 메일) */
|
||||
function relocate(ids: ReadonlySet<string>, mailboxId: string) {
|
||||
if (ids.size === 0) return
|
||||
messages.value = messages.value.map((message) => (ids.has(message.id) ? { ...message, mailboxIds: [mailboxId] } : message))
|
||||
const current = opened.selected.value
|
||||
if (current && ids.has(current.id)) opened.selected.value = { ...current, mailboxIds: [mailboxId] }
|
||||
bumpLocalChange()
|
||||
}
|
||||
|
||||
function beginPending(ids: readonly string[], changes: FlagChanges) {
|
||||
for (const id of ids) pendingFlags.set(id, { ...pendingFlags.get(id), ...changes })
|
||||
}
|
||||
|
||||
/** 보내던 표시가 끝났다(성공이든 실패든) — 그 전에 떠난 새 메일 확인은 옛 값을 들고 온다 */
|
||||
function endPending(ids: readonly string[], changes: FlagChanges) {
|
||||
for (const id of ids) {
|
||||
const pending = pendingFlags.get(id)
|
||||
if (!pending) continue
|
||||
const rest: FlagChanges = { ...pending }
|
||||
if ('isRead' in changes) delete rest.isRead
|
||||
if ('isFlagged' in changes) delete rest.isFlagged
|
||||
if (Object.keys(rest).length > 0) pendingFlags.set(id, rest)
|
||||
else pendingFlags.delete(id)
|
||||
}
|
||||
bumpLocalChange()
|
||||
}
|
||||
|
||||
/** 보내는 중인 읽음 · 중요 표시를 덮어쓴다 */
|
||||
function withPendingFlags(items: readonly MessageSummary[]): MessageSummary[] {
|
||||
if (pendingFlags.size === 0) return [...items]
|
||||
return items.map((item) => {
|
||||
const pending = pendingFlags.get(item.id)
|
||||
return pending ? { ...item, ...pending } : item
|
||||
})
|
||||
}
|
||||
|
||||
/** 다시 받은 목록의 값으로 열린 메일의 표시(읽음 · 중요 · 메일함)를 맞춘다 */
|
||||
function refreshSelectedFrom(list: readonly MessageSummary[]) {
|
||||
const current = opened.selected.value
|
||||
if (!current) return
|
||||
const fresh = list.find((message) => message.id === current.id)
|
||||
if (!fresh) return
|
||||
const changed =
|
||||
fresh.isRead !== current.isRead ||
|
||||
fresh.isFlagged !== current.isFlagged ||
|
||||
fresh.mailboxIds.join('\n') !== current.mailboxIds.join('\n')
|
||||
if (changed) {
|
||||
opened.selected.value = { ...current, isRead: fresh.isRead, isFlagged: fresh.isFlagged, mailboxIds: fresh.mailboxIds }
|
||||
}
|
||||
}
|
||||
|
||||
/** 지운 뒤 이어서 보여 줄 메일 — 다음 메일, 없으면 이전 메일 (목록 밖의 메일이면 null) */
|
||||
function neighborAfterRemoval(id: string): string | null {
|
||||
const index = messages.value.findIndex((message) => message.id === id)
|
||||
if (index === -1) return null
|
||||
return messages.value[index + 1]?.id ?? messages.value[index - 1]?.id ?? null
|
||||
}
|
||||
|
||||
// ── 목록 열기 · 맞추기 ───────────────────────────────
|
||||
|
||||
/**
|
||||
* 목록 열기 — 첫 페이지를 불러온다. 다른 목록이면 고른 것이 풀린다 (메일함을 바꾸거나 검색하면 선택을 푼다).
|
||||
* 같은 목록으로 돌아오면(쓰기 화면에서 복귀 등) 보이던 목록을 둔 채 새로 고친다.
|
||||
*/
|
||||
async function openList(next: MailListSource): Promise<void> {
|
||||
const keepVisible = sameListSource(listSource.value, next) && listStatus.value === 'ready'
|
||||
listSource.value = next
|
||||
listSeq += 1
|
||||
const seq = listSeq
|
||||
|
||||
if (keepVisible) {
|
||||
isRefreshing.value = true
|
||||
} else {
|
||||
messages.value = []
|
||||
pageInfo.value = null
|
||||
listStatus.value = 'loading'
|
||||
listError.value = null
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await mailApi.listMessages(
|
||||
{ ...listQueryOf(next), limit: DEFAULT_PAGE_SIZE },
|
||||
// 처음 여는 목록은 목록 영역이 오류 상태("다시 시도")를 직접 그린다
|
||||
{ silent: keepVisible ? undefined : true },
|
||||
)
|
||||
if (seq !== listSeq) return
|
||||
messages.value = page.items
|
||||
pageInfo.value = page.pageInfo
|
||||
listStatus.value = 'ready'
|
||||
listError.value = null
|
||||
} catch (error) {
|
||||
if (seq !== listSeq || keepVisible) return
|
||||
listStatus.value = 'error'
|
||||
listError.value = toApiError(error)
|
||||
} finally {
|
||||
if (seq === listSeq) isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 메일함 열기 — 같은 메일함으로 돌아오면 보이던 목록을 둔 채 새로 고친다 */
|
||||
async function openMailbox(mailboxId: string): Promise<void> {
|
||||
await openList({ kind: 'mailbox', mailboxId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 열기 (계약 2절 "검색") — scope 는 메일함 id 또는 전체(SEARCH_SCOPE_ALL).
|
||||
* 같은 검색 결과가 이미 보이면(결과에서 연 메일에서 돌아오는 등) 다시 찾지 않는다 — 보던 결과가 저절로 바뀌지 않게.
|
||||
* force 면(같은 검색어로 다시 Enter) 보이는 채로 다시 찾는다 — 다시 찾는 것도 검색이라 고른 것을 푼다
|
||||
*/
|
||||
async function openSearch(q: string, scope: string, options: { force?: boolean } = {}): Promise<void> {
|
||||
const next: SearchListSource = { kind: 'search', q, scope }
|
||||
const shown = sameListSource(listSource.value, next)
|
||||
if (shown && !options.force && (listStatus.value === 'ready' || listStatus.value === 'loading')) return
|
||||
if (shown && options.force) renewSource()
|
||||
await openList(next)
|
||||
}
|
||||
|
||||
/** 검색을 시작한 곳을 적어 둔다 — 범위 줄의 "그 메일함"과 검색을 닫을 때 돌아갈 화면 */
|
||||
function rememberSearchOrigin(origin: SearchOrigin) {
|
||||
searchOrigin.value = origin
|
||||
}
|
||||
|
||||
/** 새로고침 — 메일함 개수 · 사용량 · 지금 목록을 함께 다시 불러온다 */
|
||||
async function refresh(): Promise<void> {
|
||||
mailboxes.syncCounts()
|
||||
if (listSource.value) await openList(listSource.value)
|
||||
}
|
||||
|
||||
/** 다음 페이지 — pageInfo.nextCursor 를 해석하지 않고 그대로 보낸다 */
|
||||
async function loadMore(): Promise<void> {
|
||||
const cursor = pageInfo.value?.nextCursor
|
||||
const source = listSource.value
|
||||
if (!cursor || !source || isLoadingMore.value) return
|
||||
|
||||
const seq = listSeq
|
||||
isLoadingMore.value = true
|
||||
try {
|
||||
const page = await mailApi.listMessages({ ...listQueryOf(source), cursor, limit: DEFAULT_PAGE_SIZE })
|
||||
if (seq !== listSeq) return
|
||||
// 그사이 새 메일이 와서 경계가 밀렸을 수 있다 — 이미 있는 메일은 건너뛴다
|
||||
const known = new Set(messages.value.map((message) => message.id))
|
||||
messages.value = [...messages.value, ...page.items.filter((item) => !known.has(item.id))]
|
||||
pageInfo.value = page.pageInfo
|
||||
} catch {
|
||||
// 알림은 인터셉터가 띄웠다 — 버튼을 다시 누를 수 있게 그대로 둔다
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보이는 목록을 지금까지 불러온 만큼(최대 100통) 다시 받아 맞춘다 (계약 2절 "새 메일 확인") —
|
||||
* 고른 메일 · 열린 메일은 그대로, 다시 받은 범위 밖의 메일은 그대로 둔다(mail-list-merge.ts). 실패는 알리지 않는다.
|
||||
* 검색 결과는 새 메일 확인으로 저절로 바꾸지 않는다 — includeSearch 는 사용자가 한 일(되돌리기 · 여러 통 처리) 뒤에만 준다.
|
||||
* 묻는 사이 이 탭이 목록을 바꿨으면(localGeneration) 그 응답은 버리고 한 번 더 맞춘다
|
||||
*/
|
||||
async function syncVisibleList(options: { includeSearch?: boolean } = {}, attempt = 0): Promise<void> {
|
||||
const source = listSource.value
|
||||
if (!source || listStatus.value !== 'ready') return
|
||||
if (source.kind === 'search' && !options.includeSearch) return
|
||||
|
||||
const seq = listSeq
|
||||
const generation = localGeneration
|
||||
const limit = Math.min(MAIL_LIST_SYNC_MAX, Math.max(DEFAULT_PAGE_SIZE, messages.value.length))
|
||||
try {
|
||||
const page = await mailApi.listMessages({ ...listQueryOf(source), limit }, { silent: true })
|
||||
if (seq !== listSeq || listStatus.value !== 'ready') return
|
||||
if (generation !== localGeneration) {
|
||||
if (attempt === 0) await syncVisibleList(options, attempt + 1)
|
||||
return
|
||||
}
|
||||
const merged = mergeRefreshedList({
|
||||
current: messages.value,
|
||||
currentPageInfo: pageInfo.value,
|
||||
fetched: withPendingFlags(page.items),
|
||||
fetchedPageInfo: page.pageInfo,
|
||||
})
|
||||
messages.value = merged.messages
|
||||
pageInfo.value = merged.pageInfo
|
||||
refreshSelectedFrom(merged.messages)
|
||||
listSyncSeq += 1
|
||||
listSync.value = { seq: listSyncSeq, addedAbove: merged.addedAbove }
|
||||
} catch {
|
||||
// 알리지 않는다 — 다음 확인에서 다시 맞춘다
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
listSeq += 1
|
||||
listSource.value = null
|
||||
searchOrigin.value = null
|
||||
messages.value = []
|
||||
pageInfo.value = null
|
||||
listStatus.value = 'idle'
|
||||
listError.value = null
|
||||
isRefreshing.value = false
|
||||
isLoadingMore.value = false
|
||||
listSync.value = null
|
||||
pendingFlags.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
listSource,
|
||||
sourceSeq,
|
||||
currentMailboxId,
|
||||
currentMailbox,
|
||||
search,
|
||||
searchOrigin,
|
||||
messages,
|
||||
pageInfo,
|
||||
listStatus,
|
||||
listError,
|
||||
isRefreshing,
|
||||
isLoadingMore,
|
||||
listSync,
|
||||
hasMore,
|
||||
setLocalFlags,
|
||||
applySummary,
|
||||
removeFromList,
|
||||
relocate,
|
||||
beginPending,
|
||||
endPending,
|
||||
neighborAfterRemoval,
|
||||
openList,
|
||||
openMailbox,
|
||||
openSearch,
|
||||
rememberSearchOrigin,
|
||||
refresh,
|
||||
loadMore,
|
||||
syncVisibleList,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export type ListState = ReturnType<typeof createListState>
|
||||
@@ -0,0 +1,178 @@
|
||||
// 메일 스토어 모듈 — 메일함 목록 · 사용량 · 첨부 한도 · 안 읽은 개수 (mail.store.ts 가 조립한다)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getErrorMessage, toApiError, type ApiError } from '@/composables/useApi'
|
||||
import {
|
||||
MAIL_ATTACHMENT_FALLBACK_MAX_BYTES,
|
||||
MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES,
|
||||
} from '@/shared/constants/mail'
|
||||
import type { MailQuota, Mailbox, MailboxRole } from '@/shared/types/mail'
|
||||
import { findMailboxByRole, orderMailboxes } from '@/shared/utils/mailbox'
|
||||
import type { LoadStatus, MailApi } from './types'
|
||||
|
||||
/** 서버가 준 한도가 양수면 그 값, 없거나 이상하면 대체값 */
|
||||
function positiveOr(value: number | undefined, fallback: number): number {
|
||||
return typeof value === 'number' && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
export function createMailboxesState(mailApi: MailApi) {
|
||||
const mailboxes = ref<Mailbox[]>([])
|
||||
const mailboxesStatus = ref<LoadStatus>('idle')
|
||||
const mailboxesError = ref<ApiError | null>(null)
|
||||
/** GET /mail-quota */
|
||||
const quota = ref<MailQuota | null>(null)
|
||||
|
||||
let mailboxSeq = 0
|
||||
let quotaSeq = 0
|
||||
/** 응답을 기다리는 메일함 목록 요청 수 — 그 응답이 화면에서 바꾼 개수를 덮어쓸 수 있는지 판단한다 */
|
||||
let mailboxLoadsInFlight = 0
|
||||
|
||||
/**
|
||||
* 첨부 파일당 한도(바이트) — 서버 값(attachmentMaxBytes).
|
||||
* 사용량을 아직 못 받았거나 값이 이상하면 대체값(20MB)으로 사전 검사·안내한다.
|
||||
*/
|
||||
const attachmentMaxBytes = computed<number>(() =>
|
||||
positiveOr(quota.value?.attachmentMaxBytes, MAIL_ATTACHMENT_FALLBACK_MAX_BYTES),
|
||||
)
|
||||
|
||||
/**
|
||||
* 메일 한 통의 첨부 합계 한도(바이트) — 서버 값(attachmentTotalMaxBytes). 발송 때 서버가 검사하는 값과 같다.
|
||||
* 못 받았거나(이 값을 아직 주지 않는 서버 포함) 값이 이상하면 대체값(25MB)을 쓴다.
|
||||
*/
|
||||
const attachmentTotalMaxBytes = computed<number>(() =>
|
||||
positiveOr(quota.value?.attachmentTotalMaxBytes, MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES),
|
||||
)
|
||||
|
||||
const orderedMailboxes = computed(() => orderMailboxes(mailboxes.value))
|
||||
/** /mail 로 들어왔을 때 열 메일함 — 받은편지함, 없으면 첫 메일함 */
|
||||
const defaultMailbox = computed<Mailbox | null>(
|
||||
() => findMailboxByRole(mailboxes.value, 'inbox') ?? orderedMailboxes.value[0]?.mailbox ?? null,
|
||||
)
|
||||
/** 탭 제목의 "(N) " — 받은편지함의 안 읽은 메일 수 */
|
||||
const inboxUnreadCount = computed<number>(() => findMailboxByRole(mailboxes.value, 'inbox')?.unreadCount ?? 0)
|
||||
|
||||
function mailboxByRole(role: MailboxRole): Mailbox | null {
|
||||
return findMailboxByRole(mailboxes.value, role)
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 목록 — 실패하면 그 오류를 돌려준다(알리지 않는다 — 부르는 쪽이 알릴지 정한다), 받았으면 null.
|
||||
* background 면 이미 보이는 화면 상태(불러오는 중·오류)를 건드리지 않고 개수만 서버 기준으로 다시 맞춘다.
|
||||
*/
|
||||
async function loadMailboxes(options: { background?: boolean } = {}): Promise<ApiError | null> {
|
||||
mailboxSeq += 1
|
||||
const seq = mailboxSeq
|
||||
if (!options.background) {
|
||||
mailboxesStatus.value = 'loading'
|
||||
mailboxesError.value = null
|
||||
}
|
||||
mailboxLoadsInFlight += 1
|
||||
try {
|
||||
const result = await mailApi.listMailboxes({ silent: true })
|
||||
if (seq !== mailboxSeq) return null
|
||||
mailboxes.value = result.items
|
||||
mailboxesStatus.value = 'ready'
|
||||
mailboxesError.value = null
|
||||
return null
|
||||
} catch (error) {
|
||||
const apiError = toApiError(error)
|
||||
if (seq !== mailboxSeq) return apiError
|
||||
// 이미 목록이 보이면 조용히 둔다. 첫 조회를 대신한 요청이었다면 패널에 오류를 그린다
|
||||
if (options.background && mailboxesStatus.value === 'ready') return apiError
|
||||
mailboxesStatus.value = 'error'
|
||||
mailboxesError.value = apiError
|
||||
return apiError
|
||||
} finally {
|
||||
mailboxLoadsInFlight -= 1
|
||||
}
|
||||
}
|
||||
|
||||
/** 지금 보이는 개수가 곧 도착할(또는 아직 없는) 메일함 응답에 덮어쓰일 수 있는지 */
|
||||
function mailboxCountsMayBeOverwritten(): boolean {
|
||||
return mailboxLoadsInFlight > 0 || mailboxesStatus.value !== 'ready'
|
||||
}
|
||||
|
||||
/** 아직 불러오지 않았거나 실패했을 때만 불러온다 */
|
||||
async function ensureMailboxes(): Promise<void> {
|
||||
if (mailboxesStatus.value === 'idle' || mailboxesStatus.value === 'error') {
|
||||
await loadMailboxes()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 역할의 메일함(휴지통 등) — 아직 모르면 보이는 패널은 그대로 둔 채 메일함 목록을 받아 본다.
|
||||
* 그래도 모르면 오류를 돌려준다(알리지 않았다): 메일함 목록을 못 받았으면 그 오류, 받았는데 그 메일함이 없으면 BIZ_001
|
||||
*/
|
||||
async function resolveMailboxByRole(role: MailboxRole): Promise<{ mailbox: Mailbox } | { error: ApiError }> {
|
||||
const known = mailboxByRole(role)
|
||||
if (known) return { mailbox: known }
|
||||
const loadError = await loadMailboxes({ background: true })
|
||||
const loaded = mailboxByRole(role)
|
||||
if (loaded) return { mailbox: loaded }
|
||||
return { error: loadError ?? { code: 'BIZ_001', message: getErrorMessage('BIZ_001') } }
|
||||
}
|
||||
|
||||
/** 사용량 · 첨부 한도 — 실패해도 알리지 않고 이전 값을 둔다 (보조 정보) */
|
||||
async function loadQuota(): Promise<void> {
|
||||
quotaSeq += 1
|
||||
const seq = quotaSeq
|
||||
try {
|
||||
const result = await mailApi.getQuota({ silent: true })
|
||||
if (seq === quotaSeq) quota.value = result
|
||||
} catch {
|
||||
// 못 받으면 패널 하단 사용량을 숨기고, 첨부 한도는 대체값을 쓴다
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureQuota(): Promise<void> {
|
||||
if (!quota.value) await loadQuota()
|
||||
}
|
||||
|
||||
/** 보내기·삭제·새로고침·새 메일 확인 뒤 — 메일함 개수와 사용량을 서버 기준으로 다시 맞춘다 (화면은 그대로 둔 채) */
|
||||
function syncCounts() {
|
||||
void loadMailboxes({ background: true })
|
||||
void loadQuota()
|
||||
}
|
||||
|
||||
/** 안 읽은 개수를 화면에서 먼저 바꾼다 (읽음 처리 — 서버 기준은 다음 메일함 조회가 맞춘다) */
|
||||
function adjustUnread(mailboxIds: readonly string[], delta: number) {
|
||||
mailboxes.value = mailboxes.value.map((mailbox) =>
|
||||
mailboxIds.includes(mailbox.id)
|
||||
? { ...mailbox, unreadCount: Math.max(0, mailbox.unreadCount + delta) }
|
||||
: mailbox,
|
||||
)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
mailboxSeq += 1
|
||||
quotaSeq += 1
|
||||
mailboxes.value = []
|
||||
mailboxesStatus.value = 'idle'
|
||||
mailboxesError.value = null
|
||||
quota.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
mailboxes,
|
||||
mailboxesStatus,
|
||||
mailboxesError,
|
||||
quota,
|
||||
attachmentMaxBytes,
|
||||
attachmentTotalMaxBytes,
|
||||
orderedMailboxes,
|
||||
defaultMailbox,
|
||||
inboxUnreadCount,
|
||||
mailboxByRole,
|
||||
loadMailboxes,
|
||||
mailboxCountsMayBeOverwritten,
|
||||
ensureMailboxes,
|
||||
resolveMailboxByRole,
|
||||
loadQuota,
|
||||
ensureQuota,
|
||||
syncCounts,
|
||||
adjustUnread,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export type MailboxesState = ReturnType<typeof createMailboxesState>
|
||||
@@ -0,0 +1,135 @@
|
||||
// 메일 스토어 모듈 — 열린 메일(읽기 화면)과 그 동작: 열기 · 읽음 처리 · 중요 표시 · 다른 메일함으로 옮기기 (mail.store.ts 가 조립한다)
|
||||
// "열린 메일"(selectedId)은 여러 통 처리의 "고른 메일"(checkedIds — selection.ts)과 다르다.
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import type { ListState } from './list'
|
||||
import type { MailboxesState } from './mailboxes'
|
||||
import type { FlagChanges, LoadStatus, MailApi } from './types'
|
||||
|
||||
/** 열린 메일의 상태 — 목록 맞추기(list.ts)와 읽기 화면의 동작(아래)이 함께 고친다 */
|
||||
export function createOpenedMessage() {
|
||||
const selectedId = ref<string | null>(null)
|
||||
const selected = ref<MessageDetail | null>(null)
|
||||
const selectedStatus = ref<LoadStatus>('idle')
|
||||
const selectedError = ref<ApiError | null>(null)
|
||||
/** 메일을 빠르게 바꿀 때 늦게 온 본문 응답이 덮어쓰지 않게 */
|
||||
let detailSeq = 0
|
||||
|
||||
function nextDetailSeq(): number {
|
||||
detailSeq += 1
|
||||
return detailSeq
|
||||
}
|
||||
|
||||
function isCurrentDetail(seq: number): boolean {
|
||||
return seq === detailSeq
|
||||
}
|
||||
|
||||
function closeMessage() {
|
||||
detailSeq += 1
|
||||
selectedId.value = null
|
||||
selected.value = null
|
||||
selectedStatus.value = 'idle'
|
||||
selectedError.value = null
|
||||
}
|
||||
|
||||
return { selectedId, selected, selectedStatus, selectedError, nextDetailSeq, isCurrentDetail, closeMessage }
|
||||
}
|
||||
|
||||
export type OpenedMessage = ReturnType<typeof createOpenedMessage>
|
||||
|
||||
interface MessageActionsDeps {
|
||||
mailApi: MailApi
|
||||
mailboxes: MailboxesState
|
||||
list: ListState
|
||||
opened: OpenedMessage
|
||||
}
|
||||
|
||||
export function createMessageActions({ mailApi, mailboxes, list, opened }: MessageActionsDeps) {
|
||||
/** 읽음 처리 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
|
||||
async function markRead(message: Pick<MessageDetail, 'id' | 'isRead' | 'mailboxIds'>) {
|
||||
if (message.isRead) return
|
||||
// 주소로 바로 들어오면 메일함 목록보다 본문이 먼저 올 수 있다 — 그러면 아래 개수 조정이
|
||||
// 늦게 온 메일함 응답에 덮어쓰이므로, 읽음 처리가 끝난 뒤 서버 기준으로 다시 맞춘다
|
||||
const resync = mailboxes.mailboxCountsMayBeOverwritten()
|
||||
const changes: FlagChanges = { isRead: true }
|
||||
list.setLocalFlags(message.id, changes)
|
||||
mailboxes.adjustUnread(message.mailboxIds, -1)
|
||||
list.beginPending([message.id], changes)
|
||||
try {
|
||||
const summary = await mailApi.updateMessage(message.id, changes)
|
||||
list.endPending([message.id], changes)
|
||||
list.applySummary(summary)
|
||||
if (resync || mailboxes.mailboxCountsMayBeOverwritten()) void mailboxes.loadMailboxes({ background: true })
|
||||
} catch {
|
||||
list.endPending([message.id], changes)
|
||||
list.setLocalFlags(message.id, { isRead: false })
|
||||
mailboxes.adjustUnread(message.mailboxIds, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** 메일 열기 — 조회만으로는 읽음 처리되지 않으므로 열린 뒤 PATCH {isRead:true} 를 보낸다 */
|
||||
async function openMessage(id: string): Promise<void> {
|
||||
opened.selectedId.value = id
|
||||
if (opened.selected.value?.id === id && opened.selectedStatus.value === 'ready') return
|
||||
|
||||
const seq = opened.nextDetailSeq()
|
||||
opened.selected.value = null
|
||||
opened.selectedStatus.value = 'loading'
|
||||
opened.selectedError.value = null
|
||||
try {
|
||||
const detail = await mailApi.getMessage(id, { silent: true })
|
||||
if (!opened.isCurrentDetail(seq)) return
|
||||
opened.selected.value = detail
|
||||
opened.selectedStatus.value = 'ready'
|
||||
if (!detail.isRead) void markRead(detail)
|
||||
} catch (error) {
|
||||
if (!opened.isCurrentDetail(seq)) return
|
||||
opened.selectedStatus.value = 'error'
|
||||
opened.selectedError.value = toApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 중요 표시 토글 — 화면을 먼저 바꾸고, 실패하면 되돌린다 */
|
||||
async function toggleFlag(id: string): Promise<void> {
|
||||
const current =
|
||||
list.messages.value.find((message) => message.id === id) ??
|
||||
(opened.selected.value?.id === id ? opened.selected.value : null)
|
||||
if (!current) return
|
||||
|
||||
const changes: FlagChanges = { isFlagged: !current.isFlagged }
|
||||
list.setLocalFlags(id, changes)
|
||||
list.beginPending([id], changes)
|
||||
try {
|
||||
const summary = await mailApi.updateMessage(id, changes)
|
||||
list.endPending([id], changes)
|
||||
list.applySummary(summary)
|
||||
} catch {
|
||||
list.endPending([id], changes)
|
||||
list.setLocalFlags(id, { isFlagged: current.isFlagged })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일을 다른 메일함으로 옮긴다 (PATCH { mailboxId }) — 예: 버린 임시 저장본을 임시보관함으로 되돌리기.
|
||||
* 개수를 서버 기준으로 다시 맞추고, 지금 보이는 메일함이 옮긴 곳이나 휴지통이면 보이는 채로 새로 고친다.
|
||||
* 실패하면 false (알림은 인터셉터가 띄웠다).
|
||||
*/
|
||||
async function moveMessage(id: string, mailboxId: string): Promise<boolean> {
|
||||
try {
|
||||
await mailApi.updateMessage(id, { mailboxId })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
mailboxes.syncCounts()
|
||||
const source = list.listSource.value
|
||||
const trashId = mailboxes.mailboxByRole('trash')?.id ?? null
|
||||
if (source?.kind === 'mailbox' && (source.mailboxId === mailboxId || source.mailboxId === trashId)) {
|
||||
void list.openList(source)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return { markRead, openMessage, toggleFlag, moveMessage }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// 메일 스토어 모듈 — 고른 메일(여러 통 처리의 체크) (mail.store.ts 가 조립한다)
|
||||
// 고를 수 있는 것은 불러온 메일까지다. 목록에서 빠진 메일은 고른 것에서도 빠지고(새 메일 확인으로 맞출 때 남은 메일의 선택은 그대로),
|
||||
// 목록의 출처가 바뀌면(메일함을 바꾸거나 검색하면) 모두 푼다 (계약 2절 "여러 통 한꺼번에")
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import {
|
||||
checkAllState,
|
||||
checkRange,
|
||||
pruneChecked,
|
||||
toggleChecked,
|
||||
type CheckAllState,
|
||||
} from '@/shared/utils/mail-selection'
|
||||
import type { ListState } from './list'
|
||||
|
||||
export function createSelection(list: Pick<ListState, 'messages' | 'sourceSeq'>) {
|
||||
/** 고른 메일 id — 바꿀 때는 새 모음으로 바꿔 끼운다 */
|
||||
const checkedIds = shallowRef<ReadonlySet<string>>(new Set())
|
||||
/** Shift + 클릭 범위의 기준 — 마지막으로 누른 메일 */
|
||||
let checkAnchorId: string | null = null
|
||||
|
||||
const checkedCount = computed<number>(() => checkedIds.value.size)
|
||||
/** 고른 메일 — 목록 순서 그대로 */
|
||||
const checkedMessages = computed<MessageSummary[]>(() =>
|
||||
checkedIds.value.size === 0 ? [] : list.messages.value.filter((message) => checkedIds.value.has(message.id)),
|
||||
)
|
||||
/** 표 머리 고르기 상자의 상태 */
|
||||
const checkState = computed<CheckAllState>(() =>
|
||||
checkAllState(
|
||||
checkedIds.value,
|
||||
list.messages.value.map((message) => message.id),
|
||||
),
|
||||
)
|
||||
|
||||
function visibleIds(): string[] {
|
||||
return list.messages.value.map((message) => message.id)
|
||||
}
|
||||
|
||||
function clearChecked() {
|
||||
if (checkedIds.value.size > 0) checkedIds.value = new Set()
|
||||
checkAnchorId = null
|
||||
}
|
||||
|
||||
/** 하나 고르기 · 풀기 — 고르기 상자 · Ctrl(⌘) + 클릭 */
|
||||
function toggleCheck(id: string) {
|
||||
checkedIds.value = toggleChecked(checkedIds.value, id)
|
||||
checkAnchorId = id
|
||||
}
|
||||
|
||||
/** Shift + 클릭 — 마지막으로 누른 메일부터 이 메일까지 */
|
||||
function checkRangeTo(id: string) {
|
||||
checkedIds.value = checkRange(checkedIds.value, visibleIds(), checkAnchorId, id)
|
||||
checkAnchorId = id
|
||||
}
|
||||
|
||||
/** 표 머리 상자 — 불러온 메일을 모두 고르거나(일부만 골랐어도) 모두 고른 상태면 푼다 */
|
||||
function toggleCheckAll() {
|
||||
if (checkState.value === 'all') clearChecked()
|
||||
else checkedIds.value = new Set(visibleIds())
|
||||
}
|
||||
|
||||
// 목록에서 빠진 메일은 고른 것에서도 뺀다
|
||||
watch(
|
||||
list.messages,
|
||||
(messages) => {
|
||||
const next = pruneChecked(
|
||||
checkedIds.value,
|
||||
messages.map((message) => message.id),
|
||||
)
|
||||
if (next !== checkedIds.value) checkedIds.value = next
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
// 다른 목록으로 바뀌면 모두 푼다
|
||||
watch(list.sourceSeq, clearChecked, { flush: 'sync' })
|
||||
|
||||
return { checkedIds, checkedCount, checkedMessages, checkState, clearChecked, toggleCheck, checkRangeTo, toggleCheckAll }
|
||||
}
|
||||
|
||||
export type SelectionState = ReturnType<typeof createSelection>
|
||||
@@ -0,0 +1,32 @@
|
||||
// 메일 스토어(mail.store.ts)를 이루는 모듈이 함께 쓰는 타입
|
||||
import type { useMail } from '@/composables/useMail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import type { BulkOrigin, BulkResult } from '@/shared/utils/mail-bulk'
|
||||
|
||||
export type MailApi = ReturnType<typeof useMail>
|
||||
|
||||
export type LoadStatus = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
/** 읽음 · 중요 표시만 바꾸는 변경 */
|
||||
export type FlagChanges = Partial<Pick<MessageSummary, 'isRead' | 'isFlagged'>>
|
||||
|
||||
/** 여러 통 처리(한 통 삭제 포함)의 결과 */
|
||||
export interface MailBulkResult extends BulkResult {
|
||||
/**
|
||||
* 끝났을 때 보이는 목록이 다른 목록(다른 메일함 · 검색)으로 바뀌어 있었다 — 그 목록을 직접 고치지 않았고
|
||||
* 개수와 (메일함 목록이면) 그 목록을 서버 기준으로 다시 맞췄다. 부르는 쪽도 목록에 기댄 일(고른 것 풀기 · 다음 메일로 넘어가기)을 하지 않는다
|
||||
*/
|
||||
stale: boolean
|
||||
}
|
||||
|
||||
export interface BulkMoveResult extends MailBulkResult {
|
||||
/** 되돌리기 — 원래 메일함별로 묶은 메일 */
|
||||
origins: BulkOrigin[]
|
||||
}
|
||||
|
||||
/** 검색을 시작한 곳 — 범위 줄의 "그 메일함"과 검색을 닫을 때 돌아갈 화면 */
|
||||
export interface SearchOrigin {
|
||||
mailboxId: string | null
|
||||
/** 돌아갈 화면의 fullPath — 모르면(주소로 바로 들어옴) null */
|
||||
location: string | null
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import ToastHost from '@/components/feedback/ToastHost.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { useAuthStore } from './auth.store'
|
||||
import { TOAST_ACTION_DURATION_MS, TOAST_DURATION_MS, useToastStore } from './toast.store'
|
||||
|
||||
// 알림이 닫히는 시간 — 단추가 있는 알림은 10초, 마우스 · 초점이 안에 있는 동안은 닫지 않는다 (WCAG 2.2.1)
|
||||
@@ -81,4 +84,41 @@ describe('toast.store', () => {
|
||||
expect(messages()).toEqual([])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
describe('세션이 바뀌면 — 이전 세션의 되돌리기를 누를 수 없다', () => {
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
it.each([
|
||||
['로그아웃', () => useAuthStore().clearAuth()],
|
||||
['세션 만료', () => useAuthStore().expireSession()],
|
||||
['다른 계정 로그인', () => useAuthStore().startSession({ email: 'doyun.lee@arasystem.test', name: '이도윤' })],
|
||||
])('%s — 단추가 있는 알림은 닫고, 단추 없는 알림은 둔다', async (_label, endSession) => {
|
||||
useAuthStore().startSession(USER)
|
||||
const store = useToastStore()
|
||||
store.show({ kind: 'success', message: '옮겼습니다', action: undo })
|
||||
store.show({ kind: 'success', message: '보냈습니다' })
|
||||
|
||||
endSession()
|
||||
await nextTick()
|
||||
|
||||
expect(messages()).toEqual(['보냈습니다'])
|
||||
})
|
||||
|
||||
it('useToast 의 단추는 띄운 세션에서만 움직인다 — 그사이 세션이 바뀌었으면 눌러도 아무것도 하지 않는다', async () => {
|
||||
useAuthStore().startSession(USER)
|
||||
const run = vi.fn()
|
||||
useToast().success('메일 1통을 휴지통으로 옮겼습니다.', { label: '되돌리기', run })
|
||||
const action = useToastStore().toasts[0]?.action
|
||||
if (!action || !('run' in action)) throw new Error('단추가 없다')
|
||||
|
||||
action.run()
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
useAuthStore().startSession(USER)
|
||||
await nextTick()
|
||||
action.run()
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
// 화면 오른쪽 아래 알림(토스트) 상태 — ToastHost.vue 가 그린다.
|
||||
// 오류 알림은 useApi 의 showErrorUI() 만 띄운다 (같은 코드 3초 중복 억제는 그쪽 책임).
|
||||
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫지 않는다(hold · release) — 다 떠나면 그 알림의 시간을 처음부터 다시 센다
|
||||
// (WCAG 2.2.1 시간 조절 — 단추를 누르려는 사람이 시간에 쫓기지 않게).
|
||||
// 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 로그인) 단추가 있는 알림은 닫는다 — 이전 세션의 되돌리기 등을 누를 수 없게
|
||||
// (단추 자체도 만든 세션에서만 움직인다 — useToast).
|
||||
|
||||
export type ToastKind = 'error' | 'success'
|
||||
|
||||
@@ -126,5 +130,13 @@ export const useToastStore = defineStore('toast', () => {
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
/** 단추가 있는 알림을 닫는다 — 세션이 바뀌었을 때 */
|
||||
function dismissActions() {
|
||||
toasts.value.filter((toast) => toast.action !== null).forEach((toast) => dismiss(toast.id))
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
watch(() => authStore.generation, dismissActions)
|
||||
|
||||
return { toasts, show, dismiss, hold, release, clear }
|
||||
})
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
--color-brand: #0e7c86; /* 강조 버튼 · 링크 · 진행률 */
|
||||
--color-brand-strong: #0a5f67; /* 강조 글자 · 눌린 상태 */
|
||||
--color-brand-subtle: #e3f2f3; /* 선택한 행 · 현재 메일함 · 아바타 */
|
||||
--color-brand-tint: #f3f8f8; /* 여러 통 처리 도구 줄 · 새 메일 알림 줄 바탕 (디자인 원본 BulkSelect · ListStates) */
|
||||
--color-on-brand: #ffffff;
|
||||
--color-on-brand-text: #e6eff0;
|
||||
--color-on-brand-muted: #a3c2c6;
|
||||
@@ -63,6 +64,8 @@
|
||||
--color-surface-muted: #f2f4f7; /* 받는 사람 토큰 · 파일 아이콘 */
|
||||
--color-surface-sunken: #fbfbfc; /* 목록 표 머리 줄 */
|
||||
--color-row-hover: #f3f8f8; /* 목록 표 행 hover */
|
||||
--color-row-checked: #e8f4f5; /* 고른 행 (여러 통 처리) */
|
||||
--color-row-checked-hover: #ddeff0; /* 고른 행에 마우스를 올렸을 때 */
|
||||
--color-border: #e6e8ec;
|
||||
--color-border-subtle: #eef0f3;
|
||||
--color-border-strong: #d0d5dd; /* 보기 전환 세그먼트 테두리 */
|
||||
@@ -71,6 +74,7 @@
|
||||
|
||||
/* ── 색: 상태 ───────────────────────────── */
|
||||
--color-danger: #b42318;
|
||||
--color-danger-strong: #912018; /* 영구 삭제 단추에 마우스를 올렸을 때 */
|
||||
--color-danger-surface: #fef3f2;
|
||||
--color-danger-border: #fbd7d3;
|
||||
--color-warning-surface: #fff7e6;
|
||||
@@ -100,6 +104,7 @@
|
||||
--space-44: 2.75rem;
|
||||
--space-48: 3rem;
|
||||
--space-56: 3.5rem;
|
||||
--space-72: 4.5rem;
|
||||
--space-80: 5rem;
|
||||
|
||||
/* ── 모서리 ─────────────────────────────── */
|
||||
@@ -131,7 +136,18 @@
|
||||
--size-compact-control: 2rem; /* 32px */
|
||||
--size-segment-item: 1.875rem; /* 30px — 전환 버튼 한 칸 높이 (테두리·안쪽 여백 포함 36px) */
|
||||
--size-segment-item-lg: 2.25rem; /* 36px — 밀도 예외: 설정 화면의 전환 단추(그림 크기 · 미리 볼 메일) · 그림 빼기 (디자인 원본 Settings) */
|
||||
--size-icon-button: 2.5rem; /* 40px — 새로고침 · 이전/다음 버튼 폭 (높이는 44px) */
|
||||
--size-icon-button: 2.5rem; /* 40px — 새로고침 · 이전/다음 · 여러 통 처리 도구(아이콘만일 때) 버튼 폭 (높이는 44px) */
|
||||
--size-search-box: 2.5rem; /* 40px — 상단 검색 칸 높이 */
|
||||
/*
|
||||
* 3단계(새 메일 · 검색 · 여러 통 처리) 밀도 예외 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "3단계", 사용자 확정 캔버스).
|
||||
* 목록 표의 고르기 칸 32(--table-col-check — 별 칸과 같다) · 분할 보기 행의 고르기 칸 폭 36 ·
|
||||
* 검색어 지우기 40×38 · 새 메일 알림 줄 높이 40 · 검색 범위 전환 36(--size-segment-item-lg) · 이동 메뉴 항목 40(--size-editor-menu-item)
|
||||
*/
|
||||
--size-row-check: 2.25rem; /* 36px — 분할 보기 행 왼쪽의 고르기 칸 폭 · 밀도 예외 */
|
||||
--size-search-clear-width: 2.5rem; /* 40px — 검색어 지우기(✕) 폭 · 밀도 예외 */
|
||||
--size-search-clear-height: 2.375rem; /* 38px — 검색 칸(40) 안쪽 높이를 채운다 · 밀도 예외 */
|
||||
--size-notice-bar: 2.5rem; /* 40px — "새 메일 N통 · 맨 위로" 줄 높이 · 밀도 예외 */
|
||||
--size-checkbox: 1rem; /* 16px — 고르기 상자 */
|
||||
--size-rail: 4.5rem; /* 72px — 앱 레일 폭 */
|
||||
--size-rail-item: 3.75rem; /* 60px */
|
||||
--size-row-min: 2.5rem; /* 40px — 메일함 항목 */
|
||||
@@ -143,13 +159,15 @@
|
||||
--size-table-head: 2rem; /* 32px — 목록 보기 표 머리 줄 */
|
||||
--size-table-row: 2.375rem; /* 38px — 목록 보기 표 행 */
|
||||
--table-gap: 0.75rem; /* 12px — 표 칸 사이 */
|
||||
--table-col-check: 2rem; /* 32px — 고르기 칸 · 밀도 예외 (별 칸과 같다) */
|
||||
--table-col-star: 2rem; /* 32px */
|
||||
--table-col-mailbox: 6rem; /* 96px — 전체 메일함 검색 결과의 메일함 칸 (보낸 사람 뒤) */
|
||||
--table-col-who: 10.5rem; /* 168px */
|
||||
--table-col-who-narrow: 7rem; /* 112px — 600px 미만 */
|
||||
--table-col-date: 5.25rem; /* 84px */
|
||||
--table-col-date-narrow: 4.5rem; /* 72px — 600px 미만 */
|
||||
--table-col-size: 4rem; /* 64px */
|
||||
--table-min-width: 25rem; /* 400px — 이보다 좁으면 가로 스크롤 */
|
||||
--table-min-width: 27.5rem; /* 440px — 이보다 좁으면 가로 스크롤 (고르기 칸 포함, 디자인 원본 BulkSelect) */
|
||||
|
||||
/*
|
||||
* ── 콘텐츠 열 (읽기 · 쓰기 공통) ─────────────
|
||||
@@ -172,7 +190,7 @@
|
||||
* 툴바 아이콘 버튼 40×44 · 메뉴 항목 높이 40 · 색 견본 칸 40×40 · 표 고르기 칸 22(간격 3, 키보드는 화살표)
|
||||
*/
|
||||
--size-editor-tool: 2.5rem; /* 40px — 툴바 아이콘 버튼 폭 (높이는 --size-touch 44px) · 밀도 예외 */
|
||||
--size-editor-menu-item: 2.5rem; /* 40px — 메뉴 항목 높이 · 밀도 예외 */
|
||||
--size-editor-menu-item: 2.5rem; /* 40px — 메뉴 항목 높이 · 밀도 예외 (여러 통 처리의 이동 메뉴도 같은 메뉴를 쓴다) */
|
||||
--size-editor-swatch-cell: 2.5rem; /* 40px — 색 견본 칸 · 밀도 예외 */
|
||||
--size-editor-table-cell: 1.375rem; /* 22px — 표 고르기 격자 칸 · 밀도 예외 */
|
||||
--editor-table-cell-gap: 0.1875rem; /* 3px */
|
||||
|
||||
@@ -21,6 +21,13 @@ export function createTestRouter(): Router {
|
||||
{ path: '/login', name: 'login', component: Empty },
|
||||
{ path: '/mail', name: 'mail', component: Empty, meta: { requiresAuth: true } },
|
||||
{ path: '/mail/compose', name: 'mail-compose', component: Empty, meta: { requiresAuth: true } },
|
||||
{ path: '/mail/search', name: 'mail-search', component: Empty, meta: { requiresAuth: true } },
|
||||
{
|
||||
path: '/mail/search/:messageId',
|
||||
name: 'mail-search-message',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{ path: '/mail/:mailboxId', name: 'mail-mailbox', component: Empty, meta: { requiresAuth: true } },
|
||||
{
|
||||
path: '/mail/:mailboxId/:messageId',
|
||||
|
||||
Reference in New Issue
Block a user