generated from ttipo/project-base
feat(frontend): 읽기 화면 이동 · 끌어다 놓기 · 메일함 옮기기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기
- 읽기 화면 [이동 ▾](옮긴 뒤 다음 메일 · [되돌리기]), 도구 줄은 읽기 칸 폭(컨테이너 쿼리)으로 아이콘만 - [이동 ▾] 메뉴 들여쓰기 · 그 메일이 든 메일함에 하위가 있으면 흐린 줄 · 넘치면 좌우 바꾸기(EditorPopup 메뉴 모두), 같은 이름이 다른 곳에도 있을 때만 알림에 "상위 › 이름" - 목록 → 메일함 패널 끌어다 놓기(고른 것 · 대화 줄, 휴지통은 삭제), 메일 화면에 놓은 파일은 무시 - 스팸메일함으로 옮기는 세 길 같게 — 메뉴 · 끌기도 스팸 표시를 붙이고, 꺼내면 스팸 아님 - 메일함 ⋯ 옮기기 창(같은 이름이 있는 곳은 처음부터 막음) · 하위까지 지우기 확인 창 - 차단 창에 받은편지함 정리 칸(같은 탭의 필터 적용 · 스팸메일함 비우기와 함께 돌지 않음), 진행 알림 하나 - 예약 안내 줄 [지금 보내기] — 결과를 모르는 실패는 그 메일을 다시 받아 가르고 확인하지 못한 일은 단정하지 않는다 - 상단 바 좁은 폭에서 [로그아웃]이 꺾이지 않게, 같은 대화 [모두 펼치기] · 펼친 줄 머리 [답장] Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,11 +5,14 @@
|
||||
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title — 메일 줄의 고르기 칸과 같은 결)
|
||||
// - 마지막 메일이 붙잡힌 메일이면 시각 자리가 "보내는 중" · "예약 · …"(청록 굵게 — 메일 목록과 같다)
|
||||
// - 임시보관함의 제목 없는 저장본은 메일 줄처럼 흐린 "(제목 없음)" (리뷰 L5)
|
||||
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
|
||||
// 그 대화에서 이 메일함에 든 메일을 옮기고(붙잡힌 메일은 빼고), 그런 메일뿐인 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { useMailRowDrag } from '@/composables/useMailDrag'
|
||||
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
@@ -55,6 +58,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const drag = useMailRowDrag()
|
||||
|
||||
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
|
||||
const countText = computed(() => conversationCountText(props.row))
|
||||
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
|
||||
@@ -85,7 +90,10 @@ function onStar() {
|
||||
{ 'is-selected': selected, 'is-unread': unread, 'is-checked': checked, 'is-checking': checking },
|
||||
]"
|
||||
:data-row-id="row.id"
|
||||
:draggable="!held"
|
||||
@mousedown="onMouseDown"
|
||||
@dragstart="(event: DragEvent) => drag.start(event, row.id)"
|
||||
@dragend="drag.end"
|
||||
>
|
||||
<MailCheckbox
|
||||
class="thread-row__check"
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
// - [답장]은 읽기 화면의 [답장]과 같은 쓰기 화면이고, 임시 저장본 · 예약한 메일에는 없다. [이 메일만 열기]는 그 메일을 읽기 화면으로 연다(mail-conversation.ts) —
|
||||
// 분할 보기는 기록에 쌓고, 목록 보기는 이전 · 다음처럼 바꿔 끼운다([목록으로]가 쌓인 기록으로 돌아가지 않게)
|
||||
// - 본문 안에서 누른 Ctrl+P(맥 ⌘P)는 이 메일의 인쇄 화면을 연다(읽기 화면과 같은 판정 — useMailPrint)
|
||||
// - [모두 펼치기](15단계)로 펼친 줄은 본문을 바로 받지 않는다 — 이 안(data-conversation-panel)이 화면에 들어오면 칸이 스토어에 알려 차례로 받는다.
|
||||
// 받기 전에는 받는 중과 같은 뼈대(aria-busy)다 (useConversationPanelVisibility)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -70,6 +72,7 @@ function onFrameKeydown(event: KeyboardEvent) {
|
||||
<div
|
||||
:id="panelId"
|
||||
class="conversation-panel"
|
||||
:data-conversation-panel="row.message.id"
|
||||
>
|
||||
<p
|
||||
v-if="recipients"
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
// - 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 시각 자리에 목록과 같은 "보내는 중" · "예약 · …"(청록 굵게)
|
||||
// - 안 읽은 메일(row.unread — 2026-11-07)은 목록 줄(MessageListItem)과 같은 굵기 · 색: 이름 650, 미리 보기 · 시각 600(목록 줄의 제목 · 시각 자리).
|
||||
// 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, "(MAIL_LIST_ROW_TEXT). 지금 보는 메일의 줄도 같다
|
||||
// - 펼친 줄 머리 오른쪽 [답장](15단계 — 디자인 원본 Reader15 "6", 36 — 안내 줄 단추 크기): 긴 메일을 끝까지 내리지 않고 답장한다.
|
||||
// 줄 머리 전체가 펼침 단추라 머리를 감싸는 틀(마우스를 올린 바탕은 틀에) 안에 펼침 단추와 [답장]을 나란히 둔다(단추 안에 단추를 넣지 않는다).
|
||||
// 펼친 안 아래의 [답장] · [이 메일만 열기]는 그대로다. 임시 저장본 · 예약한 메일 · 그사이 지워진 메일의 줄에는 없다(아래 [답장]과 같다)
|
||||
// 뱃지 · 알약 없이 글로만 보인다. 검색 강조는 넣지 않는다
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -39,6 +42,13 @@ const emit = defineEmits<Emits>()
|
||||
|
||||
/** 펼친 안의 id — 메일 id 는 서버의 불투명한 글이라 그대로 붙인다 */
|
||||
const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
|
||||
/** 펼친 줄 머리의 [답장] — 답장할 수 있는 메일이고(row.replyLocation) 그사이 지워지지 않았을 때 (펼친 안 아래 [답장]과 같은 조건) */
|
||||
const headReply = computed(() => {
|
||||
if (!props.expanded || !props.row.replyLocation) return null
|
||||
const detail = props.detail
|
||||
if (detail?.status === 'error' && detail.error.code === 'RES_001') return null
|
||||
return props.row.replyLocation
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -69,39 +79,49 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="conversation-row__line"
|
||||
:aria-expanded="expanded"
|
||||
:aria-controls="expanded ? panelId : undefined"
|
||||
data-conversation-toggle
|
||||
@click="emit('toggle', row.message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="expanded ? 'chevron-down' : 'chevron-right'"
|
||||
:stroke-width="2"
|
||||
class="conversation-row__chevron"
|
||||
/>
|
||||
<span
|
||||
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
|
||||
aria-hidden="true"
|
||||
>{{ row.sender.initial }}</span>
|
||||
<span class="conversation-row__who">
|
||||
<div :class="['conversation-row__head', { 'conversation-row__head--reply': headReply }]">
|
||||
<button
|
||||
type="button"
|
||||
class="conversation-row__line"
|
||||
:aria-expanded="expanded"
|
||||
:aria-controls="expanded ? panelId : undefined"
|
||||
data-conversation-toggle
|
||||
@click="emit('toggle', row.message.id)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="expanded ? 'chevron-down' : 'chevron-right'"
|
||||
:stroke-width="2"
|
||||
class="conversation-row__chevron"
|
||||
/>
|
||||
<span
|
||||
v-if="row.unread"
|
||||
class="visually-hidden"
|
||||
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.sender.mailboxName"
|
||||
class="conversation-row__mailbox"
|
||||
>{{ row.sender.mailboxName }}</span>
|
||||
<span class="conversation-row__preview">{{ row.preview }}</span>
|
||||
<time
|
||||
:class="['conversation-row__time', { 'is-pending': row.pending }]"
|
||||
:datetime="row.timeValue"
|
||||
>{{ row.time }}</time>
|
||||
</button>
|
||||
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
|
||||
aria-hidden="true"
|
||||
>{{ row.sender.initial }}</span>
|
||||
<span class="conversation-row__who">
|
||||
<span
|
||||
v-if="row.unread"
|
||||
class="visually-hidden"
|
||||
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.sender.mailboxName"
|
||||
class="conversation-row__mailbox"
|
||||
>{{ row.sender.mailboxName }}</span>
|
||||
<span class="conversation-row__preview">{{ row.preview }}</span>
|
||||
<time
|
||||
:class="['conversation-row__time', { 'is-pending': row.pending }]"
|
||||
:datetime="row.timeValue"
|
||||
>{{ row.time }}</time>
|
||||
</button>
|
||||
<RouterLink
|
||||
v-if="headReply"
|
||||
class="conversation-row__reply"
|
||||
:to="headReply"
|
||||
data-conversation-head-reply
|
||||
>
|
||||
{{ MAIL_CONVERSATION_TEXT.reply }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<ConversationMessagePanel
|
||||
v-if="expanded"
|
||||
:panel-id="panelId"
|
||||
@@ -132,10 +152,47 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button.conversation-row__line:hover {
|
||||
/* 다른 메일의 머리 — 펼침 단추와 [답장]을 감싸고, 마우스를 올린 바탕은 머리 전체에 (디자인 원본 Reader15 "6") */
|
||||
.conversation-row__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.conversation-row__head:hover {
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.conversation-row__head .conversation-row__line {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* [답장]이 있으면 단추 오른쪽 안쪽을 줄인다 — [답장]의 안쪽 여백이 시각과 사이를 둔다 */
|
||||
.conversation-row__head--reply .conversation-row__line {
|
||||
padding-right: var(--space-6);
|
||||
}
|
||||
|
||||
/* 펼친 줄 머리의 [답장] — 36 글자 단추(안내 줄 단추의 밀도 예외 재사용), 펼친 안 아래의 [답장]과 같은 모양 */
|
||||
.conversation-row__reply {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-notice-action);
|
||||
margin-right: var(--space-4);
|
||||
padding: 0 var(--space-10);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.conversation-row__reply:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 상자가 넘치는 것을 자르므로(모서리) 고리를 안쪽에 그린다 */
|
||||
button.conversation-row__line:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title)
|
||||
// - 임시보관함의 제목 없는 저장본은 메일 표처럼 흐린 "(제목 없음)" (리뷰 L5)
|
||||
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
|
||||
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
|
||||
// 그 대화에서 이 메일함에 든 메일을 옮기고(붙잡힌 메일은 빼고), 그런 메일뿐인 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { useMailRowDrag } from '@/composables/useMailDrag'
|
||||
import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
@@ -47,6 +50,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const drag = useMailRowDrag()
|
||||
|
||||
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
|
||||
const countText = computed(() => conversationCountText(props.row))
|
||||
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
|
||||
@@ -80,8 +85,11 @@ function onStar() {
|
||||
<tr
|
||||
:class="['thread-table-row', { 'is-unread': unread, 'is-checked': checked }]"
|
||||
:data-row-id="row.id"
|
||||
:draggable="!held"
|
||||
@click="onRowClick"
|
||||
@mousedown="onMouseDown"
|
||||
@dragstart="(event: DragEvent) => drag.start(event, row.id)"
|
||||
@dragend="drag.end"
|
||||
>
|
||||
<td class="mail-table__check">
|
||||
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (메일 표와 같다) -->
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
// 메뉴는 편집기 메뉴(EditorMenu — 항목 높이 40, 메뉴 항목 밀도 예외)를 같이 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에 초점,
|
||||
// 마우스로 열면 메뉴 자체에 초점(EditorPopup). Esc 는 메뉴만 닫고 이 단추로 돌아간다 — EditorPopup 이 Esc 를 막아 목록 칸의 "선택 풀기"까지 가지 않는다.
|
||||
// 받은 속성(data-tool · tabindex — 도구 줄의 로빙)은 단추에 붙인다.
|
||||
// 15단계(디자인 원본 Reader15): 옮길 곳은 메일함 패널처럼 들여 쓴 나무(mail-move-menu.ts — 읽기 화면의 [이동 ▾]와 같은 모양 · 같은 규칙).
|
||||
// 고른 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 흐린 줄로 남긴다("고른 메일이 있는 메일함"). 메뉴가 화면 밖으로 넘치면 좌우를 바꿔 연다(EditorPopup)
|
||||
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 { moveMenuItems } from '@/components/mail/mail-move-menu'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { hasMoveTarget, type MailMoveTarget } from '@/shared/utils/mail-move-targets'
|
||||
|
||||
interface Props {
|
||||
/** 옮길 수 있는 메일함 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 */
|
||||
mailboxes: readonly Mailbox[]
|
||||
/** 옮길 곳 — 메일함 패널 차례 · 깊이, 고른 메일이 든 메일함 · 임시보관함 · 휴지통은 빼거나 고를 수 없는 줄로 (mail-move-targets.ts) */
|
||||
mailboxes: readonly MailMoveTarget[]
|
||||
labelMode: 'always' | 'wide' | 'never'
|
||||
disabled?: boolean
|
||||
}
|
||||
@@ -35,10 +39,10 @@ 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 })))
|
||||
const items = computed<EditorMenuItem[]>(() => moveMenuItems(props.mailboxes, MAIL_BULK_TEXT.moveHereChecked))
|
||||
|
||||
function openMenu(keyboard: boolean) {
|
||||
if (props.disabled || items.value.length === 0) return
|
||||
if (props.disabled || !hasMoveTarget(props.mailboxes)) return
|
||||
byKeyboard.value = keyboard
|
||||
open.value = true
|
||||
}
|
||||
@@ -64,10 +68,11 @@ function onClose(restoreFocus: boolean) {
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
const target = props.mailboxes.find((row) => row.mailbox.id === id && !row.disabled)
|
||||
if (!target) return
|
||||
open.value = false
|
||||
trigger.value?.focus()
|
||||
const target = props.mailboxes.find((mailbox) => mailbox.id === id)
|
||||
if (target) emit('move', target)
|
||||
emit('move', target.mailbox)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -88,6 +93,7 @@ function onSelect(id: string) {
|
||||
/>
|
||||
<EditorMenu
|
||||
v-if="open"
|
||||
class="bulk-move__menu"
|
||||
:label="MAIL_BULK_TEXT.moveMenu"
|
||||
:items="items"
|
||||
:trigger="trigger"
|
||||
@@ -103,4 +109,13 @@ function onSelect(id: string) {
|
||||
.bulk-move {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 옮길 메일함 메뉴 — 이름 길이만큼(폭 200 부터 320 까지), 그보다 긴 이름은 줄을 바꾼다 (디자인 원본 Reader15) */
|
||||
.bulk-move__menu {
|
||||
width: max-content;
|
||||
min-width: 12.5rem;
|
||||
/* 좁은 화면에서는 단추의 어느 끝에 맞춰도 화면 안에 들게 — 폭 360 이면 280 까지 */
|
||||
max-width: min(20rem, calc(100vw - 5rem));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 줄을 끄는 동안 마우스를 따라오는 표 — "메일 3통" · "대화 1개" (15단계 — 디자인 원본 ListDrag15 "끌어다 놓는 동안의 모양")
|
||||
// 알림과 같은 어두운 바탕(#0B3640)에 12.5px 굵은 흰 글자, 봉투(메일) · 말풍선(대화) 아이콘. 마우스 오른쪽 아래에 붙는다.
|
||||
// 브라우저는 끌기를 시작하는 순간 그려진 요소의 모습을 찍는다(setDragImage) — 그래서 메일 화면(MailLayout)에 늘 그려 두되 화면 밖에 두고,
|
||||
// 수 · 아이콘은 끌기를 시작할 때 useMailDrag 가 이 요소에 바로 적는다(화면 갱신을 기다리면 늦다). 화면 낭독에는 읽히지 않는다(aria-hidden)
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { registerMailDragChip } from '@/composables/useMailDrag'
|
||||
|
||||
const hostRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
registerMailDragChip(hostRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
registerMailDragChip(null)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="hostRef"
|
||||
class="drag-chip-host"
|
||||
aria-hidden="true"
|
||||
data-kind="messages"
|
||||
data-mail-drag-chip
|
||||
>
|
||||
<span class="drag-chip">
|
||||
<AppIcon
|
||||
name="mail"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
class="drag-chip__icon drag-chip__icon--messages"
|
||||
/>
|
||||
<AppIcon
|
||||
name="message-square"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
class="drag-chip__icon drag-chip__icon--conversations"
|
||||
/>
|
||||
<span data-drag-chip-text />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 화면 밖에 그려 둔다 — 보이지 않아야 하지만 그려져 있어야 브라우저가 모습을 찍는다(display: none 이면 찍히지 않는다).
|
||||
* 요소의 왼쪽 위가 마우스 자리다 — 표는 그 안에서 오른쪽 16 · 아래 22 만큼 비켜 둔다 (디자인 원본 ListDrag15)
|
||||
*/
|
||||
.drag-chip-host {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -100rem;
|
||||
padding: var(--space-22) 0 0 var(--space-16);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
/* 위아래 5 · 오른쪽 10 · 왼쪽 8 (디자인 원본 ListDrag15) */
|
||||
padding: 0.3125rem var(--space-10) 0.3125rem var(--space-8);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand-surface);
|
||||
color: var(--color-surface);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
line-height: var(--line-height-tight);
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-toast-strong);
|
||||
}
|
||||
|
||||
/* 끄는 것에 맞는 아이콘만 — 메일 줄은 봉투, 대화 줄은 말풍선 */
|
||||
.drag-chip-host[data-kind='messages'] .drag-chip__icon--conversations,
|
||||
.drag-chip-host[data-kind='conversations'] .drag-chip__icon--messages {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,422 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
import { endMailDrag, MAIL_DRAG_TYPE } from '@/composables/useMailDrag'
|
||||
import type { ConversationSummary, Mailbox, MessagesPatch, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { dragEvent, fakeDataTransfer, type FakeDataTransfer } from '@/test-utils/drag'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailboxPanel from './MailboxPanel.vue'
|
||||
import MailDragChip from './MailDragChip.vue'
|
||||
import MessageList from './MessageList.vue'
|
||||
import MessageTable from './MessageTable.vue'
|
||||
|
||||
// 15단계 — 목록 줄을 메일함 패널로 끌어다 놓기 · 목록 [이동 ▾]의 들여 쓴 나무 · 스팸메일함으로 옮기는 세 길 (디자인 원본 ListDrag15 · Reader15)
|
||||
|
||||
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0, ...partial }
|
||||
}
|
||||
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
box('inbox', 'inbox', '받은편지함'),
|
||||
box('sent', 'sent', '보낸편지함'),
|
||||
box('drafts', 'drafts', '임시보관함'),
|
||||
box('junk', 'junk', '스팸메일함'),
|
||||
box('trash', 'trash', '휴지통'),
|
||||
box('clients', null, '거래처'),
|
||||
box('quotes', null, '견적', { parentId: 'clients' }),
|
||||
box('project', null, '프로젝트', { sortOrder: 1 }),
|
||||
box('project-quotes', null, '견적', { parentId: 'project' }),
|
||||
]
|
||||
|
||||
function summary(id: string, mailboxId = 'inbox', partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: [mailboxId],
|
||||
from: { name: '박지훈', email: 'jihun.park@example.com' },
|
||||
to: [],
|
||||
subject: `견적 조건 문의드립니다 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-11-08T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
pendingSend: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function conversation(id: string, messageIds: string[], partial: Partial<ConversationSummary> = {}): ConversationSummary {
|
||||
return {
|
||||
id,
|
||||
latest: summary(messageIds[0] ?? id),
|
||||
participants: [{ name: '박지훈', email: 'jihun.park@example.com' }],
|
||||
messageCount: 3,
|
||||
messageIds,
|
||||
pendingSendIds: [],
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('목록 줄 끌어다 놓기 · 목록 [이동 ▾] (15단계)', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) wrapper.unmount()
|
||||
// 끌기 자료는 탭(모듈)에 하나다 — 놓지 않고 끝난 시험의 끌기가 다음 시험에 남지 않게
|
||||
endMailDrag()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function serve(messages: MessageSummary[], conversations: ConversationSummary[] = []) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 9 } })
|
||||
if (request.url === '/messages' && request.method === 'GET') {
|
||||
return ok({ items: messages, pageInfo: { hasNext: false, nextCursor: null, total: messages.length } })
|
||||
}
|
||||
if (request.url === '/conversations') {
|
||||
return ok({
|
||||
items: conversations,
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: conversations.length },
|
||||
basis: 'index',
|
||||
})
|
||||
}
|
||||
if (request.method === 'PATCH' && request.url === '/messages') {
|
||||
return ok({ updatedIds: (request.data as MessagesPatch).ids, failedIds: [] })
|
||||
}
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
}
|
||||
|
||||
/** 메일 화면처럼 — 메일함 패널 · 목록 · 끄는 동안의 표 */
|
||||
async function mountScreen(
|
||||
options: {
|
||||
boxId?: string
|
||||
messages?: MessageSummary[]
|
||||
conversations?: ConversationSummary[]
|
||||
view?: 'split' | 'list'
|
||||
} = {},
|
||||
) {
|
||||
const boxId = options.boxId ?? 'inbox'
|
||||
const messages = options.messages ?? [summary('m1'), summary('m2'), summary('m3')]
|
||||
serve(messages, options.conversations)
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES
|
||||
store.mailboxesStatus = 'ready'
|
||||
if (options.conversations) {
|
||||
store.listSource = { kind: 'conversations', mailboxId: boxId }
|
||||
store.conversations = options.conversations
|
||||
} else {
|
||||
store.currentMailboxId = boxId
|
||||
store.messages = messages
|
||||
}
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: options.conversations?.length ?? messages.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${boxId}`)
|
||||
await router.isReady()
|
||||
const List = options.view === 'list' ? MessageTable : MessageList
|
||||
const Screen = defineComponent({
|
||||
setup: () => () => h('div', [h(MailboxPanel), h(List), h(MailDragChip)]),
|
||||
})
|
||||
const wrapper = mount(Screen, { global: { plugins: [pinia, router] }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, store, router }
|
||||
}
|
||||
|
||||
const patches = () =>
|
||||
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
|
||||
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
|
||||
function panelRow(wrapper: VueWrapper, name: string, index = 0): DOMWrapper<Element> {
|
||||
const rows = wrapper.findAll('.mailbox-row').filter((row) => row.get('.mailbox-row__name').text() === name)
|
||||
const row = rows[index]
|
||||
if (!row) throw new Error(`${name} 줄이 없다`)
|
||||
return row
|
||||
}
|
||||
|
||||
function listRow(wrapper: VueWrapper, id: string): DOMWrapper<Element> {
|
||||
const row = wrapper.find(`[data-row-id="${id}"]`)
|
||||
if (!row.exists()) throw new Error(`${id} 줄이 없다`)
|
||||
return row
|
||||
}
|
||||
|
||||
/** 줄을 끌기 시작한다 — 줄 안의 링크에서 시작해도 줄이 받는다 (브라우저는 링크의 주소를 먼저 실어 둔다) */
|
||||
function startDrag(row: DOMWrapper<Element>): FakeDataTransfer {
|
||||
const transfer = fakeDataTransfer()
|
||||
transfer.setData('text/uri-list', 'http://localhost/mail/inbox/m2')
|
||||
transfer.setData('text/plain', 'http://localhost/mail/inbox/m2')
|
||||
const origin = row.find('a').exists() ? row.get('a').element : row.element
|
||||
origin.dispatchEvent(dragEvent('dragstart', transfer))
|
||||
return transfer
|
||||
}
|
||||
|
||||
/** 패널 줄 위로 들어와 머문다 — dragover 를 받았는가(놓을 수 있는가) */
|
||||
function hover(row: DOMWrapper<Element>, transfer: FakeDataTransfer): boolean {
|
||||
row.element.dispatchEvent(dragEvent('dragenter', transfer))
|
||||
const over = dragEvent('dragover', transfer)
|
||||
row.element.dispatchEvent(over)
|
||||
return over.defaultPrevented
|
||||
}
|
||||
|
||||
async function dropOn(row: DOMWrapper<Element>, transfer: FakeDataTransfer) {
|
||||
hover(row, transfer)
|
||||
row.element.dispatchEvent(dragEvent('drop', transfer))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('끄는 것과 끄는 동안의 모양', () => {
|
||||
it('줄을 끌면 우리 끌기 자료만 싣는다 — 링크의 메일 주소는 지우고, 마우스를 따라오는 표는 "메일 1통"', async () => {
|
||||
const { wrapper } = await mountScreen()
|
||||
|
||||
const transfer = startDrag(listRow(wrapper, 'm2'))
|
||||
|
||||
expect(transfer.types).toEqual([MAIL_DRAG_TYPE])
|
||||
expect(transfer.getData(MAIL_DRAG_TYPE)).not.toBe('')
|
||||
expect(transfer.effectAllowed).toBe('move')
|
||||
const chip = wrapper.get('[data-mail-drag-chip]')
|
||||
expect(transfer.dragImage).toBe(chip.element)
|
||||
expect(chip.text()).toBe('메일 1통')
|
||||
expect(chip.attributes('data-kind')).toBe('messages')
|
||||
expect(chip.attributes('aria-hidden')).toBe('true')
|
||||
// 끈 줄은 흐리게 하지 않는다 — 줄은 그대로
|
||||
expect(listRow(wrapper, 'm2').classes()).toEqual(['message-row'])
|
||||
})
|
||||
|
||||
it('놓을 수 있는 메일함 위에서는 점선 · 옅은 청록(is-drop-target), 나가면 지운다 — 지금 메일함 · 임시보관함 · 「예약됨」은 받지 않는다', async () => {
|
||||
const { wrapper, store } = await mountScreen()
|
||||
store.scheduledCount = 1
|
||||
await flushPromises()
|
||||
const transfer = startDrag(listRow(wrapper, 'm2'))
|
||||
|
||||
const clients = panelRow(wrapper, '거래처')
|
||||
expect(hover(clients, transfer)).toBe(true)
|
||||
expect(transfer.dropEffect).toBe('move')
|
||||
await flushPromises()
|
||||
expect(clients.classes()).toContain('is-drop-target')
|
||||
clients.element.dispatchEvent(dragEvent('dragleave', transfer))
|
||||
await flushPromises()
|
||||
expect(clients.classes()).not.toContain('is-drop-target')
|
||||
|
||||
for (const name of ['받은편지함', '임시보관함']) {
|
||||
const row = panelRow(wrapper, name)
|
||||
expect(hover(row, transfer)).toBe(false)
|
||||
await flushPromises()
|
||||
expect(row.classes()).not.toContain('is-drop-target')
|
||||
}
|
||||
const scheduled = wrapper.get('[data-scheduled-mailbox]')
|
||||
const over = dragEvent('dragover', transfer)
|
||||
scheduled.element.dispatchEvent(over)
|
||||
expect(over.defaultPrevented).toBe(false)
|
||||
// 휴지통은 놓을 수 있다 — [삭제]와 같다
|
||||
expect(hover(panelRow(wrapper, '휴지통'), transfer)).toBe(true)
|
||||
})
|
||||
|
||||
it('파일 끌기는 메일함 패널이 받지 않는다 (쓰기 화면의 첨부 놓기와 겹치지 않게)', async () => {
|
||||
const { wrapper } = await mountScreen()
|
||||
|
||||
const files = fakeDataTransfer([new File(['x'], '보고서.pdf')])
|
||||
expect(hover(panelRow(wrapper, '거래처'), files)).toBe(false)
|
||||
})
|
||||
|
||||
it('보내기를 기다리는 메일의 줄은 끌 수 없다 — draggable 이 아니고 링크를 끌어도 막는다', async () => {
|
||||
const held = summary('m9', 'sent', { pendingSend: { kind: 'scheduled', sendAt: '2026-11-10T00:00:00Z' } })
|
||||
const { wrapper } = await mountScreen({ boxId: 'sent', messages: [summary('m1', 'sent'), held] })
|
||||
|
||||
expect(listRow(wrapper, 'm9').attributes('draggable')).toBe('false')
|
||||
expect(listRow(wrapper, 'm1').attributes('draggable')).toBe('true')
|
||||
const transfer = fakeDataTransfer()
|
||||
const start = dragEvent('dragstart', transfer)
|
||||
listRow(wrapper, 'm9').get('a').element.dispatchEvent(start)
|
||||
expect(start.defaultPrevented).toBe(true)
|
||||
expect(transfer.types).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('놓으면', () => {
|
||||
it('내 메일함에 놓으면 [이동 ▾]와 같다 — "메일 1통을 거래처로 옮겼습니다." + [되돌리기]', async () => {
|
||||
const { wrapper, store } = await mountScreen()
|
||||
|
||||
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'm2')))
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m2'], mailboxId: 'clients' }])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m1', 'm3'])
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 거래처로 옮겼습니다.')
|
||||
expect(toast?.action && 'label' in toast.action ? toast.action.label : null).toBe('되돌리기')
|
||||
expect(panelRow(wrapper, '거래처').classes()).not.toContain('is-drop-target')
|
||||
})
|
||||
|
||||
it('같은 이름의 메일함이 또 있으면 위 메일함 이름을 붙인다 — "메일 1통을 프로젝트 › 견적으로 옮겼습니다."', async () => {
|
||||
const { wrapper } = await mountScreen()
|
||||
|
||||
await dropOn(panelRow(wrapper, '견적', 1), startDrag(listRow(wrapper, 'm1')))
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'project-quotes' }])
|
||||
expect(toastTexts()).toEqual(['메일 1통을 프로젝트 › 견적으로 옮겼습니다.'])
|
||||
})
|
||||
|
||||
it('고른 줄을 끌면 고른 것 모두 — 표는 "메일 2통", 휴지통에 놓으면 [삭제]와 같고 고른 것을 푼다', async () => {
|
||||
const { wrapper, store } = await mountScreen()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m3')
|
||||
await flushPromises()
|
||||
|
||||
const transfer = startDrag(listRow(wrapper, 'm3'))
|
||||
expect(wrapper.get('[data-mail-drag-chip]').text()).toBe('메일 2통')
|
||||
await dropOn(panelRow(wrapper, '휴지통'), transfer)
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m1', 'm3'], mailboxId: 'trash' }])
|
||||
expect(toastTexts()).toEqual(['메일 2통을 휴지통으로 옮겼습니다.'])
|
||||
expect(store.checkedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('고르지 않은 줄을 끌면 그 줄만 — 고른 것은 그대로 둔다', async () => {
|
||||
const { wrapper, store } = await mountScreen()
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
|
||||
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'm2')))
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m2'], mailboxId: 'clients' }])
|
||||
expect([...store.checkedIds]).toEqual(['m1'])
|
||||
})
|
||||
|
||||
it('스팸메일함에 놓으면 [스팸 신고]와 같다 — 스팸 표시를 함께, [되돌리기]는 표시도 되돌린다', async () => {
|
||||
const { wrapper } = await mountScreen({ view: 'list' })
|
||||
|
||||
await dropOn(panelRow(wrapper, '스팸메일함'), startDrag(listRow(wrapper, 'm2')))
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m2'], mailboxId: 'junk', isJunk: true }])
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 스팸메일함으로 옮겼습니다.')
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(patches().at(-1)).toEqual({ ids: ['m2'], mailboxId: 'inbox', isJunk: false })
|
||||
})
|
||||
|
||||
it('스팸메일함에서 꺼내 놓으면 [스팸 아님]과 같다 — 휴지통에 놓으면 표시는 그대로', async () => {
|
||||
const { wrapper } = await mountScreen({ boxId: 'junk', messages: [summary('m1', 'junk'), summary('m2', 'junk')] })
|
||||
|
||||
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'm1')))
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'clients', isJunk: false }])
|
||||
expect(toastTexts()).toEqual(['메일 1통을 스팸이 아닌 메일로 표시해 거래처로 옮겼습니다.'])
|
||||
|
||||
await dropOn(panelRow(wrapper, '휴지통'), startDrag(listRow(wrapper, 'm2')))
|
||||
expect(patches().at(-1)).toEqual({ ids: ['m2'], mailboxId: 'trash' })
|
||||
})
|
||||
|
||||
it('대화로 묶은 목록의 줄은 그 대화에서 이 메일함에 든 메일 — 표는 "대화 1개", 알림은 "받은편지함에 있는 메일 2통을 …"', async () => {
|
||||
const rows = [
|
||||
conversation('c1', ['m6', 'm5']),
|
||||
conversation('c2', ['m4', 'm3'], { pendingSendIds: ['m3'] }),
|
||||
conversation('c3', ['m2'], { pendingSendIds: ['m2'] }),
|
||||
]
|
||||
const { wrapper } = await mountScreen({ conversations: rows })
|
||||
|
||||
// 보내기를 기다리는 메일뿐인 줄은 끌 수 없다
|
||||
expect(listRow(wrapper, 'c3').attributes('draggable')).toBe('false')
|
||||
const transfer = startDrag(listRow(wrapper, 'c1'))
|
||||
const chip = wrapper.get('[data-mail-drag-chip]')
|
||||
expect(chip.text()).toBe('대화 1개')
|
||||
expect(chip.attributes('data-kind')).toBe('conversations')
|
||||
await dropOn(panelRow(wrapper, '견적'), transfer)
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m6', 'm5'], mailboxId: 'quotes' }])
|
||||
expect(toastTexts()).toEqual(['받은편지함에 있는 메일 2통을 거래처 › 견적으로 옮겼습니다.'])
|
||||
|
||||
// 붙잡힌 메일은 빼고 옮긴다 — [이동 ▾]와 같다
|
||||
await dropOn(panelRow(wrapper, '거래처'), startDrag(listRow(wrapper, 'c2')))
|
||||
expect(patches().at(-1)).toEqual({ ids: ['m4'], mailboxId: 'clients' })
|
||||
})
|
||||
|
||||
it('다른 탭 · 바깥에서 끌어 온 같은 종류의 자료는 받지 않는다 (이 탭에서 시작한 끌기만)', async () => {
|
||||
const { wrapper } = await mountScreen()
|
||||
|
||||
const foreign = fakeDataTransfer()
|
||||
foreign.setData(MAIL_DRAG_TYPE, 'other-tab')
|
||||
expect(hover(panelRow(wrapper, '거래처'), foreign)).toBe(false)
|
||||
panelRow(wrapper, '거래처').element.dispatchEvent(dragEvent('drop', foreign))
|
||||
await flushPromises()
|
||||
expect(patches()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('목록 [이동 ▾] — 읽기 화면과 같은 나무 · 같은 규칙', () => {
|
||||
async function openMoveMenu(wrapper: VueWrapper) {
|
||||
await wrapper.get('[data-tool="move"]').trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
return wrapper.get('[role="menu"]')
|
||||
}
|
||||
|
||||
it('메일함 패널처럼 들여 쓰고 화면 낭독은 위 메일함부터 — 고른 메일이 든 메일함에 하위가 있으면 흐린 줄 "고른 메일이 있는 메일함"', async () => {
|
||||
const { wrapper, store } = await mountScreen({
|
||||
boxId: 'clients',
|
||||
messages: [summary('m1', 'clients'), summary('m2', 'clients')],
|
||||
view: 'list',
|
||||
})
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
|
||||
const menu = await openMoveMenu(wrapper)
|
||||
const items = menu.findAll('[role="menuitem"]')
|
||||
expect(items.map((item) => item.attributes('aria-label') ?? item.find('.menu__label').text())).toEqual([
|
||||
'받은편지함',
|
||||
'보낸편지함',
|
||||
'스팸메일함',
|
||||
'거래처',
|
||||
'거래처 안의 견적',
|
||||
'프로젝트',
|
||||
'프로젝트 안의 견적',
|
||||
])
|
||||
const here = items[3]
|
||||
expect(here?.attributes('aria-disabled')).toBe('true')
|
||||
expect(here?.get('.menu__note').text()).toBe('고른 메일이 있는 메일함')
|
||||
expect(menu.findAll('hr')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('스팸메일함을 고르면 [스팸 신고]와 같다 — 스팸 표시를 함께 · 알림의 메일함 이름은 같은 이름이 있을 때만 위 메일함까지', async () => {
|
||||
const { wrapper, store } = await mountScreen({ view: 'list' })
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
|
||||
let menu = await openMoveMenu(wrapper)
|
||||
await menu.findAll('[role="menuitem"]').find((item) => item.text() === '스팸메일함')?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }])
|
||||
expect(toastTexts()).toEqual(['메일 1통을 스팸메일함으로 옮겼습니다.'])
|
||||
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
menu = await openMoveMenu(wrapper)
|
||||
await menu
|
||||
.findAll('[role="menuitem"]')
|
||||
.find((item) => item.attributes('aria-label') === '거래처 안의 견적')
|
||||
?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(patches().at(-1)).toEqual({ ids: ['m2'], mailboxId: 'quotes' })
|
||||
expect(toastTexts().at(-1)).toBe('메일 1통을 거래처 › 견적으로 옮겼습니다.')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,11 @@
|
||||
// 사용 예: <MailToolButton icon="reply" label="답장" :to="{ name: 'mail-compose', query: { replyTo: id } }" />
|
||||
// 누를 수 없는 까닭을 알려야 하면 disabled 대신 unavailable(aria-disabled — 초점은 받고 누르면 아무것도 하지 않는다) + describedby(까닭 글의 id).
|
||||
// 하는 중이면 busy(aria-busy — 누르면 아무것도 하지 않는다)
|
||||
// 15단계(디자인 원본 Reader15 "2"):
|
||||
// - collapsible: 읽기 칸이 좁으면 이름을 숨기고 아이콘만(40×44) 둘 수 있는 단추 — 숨기는 때는 읽기 화면이 정한다(MessageReader 의 컨테이너 쿼리).
|
||||
// 이름은 숨겨도 화면 낭독(aria-label)과 마우스를 올리면 뜨는 글(title)로 읽힌다
|
||||
// - menu: 메뉴를 여는 단추 — 이름 뒤 ▾ (아이콘만일 때도 ▾ 는 남긴다 — [이동 ▾])
|
||||
import { ref } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -26,6 +31,10 @@ interface Props {
|
||||
/** 까닭 · 설명 글의 id — aria-describedby */
|
||||
describedby?: string
|
||||
iconStrokeWidth?: number
|
||||
/** 좁은 읽기 칸에서 아이콘만 둘 수 있다 (15단계) — 이름은 aria-label · title 로도 */
|
||||
collapsible?: boolean
|
||||
/** 메뉴를 여는 단추 — 이름 뒤 ▾ */
|
||||
menu?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -41,33 +50,46 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
busy: false,
|
||||
describedby: undefined,
|
||||
iconStrokeWidth: 1.75,
|
||||
collapsible: false,
|
||||
menu: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
function onButtonClick(event: MouseEvent) {
|
||||
if (props.unavailable || props.busy) return
|
||||
emit('click', event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/** 단추 요소(단추일 때) — 메뉴의 바깥 누름 판정 · 초점 되돌리기에 쓴다 */
|
||||
element: buttonRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
v-if="to"
|
||||
:to="to"
|
||||
:class="['tool-button', `tool-button--${tone}`]"
|
||||
:class="['tool-button', `tool-button--${tone}`, { 'tool-button--collapsible': collapsible }]"
|
||||
:aria-label="collapsible ? label : undefined"
|
||||
:title="collapsible ? label : undefined"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
:size="15"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
<span class="tool-button__label">{{ label }}</span>
|
||||
</RouterLink>
|
||||
<a
|
||||
v-else-if="href"
|
||||
:href="href"
|
||||
:class="['tool-button', `tool-button--${tone}`]"
|
||||
:class="['tool-button', `tool-button--${tone}`, { 'tool-button--collapsible': collapsible }]"
|
||||
:aria-label="collapsible ? label : undefined"
|
||||
:title="collapsible ? label : undefined"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<AppIcon
|
||||
@@ -75,16 +97,23 @@ function onButtonClick(event: MouseEvent) {
|
||||
:size="15"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
<span class="tool-button__label">{{ label }}</span>
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
:class="['tool-button', `tool-button--${tone}`]"
|
||||
:class="[
|
||||
'tool-button',
|
||||
`tool-button--${tone}`,
|
||||
{ 'tool-button--collapsible': collapsible, 'tool-button--menu': menu },
|
||||
]"
|
||||
:disabled="disabled"
|
||||
:aria-disabled="unavailable ? 'true' : undefined"
|
||||
:aria-busy="busy ? 'true' : undefined"
|
||||
:aria-describedby="describedby"
|
||||
:aria-label="collapsible ? label : undefined"
|
||||
:title="collapsible ? label : undefined"
|
||||
@click="onButtonClick"
|
||||
>
|
||||
<AppIcon
|
||||
@@ -92,7 +121,14 @@ function onButtonClick(event: MouseEvent) {
|
||||
:size="15"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
<span class="tool-button__label">{{ label }}</span>
|
||||
<AppIcon
|
||||
v-if="menu"
|
||||
name="chevron-down"
|
||||
:size="12"
|
||||
:stroke-width="2"
|
||||
class="tool-button__chevron"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -125,6 +161,13 @@ function onButtonClick(event: MouseEvent) {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* 메뉴를 여는 단추 — ▾ 쪽 안쪽을 줄인다 (디자인 원본 Reader15 [이동 ▾] — 왼쪽 10 · 오른쪽 8) */
|
||||
.tool-button--menu {
|
||||
justify-content: center;
|
||||
min-width: var(--size-icon-button);
|
||||
padding-right: var(--space-8);
|
||||
}
|
||||
|
||||
.tool-button--accent {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 지우기 확인 (디자인 원본 MailboxManage, 공용 모양 ModalDialog — role=alertdialog)
|
||||
// - 하위 메일함이 있으면 지울 수 없다: "‘프로젝트’ 메일함을 지울 수 없습니다" · "하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요." · [확인]
|
||||
// - 아니면: "‘거래처’ 메일함을 지울까요?" · "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다."
|
||||
// 메일함 지우기 확인 (디자인 원본 MailboxManage · Mailbox15, 공용 모양 ModalDialog — role=alertdialog)
|
||||
// - "‘거래처’ 메일함을 지울까요?" · "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다."
|
||||
// (빈 메일함은 "빈 메일함입니다. …") · [지우기](빨강 채움) [취소] — 열리면 「취소」에 초점 (실수로 Enter 를 눌러도 지워지지 않게)
|
||||
// - 하위 메일함이 있으면 하위까지 지울지 묻는다 (15단계 — 지금까지의 "지울 수 없습니다" 창을 대신한다):
|
||||
// "‘거래처’ 메일함과 하위 메일함 2개를 지울까요?" · "하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 …"
|
||||
// (메일이 하나도 없으면 "하위 메일함까지 모두 비어 있습니다. …"). 하위 메일함 수 · 메일 수는 모든 깊이를 센다(부르는 쪽 — MailboxDialogs)
|
||||
// - 휴지통을 비우는 동안은 [지우기]를 누를 수 없다 (blocked — 비우기가 옮겨 간 메일까지 영구 삭제하지 않게)
|
||||
// 고정 id — #mailbox-delete-dialog · #mailbox-delete-title · #mailbox-delete-message · #mailbox-delete-confirm · #mailbox-delete-cancel
|
||||
import { computed } from 'vue'
|
||||
@@ -14,10 +16,10 @@ import { formatCount } from '@/shared/utils/format'
|
||||
interface Props {
|
||||
open: boolean
|
||||
mailboxName: string
|
||||
/** 안의 메일 수 */
|
||||
/** 휴지통으로 옮길 메일 수 — 하위 메일함이 있으면 하위까지 더한 수 */
|
||||
messageCount: number
|
||||
/** 하위 메일함이 있다 — 지울 수 없다고 알린다 */
|
||||
hasChildren: boolean
|
||||
/** 모든 깊이의 하위 메일함 수 — 있으면 하위까지 지울지 묻는다 (15단계) */
|
||||
childCount?: number
|
||||
/** 지금은 시작할 수 없다 (휴지통을 비우는 중) — [지우기]를 끈다 */
|
||||
blocked?: boolean
|
||||
}
|
||||
@@ -28,15 +30,22 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
childCount: 0,
|
||||
blocked: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const title = computed(() =>
|
||||
props.hasChildren ? MAILBOX_DIALOG_TEXT.blockedTitle(props.mailboxName) : MAILBOX_DIALOG_TEXT.deleteTitle(props.mailboxName),
|
||||
props.childCount > 0
|
||||
? MAILBOX_DIALOG_TEXT.deleteTreeTitle(props.mailboxName, formatCount(props.childCount))
|
||||
: MAILBOX_DIALOG_TEXT.deleteTitle(props.mailboxName),
|
||||
)
|
||||
const message = computed(() => {
|
||||
if (props.hasChildren) return MAILBOX_DIALOG_TEXT.blockedMessage
|
||||
if (props.childCount > 0) {
|
||||
return props.messageCount > 0
|
||||
? MAILBOX_DIALOG_TEXT.deleteTreeMessage(formatCount(props.messageCount))
|
||||
: MAILBOX_DIALOG_TEXT.deleteTreeEmptyMessage
|
||||
}
|
||||
return props.messageCount > 0
|
||||
? MAILBOX_DIALOG_TEXT.deleteMessage(formatCount(props.messageCount))
|
||||
: MAILBOX_DIALOG_TEXT.deleteEmptyMessage
|
||||
@@ -56,35 +65,23 @@ const message = computed(() => {
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
v-if="hasChildren"
|
||||
id="mailbox-delete-confirm"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
:disabled="blocked"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.deleteConfirm }}
|
||||
</button>
|
||||
<button
|
||||
id="mailbox-delete-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.blockedConfirm }}
|
||||
{{ MAILBOX_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
<template v-else>
|
||||
<button
|
||||
id="mailbox-delete-confirm"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
:disabled="blocked"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.deleteConfirm }}
|
||||
</button>
|
||||
<button
|
||||
id="mailbox-delete-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널의 대화상자 — ⋯ 메뉴 · [+ 새 메일함]이 연다 (디자인 원본 MailboxManage · useMailboxActions)
|
||||
// 메일함 패널의 대화상자 — ⋯ 메뉴 · [+ 새 메일함]이 연다 (디자인 원본 MailboxManage · Mailbox15 · useMailboxActions)
|
||||
// - 새 메일함 · 하위 메일함 · 이름 바꾸기: 이름 대화상자. 같은 이름(MAIL_009) · 형식(VAL_001)은 칸 아래에 보이고 대화상자를 둔다.
|
||||
// 메일함이 그사이 사라졌으면(RES_001 — 알림은 인터셉터) 닫고 메일함 목록을 다시 맞춘다. 그 밖의 실패는 알림을 보고 다시 누를 수 있게 둔다.
|
||||
// 이름을 바꾸지 않았으면 보내지 않고 닫는다. 기다리는 사이 화면이 사라졌으면 칸 아래에 보일 실패를 알림으로 알린다 (notifyApiError)
|
||||
// - 지우기: 하위 메일함이 있으면 지울 수 없다고 알린다. 확인하면 닫고 지우기를 시작한다 (진행은 알림으로)
|
||||
// - 옮기기(15단계): 옮길 곳 창. 같은 이름(MAIL_009 — 그사이 다른 창에서 만듦)은 목록 아래에 보이고 창을 둔다. 메일함 · 옮길 곳이 사라졌으면(RES_001) ·
|
||||
// 옮길 수 없는 자리였으면(VAL_001 — 그사이 나무가 바뀜) 알림(인터셉터)을 보고 목록을 다시 맞춘다 — RES_001 은 창을 닫는다. 지금 있는 곳은 고를 수 없다
|
||||
// - 지우기: 하위 메일함이 있으면 하위까지 지울지 묻는다(15단계 — 하위 메일함 수 · 메일 수는 모든 깊이). 확인하면 닫고 지우기를 시작한다 (진행은 알림으로)
|
||||
// - 비우기: 확인하면 닫고 비우기를 시작한다
|
||||
// - 휴지통 비우기 · 메일함 지우기는 서로 도는 동안 시작할 수 없다 — 확인 단추를 끄고, 누르는 순간에도 다시 본다 (useMailboxActions 의 isBlocked)
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import MailboxDeleteDialog from '@/components/mail/MailboxDeleteDialog.vue'
|
||||
import MailboxEmptyDialog from '@/components/mail/MailboxEmptyDialog.vue'
|
||||
import MailboxMoveDialog from '@/components/mail/MailboxMoveDialog.vue'
|
||||
import MailboxNameDialog from '@/components/mail/MailboxNameDialog.vue'
|
||||
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
|
||||
import { notifyApiError } from '@/composables/useApi'
|
||||
import { useMailboxActions } from '@/composables/useMailboxActions'
|
||||
import { useMailboxActions, type MailboxNameResult } from '@/composables/useMailboxActions'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { hasChildMailbox } from '@/shared/utils/mailbox'
|
||||
import { descendantMailboxes, hasChildMailbox, mailboxMoveTargets } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -34,6 +37,8 @@ const actions = useMailboxActions()
|
||||
|
||||
const pending = ref<boolean>(false)
|
||||
const nameError = ref<string | null>(null)
|
||||
/** 옮기기 창 목록 아래의 거절 (MAIL_009) */
|
||||
const moveError = ref<string | null>(null)
|
||||
/** 화면(메일 모듈)이 사라졌다 — 칸 아래에 보일 곳이 없다 */
|
||||
let unmounted = false
|
||||
|
||||
@@ -49,6 +54,16 @@ const target = computed<Mailbox | null>(() => {
|
||||
})
|
||||
const parentName = computed(() => (props.request?.kind === 'create' ? (props.request.parent?.name ?? null) : null))
|
||||
const hasChildren = computed(() => (target.value ? hasChildMailbox(mailStore.mailboxes, target.value.id) : false))
|
||||
/** 모든 깊이의 하위 메일함 — 지우기 확인 창의 "하위 메일함 2개" · 메일 수 (15단계) */
|
||||
const descendants = computed(() => (target.value ? descendantMailboxes(mailStore.mailboxes, target.value.id) : []))
|
||||
/** 지우면 휴지통으로 갈 메일 수 — 하위 메일함까지 더한다 */
|
||||
const treeMessageCount = computed(() =>
|
||||
[target.value, ...descendants.value].reduce((sum, mailbox) => sum + Math.max(0, mailbox?.totalCount ?? 0), 0),
|
||||
)
|
||||
/** 옮길 곳 — 옮기기 창이 열려 있을 때만 */
|
||||
const moveTargets = computed(() =>
|
||||
props.request?.kind === 'move' && target.value ? mailboxMoveTargets(mailStore.mailboxes, target.value) : [],
|
||||
)
|
||||
/** 지금은 시작할 수 없다 — 휴지통 비우기 ↔ 메일함 지우기 */
|
||||
const deleteBlocked = computed(() => (target.value ? actions.isBlocked('delete', target.value) : false))
|
||||
const emptyBlocked = computed(() => (target.value ? actions.isBlocked('empty', target.value) : false))
|
||||
@@ -56,9 +71,44 @@ const emptyBlocked = computed(() => (target.value ? actions.isBlocked('empty', t
|
||||
function close() {
|
||||
pending.value = false
|
||||
nameError.value = null
|
||||
moveError.value = null
|
||||
emit('close')
|
||||
}
|
||||
|
||||
/**
|
||||
* 이름 · 옮기기 창의 결과를 받는다 — 칸 안에 보일 실패(inline)는 보여 주고 창을 둔다, 그사이 사라졌으면(RES_001) 닫고 목록을 다시 맞춘다.
|
||||
* 기다리는 사이 화면이 사라졌으면 칸 안에 보일 실패를 알림으로, 다른 창으로 바뀌었으면 건드리지 않는다
|
||||
*/
|
||||
function settleResult(
|
||||
request: MailboxDialogRequest,
|
||||
result: MailboxNameResult | null,
|
||||
showInline: (message: string) => void,
|
||||
) {
|
||||
if (unmounted) {
|
||||
// 칸 아래에 보일 곳이 사라졌다 — 끈 알림을 띄운다 (성공 알림은 이미 떴다)
|
||||
if (result?.status === 'failed' && result.inline) notifyApiError(result.error)
|
||||
return
|
||||
}
|
||||
// 기다리는 사이 닫았거나 다른 대화상자를 열었으면 그 대화상자를 건드리지 않는다 (결과 알림은 이미 떴다)
|
||||
if (props.request !== request) return
|
||||
pending.value = false
|
||||
if (!result || result.status !== 'failed') {
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (result.inline) {
|
||||
showInline(result.error.message)
|
||||
return
|
||||
}
|
||||
if (result.error.code === 'RES_001') {
|
||||
void mailStore.loadMailboxes({ background: true })
|
||||
close()
|
||||
return
|
||||
}
|
||||
// 옮길 수 없는 자리(VAL_001 — 그사이 나무가 바뀜)는 알림을 본 뒤 바뀐 나무로 다시 고를 수 있게 목록만 다시 맞춘다
|
||||
if (request.kind === 'move' && result.error.code === 'VAL_001') void mailStore.loadMailboxes({ background: true })
|
||||
}
|
||||
|
||||
async function onSubmitName(name: string) {
|
||||
const request = props.request
|
||||
if (!request || pending.value) return
|
||||
@@ -73,34 +123,31 @@ async function onSubmitName(name: string) {
|
||||
: request.kind === 'create'
|
||||
? await actions.create(name, request.parent?.id ?? null)
|
||||
: null
|
||||
if (unmounted) {
|
||||
// 칸 아래에 보일 곳이 사라졌다 — 끈 알림을 띄운다 (성공 알림은 이미 떴다)
|
||||
if (result?.status === 'failed' && result.inline) notifyApiError(result.error)
|
||||
return
|
||||
}
|
||||
// 기다리는 사이 닫았거나 다른 대화상자를 열었으면 그 대화상자를 건드리지 않는다 (결과 알림은 이미 떴다)
|
||||
if (props.request !== request) return
|
||||
pending.value = false
|
||||
if (!result || result.status !== 'failed') {
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (result.inline) {
|
||||
nameError.value = result.error.message
|
||||
return
|
||||
}
|
||||
if (result.error.code === 'RES_001') {
|
||||
void mailStore.loadMailboxes({ background: true })
|
||||
close()
|
||||
}
|
||||
settleResult(request, result, (message) => {
|
||||
nameError.value = message
|
||||
})
|
||||
}
|
||||
|
||||
async function onSubmitMove(parentId: string | null) {
|
||||
const request = props.request
|
||||
const mailbox = target.value
|
||||
if (!request || request.kind !== 'move' || !mailbox || pending.value) return
|
||||
pending.value = true
|
||||
moveError.value = null
|
||||
const result = await actions.move(mailbox, parentId)
|
||||
settleResult(request, result, (message) => {
|
||||
moveError.value = message
|
||||
})
|
||||
}
|
||||
|
||||
function onConfirmDelete() {
|
||||
const mailbox = target.value
|
||||
// 누르는 순간 다시 본다 — 그사이 휴지통 비우기가 시작됐으면 대화상자를 그대로 둔다
|
||||
if (!mailbox || deleteBlocked.value) return
|
||||
// 하위 메일함이 있으면 확인 창이 하위까지 지운다고 물었다 — 그때만 하위까지 지운다 (15단계)
|
||||
const withChildren = hasChildren.value
|
||||
close()
|
||||
void actions.remove(mailbox)
|
||||
void actions.remove(mailbox, { withChildren })
|
||||
}
|
||||
|
||||
function onConfirmEmpty() {
|
||||
@@ -115,6 +162,7 @@ watch(
|
||||
() => props.request,
|
||||
() => {
|
||||
nameError.value = null
|
||||
moveError.value = null
|
||||
},
|
||||
)
|
||||
|
||||
@@ -135,11 +183,22 @@ onBeforeUnmount(() => {
|
||||
@cancel="close"
|
||||
@clear-error="nameError = null"
|
||||
/>
|
||||
<MailboxMoveDialog
|
||||
:open="request?.kind === 'move'"
|
||||
:mailbox-name="target?.name ?? ''"
|
||||
:has-children="hasChildren"
|
||||
:targets="moveTargets"
|
||||
:pending="pending"
|
||||
:error="moveError"
|
||||
@submit="onSubmitMove"
|
||||
@cancel="close"
|
||||
@clear-error="moveError = null"
|
||||
/>
|
||||
<MailboxDeleteDialog
|
||||
:open="request?.kind === 'delete'"
|
||||
:mailbox-name="target?.name ?? ''"
|
||||
:message-count="target?.totalCount ?? 0"
|
||||
:has-children="hasChildren"
|
||||
:message-count="treeMessageCount"
|
||||
:child-count="descendants.length"
|
||||
:blocked="deleteBlocked"
|
||||
@confirm="onConfirmDelete"
|
||||
@cancel="close"
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 옮기기 창 (15단계 — 디자인 원본 Mailbox15 "옮기기 창" · "그사이 같은 이름이 생겼을 때", 공용 모양 ModalDialog — 폭 400 medium)
|
||||
// 제목 "‘2025’ 메일함을 어디로 옮길까요?" · 글 "하위 메일함과 안의 메일도 함께 옮깁니다."(하위가 없으면 "안의 메일도 함께 옮깁니다.") →
|
||||
// 옮길 곳 목록(테두리 칸 · 줄 40 · 메일함 패널과 같은 차례 · 들여쓰기 10 · 28 · 44 · 56): 맨 위 · 받은편지함(과 그 아래) · "내 메일함" 묶음 → [옮기기] [취소]
|
||||
// - 처음에는 아무 곳도 고르지 않은 채 열리고, 고르기 전에는 [옮기기]를 누를 수 없다. 고른 줄은 청록 바탕 · 굵은 글자 · 오른쪽 체크
|
||||
// - 고를 수 없는 줄은 이름 · 아이콘만 흐리게(까닭 글은 그대로): 옮기는 메일함과 그 하위(까닭 없음) · 지금 있는 곳 · 같은 이름의 메일함이 있는 곳
|
||||
// (대소문자 무시 — 서버에 묻기 전에 화면이 하는 검사). 맨 위 줄은 평소 "다른 메일함 안에 두지 않습니다"
|
||||
// - 줄은 radiogroup 의 radio — 화살표 · Home · End 로 줄을 옮기면(꺼진 줄에도 초점은 간다 — 까닭을 읽게) 고를 수 있는 줄은 바로 고른다.
|
||||
// Enter 는 [옮기기], Esc 는 [취소]와 같다. 들여 쓴 줄의 이름은 위 메일함부터 읽는다("거래처 안의 2025")
|
||||
// - 그사이 같은 이름이 생겨 서버가 거절하면(MAIL_009) 목록 아래 빨간 사전 문구 — 알림 없이 창을 둔다. 다른 곳을 고르면 지운다(clear-error)
|
||||
// - 옮기는 동안(pending)은 [옮기기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게
|
||||
// 고정 id — #mailbox-move-dialog · #mailbox-move-title · #mailbox-move-message · #mailbox-move-error · #mailbox-move-confirm · #mailbox-move-cancel
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { MAILBOX_MOVE_TEXT, MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { mailboxIcon, mailboxSpokenName, type MailboxMoveTarget } from '@/shared/utils/mailbox'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 옮기는 메일함의 이름 */
|
||||
mailboxName: string
|
||||
/** 하위 메일함이 있다 — 제목 아래 글이 바뀐다 */
|
||||
hasChildren: boolean
|
||||
/** 옮길 곳 (mailbox.ts 의 mailboxMoveTargets) */
|
||||
targets: readonly MailboxMoveTarget[]
|
||||
/** 옮기는 중 — [옮기기]를 다시 누를 수 없다 */
|
||||
pending?: boolean
|
||||
/** 서버가 거절한 까닭(사전 문구 — MAIL_009). 다른 곳을 고르면 부모가 지운다(clear-error) */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** parentId — 새 상위, 맨 위면 null */
|
||||
(e: 'submit', parentId: string | null): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'clear-error'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pending: false,
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 들여쓰기는 3단계까지 (메일함 패널과 같다) */
|
||||
const MAX_INDENT_DEPTH = 3
|
||||
/** 맨 위 줄의 열쇠 — 메일함 id 는 비지 않는다 */
|
||||
const TOP_KEY = ''
|
||||
const ERROR_ID = 'mailbox-move-error'
|
||||
|
||||
interface MoveRow {
|
||||
key: string
|
||||
parentId: string | null
|
||||
icon: IconName
|
||||
name: string
|
||||
/** 화면 낭독 이름 — 들여 쓴 줄은 위 메일함부터 */
|
||||
spokenName: string
|
||||
depth: number
|
||||
/** 이름 오른쪽 작은 글 — 고를 수 없는 까닭 · 맨 위 줄의 설명 */
|
||||
note: string | null
|
||||
pickable: boolean
|
||||
/** 이 줄 앞에 "내 메일함" 묶음 이름을 둔다 */
|
||||
headingBefore: boolean
|
||||
}
|
||||
|
||||
/** 고를 수 없는 까닭의 글 — 옮기는 메일함 자신 · 하위는 글이 없다 */
|
||||
const BLOCK_NOTES = {
|
||||
self: null,
|
||||
current: MAILBOX_MOVE_TEXT.current,
|
||||
sameName: MAILBOX_MOVE_TEXT.sameName,
|
||||
} as const
|
||||
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
/** 고른 줄 — 처음에는 없다 */
|
||||
const chosenKey = ref<string | null>(null)
|
||||
/** 탭으로 들어올 줄 (로빙 tabindex) — 고른 줄, 없으면 첫 고를 수 있는 줄 */
|
||||
const focusKey = ref<string>(TOP_KEY)
|
||||
|
||||
const message = computed(() => (props.hasChildren ? MAILBOX_MOVE_TEXT.messageWithChildren : MAILBOX_MOVE_TEXT.message))
|
||||
const rows = computed<MoveRow[]>(() => {
|
||||
let headingShown = false
|
||||
return props.targets.map((target) => {
|
||||
const entry = target.entry
|
||||
const headingBefore = target.group === 'mine' && !headingShown
|
||||
if (headingBefore) headingShown = true
|
||||
const note = target.block ? BLOCK_NOTES[target.block] : entry ? null : MAILBOX_MOVE_TEXT.topNote
|
||||
return {
|
||||
key: target.parentId ?? TOP_KEY,
|
||||
parentId: target.parentId,
|
||||
icon: entry ? mailboxIcon(entry.mailbox) : 'arrow-up',
|
||||
name: entry ? entry.mailbox.name : MAILBOX_MOVE_TEXT.top,
|
||||
spokenName: entry ? mailboxSpokenName(entry) : MAILBOX_MOVE_TEXT.top,
|
||||
depth: entry ? Math.min(entry.depth, MAX_INDENT_DEPTH) : 0,
|
||||
note,
|
||||
pickable: target.block === null,
|
||||
headingBefore,
|
||||
}
|
||||
})
|
||||
})
|
||||
/** 고른 줄 — 목록이 바뀌어(다시 받음) 고를 수 없게 됐으면 고르지 않은 것으로 본다 */
|
||||
const chosen = computed<MoveRow | null>(() => rows.value.find((row) => row.key === chosenKey.value && row.pickable) ?? null)
|
||||
|
||||
/** 줄마다 겹치지 않는 이름 — 맨 위 줄과 메일함 줄을 가른다 (메일함 id 가 "top" 이어도 겹치지 않게) */
|
||||
function rowName(row: MoveRow): string {
|
||||
return row.parentId === null ? 'top' : `m-${row.key}`
|
||||
}
|
||||
|
||||
function noteId(row: MoveRow): string | undefined {
|
||||
return row.note ? `mailbox-move-note-${rowName(row)}` : undefined
|
||||
}
|
||||
|
||||
function rowElement(key: string): HTMLElement | null {
|
||||
const list = listRef.value
|
||||
if (!list) return null
|
||||
return [...list.querySelectorAll<HTMLElement>('[data-move-row]')].find((element) => element.dataset.moveRow === key) ?? null
|
||||
}
|
||||
|
||||
function choose(row: MoveRow) {
|
||||
focusKey.value = row.key
|
||||
if (!row.pickable || props.pending) return
|
||||
if (chosenKey.value !== row.key && props.error) emit('clear-error')
|
||||
chosenKey.value = row.key
|
||||
}
|
||||
|
||||
/** 화살표 · Home · End — 줄을 옮기고 고를 수 있으면 고른다 (꺼진 줄에도 초점은 간다) */
|
||||
function moveFocus(target: MoveRow | undefined) {
|
||||
if (!target) return
|
||||
choose(target)
|
||||
void nextTick(() => rowElement(target.key)?.focus())
|
||||
}
|
||||
|
||||
function onListKeydown(event: KeyboardEvent) {
|
||||
const list = rows.value
|
||||
const index = list.findIndex((row) => row.key === focusKey.value)
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
moveFocus(list[(index + 1) % list.length])
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
moveFocus(list[(index - 1 + list.length) % list.length])
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
moveFocus(list[0])
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault()
|
||||
moveFocus(list[list.length - 1])
|
||||
} else if (event.key === 'Enter') {
|
||||
// Enter 는 [옮기기]와 같다 (줄 단추의 눌림으로 넘기지 않는다)
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const row = chosen.value
|
||||
if (!row || props.pending) return
|
||||
emit('submit', row.parentId)
|
||||
}
|
||||
|
||||
/** 처음 초점 — 고를 수 있는 첫 줄 (없으면 첫 줄) */
|
||||
function initialFocusKey(): string {
|
||||
return (rows.value.find((row) => row.pickable) ?? rows.value[0])?.key ?? TOP_KEY
|
||||
}
|
||||
|
||||
// 열 때마다 아무것도 고르지 않은 채로
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
chosenKey.value = null
|
||||
focusKey.value = initialFocusKey()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="mailbox-move-dialog"
|
||||
:open="open"
|
||||
:title="MAILBOX_MOVE_TEXT.title(mailboxName)"
|
||||
:message="message"
|
||||
title-id="mailbox-move-title"
|
||||
message-id="mailbox-move-message"
|
||||
medium
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<div
|
||||
ref="listRef"
|
||||
role="radiogroup"
|
||||
class="mailbox-move__list"
|
||||
:aria-label="MAILBOX_MOVE_TEXT.listLabel"
|
||||
:aria-describedby="error ? ERROR_ID : undefined"
|
||||
@keydown="onListKeydown"
|
||||
>
|
||||
<template
|
||||
v-for="row in rows"
|
||||
:key="rowName(row)"
|
||||
>
|
||||
<p
|
||||
v-if="row.headingBefore"
|
||||
class="mailbox-move__heading"
|
||||
>
|
||||
{{ MAILBOX_PANEL_TEXT.myMailboxes }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
:class="['mailbox-move__row', `mailbox-move__row--depth-${row.depth}`]"
|
||||
:aria-checked="chosen?.key === row.key ? 'true' : 'false'"
|
||||
:aria-disabled="row.pickable ? undefined : 'true'"
|
||||
:aria-label="row.spokenName"
|
||||
:aria-describedby="noteId(row)"
|
||||
:tabindex="row.key === focusKey ? 0 : -1"
|
||||
:autofocus="row.key === focusKey"
|
||||
:data-move-row="row.key"
|
||||
@click="choose(row)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="row.icon"
|
||||
class="mailbox-move__icon"
|
||||
/>
|
||||
<span class="mailbox-move__name">{{ row.name }}</span>
|
||||
<span
|
||||
v-if="row.note"
|
||||
:id="noteId(row)"
|
||||
class="mailbox-move__note"
|
||||
>{{ row.note }}</span>
|
||||
<AppIcon
|
||||
v-if="chosen?.key === row.key"
|
||||
name="check"
|
||||
:stroke-width="2"
|
||||
class="mailbox-move__check"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<p
|
||||
v-if="error"
|
||||
:id="ERROR_ID"
|
||||
class="form-message form-message--error"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
<template #actions>
|
||||
<button
|
||||
id="mailbox-move-confirm"
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
:disabled="!chosen"
|
||||
:aria-disabled="pending ? 'true' : undefined"
|
||||
:aria-busy="pending"
|
||||
@click="submit"
|
||||
>
|
||||
{{ MAILBOX_MOVE_TEXT.confirm }}
|
||||
</button>
|
||||
<button
|
||||
id="mailbox-move-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_MOVE_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 옮길 곳 목록 — 테두리 칸 · 안쪽 4 · 줄 사이 1 (디자인 원본 Mailbox15). 메일함이 많으면 칸 안에서 스크롤한다 */
|
||||
.mailbox-move__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
max-height: min(20rem, 50vh);
|
||||
overflow-y: auto;
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* "내 메일함" 묶음 이름 — 패널과 같은 12 굵게 회색 */
|
||||
.mailbox-move__heading {
|
||||
margin: var(--space-8) var(--space-10) var(--space-2);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 줄 40 — 메일함 패널의 줄과 같은 높이 · 들여쓰기 */
|
||||
.mailbox-move__row {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
width: 100%;
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mailbox-move__row--depth-1 {
|
||||
padding-left: var(--space-28);
|
||||
}
|
||||
|
||||
.mailbox-move__row--depth-2 {
|
||||
padding-left: var(--space-44);
|
||||
}
|
||||
|
||||
.mailbox-move__row--depth-3 {
|
||||
padding-left: var(--space-56);
|
||||
}
|
||||
|
||||
.mailbox-move__row:hover:not([aria-disabled='true']):not([aria-checked='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
/* 고른 줄 — 패널의 지금 메일함처럼 청록 바탕 · 굵은 글자 */
|
||||
.mailbox-move__row[aria-checked='true'] {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 고를 수 없는 줄 — 이름 · 아이콘만 흐리게 (까닭 글은 그대로) */
|
||||
.mailbox-move__row[aria-disabled='true'] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mailbox-move__row[aria-disabled='true'] .mailbox-move__icon,
|
||||
.mailbox-move__row[aria-disabled='true'] .mailbox-move__name {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.mailbox-move__icon,
|
||||
.mailbox-move__check {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mailbox-move__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mailbox-move__note {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,437 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } 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 { useMailBlockRunStore } from '@/stores/mail-block-run.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 MailboxPanel from './MailboxPanel.vue'
|
||||
|
||||
// 메일함 패널 — 15단계 메일함 옮기기 · 하위 메일함까지 지우기 (디자인 원본 Mailbox15 · 계약 2절 "메일함 관리")
|
||||
|
||||
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0, ...partial }
|
||||
}
|
||||
|
||||
/** 보드의 예 — 거래처 안에 2025(그 안에 정산), 보관 안에도 2025, 프로젝트 안에 견적 */
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
box('inbox', 'inbox', '받은편지함', { unreadCount: 3, totalCount: 148 }),
|
||||
box('sent', 'sent', '보낸편지함', { totalCount: 64 }),
|
||||
box('drafts', 'drafts', '임시보관함', { totalCount: 2 }),
|
||||
box('junk', 'junk', '스팸메일함', { totalCount: 5 }),
|
||||
box('trash', 'trash', '휴지통', { totalCount: 21 }),
|
||||
box('c', null, '거래처', { totalCount: 1 }),
|
||||
box('c25', null, '2025', { parentId: 'c', totalCount: 4 }),
|
||||
box('c25s', null, '정산', { parentId: 'c25', totalCount: 35 }),
|
||||
box('b', null, '보관'),
|
||||
box('b25', null, '2025', { parentId: 'b' }),
|
||||
box('p', null, '프로젝트', { totalCount: 2 }),
|
||||
box('pq', null, '견적', { parentId: 'p' }),
|
||||
]
|
||||
|
||||
interface Deferred {
|
||||
promise: Promise<MockResult>
|
||||
resolve: (result: MockResult) => void
|
||||
}
|
||||
|
||||
function deferred(): Deferred {
|
||||
let resolve: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
type Reply = MockResult | Deferred
|
||||
|
||||
describe('MailboxPanel — 메일함 옮기기 · 하위까지 지우기 (15단계)', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let replies: Map<string, Reply[]>
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
replies = new Map()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function reply(key: string, ...results: Reply[]) {
|
||||
replies.set(key, results)
|
||||
}
|
||||
|
||||
async function mountPanel(mailboxes: Mailbox[] = MAILBOXES, viewing = 'inbox') {
|
||||
requests = installMockApi((request) => {
|
||||
const queue = replies.get(`${request.method} ${request.url}`)
|
||||
if (queue && queue.length > 0) {
|
||||
const next = queue.length > 1 ? queue.shift() : queue[0]
|
||||
if (next) return typeof next === 'object' && 'promise' in next ? next.promise : next
|
||||
}
|
||||
if (request.url === '/mailboxes') {
|
||||
return ok({ items: [...useMailStore().mailboxes], pageInfo: { hasNext: false, nextCursor: null, total: 12 } })
|
||||
}
|
||||
if (request.url === '/messages') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
return ok(null)
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = mailboxes.map((mailbox) => ({ ...mailbox }))
|
||||
store.mailboxesStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${viewing}`)
|
||||
await router.isReady()
|
||||
store.currentMailboxId = viewing
|
||||
const wrapper = mount(MailboxPanel, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
/** 패널의 줄 — 같은 이름이 둘이면 몇 번째인지 */
|
||||
const row = (wrapper: VueWrapper, name: string, nth = 0) => {
|
||||
const found = wrapper.findAll('.mailbox-row').filter((item) => item.get('.mailbox-row__name').text() === name)[nth]
|
||||
if (!found) throw new Error(`${name} 줄이 없다`)
|
||||
return found
|
||||
}
|
||||
|
||||
async function choose(wrapper: VueWrapper, name: string, label: string, nth = 0) {
|
||||
await row(wrapper, name, nth).get('[data-mailbox-more]').trigger('click')
|
||||
await flushPromises()
|
||||
const item = wrapper.findAll('[role="menu"] [role^="menuitem"]').find((candidate) => candidate.text() === label)
|
||||
if (!item) throw new Error(`${name} 메뉴에 ${label} 이 없다`)
|
||||
await item.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const dialogOpen = (id: string) => document.getElementById(id)?.hasAttribute('open') ?? false
|
||||
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const sent = (method: string, url: string) => requests.filter((request) => request.method === method && request.url === url)
|
||||
const moveRows = (wrapper: VueWrapper) => wrapper.findAll('[data-move-row]')
|
||||
const moveRow = (wrapper: VueWrapper, key: string) => wrapper.get(`[data-move-row="${key}"]`)
|
||||
const panelNames = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('.mailbox-tree__list')[1]?.findAll('.mailbox-row__name').map((name) => name.text())
|
||||
|
||||
/** 알림의 단추를 누른다 — 알림을 닫고 그 단추의 일을 한다 (ToastHost 와 같은 길 — takeAction) */
|
||||
function runToastAction(index = 0) {
|
||||
const toast = useToastStore().toasts[index]
|
||||
const action = toast ? useToastStore().takeAction(toast.id) : null
|
||||
if (!action || !('run' in action)) throw new Error('단추가 없다')
|
||||
action.run()
|
||||
}
|
||||
|
||||
describe('옮기기', () => {
|
||||
it('⋯ 「옮기기」 → 창 — 맨 위 · 받은편지함 · 내 메일함 차례, 고를 수 없는 줄은 흐리게 · 까닭, 처음에는 아무것도 고르지 않고 [옮기기]가 꺼져 있다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
await choose(wrapper, '2025', '옮기기')
|
||||
|
||||
expect(dialogOpen('mailbox-move-dialog')).toBe(true)
|
||||
expect(wrapper.get('#mailbox-move-title').text()).toBe('‘2025’ 메일함을 어디로 옮길까요?')
|
||||
expect(wrapper.get('#mailbox-move-message').text()).toBe('하위 메일함과 안의 메일도 함께 옮깁니다.')
|
||||
expect(wrapper.get('[role="radiogroup"]').attributes('aria-label')).toBe('옮길 곳')
|
||||
expect(wrapper.get('.mailbox-move__heading').text()).toBe('내 메일함')
|
||||
expect(
|
||||
moveRows(wrapper).map((item) => [
|
||||
item.get('.mailbox-move__name').text(),
|
||||
item.find('.mailbox-move__note').exists() ? item.get('.mailbox-move__note').text() : null,
|
||||
item.attributes('aria-disabled') ?? null,
|
||||
]),
|
||||
).toEqual([
|
||||
['맨 위', '다른 메일함 안에 두지 않습니다', null],
|
||||
['받은편지함', null, null],
|
||||
['거래처', '지금 있는 곳', 'true'],
|
||||
['2025', null, 'true'],
|
||||
['정산', null, 'true'],
|
||||
['보관', '같은 이름의 메일함이 있습니다', 'true'],
|
||||
['2025', null, null],
|
||||
['프로젝트', null, null],
|
||||
['견적', null, null],
|
||||
])
|
||||
// 보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통은 옮길 곳이 아니다
|
||||
expect(moveRows(wrapper).some((item) => ['sent', 'drafts', 'junk', 'trash'].includes(item.attributes('data-move-row') ?? ''))).toBe(
|
||||
false,
|
||||
)
|
||||
// 들여 쓴 줄은 위 메일함부터 읽는다 · 까닭 글은 설명으로
|
||||
expect(moveRow(wrapper, 'b25').attributes('aria-label')).toBe('보관 안의 2025')
|
||||
expect(moveRow(wrapper, 'c25s').attributes('aria-label')).toBe('거래처 안의 2025 안의 정산')
|
||||
expect(moveRow(wrapper, 'c25s').classes()).toContain('mailbox-move__row--depth-2')
|
||||
expect(moveRow(wrapper, 'c').attributes('aria-describedby')).toBe('mailbox-move-note-m-c')
|
||||
expect(moveRows(wrapper).every((item) => item.attributes('aria-checked') === 'false')).toBe(true)
|
||||
expect(wrapper.get('#mailbox-move-confirm').attributes('disabled')).toBeDefined()
|
||||
// 처음 초점은 고를 수 있는 첫 줄 — 로빙 tabindex
|
||||
expect(document.activeElement).toBe(moveRow(wrapper, '').element)
|
||||
expect(moveRows(wrapper).filter((item) => item.attributes('tabindex') === '0')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('줄을 눌러 고르고 [옮기기] → PATCH { parentId } · 패널에 바로 · "‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다." + [되돌리기]는 원래 자리로', async () => {
|
||||
const moved = box('c25', null, '2025', { parentId: 'p', totalCount: 4 })
|
||||
reply('PATCH /mailboxes/c25', ok(moved), ok({ ...moved, parentId: 'c' }))
|
||||
const { wrapper, store } = await mountPanel()
|
||||
await choose(wrapper, '2025', '옮기기')
|
||||
|
||||
// 고를 수 없는 줄은 눌러도 고르지 않는다
|
||||
await moveRow(wrapper, 'b').trigger('click')
|
||||
expect(wrapper.get('#mailbox-move-confirm').attributes('disabled')).toBeDefined()
|
||||
await moveRow(wrapper, 'p').trigger('click')
|
||||
expect(moveRow(wrapper, 'p').attributes('aria-checked')).toBe('true')
|
||||
expect(moveRow(wrapper, 'p').find('.mailbox-move__check').exists()).toBe(true)
|
||||
await wrapper.get('#mailbox-move-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('PATCH', '/mailboxes/c25').map((request) => request.data)).toEqual([{ parentId: 'p' }])
|
||||
expect(dialogOpen('mailbox-move-dialog')).toBe(false)
|
||||
expect(panelNames(wrapper)).toEqual(['거래처', '보관', '2025', '프로젝트', '2025', '정산', '견적'])
|
||||
expect(toastTexts()).toEqual(['‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다.'])
|
||||
expect(useToastStore().toasts[0]?.action?.label).toBe('되돌리기')
|
||||
|
||||
runToastAction()
|
||||
await flushPromises()
|
||||
expect(sent('PATCH', '/mailboxes/c25').map((request) => request.data)).toEqual([{ parentId: 'p' }, { parentId: 'c' }])
|
||||
expect(toastTexts()).toEqual(['‘2025’ 메일함을 ‘거래처’ 안으로 되돌렸습니다.'])
|
||||
expect(store.mailboxes.find((mailbox) => mailbox.id === 'c25')?.parentId).toBe('c')
|
||||
})
|
||||
|
||||
it('맨 위로는 parentId: null — "다른 메일함 밖으로", 받은편지함 안으로는 따옴표 없이', async () => {
|
||||
reply(
|
||||
'PATCH /mailboxes/pq',
|
||||
ok(box('pq', null, '견적')),
|
||||
ok(box('pq', null, '견적', { parentId: 'inbox' })),
|
||||
)
|
||||
const { wrapper } = await mountPanel()
|
||||
await choose(wrapper, '견적', '옮기기')
|
||||
// 하위 메일함이 없으면 글이 짧다
|
||||
expect(wrapper.get('#mailbox-move-message').text()).toBe('안의 메일도 함께 옮깁니다.')
|
||||
await moveRow(wrapper, '').trigger('click')
|
||||
await wrapper.get('#mailbox-move-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('PATCH', '/mailboxes/pq').map((request) => request.data)).toEqual([{ parentId: null }])
|
||||
expect(toastTexts()).toEqual(['‘견적’ 메일함을 다른 메일함 밖으로 옮겼습니다.'])
|
||||
|
||||
await choose(wrapper, '견적', '옮기기')
|
||||
// 맨 위에 있는 메일함 — 맨 위 줄이 지금 있는 곳
|
||||
expect(moveRow(wrapper, '').get('.mailbox-move__note').text()).toBe('지금 있는 곳')
|
||||
await moveRow(wrapper, 'inbox').trigger('click')
|
||||
await wrapper.get('#mailbox-move-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toContain('‘견적’ 메일함을 받은편지함 안으로 옮겼습니다.')
|
||||
})
|
||||
|
||||
it('키보드 — 화살표로 줄을 옮기며 고른다(꺼진 줄에도 초점은 간다 — 고른 것은 그대로) · Enter 는 [옮기기]', async () => {
|
||||
reply('PATCH /mailboxes/c25', ok(box('c25', null, '2025', { parentId: 'inbox' })))
|
||||
const { wrapper } = await mountPanel()
|
||||
await choose(wrapper, '2025', '옮기기')
|
||||
|
||||
await moveRow(wrapper, '').trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
expect(document.activeElement).toBe(moveRow(wrapper, 'inbox').element)
|
||||
expect(moveRow(wrapper, 'inbox').attributes('aria-checked')).toBe('true')
|
||||
expect(moveRow(wrapper, 'inbox').attributes('tabindex')).toBe('0')
|
||||
|
||||
await moveRow(wrapper, 'inbox').trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
expect(document.activeElement).toBe(moveRow(wrapper, 'c').element)
|
||||
expect(moveRow(wrapper, 'c').attributes('aria-checked')).toBe('false')
|
||||
expect(moveRow(wrapper, 'inbox').attributes('aria-checked')).toBe('true')
|
||||
|
||||
await moveRow(wrapper, 'c').trigger('keydown', { key: 'End' })
|
||||
await flushPromises()
|
||||
expect(document.activeElement).toBe(moveRow(wrapper, 'pq').element)
|
||||
await moveRow(wrapper, 'pq').trigger('keydown', { key: 'Home' })
|
||||
await flushPromises()
|
||||
expect(moveRow(wrapper, '').attributes('aria-checked')).toBe('true')
|
||||
await moveRow(wrapper, '').trigger('keydown', { key: 'ArrowUp' })
|
||||
await flushPromises()
|
||||
// 처음에서 위로 가면 끝으로 돈다
|
||||
expect(moveRow(wrapper, 'pq').attributes('aria-checked')).toBe('true')
|
||||
|
||||
await moveRow(wrapper, 'pq').trigger('keydown', { key: 'Home' })
|
||||
await moveRow(wrapper, '').trigger('keydown', { key: 'ArrowDown' })
|
||||
await moveRow(wrapper, 'inbox').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
expect(sent('PATCH', '/mailboxes/c25').map((request) => request.data)).toEqual([{ parentId: 'inbox' }])
|
||||
})
|
||||
|
||||
it('그사이 같은 이름(409 MAIL_009) — 알림 없이 목록 아래 빨간 사전 문구 · 창은 그대로, 다른 곳을 고르면 사라진다', async () => {
|
||||
reply('PATCH /mailboxes/c25', fail(409, 'MAIL_009'))
|
||||
const { wrapper } = await mountPanel()
|
||||
await choose(wrapper, '2025', '옮기기')
|
||||
await moveRow(wrapper, 'p').trigger('click')
|
||||
await wrapper.get('#mailbox-move-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const error = wrapper.get('#mailbox-move-error')
|
||||
expect(error.text()).toBe(ERROR_CODE_MESSAGES.MAIL_009)
|
||||
expect(error.attributes('role')).toBe('alert')
|
||||
expect(wrapper.get('[role="radiogroup"]').attributes('aria-describedby')).toBe('mailbox-move-error')
|
||||
expect(dialogOpen('mailbox-move-dialog')).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
await moveRow(wrapper, 'pq').trigger('click')
|
||||
expect(wrapper.find('#mailbox-move-error').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('메일함이 그사이 사라졌으면(404 RES_001) — 알림을 보고 창을 닫은 뒤 메일함 목록을 다시 받는다', async () => {
|
||||
reply('PATCH /mailboxes/c25', fail(404, 'RES_001'))
|
||||
const { wrapper } = await mountPanel()
|
||||
await choose(wrapper, '2025', '옮기기')
|
||||
const before = sent('GET', '/mailboxes').length
|
||||
await moveRow(wrapper, 'p').trigger('click')
|
||||
await wrapper.get('#mailbox-move-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.RES_001])
|
||||
expect(dialogOpen('mailbox-move-dialog')).toBe(false)
|
||||
expect(sent('GET', '/mailboxes').length).toBeGreaterThan(before)
|
||||
})
|
||||
|
||||
it('옮기는 동안 [옮기기]는 aria-disabled · aria-busy — 다시 눌러도 한 번만 보낸다', async () => {
|
||||
const pending = deferred()
|
||||
reply('PATCH /mailboxes/c25', pending)
|
||||
const { wrapper } = await mountPanel()
|
||||
await choose(wrapper, '2025', '옮기기')
|
||||
await moveRow(wrapper, 'p').trigger('click')
|
||||
const confirm = wrapper.get('#mailbox-move-confirm')
|
||||
await confirm.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirm.attributes('aria-disabled')).toBe('true')
|
||||
expect(confirm.attributes('aria-busy')).toBe('true')
|
||||
await confirm.trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('PATCH', '/mailboxes/c25')).toHaveLength(1)
|
||||
|
||||
pending.resolve(ok(box('c25', null, '2025', { parentId: 'p' })))
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-move-dialog')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('하위 메일함까지 지우기', () => {
|
||||
it('확인(처음 초점 「취소」) 뒤 DELETE ?withChildren=true 를 deleted 가 될 때까지 — 진행 수는 나무 전체, 지우는 동안 하위 메일함의 메뉴도 꺼지고 끝나면 나무 전체가 빠진다', async () => {
|
||||
const second = deferred()
|
||||
reply(
|
||||
'DELETE /mailboxes/c',
|
||||
ok({ deleted: false, processed: 12, movedToTrash: 12, remaining: 28 }),
|
||||
second,
|
||||
ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }),
|
||||
)
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘거래처’ 메일함과 하위 메일함 2개를 지울까요?')
|
||||
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
|
||||
'하위 메일함에 든 메일까지 모두 40통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.',
|
||||
)
|
||||
expect(document.activeElement?.id).toBe('mailbox-delete-cancel')
|
||||
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['progress', '‘거래처’ 메일함을 지우는 중 · 12 / 40통'],
|
||||
])
|
||||
// 지우는 나무의 메일함은 모든 항목을 누를 수 없다
|
||||
await row(wrapper, '정산').get('[data-mailbox-more]').trigger('click')
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menu"] [role^="menuitem"]')
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
expect(items.every((item) => item.attributes('aria-disabled') === 'true')).toBe(true)
|
||||
await row(wrapper, '정산').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
second.resolve(ok({ deleted: false, processed: 28, movedToTrash: 28, remaining: 0 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('DELETE', '/mailboxes/c').map((request) => request.params)).toEqual([
|
||||
{ withChildren: true },
|
||||
{ withChildren: true },
|
||||
{ withChildren: true },
|
||||
])
|
||||
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다. 안의 메일 40통은 휴지통에 있습니다.'])
|
||||
expect(store.mailboxes.map((mailbox) => mailbox.id)).not.toEqual(expect.arrayContaining(['c']))
|
||||
expect(store.mailboxes.some((mailbox) => ['c', 'c25', 'c25s'].includes(mailbox.id))).toBe(false)
|
||||
expect(panelNames(wrapper)).toEqual(['보관', '2025', '프로젝트', '견적'])
|
||||
})
|
||||
|
||||
it('하위 메일함을 보고 있었어도 지운 뒤 받은편지함으로 간다', async () => {
|
||||
reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 40, movedToTrash: 40, remaining: 0 }))
|
||||
const { wrapper, router } = await mountPanel(MAILBOXES, 'c25s')
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('하위 메일함까지 비어 있으면 "하위 메일함까지 모두 비어 있습니다." · 하위가 없는 메일함은 지금 지우기 그대로(withChildren 없이)', async () => {
|
||||
reply('DELETE /mailboxes/b', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
|
||||
reply('DELETE /mailboxes/pq', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '보관', '지우기')
|
||||
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘보관’ 메일함과 하위 메일함 1개를 지울까요?')
|
||||
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
|
||||
'하위 메일함까지 모두 비어 있습니다. 지운 메일함은 되돌릴 수 없습니다.',
|
||||
)
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/b').map((request) => request.params)).toEqual([{ withChildren: true }])
|
||||
|
||||
await choose(wrapper, '견적', '지우기')
|
||||
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘견적’ 메일함을 지울까요?')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/pq').map((request) => request.params)).toEqual([{}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('차단할 때 받은 메일 옮기기와 스팸메일함 비우기', () => {
|
||||
it('이 탭에서 차단 정리가 도는 동안은 「스팸메일함 비우기」를 누를 수 없다 · 확인 단추도 꺼지고 스토어도 시작하지 않는다 — 끝나면 다시 켜진다', async () => {
|
||||
const { wrapper, store } = await mountPanel()
|
||||
const blockRun = useMailBlockRunStore()
|
||||
const junk = store.mailboxes.find((mailbox) => mailbox.id === 'junk')
|
||||
if (!junk) throw new Error('스팸메일함이 없다')
|
||||
|
||||
// 확인 창을 연 뒤에 정리가 시작돼도 누르는 순간 다시 본다
|
||||
await choose(wrapper, '스팸메일함', '스팸메일함 비우기')
|
||||
blockRun.running = true
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#mailbox-empty-confirm').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/junk/messages')).toEqual([])
|
||||
await wrapper.get('#mailbox-empty-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click')
|
||||
await flushPromises()
|
||||
const item = wrapper.findAll('[role="menu"] [role^="menuitem"]').find((candidate) => candidate.text() === '스팸메일함 비우기')
|
||||
expect(item?.attributes('aria-disabled')).toBe('true')
|
||||
await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click')
|
||||
expect((await store.runMailboxOperation('empty', junk, () => undefined)).status).toBe('canceled')
|
||||
// 휴지통 비우기는 상관없다
|
||||
expect(store.isMailboxOperationBlocked('empty', { role: 'trash' })).toBe(false)
|
||||
|
||||
blockRun.running = false
|
||||
await flushPromises()
|
||||
expect(store.isMailboxOperationBlocked('empty', junk)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -169,15 +169,18 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
['스팸메일함 비우기', true],
|
||||
])
|
||||
expect(await labels('휴지통')).toEqual([['휴지통 비우기', true]])
|
||||
// 내 메일함에는 「옮기기」 — 이름 바꾸기 다음 · 지우기 앞 (15단계)
|
||||
expect(await labels('프로젝트')).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['하위 메일함 만들기', false],
|
||||
['이름 바꾸기', false],
|
||||
['옮기기', false],
|
||||
['지우기', true],
|
||||
])
|
||||
expect(await labels('견적')).toEqual([
|
||||
['하위 메일함 만들기', false],
|
||||
['이름 바꾸기', false],
|
||||
['옮기기', false],
|
||||
['지우기', true],
|
||||
])
|
||||
expect(row(wrapper, '보낸편지함').find('[data-mailbox-more]').exists()).toBe(false)
|
||||
@@ -302,16 +305,17 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
})
|
||||
|
||||
describe('지우기 — 안의 메일은 휴지통으로', () => {
|
||||
it('하위 메일함이 있으면 지울 수 없다 — [확인]만, 보내지 않는다', async () => {
|
||||
it('하위 메일함이 있으면 하위까지 지울지 묻는다 (15단계) — [취소]면 보내지 않는다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
await choose(wrapper, '프로젝트', '지우기')
|
||||
|
||||
expect(dialogOpen('mailbox-delete-dialog')).toBe(true)
|
||||
expect(wrapper.get('#mailbox-delete-dialog').attributes('role')).toBe('alertdialog')
|
||||
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘프로젝트’ 메일함을 지울 수 없습니다')
|
||||
expect(wrapper.get('#mailbox-delete-message').text()).toBe('하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.')
|
||||
expect(wrapper.find('#mailbox-delete-confirm').exists()).toBe(false)
|
||||
expect(wrapper.get('#mailbox-delete-cancel').text()).toBe('확인')
|
||||
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘프로젝트’ 메일함과 하위 메일함 1개를 지울까요?')
|
||||
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
|
||||
'하위 메일함에 든 메일까지 모두 24통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.',
|
||||
)
|
||||
expect(document.activeElement?.id).toBe('mailbox-delete-cancel')
|
||||
|
||||
await wrapper.get('#mailbox-delete-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
@@ -603,6 +607,7 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
['모두 읽음으로 표시', false],
|
||||
['하위 메일함 만들기', false],
|
||||
['이름 바꾸기', false],
|
||||
['옮기기', false],
|
||||
['지우기', true],
|
||||
])
|
||||
await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 아래 안내 · 사용량 (디자인 원본 Main · Compose 왼쪽 · MailboxManage)
|
||||
// - 메일함 목록은 두 묶음(기본 메일함 · "내 메일함")과 [+ 새 메일함] — MailboxTree
|
||||
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
|
||||
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 옮기기(15단계) · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
|
||||
// - 아래: 부재중 자동 응답이 켜져 있으면 안내(MailVacationNotice) → 사용량
|
||||
// - 「예약됨」(9단계)은 예약한 메일이 있을 때만 보낸편지함 아래 — 수는 메일 화면이 받아 둔다(mail.store 의 scheduledCount)
|
||||
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
|
||||
@@ -42,6 +42,7 @@ function onAction(action: MailboxAction, mailbox: Mailbox) {
|
||||
}
|
||||
if (action === 'createChild') dialog.value = { kind: 'create', parent: mailbox }
|
||||
else if (action === 'rename') dialog.value = { kind: 'rename', mailbox }
|
||||
else if (action === 'move') dialog.value = { kind: 'move', mailbox }
|
||||
else if (action === 'delete') dialog.value = { kind: 'delete', mailbox }
|
||||
else dialog.value = { kind: 'empty', mailbox }
|
||||
}
|
||||
|
||||
@@ -4,11 +4,22 @@
|
||||
// - ⋯ 는 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다 (터치 화면은 늘 보인다). 할 일이 없는 메일함은 ⋯ 를 그리지 않는다
|
||||
// - 메뉴 항목은 메일함마다 다르다 (shared/utils/mailbox.ts 의 mailboxActions). 같은 처리가 도는 동안은 그 항목을 끈다 —
|
||||
// 휴지통을 비우는 동안의 「지우기」, 메일함을 지우는 동안의 「휴지통 비우기」도 끈다 (함께 돌 수 없다 — useMailboxActions 의 isBlocked)
|
||||
import { computed, ref } from 'vue'
|
||||
// - 목록 줄을 끌어다 놓는 곳(15단계 — 디자인 원본 ListDrag15): 놓을 수 있는 메일함 위에서는 줄에 2px 청록 점선 · 옅은 청록 바탕
|
||||
// (쓰기 화면 파일 놓기와 같은 모양 — 초점 선(실선)과 헷갈리지 않게). 놓을 수 없는 곳(메일이 이미 든 메일함 · 임시보관함)은 줄이 그대로이고
|
||||
// dragover 를 받지 않아 브라우저의 금지 커서가 뜬다. 우리 메일 끌기만 받는다 — 파일 끌기는 받지 않는다(쓰기 화면의 첨부 놓기와 겹치지 않게).
|
||||
// 놓으면 drop-mail 로 알리고 옮기는 일은 메일함 나무(MailboxTree)가 한다
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxMoreButton from '@/components/mail/MailboxMoreButton.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import {
|
||||
activeMailDrag,
|
||||
canDropDragOn,
|
||||
mailDragging,
|
||||
takeMailDrop,
|
||||
type MailDragPayload,
|
||||
} from '@/composables/useMailDrag'
|
||||
import { MAILBOX_MENU_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
@@ -23,6 +34,8 @@ interface Props {
|
||||
|
||||
interface Emits {
|
||||
(e: 'action', action: MailboxAction, mailbox: Mailbox): void
|
||||
/** 목록 줄을 이 메일함에 끌어다 놓았다 (15단계) */
|
||||
(e: 'drop-mail', mailbox: Mailbox, drag: MailDragPayload): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -34,6 +47,9 @@ const mailStore = useMailStore()
|
||||
const MAX_INDENT_DEPTH = 3
|
||||
|
||||
const menuOpen = ref<boolean>(false)
|
||||
/** 놓을 수 있는 끌기가 이 줄 위에 있다 — 들어온 요소 수를 세어 줄 안의 링크 · 단추 사이를 지날 때 깜빡이지 않게 */
|
||||
const dropDepth = ref<number>(0)
|
||||
const dropOver = computed(() => dropDepth.value > 0)
|
||||
|
||||
/** 그 메일함에서 돌고 있는 처리 — 지우는 중이면 모든 항목을 끈다. 함께 돌 수 없는 처리가 도는 동안의 지우기 · 비우기도 끈다 */
|
||||
function isDisabled(action: MailboxAction): boolean {
|
||||
@@ -67,10 +83,52 @@ function onSelect(id: string) {
|
||||
const action = mailboxActions(props.mailbox).find((candidate) => candidate === id)
|
||||
if (action) emit('action', action, props.mailbox)
|
||||
}
|
||||
|
||||
/** 이 메일함에 놓을 수 있는 끌기인가 — 우리 메일 끌기이고 놓을 수 있는 메일함일 때만 (아니면 null — 브라우저의 금지 커서) */
|
||||
function droppableDrag(event: DragEvent): MailDragPayload | null {
|
||||
const drag = activeMailDrag(event)
|
||||
return drag && canDropDragOn(drag, props.mailbox) ? drag : null
|
||||
}
|
||||
|
||||
function onDragEnter(event: DragEvent) {
|
||||
if (!droppableDrag(event)) return
|
||||
event.preventDefault()
|
||||
dropDepth.value += 1
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
if (!droppableDrag(event)) return
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
function onDragLeave(event: DragEvent) {
|
||||
if (!droppableDrag(event)) return
|
||||
dropDepth.value = Math.max(0, dropDepth.value - 1)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dropDepth.value = 0
|
||||
if (!droppableDrag(event)) return
|
||||
event.preventDefault()
|
||||
const drag = takeMailDrop(event)
|
||||
if (drag) emit('drop-mail', props.mailbox, drag)
|
||||
}
|
||||
|
||||
// 끌기가 끝나면(다른 곳에 놓음 · Esc 로 그만둠) 점선을 끈다 — 마지막 dragleave 를 놓쳤어도 남지 않게
|
||||
watch(mailDragging, (dragging) => {
|
||||
if (!dragging) dropDepth.value = 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['mailbox-row', { 'is-active': active, 'is-open': menuOpen }]">
|
||||
<li
|
||||
:class="['mailbox-row', { 'is-active': active, 'is-open': menuOpen, 'is-drop-target': dropOver }]"
|
||||
@dragenter="onDragEnter"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<RouterLink
|
||||
:to="{ name: 'mail-mailbox', params: { mailboxId: mailbox.id } }"
|
||||
:class="['mailbox-row__link', depthClass]"
|
||||
@@ -166,6 +224,19 @@ function onSelect(id: string) {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 끌어다 놓을 수 있는 메일함 위 — 2px 청록 점선 · 옅은 청록 바탕 (쓰기 화면 파일 놓기와 같은 모양, 디자인 원본 ListDrag15) */
|
||||
.mailbox-row.is-drop-target {
|
||||
outline: var(--focus-ring-width) dashed var(--color-brand);
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
.mailbox-row.is-drop-target .mailbox-row__link,
|
||||
.mailbox-row.is-drop-target .mailbox-row__count {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ⋯ — 마우스를 올리거나 초점이 가면, 메뉴가 열린 동안은 계속 보인다 */
|
||||
.mailbox-row__more {
|
||||
opacity: 0;
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
// "내 메일함" 이 비어도 묶음 이름과 [+ 새 메일함]은 둔다 — 첫 메일함을 만들 수 있게
|
||||
// 「예약됨」(9단계 — 예약한 메일이 있을 때만)은 기본 메일함 묶음의 보낸편지함(과 그 하위) 바로 아래 — 보낸편지함이 없으면 묶음 끝.
|
||||
// 예약됨을 보는 동안은 수가 0 이 되어도 남긴다 — 다 취소하거나 보낸 뒤에도 지금 있는 곳이 패널에 보이게
|
||||
// 목록 줄을 메일함 줄에 끌어다 놓으면(15단계 — 디자인 원본 ListDrag15) 여기서 옮긴다 — 휴지통이면 [삭제], 스팸메일함이면 [스팸 신고]와 같고
|
||||
// 그 밖은 [이동 ▾]와 같다(알림 · [되돌리기]도 같다 — useBulkMailActions 의 moveDragged). 「예약됨」에는 놓을 수 없다
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxPanelRow from '@/components/mail/MailboxPanelRow.vue'
|
||||
import MailboxScheduledRow from '@/components/mail/MailboxScheduledRow.vue'
|
||||
import { useBulkMailActions } from '@/composables/useBulkMailActions'
|
||||
import type { MailDragPayload } from '@/composables/useMailDrag'
|
||||
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { splitMailboxGroups, type MailboxAction, type MailboxNode } from '@/shared/utils/mailbox'
|
||||
@@ -34,6 +38,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const bulk = useBulkMailActions()
|
||||
|
||||
const MY_GROUP_ID = 'mailbox-panel-mine'
|
||||
/** 보낸편지함이 없을 때 — 「예약됨」을 기본 메일함 묶음 끝에 (메일함 id 는 비지 않는다) */
|
||||
const AT_GROUP_END = ''
|
||||
@@ -43,6 +49,11 @@ const groups = computed(() => splitMailboxGroups(props.nodes))
|
||||
* 「예약됨」을 넣을 자리 — 기본 메일함 묶음에서 이 메일함 줄 뒤(보낸편지함 하위의 끝). 보낸편지함이 없으면 묶음 끝(AT_GROUP_END).
|
||||
* 예약한 메일이 없으면 null (그리지 않는다) — 다만 예약됨을 보고 있으면 남긴다
|
||||
*/
|
||||
/** 끌어다 놓은 메일을 그 메일함으로 옮긴다 */
|
||||
function onDropMail(mailbox: Mailbox, drag: MailDragPayload) {
|
||||
void bulk.moveDragged(drag, mailbox)
|
||||
}
|
||||
|
||||
const scheduledAfterId = computed<string | null>(() => {
|
||||
if (props.scheduledCount <= 0 && !props.scheduledActive) return null
|
||||
const system = groups.value.system
|
||||
@@ -67,6 +78,7 @@ const scheduledAfterId = computed<string | null>(() => {
|
||||
:depth="node.depth"
|
||||
:active="node.mailbox.id === activeId"
|
||||
@action="(action, mailbox) => emit('action', action, mailbox)"
|
||||
@drop-mail="onDropMail"
|
||||
/>
|
||||
<MailboxScheduledRow
|
||||
v-if="scheduledAfterId === node.mailbox.id"
|
||||
@@ -98,6 +110,7 @@ const scheduledAfterId = computed<string | null>(() => {
|
||||
:depth="node.depth"
|
||||
:active="node.mailbox.id === activeId"
|
||||
@action="(action, mailbox) => emit('action', action, mailbox)"
|
||||
@drop-mail="onDropMail"
|
||||
/>
|
||||
</ul>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { MailBlockRunBlock } from '@/stores/mail-block-run.store'
|
||||
import MessageBlockDialog from './MessageBlockDialog.vue'
|
||||
|
||||
// 보낸 사람 차단 창의 15단계 새 칸 — 「받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기」
|
||||
// (디자인 원본 Mailbox15 "보낸 사람 차단 창" · 디자인 README "15단계")
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean
|
||||
senderEmail: string
|
||||
inJunk?: boolean
|
||||
inInbox?: boolean
|
||||
cleanupBlock?: MailBlockRunBlock | null
|
||||
}
|
||||
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) wrapper.unmount()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function mountDialog(props: Partial<DialogProps> = {}) {
|
||||
const wrapper = mount(MessageBlockDialog, {
|
||||
props: { open: true, senderEmail: 'News@Example.com', inInbox: true, ...props },
|
||||
attachTo: document.body,
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const moveBox = (wrapper: VueWrapper) => wrapper.get<HTMLInputElement>('[data-block-move]')
|
||||
const cleanBox = (wrapper: VueWrapper) => wrapper.get<HTMLInputElement>('[data-block-clean]')
|
||||
const confirmed = (wrapper: VueWrapper) => wrapper.emitted('confirm') ?? []
|
||||
|
||||
describe('MessageBlockDialog — 받은편지함의 메일도 옮기기 (15단계)', () => {
|
||||
it('처음에는 첫 칸 켜짐 · 새 칸 꺼짐 — 새 칸 아래 "옮긴 메일은 한꺼번에 되돌릴 수 없습니다." · [차단]은 지금처럼', async () => {
|
||||
const wrapper = mountDialog()
|
||||
|
||||
expect(moveBox(wrapper).element.checked).toBe(true)
|
||||
expect(moveBox(wrapper).attributes('aria-disabled')).toBeUndefined()
|
||||
expect(cleanBox(wrapper).element.checked).toBe(false)
|
||||
expect(cleanBox(wrapper).element.closest('label')?.textContent?.trim()).toBe(
|
||||
'받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기',
|
||||
)
|
||||
expect(cleanBox(wrapper).attributes('aria-describedby')).toBe('block-dialog-clean-note')
|
||||
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('옮긴 메일은 한꺼번에 되돌릴 수 없습니다.')
|
||||
|
||||
await wrapper.get('[data-block-confirm]').trigger('click')
|
||||
expect(confirmed(wrapper)).toEqual([['news@example.com', true, false]])
|
||||
})
|
||||
|
||||
it('새 칸을 켜면 받은편지함의 메일이면 첫 칸은 켠 채 잠긴다(aria-disabled · 까닭) — 눌러도 그대로, 새 칸을 끄면 원래 값으로', async () => {
|
||||
const wrapper = mountDialog()
|
||||
// 먼저 첫 칸을 꺼 둔다
|
||||
await moveBox(wrapper).setValue(false)
|
||||
expect(moveBox(wrapper).element.checked).toBe(false)
|
||||
|
||||
await cleanBox(wrapper).setValue(true)
|
||||
expect(moveBox(wrapper).element.checked).toBe(true)
|
||||
expect(moveBox(wrapper).attributes('aria-disabled')).toBe('true')
|
||||
expect(moveBox(wrapper).attributes('aria-describedby')).toBe('block-dialog-move-lock')
|
||||
expect(wrapper.get('#block-dialog-move-lock').text()).toBe('받은편지함의 메일을 옮길 때 함께 옮깁니다.')
|
||||
|
||||
// 잠긴 칸은 눌러도 바뀌지 않는다
|
||||
await moveBox(wrapper).trigger('click')
|
||||
await nextTick()
|
||||
expect(moveBox(wrapper).element.checked).toBe(true)
|
||||
|
||||
await wrapper.get('[data-block-confirm]').trigger('click')
|
||||
expect(confirmed(wrapper).at(-1)).toEqual(['news@example.com', true, true])
|
||||
|
||||
await cleanBox(wrapper).setValue(false)
|
||||
expect(moveBox(wrapper).element.checked).toBe(false)
|
||||
expect(moveBox(wrapper).attributes('aria-disabled')).toBeUndefined()
|
||||
expect(wrapper.find('#block-dialog-move-lock').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('이 메일이 받은편지함이 아니면 잠그지 않는다 · 스팸메일함의 메일이면 첫 칸이 없고 새 칸만', async () => {
|
||||
const elsewhere = mountDialog({ inInbox: false })
|
||||
await cleanBox(elsewhere).setValue(true)
|
||||
expect(moveBox(elsewhere).attributes('aria-disabled')).toBeUndefined()
|
||||
await moveBox(elsewhere).setValue(false)
|
||||
await elsewhere.get('[data-block-confirm]').trigger('click')
|
||||
expect(confirmed(elsewhere)).toEqual([['news@example.com', false, true]])
|
||||
|
||||
const inJunk = mountDialog({ inInbox: false, inJunk: true })
|
||||
expect(inJunk.find('[data-block-move]').exists()).toBe(false)
|
||||
await cleanBox(inJunk).setValue(true)
|
||||
await inJunk.get('[data-block-confirm]').trigger('click')
|
||||
expect(confirmed(inJunk)).toEqual([['news@example.com', false, true]])
|
||||
})
|
||||
|
||||
it('「이 도메인 전체」를 고르면 새 칸이 "이 도메인의 메일"로 바뀌고 @도메인으로 막는다', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await wrapper.get('[data-block-scope="domain"]').setValue(true)
|
||||
await cleanBox(wrapper).setValue(true)
|
||||
|
||||
expect(cleanBox(wrapper).element.closest('label')?.textContent?.trim()).toBe(
|
||||
'받은편지함에 있는 이 도메인의 메일도 스팸메일함으로 옮기기',
|
||||
)
|
||||
await wrapper.get('[data-block-confirm]').trigger('click')
|
||||
expect(confirmed(wrapper)).toEqual([['@example.com', true, true]])
|
||||
})
|
||||
|
||||
it('이 탭에서 받은편지함을 정리하는 중이면 새 칸을 끄고(aria-disabled) 칸 아래 글을 까닭으로 — 눌러도 켜지지 않는다', async () => {
|
||||
const wrapper = mountDialog({ cleanupBlock: 'inbox' })
|
||||
|
||||
expect(cleanBox(wrapper).attributes('aria-disabled')).toBe('true')
|
||||
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
|
||||
await cleanBox(wrapper).trigger('click')
|
||||
await nextTick()
|
||||
expect(cleanBox(wrapper).element.checked).toBe(false)
|
||||
await wrapper.get('[data-block-confirm]').trigger('click')
|
||||
expect(confirmed(wrapper)).toEqual([['news@example.com', true, false]])
|
||||
})
|
||||
|
||||
it('이 탭에서 스팸메일함을 비우는 중이면 새 칸을 끄고 칸 아래 글을 그 까닭으로 — 켜 둔 칸도 꺼진 것으로 보낸다, 끝나면 다시 고를 수 있다', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await cleanBox(wrapper).setValue(true)
|
||||
await wrapper.setProps({ cleanupBlock: 'junk' })
|
||||
|
||||
expect(cleanBox(wrapper).attributes('aria-disabled')).toBe('true')
|
||||
expect(cleanBox(wrapper).element.checked).toBe(false)
|
||||
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.')
|
||||
// 첫 칸의 잠금도 풀린다 — 새 칸이 꺼진 것과 같다
|
||||
expect(moveBox(wrapper).attributes('aria-disabled')).toBeUndefined()
|
||||
await wrapper.get('[data-block-confirm]').trigger('click')
|
||||
expect(confirmed(wrapper)).toEqual([['news@example.com', true, false]])
|
||||
|
||||
await wrapper.setProps({ cleanupBlock: null })
|
||||
expect(cleanBox(wrapper).attributes('aria-disabled')).toBeUndefined()
|
||||
expect(wrapper.get('#block-dialog-clean-note').text()).toBe('옮긴 메일은 한꺼번에 되돌릴 수 없습니다.')
|
||||
})
|
||||
|
||||
it('열 때마다 처음 값으로 — 새 칸은 꺼진 채 시작한다', async () => {
|
||||
const wrapper = mountDialog()
|
||||
await cleanBox(wrapper).setValue(true)
|
||||
await wrapper.setProps({ open: false })
|
||||
await wrapper.setProps({ open: true })
|
||||
|
||||
expect(cleanBox(wrapper).element.checked).toBe(false)
|
||||
expect(moveBox(wrapper).element.checked).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,56 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
// 보낸 사람 차단 확인 창 (디자인 원본 MailReadTools · README "9단계 › 차단 확인 창") — 모달 400(medium)
|
||||
// 보낸 사람 차단 확인 창 (디자인 원본 MailReadTools · Mailbox15 · README "9단계 › 차단 확인 창" · "15단계 › 보낸 사람 차단 창") — 모달 400(medium)
|
||||
// 제목 "보낸 사람 차단" · 설명 → 고르기 둘(「이 주소만」 주소 · 「이 도메인 전체」 @도메인 에서 오는 메일 모두) →
|
||||
// 체크 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐 — 이미 스팸메일함의 메일이면 두지 않는다) → 안내 → [차단](처음 초점 — 되돌릴 수 있는 일) [취소]
|
||||
// 체크 둘(붙여 둔다 — 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춘다):
|
||||
// 「이 메일도 스팸메일함으로 옮기기」(처음 켜짐 — 이미 스팸메일함의 메일이면 두지 않는다)
|
||||
// 「받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기」(15단계 — 처음 꺼짐, 「이 도메인 전체」를 고르면 "이 도메인의 메일") +
|
||||
// 그 아래 "옮긴 메일은 한꺼번에 되돌릴 수 없습니다."
|
||||
// → 안내 → [차단](처음 초점 — 되돌릴 수 있는 일) [취소]
|
||||
// - 새 칸을 켰는데 이 메일이 받은편지함에 있으면 첫 칸은 켜진 채 잠그고(aria-disabled) 아래에 "받은편지함의 메일을 옮길 때 함께 옮깁니다." —
|
||||
// 새 칸을 끄면 첫 칸은 원래 값으로 돌아온다. 이 메일이 다른 메일함에 있으면 잠그지 않는다
|
||||
// - 이 탭에서 받은편지함을 정리하거나(필터 적용 · 다른 차단 정리) 스팸메일함을 비우는 중이면(cleanupBlock — 15단계 리뷰) 새 칸을 끄고(aria-disabled)
|
||||
// 칸 아래 글을 그 까닭으로 바꾼다
|
||||
// 여러 사람이 쓰는 메일 서비스(gmail.com · naver.com 등 — 서버와 같은 목록)는 「이 도메인 전체」를 끄고 그 아래에 까닭을 둔다.
|
||||
// 열 때마다 「이 주소만」 · 옮기기 켜짐으로 시작한다. Esc 는 [취소]와 같다
|
||||
// 열 때마다 「이 주소만」 · 첫 칸 켜짐 · 새 칸 꺼짐으로 시작한다. Esc 는 [취소]와 같다
|
||||
// 칸 아래 글은 고르기 상자 글 선(28)에 맞춰 들여 쓴다 — 고정 id #block-dialog-move-lock · #block-dialog-clean-note
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
|
||||
import { normalizeEmail } from '@/shared/utils/mail-address'
|
||||
import { domainBlockOf, emailDomainOf, isPublicMailDomain } from '@/shared/utils/mail-block'
|
||||
import type { MailBlockRunBlock } from '@/stores/mail-block-run.store'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 보낸 사람 주소 */
|
||||
senderEmail: string
|
||||
/** 이 메일이 이미 스팸메일함에 있다 — 옮기기 칸을 두지 않는다 */
|
||||
/** 이 메일이 이미 스팸메일함에 있다 — 첫 칸을 두지 않는다 */
|
||||
inJunk?: boolean
|
||||
/** 이 메일이 받은편지함에 있다 — 새 칸을 켜면 첫 칸을 켠 채 잠근다 (15단계) */
|
||||
inInbox?: boolean
|
||||
/** 새 칸을 지금 고를 수 없는 까닭 — 이 탭에서 받은편지함을 정리하는 중(inbox) · 스팸메일함을 비우는 중(junk), 없으면 null (15단계) */
|
||||
cleanupBlock?: MailBlockRunBlock | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** target 은 주소 또는 "@도메인" (소문자) */
|
||||
(e: 'confirm', target: string, moveToJunk: boolean): void
|
||||
/** target 은 주소 또는 "@도메인" (소문자) · cleanInbox 는 받은편지함의 그 보낸 사람 메일도 옮긴다 (15단계) */
|
||||
(e: 'confirm', target: string, moveToJunk: boolean, cleanInbox: boolean): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
inJunk: false,
|
||||
inInbox: false,
|
||||
cleanupBlock: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const LOCK_NOTE_ID = 'block-dialog-move-lock'
|
||||
const CLEAN_NOTE_ID = 'block-dialog-clean-note'
|
||||
|
||||
const scope = ref<'address' | 'domain'>('address')
|
||||
/** 첫 칸에 고른 값 — 잠긴 동안에도 기억했다가 새 칸을 끄면 돌아온다 */
|
||||
const moveToJunk = ref<boolean>(true)
|
||||
const cleanInbox = ref<boolean>(false)
|
||||
/** 새 칸을 고를 수 없다 */
|
||||
const cleanupBusy = computed(() => props.cleanupBlock !== null)
|
||||
|
||||
const address = computed(() => normalizeEmail(props.senderEmail))
|
||||
const domain = computed(() => emailDomainOf(props.senderEmail))
|
||||
const domainTarget = computed(() => domainBlockOf(domain.value))
|
||||
/** 「이 도메인 전체」를 고를 수 없다 — 여러 사람이 쓰는 메일 서비스 · 도메인이 없는 주소 */
|
||||
const domainDisabled = computed(() => domain.value === '' || isPublicMailDomain(domain.value))
|
||||
const blocksDomain = computed(() => scope.value === 'domain' && !domainDisabled.value)
|
||||
/** 새 칸이 켜져 있다 — 고를 수 없는 동안은 꺼진 것으로 본다 */
|
||||
const cleaning = computed(() => cleanInbox.value && !cleanupBusy.value)
|
||||
/** 첫 칸을 켠 채 잠근다 — 받은편지함의 메일을 옮길 때 이 메일도 함께 옮긴다 */
|
||||
const moveLocked = computed(() => cleaning.value && props.inInbox && !props.inJunk)
|
||||
const moveChecked = computed(() => moveLocked.value || moveToJunk.value)
|
||||
const cleanLabel = computed(() => (blocksDomain.value ? MAIL_BLOCK_TEXT.cleanInboxDomain : MAIL_BLOCK_TEXT.cleanInboxSender))
|
||||
const cleanNote = computed(() => {
|
||||
if (props.cleanupBlock === 'junk') return MAIL_BLOCK_TEXT.cleanJunkBusy
|
||||
return props.cleanupBlock === 'inbox' ? MAIL_BLOCK_TEXT.cleanInboxBusy : MAIL_BLOCK_TEXT.cleanInboxNote
|
||||
})
|
||||
|
||||
function confirm() {
|
||||
const target = scope.value === 'domain' && !domainDisabled.value ? domainTarget.value : address.value
|
||||
emit('confirm', target, moveToJunk.value && !props.inJunk)
|
||||
/** 잠긴 칸 · 고를 수 없는 칸은 눌러도 바꾸지 않는다 (aria-disabled — 초점은 받는다) */
|
||||
function onMoveClick(event: MouseEvent) {
|
||||
if (moveLocked.value) event.preventDefault()
|
||||
}
|
||||
|
||||
// 열 때마다 처음 값으로 — 「이 주소만」 · 옮기기 켜짐
|
||||
function onMoveChange(event: Event) {
|
||||
moveToJunk.value = (event.target as HTMLInputElement).checked
|
||||
}
|
||||
|
||||
function onCleanClick(event: MouseEvent) {
|
||||
if (cleanupBusy.value) event.preventDefault()
|
||||
}
|
||||
|
||||
function onCleanChange(event: Event) {
|
||||
cleanInbox.value = (event.target as HTMLInputElement).checked
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
const target = blocksDomain.value ? domainTarget.value : address.value
|
||||
emit('confirm', target, moveChecked.value && !props.inJunk, cleaning.value)
|
||||
}
|
||||
|
||||
// 열 때마다 처음 값으로 — 「이 주소만」 · 첫 칸 켜짐 · 새 칸 꺼짐
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
scope.value = 'address'
|
||||
moveToJunk.value = true
|
||||
cleanInbox.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -117,18 +169,49 @@ watch(
|
||||
{{ MAIL_BLOCK_TEXT.scopeDomainPublic }}
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
v-if="!inJunk"
|
||||
class="block-dialog__move"
|
||||
>
|
||||
<input
|
||||
v-model="moveToJunk"
|
||||
type="checkbox"
|
||||
class="block-dialog__input"
|
||||
data-block-move
|
||||
<div class="block-dialog__checks">
|
||||
<template v-if="!inJunk">
|
||||
<label :class="['block-dialog__check', { 'is-locked': moveLocked }]">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="block-dialog__input"
|
||||
:checked="moveChecked"
|
||||
:aria-disabled="moveLocked ? 'true' : undefined"
|
||||
:aria-describedby="moveLocked ? LOCK_NOTE_ID : undefined"
|
||||
data-block-move
|
||||
@click="onMoveClick"
|
||||
@change="onMoveChange"
|
||||
>
|
||||
<span class="block-dialog__check-label">{{ MAIL_BLOCK_TEXT.moveToJunk }}</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="moveLocked"
|
||||
:id="LOCK_NOTE_ID"
|
||||
class="form-message block-dialog__note block-dialog__note--lock"
|
||||
>
|
||||
{{ MAIL_BLOCK_TEXT.moveToJunkLocked }}
|
||||
</p>
|
||||
</template>
|
||||
<label :class="['block-dialog__check', { 'is-disabled': cleanupBusy }]">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="block-dialog__input"
|
||||
:checked="cleaning"
|
||||
:aria-disabled="cleanupBusy ? 'true' : undefined"
|
||||
:aria-describedby="CLEAN_NOTE_ID"
|
||||
data-block-clean
|
||||
@click="onCleanClick"
|
||||
@change="onCleanChange"
|
||||
>
|
||||
<span class="block-dialog__check-label">{{ cleanLabel }}</span>
|
||||
</label>
|
||||
<p
|
||||
:id="CLEAN_NOTE_ID"
|
||||
class="form-message block-dialog__note block-dialog__note--clean"
|
||||
>
|
||||
<span>{{ MAIL_BLOCK_TEXT.moveToJunk }}</span>
|
||||
</label>
|
||||
{{ cleanNote }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="form-message">
|
||||
{{ MAIL_BLOCK_TEXT.settingsHint }}
|
||||
</p>
|
||||
@@ -220,20 +303,45 @@ watch(
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
/* 「이 도메인 전체」를 끈 까닭 — 고르기 글자 선(28)에 맞춰 들여 쓴다 */
|
||||
/* 칸 아래 까닭 · 안내 — 고르기 글자 선(28)에 맞춰 들여 쓴다 */
|
||||
.block-dialog__note {
|
||||
margin-left: var(--space-28);
|
||||
}
|
||||
|
||||
.block-dialog__move {
|
||||
/* 체크 둘은 붙여 둔다 (디자인 원본 Mailbox15 — 줄 44 · 위 12, 칸 글이 두 줄이면 고르기 상자를 첫 줄에 맞춘다) */
|
||||
.block-dialog__checks {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.block-dialog__check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
padding-top: var(--space-12);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.block-dialog__move .block-dialog__input {
|
||||
margin: 0;
|
||||
/* 잠긴 첫 칸 · 고를 수 없는 새 칸 — 상자와 이름을 흐리게 (aria-disabled — 초점은 받는다) */
|
||||
.block-dialog__check.is-locked,
|
||||
.block-dialog__check.is-disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.block-dialog__check.is-locked .block-dialog__input,
|
||||
.block-dialog__check.is-locked .block-dialog__check-label,
|
||||
.block-dialog__check.is-disabled .block-dialog__input,
|
||||
.block-dialog__check.is-disabled .block-dialog__check-label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 잠긴 첫 칸의 까닭은 그 칸 바로 아래로 당긴다 (보드 -10) */
|
||||
.block-dialog__note--lock {
|
||||
margin-top: calc(var(--space-10) * -1);
|
||||
}
|
||||
|
||||
.block-dialog__note--clean {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import type { Mailbox, MessageDetail, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
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'
|
||||
|
||||
// 15단계 — 같은 대화 칸의 [모두 펼치기] · [모두 접기]와 펼친 줄 머리의 [답장] (디자인 원본 Reader15 "6")
|
||||
// - [모두 펼치기]는 불러온 줄을 모두 펼친다 — 본문은 펼친 안이 화면에 들어오는 줄부터 하나씩 받는다(한꺼번에 부르지 않는다)
|
||||
// - 읽음은 지금 규칙 그대로 — 본문이 화면에 보인 메일만 읽음이다(펼쳤어도 화면 밖이면 그대로)
|
||||
// 화면에 들어오고 나가는 것은 가짜 IntersectionObserver 로 흉내 낸다 (jsdom 에는 없다)
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 2 }
|
||||
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', unreadCount: 0 }
|
||||
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
|
||||
const MAILBOXES = [INBOX, SENT, DRAFTS]
|
||||
const NOW = new Date(2026, 10, 9, 10).getTime()
|
||||
const at = (...parts: [number, number, number, number, number]) => new Date(...parts).toISOString()
|
||||
|
||||
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: 'Re: 유지보수 계약 갱신 건',
|
||||
preview: '네, 확인했습니다.',
|
||||
receivedAt: at(2026, 10, 3, 9, 30),
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
pendingSend: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function detail(base: MessageSummary): MessageDetail {
|
||||
return {
|
||||
...base,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: `${base.id} 본문`,
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
}
|
||||
|
||||
const FIRST = summary('c1', {
|
||||
mailboxIds: ['sent'],
|
||||
from: { name: '김아라', email: USER.email },
|
||||
receivedAt: at(2026, 10, 3, 14, 10),
|
||||
})
|
||||
const SECOND = summary('c2', { isRead: false, receivedAt: at(2026, 10, 5, 9, 30) })
|
||||
const THIRD = summary('c3', { isRead: false, receivedAt: at(2026, 10, 5, 13, 5) })
|
||||
const DRAFT = summary('c5', { mailboxIds: ['drafts'], from: USER, receivedAt: at(2026, 10, 9, 9, 52) })
|
||||
const CURRENT = summary('c4', { receivedAt: at(2026, 10, 9, 9, 30) })
|
||||
const THREAD = [FIRST, SECOND, THIRD, CURRENT, DRAFT]
|
||||
|
||||
function page(items: MessageSummary[], nextCursor: string | null = null, total: number | null = items.length): MockResult {
|
||||
return ok({ items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total }, basis: 'index' })
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((settle) => {
|
||||
resolve = settle
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
/** 가짜 IntersectionObserver — 테스트가 요소를 화면에 넣고 뺀다 */
|
||||
class FakeIntersectionObserver {
|
||||
static instances: FakeIntersectionObserver[] = []
|
||||
readonly targets = new Set<Element>()
|
||||
|
||||
constructor(private readonly callback: IntersectionObserverCallback) {
|
||||
FakeIntersectionObserver.instances.push(this)
|
||||
}
|
||||
|
||||
observe(element: Element) {
|
||||
this.targets.add(element)
|
||||
}
|
||||
|
||||
unobserve(element: Element) {
|
||||
this.targets.delete(element)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.targets.clear()
|
||||
}
|
||||
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/** 그 요소들이 화면에 들어왔다 · 나갔다 — 보고 있는 것만 알린다 */
|
||||
static set(elements: Element[], visible: boolean) {
|
||||
for (const instance of FakeIntersectionObserver.instances) {
|
||||
const entries = elements
|
||||
.filter((element) => instance.targets.has(element))
|
||||
.map((target) => ({ target, isIntersecting: visible }) as IntersectionObserverEntry)
|
||||
if (entries.length > 0) instance.callback(entries, instance as unknown as IntersectionObserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('MessageConversation — [모두 펼치기] · 펼친 줄 머리의 [답장] (15단계)', () => {
|
||||
let pinia: Pinia
|
||||
let clock: ManualClock
|
||||
let requests: MockRequest[]
|
||||
let details: Map<string, ReturnType<typeof deferred>>
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
clock = createManualClock(NOW)
|
||||
FakeIntersectionObserver.instances = []
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||
details = new Map()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
/** 본문은 테스트가 풀어 줄 때 온다 (deferredDetails) — 아니면 바로 */
|
||||
function serve(options: { conversation?: (request: MockRequest) => MockResult | Promise<MockResult>; hold?: boolean } = {}) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url.endsWith('/conversation')) return options.conversation?.(request) ?? page(THREAD)
|
||||
if (request.method === 'PATCH') return ok({ ...SECOND, isRead: true })
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
|
||||
if (request.url.startsWith('/messages/')) {
|
||||
const id = request.url.split('/').pop() ?? ''
|
||||
const body = ok(detail(THREAD.find((item) => item.id === id) ?? summary(id)))
|
||||
if (!options.hold) return body
|
||||
const wait = deferred()
|
||||
details.set(id, wait)
|
||||
return wait.promise.then(() => body)
|
||||
}
|
||||
return ok(null)
|
||||
})
|
||||
}
|
||||
|
||||
async function mountReader() {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES
|
||||
store.listSource = { kind: 'mailbox', mailboxId: 'inbox' }
|
||||
store.messages = [SECOND, THIRD, CURRENT]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 3 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = detail(CURRENT)
|
||||
store.selectedId = CURRENT.id
|
||||
store.selectedStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/inbox/${CURRENT.id}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, {
|
||||
props: { layout: 'split' },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, store }
|
||||
}
|
||||
|
||||
const detailGets = () =>
|
||||
requests
|
||||
.filter((request) => request.method === 'GET' && /^\/messages\/[^/]+$/.test(request.url))
|
||||
.map((request) => request.url.split('/').pop())
|
||||
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.url)
|
||||
const rowOf = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-conversation-row="${id}"]`)
|
||||
const toggleAll = (wrapper: VueWrapper) => wrapper.get('[data-conversation-toggle-all]')
|
||||
const panelOf = (wrapper: VueWrapper, id: string) => wrapper.get(`[data-conversation-panel="${id}"]`).element
|
||||
|
||||
async function show(wrapper: VueWrapper, ids: string[], visible = true) {
|
||||
FakeIntersectionObserver.set(
|
||||
ids.map((id) => panelOf(wrapper, id)),
|
||||
visible,
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('칸 머리 오른쪽에 [모두 펼치기] — 누르면 불러온 줄(지금 메일 빼고)을 모두 펼치고 [모두 접기]가 된다', async () => {
|
||||
serve()
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
const button = toggleAll(wrapper)
|
||||
expect(button.text()).toBe('모두 펼치기')
|
||||
expect(button.element.tagName).toBe('BUTTON')
|
||||
// 칸 제목과 같은 머리 줄
|
||||
expect(button.element.parentElement?.querySelector('h3')?.textContent?.trim()).toBe('같은 대화 5통')
|
||||
|
||||
await button.trigger('click')
|
||||
expect(toggleAll(wrapper).text()).toBe('모두 접기')
|
||||
for (const id of ['c1', 'c2', 'c3', 'c5']) {
|
||||
expect(rowOf(wrapper, id).get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('true')
|
||||
}
|
||||
// 지금 보는 메일의 줄은 지금처럼 펼치지 않는다
|
||||
expect(rowOf(wrapper, 'c4').find('[data-conversation-toggle]').exists()).toBe(false)
|
||||
// 누른 것만으로는 본문을 받지 않는다 — 화면에 들어온 줄부터
|
||||
expect(detailGets()).toEqual([])
|
||||
expect(rowOf(wrapper, 'c3').find('[data-conversation-detail-loading]').attributes('aria-busy')).toBe('true')
|
||||
|
||||
await toggleAll(wrapper).trigger('click')
|
||||
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
|
||||
for (const id of ['c1', 'c2', 'c3', 'c5']) {
|
||||
expect(rowOf(wrapper, id).get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('false')
|
||||
}
|
||||
expect(wrapper.find('[data-conversation-panel]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('본문은 화면에 들어온 줄부터 하나씩 받는다 — 앞 줄을 받는 동안 다음 줄은 기다리고, 차례 전에 화면 밖으로 나간 줄은 건너뛴다', async () => {
|
||||
serve({ hold: true })
|
||||
const { wrapper } = await mountReader()
|
||||
await toggleAll(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await show(wrapper, ['c1', 'c2', 'c3'])
|
||||
expect(detailGets()).toEqual(['c1'])
|
||||
|
||||
// c3 는 차례가 오기 전에 화면 밖으로 나갔다
|
||||
await show(wrapper, ['c3'], false)
|
||||
details.get('c1')?.resolve(ok(null))
|
||||
await flushPromises()
|
||||
expect(detailGets()).toEqual(['c1', 'c2'])
|
||||
details.get('c2')?.resolve(ok(null))
|
||||
await flushPromises()
|
||||
expect(detailGets()).toEqual(['c1', 'c2'])
|
||||
expect(rowOf(wrapper, 'c1').find('iframe').exists()).toBe(true)
|
||||
expect(rowOf(wrapper, 'c3').find('iframe').exists()).toBe(false)
|
||||
|
||||
// 다시 들어오면 받는다 · 한 번 받은 줄은 다시 들어와도 다시 받지 않는다
|
||||
await show(wrapper, ['c3', 'c1'])
|
||||
expect(detailGets()).toEqual(['c1', 'c2', 'c3'])
|
||||
})
|
||||
|
||||
it('읽음은 본문이 화면에 보인 메일만 — 받는 사이 화면 밖으로 나갔으면 들어올 때 읽음으로', async () => {
|
||||
serve({ hold: true })
|
||||
const { wrapper, store } = await mountReader()
|
||||
await toggleAll(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await show(wrapper, ['c2'])
|
||||
expect(detailGets()).toEqual(['c2'])
|
||||
// 받는 사이 내려서 화면 밖으로
|
||||
await show(wrapper, ['c2'], false)
|
||||
details.get('c2')?.resolve(ok(null))
|
||||
await flushPromises()
|
||||
expect(patches()).toEqual([])
|
||||
expect(store.messages.find((message) => message.id === 'c2')?.isRead).toBe(false)
|
||||
|
||||
await show(wrapper, ['c2'])
|
||||
expect(patches()).toEqual(['/messages/c2'])
|
||||
expect(store.messages.find((message) => message.id === 'c2')?.isRead).toBe(true)
|
||||
|
||||
// 다시 드나들어도 다시 묻지 않는다
|
||||
await show(wrapper, ['c2'], false)
|
||||
await show(wrapper, ['c2'])
|
||||
expect(patches()).toEqual(['/messages/c2'])
|
||||
})
|
||||
|
||||
it('하나씩 다 펼쳐도 [모두 접기] — 하나라도 접으면 다시 [모두 펼치기]', async () => {
|
||||
serve()
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
for (const id of ['c1', 'c2', 'c3', 'c5']) await rowOf(wrapper, id).get('[data-conversation-toggle]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toggleAll(wrapper).text()).toBe('모두 접기')
|
||||
|
||||
await rowOf(wrapper, 'c1').get('[data-conversation-toggle]').trigger('click')
|
||||
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
|
||||
})
|
||||
|
||||
it('[더 보기]로 더 받은 줄은 접힌 채로 오고 단추는 다시 [모두 펼치기]', async () => {
|
||||
const filler = Array.from({ length: 95 }, (_, index) =>
|
||||
summary(`p${index}`, { receivedAt: at(2026, 9, 1, 9, index % 60) }),
|
||||
)
|
||||
const later = summary('c9', { receivedAt: at(2026, 10, 9, 9, 59) })
|
||||
serve({
|
||||
conversation: ({ params }) =>
|
||||
params.cursor ? page([later], null, null) : page([...filler, ...THREAD], 'next', null),
|
||||
})
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
await toggleAll(wrapper).trigger('click')
|
||||
expect(toggleAll(wrapper).text()).toBe('모두 접기')
|
||||
|
||||
await wrapper.get('[data-conversation-more]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(rowOf(wrapper, 'c9').get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('false')
|
||||
expect(rowOf(wrapper, 'c2').get('[data-conversation-toggle]').attributes('aria-expanded')).toBe('true')
|
||||
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
|
||||
})
|
||||
|
||||
it('펼칠 줄이 없으면(지금 메일만 받았고 나머지를 받는 중) [모두 펼치기]가 없다', async () => {
|
||||
const rest = deferred()
|
||||
serve({ conversation: ({ params }) => (params.cursor ? rest.promise : page([CURRENT], 'next', 3)) })
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
expect(wrapper.find('[data-conversation]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-conversation-toggle-all]').exists()).toBe(false)
|
||||
|
||||
rest.resolve(page([FIRST, SECOND], null, 3))
|
||||
await flushPromises()
|
||||
expect(toggleAll(wrapper).text()).toBe('모두 펼치기')
|
||||
})
|
||||
|
||||
it('펼친 줄 머리 오른쪽에 [답장](펼침 단추와 따로 놓인 링크) — 접힌 줄 · 임시 저장본의 줄에는 없다', async () => {
|
||||
serve()
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
expect(rowOf(wrapper, 'c2').find('[data-conversation-head-reply]').exists()).toBe(false)
|
||||
await rowOf(wrapper, 'c2').get('[data-conversation-toggle]').trigger('click')
|
||||
await rowOf(wrapper, 'c5').get('[data-conversation-toggle]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const reply = rowOf(wrapper, 'c2').get('[data-conversation-head-reply]')
|
||||
expect(reply.text()).toBe('답장')
|
||||
expect(reply.attributes('href')).toBe('/mail/compose?replyTo=c2')
|
||||
// 단추 안에 넣지 않고 머리 틀 안에 나란히 둔다
|
||||
expect(reply.element.closest('button')).toBeNull()
|
||||
expect(reply.element.parentElement?.querySelector('[data-conversation-toggle]')).not.toBeNull()
|
||||
// 아래쪽 [답장] · [이 메일만 열기]는 그대로
|
||||
expect(rowOf(wrapper, 'c2').find('[data-conversation-reply]').exists()).toBe(true)
|
||||
expect(rowOf(wrapper, 'c2').find('[data-conversation-open]').exists()).toBe(true)
|
||||
|
||||
expect(rowOf(wrapper, 'c5').find('[data-conversation-head-reply]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -11,11 +11,15 @@
|
||||
// 다시 받아 빠진 줄) 그 줄이 남았으면 그 줄의 펼침 단추로, 아니면 칸 제목(h3 — tabindex -1)으로. 받는 중이면 다 받을 때까지 기다리고,
|
||||
// 칸이 없어졌으면 읽기 화면에 맡긴다(settled — 본문으로). 초점이 칸 밖에 있었으면 건드리지 않는다
|
||||
// 받는 때(메일이 바뀜 · 쓰기 화면에서 돌아옴 · 메일함들의 통 수가 바뀜을 모아서)는 useConversationSync 가 정한다
|
||||
// - 칸 머리 오른쪽 [모두 펼치기](15단계 — 디자인 원본 Reader15 "6" · 36 글자 단추): 불러온 줄을 모두 펼치고 [모두 접기]가 된다(하나씩 다 펼쳐도).
|
||||
// 본문은 펼친 안이 화면에 들어오는 줄부터 하나씩 받는다(useConversationPanelVisibility → 스토어의 noteConversationPanelVisible).
|
||||
// 펼칠 줄이 없으면(지금 메일뿐 — 나머지를 받는 중) 단추가 없다
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ConversationMessageRow from '@/components/mail/ConversationMessageRow.vue'
|
||||
import { useClock } from '@/composables/useClock'
|
||||
import { useConversationPanelVisibility } from '@/composables/useConversationPanelVisibility'
|
||||
import { useConversationSync } from '@/composables/useConversationSync'
|
||||
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
|
||||
import { formatCount, formatTraceCode } from '@/shared/utils/format'
|
||||
@@ -77,6 +81,15 @@ function loadMore() {
|
||||
if (!mailStore.conversationLoadingMore) void mailStore.loadMoreConversation()
|
||||
}
|
||||
|
||||
/** 펼칠 줄이 있다 — [모두 펼치기] · [모두 접기]를 둔다 */
|
||||
const canExpandAll = computed(() => mailStore.conversationExpandableIds.length > 0)
|
||||
|
||||
/** [모두 펼치기] ↔ [모두 접기] — 하나라도 접혀 있으면 모두 펼친다 */
|
||||
function toggleAll() {
|
||||
if (mailStore.conversationAllExpanded) mailStore.collapseAllConversation()
|
||||
else mailStore.expandAllConversation()
|
||||
}
|
||||
|
||||
// ── 초점 지키기 ─────────────────────────────────────────
|
||||
|
||||
/** 다시 그릴 때 바뀌는 것 — 칸 · 안내 · [더 보기] · 줄 · 펼친 줄과 그 본문의 상태 */
|
||||
@@ -146,6 +159,13 @@ watch(
|
||||
{ flush: 'pre' },
|
||||
)
|
||||
|
||||
// 펼친 안이 화면에 들어오고 나가는 것을 스토어에 알린다 — [모두 펼치기]의 본문은 화면에 들어온 줄부터 하나씩 (15단계)
|
||||
useConversationPanelVisibility({
|
||||
root: sectionRef,
|
||||
watchKey: () => renderKey.value,
|
||||
onChange: (id, inView) => mailStore.noteConversationPanelVisible(id, inView),
|
||||
})
|
||||
|
||||
// 받지 못했을 때만 읽어 준다 — 비웠다가 넣어 다시 못 받아도 다시 읽게
|
||||
watch(failed, async (isFailed) => {
|
||||
announcement.value = ''
|
||||
@@ -163,14 +183,25 @@ watch(failed, async (isFailed) => {
|
||||
aria-labelledby="reader-conversation-title"
|
||||
data-conversation
|
||||
>
|
||||
<h3
|
||||
id="reader-conversation-title"
|
||||
ref="titleRef"
|
||||
class="conversation__title"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="conversation__head">
|
||||
<h3
|
||||
id="reader-conversation-title"
|
||||
ref="titleRef"
|
||||
class="conversation__title"
|
||||
tabindex="-1"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
v-if="canExpandAll"
|
||||
type="button"
|
||||
class="conversation__toggle-all"
|
||||
data-conversation-toggle-all
|
||||
@click="toggleAll"
|
||||
>
|
||||
{{ mailStore.conversationAllExpanded ? MAIL_CONVERSATION_TEXT.collapseAll : MAIL_CONVERSATION_TEXT.expandAll }}
|
||||
</button>
|
||||
</div>
|
||||
<ol class="conversation__list">
|
||||
<ConversationMessageRow
|
||||
v-for="row in rows"
|
||||
@@ -245,6 +276,33 @@ watch(failed, async (isFailed) => {
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* 칸 머리 — 제목 · 오른쪽 [모두 펼치기] (디자인 원본 Reader15 "6" — 높이 36) */
|
||||
.conversation__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-notice-action);
|
||||
}
|
||||
|
||||
/* [모두 펼치기] · [모두 접기] — 36 글자 단추, 펼친 줄 머리의 [답장]과 같은 크기(안내 줄 단추의 밀도 예외 재사용). 글자 끝을 상자 끝에 맞춘다 */
|
||||
.conversation__toggle-all {
|
||||
flex: 0 0 auto;
|
||||
min-height: var(--size-notice-action);
|
||||
margin-right: calc(var(--space-10) * -1);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversation__toggle-all:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.conversation__title {
|
||||
margin: 0;
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (useMessageRemoval 의 leaveRemoved).
|
||||
// 그사이 다른 목록 · 다른 메일로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
|
||||
// - 읽기 칸이 좁으면 아이콘만 (15단계 — MailToolButton 의 collapsible · MessageReader 의 컨테이너 쿼리 — 「영구 삭제」는 이름이 길어 더 넓은 폭에서 접는다)
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
@@ -95,6 +96,7 @@ watch([held, permanent, () => props.message.id], () => {
|
||||
:label="permanent ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete"
|
||||
tone="danger"
|
||||
:disabled="working"
|
||||
collapsible
|
||||
data-reader-delete
|
||||
@click="onClick"
|
||||
/>
|
||||
|
||||
@@ -10,12 +10,15 @@
|
||||
// - 행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 (새 탭 · 새 창으로 열지 않는다)
|
||||
// - 아직 보내지 않은 메일(9단계)은 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다
|
||||
// (옮기거나 지워도 엔진은 그 시각에 보낸다 — 열어서 취소하거나 삭제한다. 스토어도 건너뛴다 — selection.ts)
|
||||
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
|
||||
// 보내기를 기다리는 메일의 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import HighlightedText from '@/components/mail/HighlightedText.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { useMailRowDrag } from '@/composables/useMailDrag'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
|
||||
@@ -60,6 +63,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const drag = useMailRowDrag()
|
||||
|
||||
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'split' }))
|
||||
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
|
||||
/** 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
|
||||
@@ -104,7 +109,10 @@ function onMouseDown(event: MouseEvent) {
|
||||
},
|
||||
]"
|
||||
:data-row-id="message.id"
|
||||
:draggable="!held"
|
||||
@mousedown="onMouseDown"
|
||||
@dragstart="(event: DragEvent) => drag.start(event, message.id)"
|
||||
@dragend="drag.end"
|
||||
>
|
||||
<MailCheckbox
|
||||
class="message-row__check"
|
||||
|
||||
@@ -44,6 +44,7 @@ import { isHeldMessage } from '@/shared/utils/pending-send'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useContactsStore } from '@/stores/contacts.store'
|
||||
import { useMailBlockStore } from '@/stores/mail-block.store'
|
||||
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
|
||||
import { useMailFilterStore } from '@/stores/mail-filter.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -63,6 +64,8 @@ const mailStore = useMailStore()
|
||||
const filterStore = useMailFilterStore()
|
||||
const contactsStore = useContactsStore()
|
||||
const blockStore = useMailBlockStore()
|
||||
/** 차단 창의 새 칸을 지금 고를 수 없는 까닭 — 이 탭에서 받은편지함을 정리하거나 스팸메일함을 비우는 중 (15단계 — startBlock) */
|
||||
const blockRunStore = useMailBlockRunStore()
|
||||
const contactDialog = useMailContactDialog()
|
||||
const print = useMailPrint()
|
||||
const senderBlock = useSenderBlock()
|
||||
@@ -93,6 +96,11 @@ const blockable = computed(
|
||||
/** 이미 막고 있는 차단 (주소 또는 도메인) */
|
||||
const senderBlockEntry = computed(() => (blockable.value ? blockStore.blockOf(sender.value) : null))
|
||||
const inJunk = computed(() => removal.isInJunk(props.message))
|
||||
/** 받은편지함의 메일이다 — 차단 창의 새 칸을 켜면 「이 메일도 …」를 켠 채 잠근다 (15단계) */
|
||||
const inInbox = computed(() => {
|
||||
const inboxId = mailStore.mailboxByRole('inbox')?.id
|
||||
return inboxId !== undefined && props.message.mailboxIds.includes(inboxId)
|
||||
})
|
||||
|
||||
const items = computed<EditorMenuItem[]>(() => {
|
||||
const list: EditorMenuItem[] = [
|
||||
@@ -206,14 +214,15 @@ function focusButton() {
|
||||
|
||||
/**
|
||||
* 차단 확인 창의 [차단] — 창을 닫고 막는다. 이 메일을 스팸메일함으로 옮겼으면 삭제처럼 다음 메일 · 목록으로.
|
||||
* 받은편지함의 그 보낸 사람 메일도 옮기기로 했으면(cleanInbox — 15단계) 정리는 알림이 이어 보인다(기다리지 않는다).
|
||||
* 기다리는 사이 다른 목록 · 다른 메일로 옮겨 갔으면(stale · leaveRemoved 의 stillShowing) 움직이지 않는다
|
||||
*/
|
||||
async function onBlock(target: string, moveToJunk: boolean) {
|
||||
async function onBlock(target: string, moveToJunk: boolean, cleanInbox: boolean) {
|
||||
blockDialogOpen.value = false
|
||||
const message = props.message
|
||||
const source = mailStore.listSource
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const outcome = await senderBlock.block(message, target, moveToJunk)
|
||||
const outcome = await senderBlock.block(message, target, moveToJunk, cleanInbox)
|
||||
if (!outcome.moved || outcome.stale) return
|
||||
const removed = !mailStore.isListedMessage(message.id)
|
||||
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, props.layout, message.id)
|
||||
@@ -269,6 +278,8 @@ watch(
|
||||
:open="blockDialogOpen"
|
||||
:sender-email="sender"
|
||||
:in-junk="inJunk"
|
||||
:in-inbox="inInbox"
|
||||
:cleanup-block="blockRunStore.startBlock"
|
||||
@confirm="onBlock"
|
||||
@cancel="blockDialogOpen = false"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면의 「이동 ▾」 — 열린 메일 한 통을 고른 메일함으로 (15단계 — 디자인 원본 Reader15 "1 · 2 · 3", README "15단계")
|
||||
// - 자리: 답장 · 전체 답장 · 전달 | 이동 ▾ · 스팸 신고 · 삭제 · ⋯ — 메일이 어디로 가는지를 한 묶음에 둔다
|
||||
// - 메뉴는 목록 도구 줄의 [이동 ▾]와 같은 부품 · 같은 목록(mail-move-menu.ts): 메일함 패널처럼 들여 쓴 나무에서 그 메일이 든 메일함
|
||||
// (검색 결과에서 연 메일도 그 메일이 든 메일함) · 임시보관함 · 휴지통을 뺀다. 그 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는
|
||||
// 흐린 줄로 남기고 이름 아래에 "이 메일이 있는 메일함". 휴지통으로는 [삭제]로 옮긴다
|
||||
// - 옮긴 뒤는 [삭제]와 같은 길: 분할 보기는 다음 메일, 목록 보기는 목록으로 + "메일 1통을 거래처 › 2025로 옮겼습니다." + [되돌리기]
|
||||
// (같은 이름이 다른 곳에도 있을 때만 위 메일함 이름 — useMailMoveFeedback). 스팸메일함으로 옮기면 [스팸 신고], 스팸메일함에서 꺼내면 [스팸 아님]과 같다
|
||||
// - 보내기를 기다리는 메일(보내는 중 · 예약)에는 없다 — 읽기 화면이 그리지 않는다(취소해야만 보낸편지함을 떠난다)
|
||||
// - 키보드는 목록의 [이동 ▾]와 같다: Enter · Space · 아래 화살표로 열면 첫 항목에 초점, Esc 는 메뉴만 닫고 이 단추로 돌아간다.
|
||||
// 마우스로 열면 메뉴 자체에 초점(EditorPopup). 메뉴가 화면 밖으로 넘치면 단추 오른쪽 끝에 맞춘다(EditorPopup 의 좌우 바꾸기)
|
||||
// - 읽기 칸이 좁으면 이름을 숨기고 아이콘 + ▾ 만(MailToolButton 의 collapsible — MessageReader 의 컨테이너 쿼리)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import { moveMenuItems } from '@/components/mail/mail-move-menu'
|
||||
import { useMessageRemoval } from '@/composables/useMessageRemoval'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { hasMoveTarget, mailMoveTargets } from '@/shared/utils/mail-move-targets'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
message: MessageSummary
|
||||
/** split: 옮기면 다음 메일을 연다 · list: 옮기면 목록으로 */
|
||||
layout: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const removal = useMessageRemoval()
|
||||
|
||||
const buttonRef = ref<InstanceType<typeof MailToolButton> | null>(null)
|
||||
const open = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
const working = ref<boolean>(false)
|
||||
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value?.element ?? null)
|
||||
/** 옮길 곳 — 그 메일이 든 메일함은 빼거나(하위가 있으면) 고를 수 없는 줄로 */
|
||||
const targets = computed(() => mailMoveTargets(mailStore.mailboxes, new Set(props.message.mailboxIds)))
|
||||
const items = computed<EditorMenuItem[]>(() => moveMenuItems(targets.value, MAIL_BULK_TEXT.moveHere))
|
||||
/** 옮길 곳이 하나도 없으면(메일함 목록을 아직 못 받음) 그리지 않는다 */
|
||||
const available = computed(() => hasMoveTarget(targets.value))
|
||||
|
||||
function openMenu(keyboard: boolean) {
|
||||
if (working.value || !available.value) 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()
|
||||
}
|
||||
|
||||
async function onSelect(id: string) {
|
||||
const target = targets.value.find((row) => row.mailbox.id === id && !row.disabled)
|
||||
if (!target || working.value) return
|
||||
open.value = false
|
||||
trigger.value?.focus()
|
||||
const message = props.message
|
||||
const source = mailStore.listSource
|
||||
working.value = true
|
||||
try {
|
||||
const outcome = await removal.moveTo(message, target.mailbox)
|
||||
await removal.leaveRemoved(outcome, source, props.layout, message.id)
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="available"
|
||||
class="reader-move"
|
||||
>
|
||||
<MailToolButton
|
||||
ref="buttonRef"
|
||||
icon="folder-move"
|
||||
:label="MAIL_BULK_TEXT.move"
|
||||
collapsible
|
||||
menu
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
:disabled="working"
|
||||
data-reader-move
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<EditorMenu
|
||||
v-if="open"
|
||||
class="reader-move__menu"
|
||||
:label="MAIL_BULK_TEXT.moveMenu"
|
||||
:items="items"
|
||||
:trigger="trigger"
|
||||
:focus-content="!byKeyboard"
|
||||
@select="onSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메뉴는 이 단추 바로 아래 — 도구 줄 아래 선 밑에서 연다 (EditorPopup) */
|
||||
.reader-move {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* 옮길 메일함 메뉴 — 목록의 [이동 ▾]와 같은 폭 (MailBulkMoveButton) */
|
||||
.reader-move__menu {
|
||||
width: max-content;
|
||||
min-width: 12.5rem;
|
||||
/* 좁은 화면에서는 단추의 어느 끝에 맞춰도 화면 안에 들게 — 폭 360 이면 280 까지 */
|
||||
max-width: min(20rem, calc(100vw - 5rem));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,606 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { Mailbox, MessageDetail, MessagesPatch, MessageSummary } from '@/shared/types/mail'
|
||||
import type { MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.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 MessageReader from './MessageReader.vue'
|
||||
|
||||
// 읽기 화면의 15단계 — 예약 안내 줄 [지금 보내기] · 보낸 사람 차단 창의 「받은편지함에 있는 이 보낸 사람의 메일도 …」
|
||||
// (디자인 원본 Reader15 "지금 보내기" · Mailbox15 "보낸 사람 차단 창" · 계약 2절 "보내기 취소 · 예약 발송" · "보낸 사람 차단")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 120, unreadCount: 0 }
|
||||
const SENT: Mailbox = { ...INBOX, id: 'sent', name: '보낸편지함', role: 'sent', totalCount: 2 }
|
||||
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', totalCount: 0 }
|
||||
const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk', totalCount: 0 }
|
||||
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash', totalCount: 0 }
|
||||
const MAILBOXES = [INBOX, SENT, DRAFTS, JUNK, TRASH]
|
||||
|
||||
const NEWSLETTER: MessageDetail = {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '클라우드 브리프', email: 'news@cloudbrief.example' },
|
||||
to: [USER],
|
||||
subject: '[클라우드 브리프] 11월 둘째 주',
|
||||
preview: '',
|
||||
receivedAt: '2026-11-09T00:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
pendingSend: null,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '이번 주 소식입니다.',
|
||||
hasBlockedImages: false,
|
||||
imagesShown: false,
|
||||
attachments: [],
|
||||
}
|
||||
const SEND_AT = '2026-11-10T00:00:00.000Z'
|
||||
/** 내가 예약한 메일 — 보낸편지함 · 예약됨 */
|
||||
const SCHEDULED: MessageDetail = {
|
||||
...NEWSLETTER,
|
||||
id: 's1',
|
||||
mailboxIds: ['sent'],
|
||||
from: USER,
|
||||
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }],
|
||||
subject: '11월 셋째 주 배포 일정 확인 부탁드립니다',
|
||||
pendingSend: { kind: 'scheduled', sendAt: SEND_AT },
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((settle) => {
|
||||
resolve = settle
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
message?: MessageDetail
|
||||
source?: MailListSource
|
||||
layout?: 'split' | 'list'
|
||||
extra?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined
|
||||
}
|
||||
|
||||
describe('MessageReader — 15단계 지금 보내기 · 차단할 때 받은 메일 옮기기', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().startSession(USER)
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountWith(options: MountOptions = {}) {
|
||||
const message = options.message ?? NEWSLETTER
|
||||
const source = options.source ?? { kind: 'mailbox', mailboxId: message.mailboxIds[0] ?? 'inbox' }
|
||||
requests = installMockApi((request) => {
|
||||
const handled = options.extra?.(request)
|
||||
if (handled) return handled
|
||||
if (request.url === '/mail-filters') return ok({ rules: [], version: 'v1', isActive: true })
|
||||
if (request.url === '/contacts') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
if (request.url === '/mail-blocks' && request.method === 'GET') return ok({ senders: [], isActive: true })
|
||||
if (request.url === '/mail-blocks' && request.method === 'POST') {
|
||||
return ok({ address: (request.data as { address: string }).address, blockedAt: '2026-11-09T05:00:00.000Z' }, 201)
|
||||
}
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 5 } })
|
||||
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 === '/scheduled-messages') {
|
||||
return ok({ items: [{ ...SCHEDULED, id: 'm2' }], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
}
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES
|
||||
store.listSource = source
|
||||
store.messages = [message, { ...message, id: 'm2', subject: '다음 메일', pendingSend: message.pendingSend }] as MessageSummary[]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = message
|
||||
store.selectedId = message.id
|
||||
store.selectedStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
const listPath = source.kind === 'scheduled' ? '/mail/scheduled' : `/mail/${message.mailboxIds[0] ?? 'inbox'}`
|
||||
await router.push(listPath)
|
||||
await router.push(`${listPath}/${message.id}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, {
|
||||
props: { layout: options.layout ?? 'split' },
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const releases = () => requests.filter((request) => request.method === 'POST' && request.url.endsWith('/release'))
|
||||
const toasts = () => useToastStore().toasts
|
||||
|
||||
/** 설정 › 보내기 취소 10초를 받아 두었다 (메일 화면에 들어올 때 받는다 — MailLayout) */
|
||||
function knowUndoSeconds() {
|
||||
const settings = useMailSettingsStore()
|
||||
settings.settings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: false }
|
||||
settings.status = 'ready'
|
||||
}
|
||||
|
||||
describe('예약 안내 줄 [지금 보내기]', () => {
|
||||
it('POST …/release(본문 없음) → "메일을 보냈습니다." + [보내기 취소](설정한 초로 센 마감) · 예약됨에서 빠지고 다음 예약 메일 · 예약됨 수를 다시 묻는다', async () => {
|
||||
knowUndoSeconds()
|
||||
const { wrapper, router, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) =>
|
||||
request.url === '/scheduled-messages/s1/release'
|
||||
? ok({ id: 's1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(Date.now() + 10_000).toISOString() } })
|
||||
: undefined,
|
||||
})
|
||||
const countsBefore = requests.filter((request) => request.url === '/scheduled-messages').length
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(releases().map((request) => [request.url, request.data])).toEqual([['/scheduled-messages/s1/release', undefined]])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
|
||||
expect(requests.filter((request) => request.url === '/scheduled-messages').length).toBeGreaterThan(countsBefore)
|
||||
const [toast] = toasts()
|
||||
expect(toasts()).toHaveLength(1)
|
||||
expect(toast).toMatchObject({ kind: 'success', message: '메일을 보냈습니다.', action: { label: '보내기 취소' } })
|
||||
// 마감은 설정한 10초에서 1초를 뺀 때 — 이 PC 시계가 서버와 어긋나도 알림이 늘어나지 않게
|
||||
const span = (toast?.expiresAt ?? 0) - (toast?.shownAt ?? 0)
|
||||
expect(span).toBeGreaterThan(8_900)
|
||||
expect(span).toBeLessThanOrEqual(9_000)
|
||||
})
|
||||
|
||||
it('누르는 동안 세 단추를 함께 막는다(aria-disabled) — [지금 보내기]는 aria-busy, 다시 눌러도 한 번만 보낸다', async () => {
|
||||
const pending = deferred()
|
||||
const { wrapper } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => (request.url === '/scheduled-messages/s1/release' ? pending.promise : undefined),
|
||||
})
|
||||
const notice = wrapper.get('[data-notice="scheduled"]')
|
||||
|
||||
await notice.get('[data-scheduled-send]').trigger('click')
|
||||
expect(notice.findAll('button').map((button) => button.attributes('aria-disabled'))).toEqual(['true', 'true', 'true'])
|
||||
expect(notice.get('[data-scheduled-send]').attributes('aria-busy')).toBe('true')
|
||||
await notice.get('[data-scheduled-send]').trigger('click')
|
||||
await notice.get('[data-scheduled-cancel]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(releases()).toHaveLength(1)
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
|
||||
pending.resolve(ok({ id: 's1', rejectedRecipients: [], pendingSend: null }))
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('바로 보냈거나 예약 시각이 막 지나 이미 보낸 메일(pendingSend: null) — "메일을 보냈습니다." + [보낸편지함 보기]', async () => {
|
||||
const { wrapper } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) =>
|
||||
request.url === '/scheduled-messages/s1/release' ? ok({ id: 's1', rejectedRecipients: [], pendingSend: null }) : undefined,
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts()).toMatchObject([
|
||||
{
|
||||
kind: 'success',
|
||||
message: '메일을 보냈습니다.',
|
||||
expiresAt: null,
|
||||
action: { label: '보낸편지함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'sent' } } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('받는 사람 일부가 받지 못하면 그 문장 — 보내기 취소를 기다리는 동안이면 [보내기 취소]와 함께', async () => {
|
||||
const { wrapper } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) =>
|
||||
request.url === '/scheduled-messages/s1/release'
|
||||
? ok({
|
||||
id: 's1',
|
||||
rejectedRecipients: [{ email: 'nobody@arasystem.it.kr', reason: 'not-found' }],
|
||||
pendingSend: { kind: 'undo', sendAt: new Date(Date.now() + 10_000).toISOString() },
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts()).toMatchObject([
|
||||
{ kind: 'error', message: '보냈지만 1명은 받지 못합니다. nobody@arasystem.it.kr (없는 주소)', action: { label: '보내기 취소' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('보낸편지함에서 연 예약 메일 — 그 자리에 남고 안내 줄만 사라진다(초점은 본문으로) · 붙잡힌 발송은 보내기 취소를 기다리는 것으로 바뀐다', async () => {
|
||||
const sendAt = new Date(Date.now() + 10_000).toISOString()
|
||||
const { wrapper, router, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'mailbox', mailboxId: 'sent' },
|
||||
extra: (request) =>
|
||||
request.url === '/scheduled-messages/s1/release'
|
||||
? ok({ id: 's1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt } })
|
||||
: undefined,
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/s1')
|
||||
expect(store.selected?.pendingSend).toEqual({ kind: 'undo', sendAt })
|
||||
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('iframe').element)
|
||||
})
|
||||
|
||||
describe('보내지 못했거나 결과를 모를 때(503 MAIL_001 · SYS_001) — 그 메일을 다시 받아 붙잡은 발송 · 메일함으로 가른다', () => {
|
||||
/** 예약은 풀렸는데 다시 내지 못한 메일 — 서버가 결과를 모를 때 남기는 꼴(보낸편지함 · 붙잡은 발송 없음) */
|
||||
const UNSCHEDULED: MessageDetail = { ...SCHEDULED, pendingSend: null }
|
||||
/** 보낸편지함 목록 — 다시 맞추면 그 메일은 남고(예약만 풀림) 다음 예약 메일도 있다 */
|
||||
const sentList = (first: MessageDetail) =>
|
||||
ok({ items: [first, { ...SCHEDULED, id: 'm2', subject: '다음 메일' }], pageInfo: { hasNext: false, nextCursor: null, total: 2 } })
|
||||
|
||||
it('임시보관함으로 되돌아갔으면 "지금 보내지 못해 임시보관함으로 옮겼습니다." + [임시보관함 보기] · 예약됨을 떠났으니 안내 줄을 내리고 다음 예약 메일', async () => {
|
||||
const { wrapper, router } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => {
|
||||
if (request.url === '/scheduled-messages/s1/release') return fail(503, 'MAIL_001')
|
||||
// 엔진이 거절해 서버가 그 메일을 임시보관함으로 되돌렸다
|
||||
if (request.method === 'GET' && request.url === '/messages/s1') {
|
||||
return ok({ ...SCHEDULED, mailboxIds: ['drafts'], pendingSend: null })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const listsBefore = requests.filter((request) => request.url === '/scheduled-messages').length
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// 사전 문구(MAIL_001) 대신 화면 글 하나
|
||||
expect(toasts()).toMatchObject([
|
||||
{
|
||||
kind: 'error',
|
||||
message: '지금 보내지 못해 임시보관함으로 옮겼습니다.',
|
||||
action: { label: '임시보관함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'drafts' } } },
|
||||
},
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
|
||||
expect(requests.filter((request) => request.url === '/scheduled-messages').length).toBeGreaterThan(listsBefore)
|
||||
})
|
||||
|
||||
it('예약이 그대로면(예약을 거두기 전에 멈춤) "지금 보내지 못했습니다. 다시 시도해 주세요." + [다시 시도] · 안내 줄은 그대로 — [다시 시도]는 같은 요청을 보낸다', async () => {
|
||||
let attempt = 0
|
||||
const { wrapper, router, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => {
|
||||
if (request.url === '/scheduled-messages/s1/release') {
|
||||
attempt += 1
|
||||
return attempt === 1 ? fail(503, 'MAIL_001') : ok({ id: 's1', rejectedRecipients: [], pendingSend: null })
|
||||
}
|
||||
// 예약 제출이 아직 기다린다 — 예약 표시 그대로
|
||||
if (request.method === 'GET' && request.url === '/messages/s1') return ok(SCHEDULED)
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const [failed] = toasts()
|
||||
expect(toasts()).toMatchObject([
|
||||
{ kind: 'error', message: '지금 보내지 못했습니다. 다시 시도해 주세요.', action: { label: '다시 시도' } },
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/s1')
|
||||
expect(store.selected?.pendingSend).toEqual(SCHEDULED.pendingSend)
|
||||
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-scheduled-send]').attributes('aria-disabled')).toBeUndefined()
|
||||
|
||||
const action = failed ? useToastStore().takeAction(failed.id) : null
|
||||
if (!action || !('run' in action)) throw new Error('단추가 없다')
|
||||
action.run()
|
||||
await flushPromises()
|
||||
expect(releases()).toHaveLength(2)
|
||||
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
|
||||
})
|
||||
|
||||
it('예약만 풀렸으면(보낸편지함 · 기다리는 발송 없음 — 이미 나갔을 수 있다) 예약됨에서 연 메일: "보냈는지 확인하지 못했습니다 …" + [다시 시도] · 예약됨에서 빠지고 다음 예약 메일', async () => {
|
||||
let attempt = 0
|
||||
const { wrapper, router, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => {
|
||||
if (request.url === '/scheduled-messages/s1/release') {
|
||||
attempt += 1
|
||||
return attempt === 1 ? fail(503, 'MAIL_001') : ok({ id: 's1', rejectedRecipients: [], pendingSend: null })
|
||||
}
|
||||
if (request.method === 'GET' && request.url === '/messages/s1') return ok(UNSCHEDULED)
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const [failed] = toasts()
|
||||
expect(toasts()).toMatchObject([
|
||||
{ kind: 'error', message: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.', action: { label: '다시 시도' } },
|
||||
])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
|
||||
// 분할 보기 화면이 다음 메일을 연다 (이 시험은 읽기 화면만 띄운다)
|
||||
store.selectedId = 'm2'
|
||||
|
||||
// [다시 시도]는 같은 요청 — 서버가 이어 낸다. 다른 메일을 보고 있으니 그 자리에 둔다
|
||||
const action = failed ? useToastStore().takeAction(failed.id) : null
|
||||
if (!action || !('run' in action)) throw new Error('단추가 없다')
|
||||
action.run()
|
||||
await flushPromises()
|
||||
expect(releases()).toHaveLength(2)
|
||||
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
|
||||
})
|
||||
|
||||
it('예약만 풀렸으면 보낸편지함에서 연 메일도 안내 줄을 내린다 — 그 자리에 남고(목록에도 남는다) 초점은 본문으로', async () => {
|
||||
const { wrapper, router, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'mailbox', mailboxId: 'sent' },
|
||||
extra: (request) => {
|
||||
if (request.url === '/scheduled-messages/s1/release') return fail(503, 'MAIL_001')
|
||||
if (request.method === 'GET' && request.url === '/messages/s1') return ok(UNSCHEDULED)
|
||||
if (request.method === 'GET' && request.url === '/messages') return sentList(UNSCHEDULED)
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts()).toMatchObject([
|
||||
{ kind: 'error', message: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.', action: { label: '다시 시도' } },
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent/s1')
|
||||
expect(store.selected?.pendingSend).toBeNull()
|
||||
expect(store.selected?.mailboxIds).toEqual(['sent'])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['s1', 'm2'])
|
||||
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('iframe').element)
|
||||
})
|
||||
|
||||
it('결과를 모르는 실패(504 SYS_001 — 앞단이 기다리다 끊음)도 같은 갈래 — 사전 문구 알림 없이 다시 받아 가른다', async () => {
|
||||
const { wrapper, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'mailbox', mailboxId: 'sent' },
|
||||
extra: (request) => {
|
||||
if (request.url === '/scheduled-messages/s1/release') return fail(504, 'SYS_001')
|
||||
if (request.method === 'GET' && request.url === '/messages/s1') return ok(UNSCHEDULED)
|
||||
if (request.method === 'GET' && request.url === '/messages') return sentList(UNSCHEDULED)
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'GET' && request.url === '/messages/s1')).toHaveLength(1)
|
||||
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.'])
|
||||
expect(store.selected?.pendingSend).toBeNull()
|
||||
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('연결이 끊겼는데(SYS_001) 다시 받아 보니 다시 낸 발송이 보내기 취소를 기다리면 보낸 것과 같다 — "메일을 보냈습니다." + [보내기 취소]', async () => {
|
||||
knowUndoSeconds()
|
||||
const sendAt = new Date(Date.now() + 10_000).toISOString()
|
||||
const { wrapper, router, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => {
|
||||
if (request.url === '/scheduled-messages/s1/release') return 'network'
|
||||
if (request.method === 'GET' && request.url === '/messages/s1') {
|
||||
return ok({ ...SCHEDULED, pendingSend: { kind: 'undo', sendAt } })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts()).toMatchObject([{ kind: 'success', message: '메일을 보냈습니다.', action: { label: '보내기 취소' } }])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/scheduled/m2')
|
||||
})
|
||||
|
||||
it('그 메일을 다시 받지 못하면 보냈다 · 못 보냈다 · 되돌렸다고 하지 않는다 — "보냈는지 확인하지 못했습니다 …" + [다시 시도] · 안내 줄은 마지막으로 안 그대로', async () => {
|
||||
const { wrapper, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => {
|
||||
if (request.url === '/scheduled-messages/s1/release') return fail(503, 'MAIL_001')
|
||||
if (request.method === 'GET' && request.url === '/messages/s1') return 'network'
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts()).toMatchObject([{ kind: 'error', message: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.', action: { label: '다시 시도' } }])
|
||||
expect(store.selected?.pendingSend).toEqual(SCHEDULED.pendingSend)
|
||||
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('받는 사람이 모두 받지 않으면(400 MAIL_010 — 임시보관함으로) 지금처럼 사전 문구 · 예약 표시를 서버 기준으로 다시 맞춘다', async () => {
|
||||
const { wrapper, store } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => {
|
||||
if (request.url === '/scheduled-messages/s1/release') return fail(400, 'MAIL_010')
|
||||
if (request.method === 'GET' && request.url === '/messages/s1') {
|
||||
return ok({ ...SCHEDULED, mailboxIds: ['drafts'], pendingSend: null })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts().map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_010])
|
||||
expect(store.selected?.pendingSend).toBeNull()
|
||||
expect(wrapper.find('[data-notice="scheduled"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('혹시 409 MAIL_014(이미 보냄)로 와도 오류가 아니다 — "메일을 보냈습니다."', async () => {
|
||||
const { wrapper } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => (request.url === '/scheduled-messages/s1/release' ? fail(409, 'MAIL_014') : undefined),
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts().map((toast) => toast.message)).toEqual(['메일을 보냈습니다.'])
|
||||
})
|
||||
|
||||
it('알림의 [보내기 취소]는 지금의 보내기 취소와 같다 — DELETE 로 취소하고 그 메일로 쓰기 화면', async () => {
|
||||
knowUndoSeconds()
|
||||
const { wrapper, router } = await mountWith({
|
||||
message: SCHEDULED,
|
||||
source: { kind: 'scheduled' },
|
||||
extra: (request) => {
|
||||
if (request.url === '/scheduled-messages/s1/release') {
|
||||
return ok({ id: 's1', rejectedRecipients: [], pendingSend: { kind: 'undo', sendAt: new Date(Date.now() + 10_000).toISOString() } })
|
||||
}
|
||||
if (request.method === 'DELETE' && request.url === '/scheduled-messages/s1') return ok({ draftId: 's1' })
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-scheduled-send]').trigger('click')
|
||||
await flushPromises()
|
||||
const [toast] = toasts()
|
||||
const action = toast ? useToastStore().takeAction(toast.id) : null
|
||||
if (!action || !('run' in action)) throw new Error('단추가 없다')
|
||||
action.run()
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/scheduled-messages/s1',
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('차단 창 — 받은편지함에 있는 이 보낸 사람의 메일도 옮기기', () => {
|
||||
async function openBlockDialog(wrapper: VueWrapper) {
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('[role="menuitem"]').at(-1)?.trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper.get('[data-block-dialog]')
|
||||
}
|
||||
|
||||
it('새 칸을 켜고 [차단] — 이 메일을 먼저 옮기고 받은편지함 정리를 시작한다 · 차단 알림 대신 알림 하나가 결과(이 메일까지 센 수) + [스팸메일함 보기]', async () => {
|
||||
const { wrapper, router } = await mountWith({
|
||||
extra: (request) =>
|
||||
request.url === '/mail-block-runs' ? ok({ scanned: 120, matched: 3, remaining: 0, cursor: null }) : undefined,
|
||||
})
|
||||
|
||||
const dialog = await openBlockDialog(wrapper)
|
||||
await dialog.get('[data-block-clean]').setValue(true)
|
||||
// 받은편지함의 메일이라 첫 칸은 켠 채 잠긴다
|
||||
expect(dialog.get('[data-block-move]').attributes('aria-disabled')).toBe('true')
|
||||
await wrapper.get('[data-block-confirm]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const order = requests
|
||||
.filter((request) => request.method !== 'GET')
|
||||
.map((request) => `${request.method} ${request.url}`)
|
||||
expect(order).toEqual(['POST /mail-blocks', 'PATCH /messages', 'POST /mail-block-runs'])
|
||||
expect(requests.find((request) => request.method === 'PATCH')?.data).toEqual({ ids: ['m1'], mailboxId: 'junk', isJunk: true })
|
||||
expect(requests.find((request) => request.url === '/mail-block-runs')?.data).toEqual({
|
||||
sender: 'news@cloudbrief.example',
|
||||
cursor: null,
|
||||
})
|
||||
expect(toasts()).toMatchObject([
|
||||
{
|
||||
kind: 'success',
|
||||
message: '‘news@cloudbrief.example’을(를) 차단하고 받은편지함의 메일 4통을 스팸메일함으로 옮겼습니다.',
|
||||
action: { label: '스팸메일함 보기' },
|
||||
},
|
||||
])
|
||||
// 옮겼으니 삭제처럼 다음 메일을 연다
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
})
|
||||
|
||||
it('이 탭에서 필터를 적용하는 중이면 새 칸이 꺼져 있다 — 까닭 글', async () => {
|
||||
const { wrapper } = await mountWith()
|
||||
useMailFilterRunStore().running = true
|
||||
|
||||
const dialog = await openBlockDialog(wrapper)
|
||||
expect(dialog.get('[data-block-clean]').attributes('aria-disabled')).toBe('true')
|
||||
expect(dialog.get('#block-dialog-clean-note').text()).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
|
||||
})
|
||||
|
||||
it('이 탭에서 스팸메일함을 비우는 중이면 새 칸이 꺼져 있다 — 그 까닭 글, 비우기가 끝나면 다시 고를 수 있다', async () => {
|
||||
const emptying = deferred()
|
||||
const { wrapper, store } = await mountWith({
|
||||
extra: (request) =>
|
||||
request.method === 'DELETE' && request.url === '/mailboxes/junk/messages' ? emptying.promise : undefined,
|
||||
})
|
||||
const empty = store.runMailboxOperation('empty', JUNK, () => undefined)
|
||||
|
||||
const dialog = await openBlockDialog(wrapper)
|
||||
expect(dialog.get('[data-block-clean]').attributes('aria-disabled')).toBe('true')
|
||||
expect(dialog.get('#block-dialog-clean-note').text()).toBe('스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.')
|
||||
|
||||
emptying.resolve(ok({ processed: 0, remaining: 0 }))
|
||||
await empty
|
||||
await flushPromises()
|
||||
expect(dialog.get('[data-block-clean]').attributes('aria-disabled')).toBeUndefined()
|
||||
expect(dialog.get('#block-dialog-clean-note').text()).toBe('옮긴 메일은 한꺼번에 되돌릴 수 없습니다.')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -565,7 +565,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
|
||||
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
|
||||
|
||||
it('도구 줄 — 답장 · 전체 답장 · 전달 | 스팸 신고 · 삭제 · ⋯(더 보기)', async () => {
|
||||
it('도구 줄 — 답장 · 전체 답장 · 전달 | 이동 ▾(15단계) · 스팸 신고 · 삭제 · ⋯(더 보기)', async () => {
|
||||
const { wrapper } = await mountWith()
|
||||
const toolbar = wrapper.get('.reader__toolbar')
|
||||
|
||||
@@ -573,6 +573,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
'답장',
|
||||
'전체 답장',
|
||||
'전달',
|
||||
'이동',
|
||||
'스팸 신고',
|
||||
'삭제',
|
||||
])
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { Mailbox, MessageDetail, MessagesPatch, MessageSummary } 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, type MockRequest } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageReader from './MessageReader.vue'
|
||||
|
||||
// 15단계 — 읽기 화면의 [이동 ▾] · 좁은 읽기 칸의 아이콘만 도구 줄 (디자인 원본 Reader15 "1 · 2 · 3")
|
||||
|
||||
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 3, unreadCount: 0, ...partial }
|
||||
}
|
||||
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
box('inbox', 'inbox', '받은편지함'),
|
||||
box('sent', 'sent', '보낸편지함'),
|
||||
box('drafts', 'drafts', '임시보관함'),
|
||||
box('junk', 'junk', '스팸메일함'),
|
||||
box('trash', 'trash', '휴지통'),
|
||||
box('clients', null, '거래처'),
|
||||
box('clients-2025', null, '2025', { parentId: 'clients' }),
|
||||
box('quotes', null, '견적', { parentId: 'clients' }),
|
||||
box('project', null, '프로젝트', { sortOrder: 1 }),
|
||||
box('project-2025', null, '2025', { parentId: 'project' }),
|
||||
]
|
||||
|
||||
function message(id: string, mailboxId: string, partial: Partial<MessageSummary> = {}): MessageDetail {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: [mailboxId],
|
||||
from: { name: '(주)한빛솔루션 영업팀', email: 'sales@example.com' },
|
||||
to: [{ name: '김아라', email: 'ara.kim@arasystem.it.kr' }],
|
||||
subject: `2025년 유지보수 정산 자료 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-11-09T01:42:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
pendingSend: null,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: null,
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '본문',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MessageReader — [이동 ▾] (15단계)', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) wrapper.unmount()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountReader(
|
||||
options: { box?: string; layout?: 'split' | 'list'; open?: Partial<MessageSummary> } = {},
|
||||
) {
|
||||
const boxId = options.box ?? 'inbox'
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 10 } })
|
||||
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: [] })
|
||||
return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES
|
||||
store.listSource = { kind: 'mailbox', mailboxId: boxId }
|
||||
const open = message('m1', boxId, options.open)
|
||||
store.messages = [open, message('m2', boxId)] as MessageSummary[]
|
||||
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/${boxId}/m1`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, {
|
||||
props: { layout: options.layout ?? 'split' },
|
||||
slots: options.layout === 'list' ? { 'toolbar-start': () => h('a', { href: `/mail/${boxId}` }, '목록으로') } : {},
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const patches = () =>
|
||||
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
|
||||
const toastTexts = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
|
||||
async function openMenu(wrapper: VueWrapper) {
|
||||
await wrapper.get('[data-reader-move]').trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
return wrapper.get('[role="menu"]')
|
||||
}
|
||||
|
||||
async function pick(wrapper: VueWrapper, label: string) {
|
||||
const menu = await openMenu(wrapper)
|
||||
const item = menu.findAll('[role="menuitem"]').find((entry) => (entry.attributes('aria-label') ?? entry.text()) === label)
|
||||
if (!item) throw new Error(`${label} 항목이 없다`)
|
||||
await item.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('[전달] 뒤 구분선 다음, [스팸 신고] 앞 — 메뉴를 여는 단추(▾)이고 키보드로 열면 첫 항목에 초점', async () => {
|
||||
const { wrapper } = await mountReader()
|
||||
const toolbar = wrapper.get('.reader__toolbar')
|
||||
const move = wrapper.get('[data-reader-move]')
|
||||
|
||||
const order = [...toolbar.element.children].map((child) =>
|
||||
child.matches('.toolbar-divider') ? '|' : (child.textContent ?? '').trim() || (child.getAttribute('aria-label') ?? ''),
|
||||
)
|
||||
expect(order.slice(0, 7)).toEqual(['답장', '전체 답장', '전달', '|', '이동', '스팸 신고', '삭제'])
|
||||
expect(move.attributes('aria-haspopup')).toBe('menu')
|
||||
expect(move.attributes('aria-expanded')).toBe('false')
|
||||
expect(move.find('.tool-button__chevron').exists()).toBe(true)
|
||||
|
||||
const menu = await openMenu(wrapper)
|
||||
expect(menu.attributes('aria-label')).toBe('옮길 메일함')
|
||||
expect(document.activeElement).toBe(menu.find('[role="menuitem"]').element)
|
||||
expect(move.attributes('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('옮길 곳은 메일함 패널처럼 들여 쓴다 — 그 메일이 든 받은편지함 · 임시보관함 · 휴지통은 없고, 기본 메일함과 내 메일함 사이에 선', async () => {
|
||||
const { wrapper } = await mountReader()
|
||||
const menu = await openMenu(wrapper)
|
||||
|
||||
const entries = [...menu.element.children].map((child) => {
|
||||
if (child.tagName === 'HR') return '---'
|
||||
const depth = [...child.classList].find((name) => name.startsWith('menu__item--depth-'))
|
||||
return `${child.textContent?.trim()}${depth ? ` (${depth.slice(-1)})` : ''}`
|
||||
})
|
||||
expect(entries).toEqual(['보낸편지함', '스팸메일함', '---', '거래처', '2025 (1)', '견적 (1)', '프로젝트', '2025 (1)'])
|
||||
// 들여 쓴 메일함은 위 메일함부터 읽는다 — 같은 이름이 둘이어도 가른다
|
||||
const labels = menu.findAll('[role="menuitem"]').map((item) => item.attributes('aria-label') ?? null)
|
||||
expect(labels).toEqual([null, null, null, '거래처 안의 2025', '거래처 안의 견적', null, '프로젝트 안의 2025'])
|
||||
// 앞에 패널과 같은 아이콘
|
||||
expect(menu.findAll('[role="menuitem"] .menu__icon')).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('그 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 흐린 줄 "이 메일이 있는 메일함" — 눌러도 옮기지 않는다', async () => {
|
||||
const { wrapper } = await mountReader({ box: 'clients' })
|
||||
const menu = await openMenu(wrapper)
|
||||
|
||||
const here = menu.findAll('[role="menuitem"]').find((item) => item.text().startsWith('거래처'))
|
||||
expect(here?.attributes('aria-disabled')).toBe('true')
|
||||
expect(here?.get('.menu__note').text()).toBe('이 메일이 있는 메일함')
|
||||
expect(here?.attributes('aria-describedby')).toBe(here?.get('.menu__note').attributes('id'))
|
||||
// 받은편지함이 처음으로 든다 (그 메일은 거래처에 있다)
|
||||
expect(menu.findAll('[role="menuitem"]')[0]?.text()).toBe('받은편지함')
|
||||
|
||||
await here?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(patches()).toEqual([])
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('옮기면 [삭제]와 같은 길 — 다음 메일을 열고 "메일 1통을 거래처 › 2025로 옮겼습니다."(같은 이름이 또 있어서) + [되돌리기]', async () => {
|
||||
const { wrapper, router, store } = await mountReader()
|
||||
|
||||
await pick(wrapper, '거래처 안의 2025')
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'clients-2025' }])
|
||||
expect(store.messages.map((item) => item.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 거래처 › 2025로 옮겼습니다.')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox' })
|
||||
expect(toastTexts().at(-1)).toBe('메일 1통을 받은편지함으로 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('같은 이름이 하나뿐이면 이름만 — "메일 1통을 견적으로 옮겼습니다." · 목록 보기는 목록으로 돌아간다', async () => {
|
||||
const { wrapper, router } = await mountReader({ layout: 'list' })
|
||||
|
||||
await pick(wrapper, '거래처 안의 견적')
|
||||
|
||||
expect(toastTexts()).toEqual(['메일 1통을 견적으로 옮겼습니다.'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('스팸메일함을 고르면 [스팸 신고]와 같다 — 스팸 표시를 함께 보내고 [되돌리기]는 표시도 되돌린다', async () => {
|
||||
const { wrapper } = await mountReader()
|
||||
|
||||
await pick(wrapper, '스팸메일함')
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }])
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 스팸메일함으로 옮겼습니다.')
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox', isJunk: false })
|
||||
})
|
||||
|
||||
it('스팸메일함에서 휴지통 밖으로 꺼내면 [스팸 아님]과 같다 — "스팸이 아닌 메일로 표시해 …로 옮겼습니다."', async () => {
|
||||
const { wrapper } = await mountReader({ box: 'junk' })
|
||||
|
||||
await pick(wrapper, '프로젝트')
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'project', isJunk: false }])
|
||||
expect(toastTexts()).toEqual(['메일 1통을 스팸이 아닌 메일로 표시해 프로젝트로 옮겼습니다.'])
|
||||
})
|
||||
|
||||
it('보내기를 기다리는 메일(보내는 중 · 예약)에는 [이동 ▾]가 없다 — [스팸 신고]처럼', async () => {
|
||||
const sendAt = '2026-11-10T00:00:00Z'
|
||||
const { wrapper } = await mountReader({ box: 'sent', open: { pendingSend: { kind: 'undo', sendAt } } })
|
||||
|
||||
expect(wrapper.find('[data-reader-move]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-reader-spam]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader — 좁은 읽기 칸의 아이콘만 도구 줄 (15단계 — 디자인 원본 Reader15 "2")', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountWith(boxId: string, layout: 'split' | 'list' = 'split') {
|
||||
const store = useMailStore()
|
||||
store.mailboxes = MAILBOXES
|
||||
store.listSource = { kind: 'mailbox', mailboxId: boxId }
|
||||
const open = message('m1', boxId)
|
||||
store.selected = open
|
||||
store.selectedId = open.id
|
||||
store.selectedStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${boxId}/m1`)
|
||||
await router.isReady()
|
||||
return mount(MessageReader, { props: { layout }, global: { plugins: [pinia, router] } })
|
||||
}
|
||||
|
||||
it('도구 줄의 이름 단추는 이름을 숨겨도 읽히게 aria-label · title 을 둔다 (숨기는 때는 읽기 칸 폭의 컨테이너 쿼리)', async () => {
|
||||
const wrapper = await mountWith('inbox')
|
||||
|
||||
const buttons = wrapper.findAll('.reader__toolbar .tool-button--collapsible')
|
||||
expect(buttons.map((button) => [button.attributes('aria-label'), button.attributes('title')])).toEqual([
|
||||
['답장', '답장'],
|
||||
['전체 답장', '전체 답장'],
|
||||
['전달', '전달'],
|
||||
['이동', '이동'],
|
||||
['스팸 신고', '스팸 신고'],
|
||||
['삭제', '삭제'],
|
||||
])
|
||||
// 이름은 따로 감싼 글 — 좁으면 이것만 숨긴다
|
||||
expect(buttons.map((button) => button.get('.tool-button__label').text())).toEqual([
|
||||
'답장',
|
||||
'전체 답장',
|
||||
'전달',
|
||||
'이동',
|
||||
'스팸 신고',
|
||||
'삭제',
|
||||
])
|
||||
})
|
||||
|
||||
it('접는 폭을 가르는 모양 — 분할 보기 · 목록 보기, 「영구 삭제」가 있는 휴지통은 따로', async () => {
|
||||
const inbox = await mountWith('inbox')
|
||||
expect(inbox.get('.reader__toolbar').classes()).toEqual(['reader__toolbar', 'reader__toolbar--split'])
|
||||
inbox.unmount()
|
||||
|
||||
const trash = await mountWith('trash')
|
||||
expect(trash.get('.reader__toolbar').classes()).toContain('reader__toolbar--long')
|
||||
expect(trash.get('[data-reader-delete]').text()).toBe('영구 삭제')
|
||||
trash.unmount()
|
||||
|
||||
const list = await mountWith('inbox', 'list')
|
||||
expect(list.get('.reader__toolbar').classes()).toContain('reader__toolbar--list')
|
||||
list.unmount()
|
||||
})
|
||||
})
|
||||
@@ -289,12 +289,12 @@ describe('MessageReader — 9단계', () => {
|
||||
describe('예약한 메일 — 청록 안내 줄 · 답장 · 전달 · 스팸 신고 없음 · 삭제는 예약 취소 뒤 휴지통 (디자인 원본 MailSendLater)', () => {
|
||||
const cancels = () => requests.filter((request) => request.method === 'DELETE' && request.url.startsWith('/scheduled-messages/'))
|
||||
|
||||
it('머리 아래 "10월 30일 (금) 오전 9:00에 보낼 메일입니다."(조사는 붙여 쓴다) + [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
|
||||
it('머리 아래 "10월 30일 (금) 오전 9:00에 보낼 메일입니다."(조사는 붙여 쓴다) + [지금 보내기](15단계) [고치기] [예약 취소] — 도구 줄은 삭제 · ⋯ 뿐이다', async () => {
|
||||
const { wrapper } = await mountWith({ message: SCHEDULED, source: { kind: 'scheduled' } })
|
||||
const notice = wrapper.get('[data-notice="scheduled"]')
|
||||
|
||||
expect(notice.get('.reader__notice-text').text()).toBe(`${formatScheduleTime(SEND_AT)}에 보낼 메일입니다.`)
|
||||
expect(notice.findAll('button').map((button) => button.text())).toEqual(['고치기', '예약 취소'])
|
||||
expect(notice.findAll('button').map((button) => button.text())).toEqual(['지금 보내기', '고치기', '예약 취소'])
|
||||
expect(wrapper.findAll('.reader__toolbar .tool-button').map((button) => button.text())).toEqual(['삭제'])
|
||||
expect(wrapper.find('[data-reader-more]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
|
||||
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
|
||||
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
|
||||
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 인쇄(9단계) · 원문 내려받기 ·
|
||||
// 도구 줄: 답장 · 전체 답장 · 전달 | 이동 ▾(15단계) · 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 인쇄(9단계) · 원문 내려받기 ·
|
||||
// 보낸 사람을 주소록에 추가(8단계) · 이런 메일로 필터 만들기 · 보낸 사람 차단(9단계))
|
||||
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길" · "9단계 › 읽기 화면").
|
||||
// 아직 보내지 않은 메일(9단계 — 보내는 중 · 예약)은 스팸 신고가 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다. 「삭제」는 확인 창 뒤 발송을 취소하고 휴지통으로.
|
||||
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길" · "9단계 › 읽기 화면" · "15단계").
|
||||
// 아직 보내지 않은 메일(9단계 — 보내는 중 · 예약)은 이동 · 스팸 신고가 없다 — 옮기거나 지워도 엔진은 그 시각에 보낸다. 「삭제」는 확인 창 뒤 발송을 취소하고 휴지통으로.
|
||||
// 읽기 칸이 좁으면 도구 줄의 이름을 숨기고 아이콘만 둔다(15단계 — 디자인 원본 Reader15 "2"). 기준은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리 —
|
||||
// 도구 줄 띠가 그 칸이다): 이름을 한 줄에 다 보일 폭 570(도구 줄 좌우 여백 포함), 「영구 삭제」가 있으면 595. 목록 보기는 앞에 [목록으로] · 이전/다음이
|
||||
// 함께 놓여 850 · 875 이고, 그 폭에서 한 줄에 들도록 단추 줄의 최대 폭(콘텐츠 열 800)을 풀어 둔다. 이름은 aria-label · title 로 읽힌다 (MailToolButton 의 collapsible)
|
||||
// 예약한 메일은 답장 · 전체 답장 · 전달도 없고, 머리 아래 청록 안내 줄([고치기] [예약 취소])이 있다.
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
|
||||
// 머리 아래 안내 줄(예약 · 차단한 보낸 사람 · 본문 한도 · 외부 이미지 [이미지 보기])은 MessageReaderNotices.
|
||||
@@ -29,10 +32,12 @@ import MessageAttachments from '@/components/mail/MessageAttachments.vue'
|
||||
import MessageConversation from '@/components/mail/MessageConversation.vue'
|
||||
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
|
||||
import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
|
||||
import MessageMoveButton from '@/components/mail/MessageMoveButton.vue'
|
||||
import MessageReaderNotices from '@/components/mail/MessageReaderNotices.vue'
|
||||
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { useMailPrint } from '@/composables/useMailPrint'
|
||||
import { useMessageRemoval } from '@/composables/useMessageRemoval'
|
||||
import { MAIL_NO_SENDER, MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
|
||||
@@ -49,21 +54,30 @@ interface Props {
|
||||
layout?: 'split' | 'list'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'split',
|
||||
})
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const { backToSourceList, replaceWithMailbox } = useMailNavigation()
|
||||
const print = useMailPrint()
|
||||
const removal = useMessageRemoval()
|
||||
|
||||
const bodyFrameRef = ref<InstanceType<typeof MailBodyFrame> | null>(null)
|
||||
|
||||
const message = computed(() => mailStore.selected)
|
||||
/** 예약한 메일 — 답장 · 전달이 없다 (9단계) */
|
||||
const scheduled = computed(() => isScheduledMessage(message.value))
|
||||
/** 아직 보내지 않은 메일(보내는 중 · 예약) — 스팸 신고(옮기기)가 없다 */
|
||||
/** 아직 보내지 않은 메일(보내는 중 · 예약) — 이동 · 스팸 신고(옮기기)가 없다 */
|
||||
const held = computed(() => isHeldMessage(message.value))
|
||||
/** 도구 줄이 「영구 삭제」를 보인다 — 이름이 길어 더 넓은 폭에서 아이콘만으로 접는다 (MessageDeleteButton 과 같은 판정) */
|
||||
const longDelete = computed(() => Boolean(message.value && !held.value && removal.deletesPermanently(message.value)))
|
||||
/** 도구 줄의 모양 — 보기 방식(목록 보기는 앞에 [목록으로] · 이전/다음)과 「영구 삭제」에 따라 아이콘만으로 접는 폭이 다르다 */
|
||||
const toolbarClass = computed(() => [
|
||||
'reader__toolbar',
|
||||
`reader__toolbar--${props.layout}`,
|
||||
{ 'reader__toolbar--long': longDelete.value },
|
||||
])
|
||||
/** 검색 결과에서 연 메일의 메일함 이름 — 메일함 목록에서 열었으면 null */
|
||||
const searchMailboxName = computed(() => {
|
||||
if (!mailStore.search || !message.value) return null
|
||||
@@ -128,7 +142,7 @@ onBeforeUnmount(() => {
|
||||
class="reader__toolbar-bar"
|
||||
>
|
||||
<div
|
||||
class="reader__toolbar"
|
||||
:class="toolbarClass"
|
||||
role="group"
|
||||
aria-label="이 메일로 할 일"
|
||||
>
|
||||
@@ -138,24 +152,32 @@ onBeforeUnmount(() => {
|
||||
<MailToolButton
|
||||
icon="reply"
|
||||
label="답장"
|
||||
collapsible
|
||||
:to="{ name: 'mail-compose', query: { replyTo: message.id } }"
|
||||
/>
|
||||
<MailToolButton
|
||||
icon="reply-all"
|
||||
label="전체 답장"
|
||||
collapsible
|
||||
:to="{ name: 'mail-compose', query: { replyTo: message.id, all: '1' } }"
|
||||
/>
|
||||
<MailToolButton
|
||||
icon="forward"
|
||||
label="전달"
|
||||
collapsible
|
||||
:to="{ name: 'mail-compose', query: { forward: message.id } }"
|
||||
/>
|
||||
<MailToolbarDivider v-if="layout === 'split'" />
|
||||
<MessageSpamButton
|
||||
v-if="!held"
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
<template v-if="!held">
|
||||
<MessageMoveButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
<MessageSpamButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<MessageDeleteButton
|
||||
:message="message"
|
||||
@@ -325,11 +347,15 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 툴바 띠 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다 */
|
||||
/*
|
||||
* 툴바 띠 — 칸 전체 폭으로 아래 선을 긋고, 스크롤해도 위에 남는다.
|
||||
* 띠가 읽기 칸 폭의 컨테이너다(15단계) — 단추 이름을 숨길지 이 폭으로 정한다 (아래 @container). 크기 봉쇄는 가로뿐이라 메뉴는 띠 밖으로 나온다
|
||||
*/
|
||||
.reader__toolbar-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
container: reader-tools / inline-size;
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
@@ -345,6 +371,75 @@ onBeforeUnmount(() => {
|
||||
padding: var(--space-4) var(--content-inline) var(--space-4) var(--content-toolbar-start);
|
||||
}
|
||||
|
||||
/* 목록 보기 — 앞의 [목록으로] · 이전/다음까지 이름이 다 보이는 줄은 콘텐츠 열(800)보다 조금 넓다. 줄의 최대 폭을 풀어 넓은 칸에서는 한 줄에 둔다 */
|
||||
.reader__toolbar--list {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* 읽기 칸이 단추 이름을 한 줄에 다 보일 폭보다 좁으면 — 이름을 숨기고 아이콘만 40×44 ([이동 ▾]는 ▾ 까지 남긴다).
|
||||
* 폭(15단계 — 디자인 원본 Reader15 "2" 실측, 도구 줄 좌우 여백 포함): 분할 보기 570 · 「영구 삭제」 595, 목록 보기는 앞의 [목록으로] · 이전/다음을 더해 850 · 875
|
||||
*/
|
||||
@container reader-tools (width < 35.625rem) {
|
||||
.reader__toolbar :deep(.tool-button--collapsible) {
|
||||
justify-content: center;
|
||||
min-width: var(--size-icon-button);
|
||||
}
|
||||
|
||||
.reader__toolbar :deep(.tool-button--collapsible:not(.tool-button--menu)) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reader__toolbar :deep(.tool-button--collapsible .tool-button__label) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container reader-tools (width < 37.1875rem) {
|
||||
.reader__toolbar--long :deep(.tool-button--collapsible) {
|
||||
justify-content: center;
|
||||
min-width: var(--size-icon-button);
|
||||
}
|
||||
|
||||
.reader__toolbar--long :deep(.tool-button--collapsible:not(.tool-button--menu)) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reader__toolbar--long :deep(.tool-button--collapsible .tool-button__label) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container reader-tools (width < 53.125rem) {
|
||||
.reader__toolbar--list :deep(.tool-button--collapsible) {
|
||||
justify-content: center;
|
||||
min-width: var(--size-icon-button);
|
||||
}
|
||||
|
||||
.reader__toolbar--list :deep(.tool-button--collapsible:not(.tool-button--menu)) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reader__toolbar--list :deep(.tool-button--collapsible .tool-button__label) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container reader-tools (width < 54.6875rem) {
|
||||
.reader__toolbar--list.reader__toolbar--long :deep(.tool-button--collapsible) {
|
||||
justify-content: center;
|
||||
min-width: var(--size-icon-button);
|
||||
}
|
||||
|
||||
.reader__toolbar--list.reader__toolbar--long :deep(.tool-button--collapsible:not(.tool-button--menu)) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reader__toolbar--list.reader__toolbar--long :deep(.tool-button--collapsible .tool-button__label) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 콘텐츠 열 — 최대 800px · 좌우 32px · 위 20px · 아래 36px, 영역 안 왼쪽 정렬 */
|
||||
.reader__content {
|
||||
display: flex;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면 머리 아래의 안내 줄 (디자인 원본 MailReadTools · MailSendLater · README "9단계" · "읽기 화면의 본문 안내")
|
||||
// 차례: 예약한 메일(청록 — [고치기] [예약 취소]) → 차단한 보낸 사람의 메일(회색 — 스팸메일함에서만, [차단 풀기]) →
|
||||
// 차례: 예약한 메일(청록 — [지금 보내기](15단계) [고치기] [예약 취소]) → 차단한 보낸 사람의 메일(회색 — 스팸메일함에서만, [차단 풀기]) →
|
||||
// 본문이 다 보이는지(생략 → 잘림 — 계약 2절 "읽기 한도") → 외부 이미지 차단(노랑 — [이미지 보기]).
|
||||
// - [이미지 보기](36 — 밀도 예외 --size-notice-action): 그 메일만 `images=show` 로 다시 받아 본문만 바꾼다(스토어의 showImages — 늦은 응답은 버린다).
|
||||
// 보이면(imagesShown — 설정으로 처음부터 보인 것 포함) 이 줄은 없다. 다시 열면 설정대로다
|
||||
// - 예약 안내 줄의 단추는 붙잡은 발송 취소(useScheduledMessageActions) — 하는 동안은 두 단추를 함께 막는다(aria-disabled · aria-busy)
|
||||
// - 단추를 누른 뒤 줄이 사라지면 초점을 본문으로 돌려 달라고 알린다(settled — 읽기 화면이 옮긴다. [이미지 보기] · [차단 풀기] · [고치기] · [예약 취소])
|
||||
// - 예약 안내 줄의 단추는 지금 보내기 · 붙잡은 발송 취소(useScheduledMessageActions) — 하는 동안은 세 단추를 함께 막는다(aria-disabled · aria-busy)
|
||||
// - 단추를 누른 뒤 줄이 사라지면 초점을 본문으로 돌려 달라고 알린다(settled — 읽기 화면이 옮긴다. [이미지 보기] · [차단 풀기] · [지금 보내기] · [고치기] · [예약 취소])
|
||||
// 줄 안의 단추는 disabled 대신 aria-disabled — 초점이 그대로 남게 (누르면 아무것도 하지 않는다)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -42,7 +42,7 @@ const scheduledActions = useScheduledMessageActions()
|
||||
const senderBlock = useSenderBlock()
|
||||
|
||||
/** 예약 줄 · 차단 줄의 단추가 하는 중 */
|
||||
const working = ref<'edit' | 'cancel' | 'unblock' | null>(null)
|
||||
const working = ref<'send' | 'edit' | 'cancel' | 'unblock' | null>(null)
|
||||
|
||||
const pending = computed(() => messagePendingSend(props.message))
|
||||
const scheduledTime = computed(() =>
|
||||
@@ -63,16 +63,18 @@ async function showImages() {
|
||||
if (await mailStore.showImages()) emit('settled')
|
||||
}
|
||||
|
||||
/** [고치기] · [예약 취소] — 취소했으면 이 줄이 단추와 함께 사라진다(초점을 옮겨 달라 — 다른 화면 · 메일로 갔으면 읽기 화면이 그대로 둔다) */
|
||||
async function runScheduled(kind: 'edit' | 'cancel') {
|
||||
/**
|
||||
* [지금 보내기] · [고치기] · [예약 취소] — 했으면 이 줄이 단추와 함께 사라진다(초점을 옮겨 달라 — 다른 화면 · 메일로 갔으면 읽기 화면이 그대로 둔다).
|
||||
* 지금 보내기(15단계)는 예약을 취소하지 않고 보낸다 — 보내기 취소를 기다리는 동안은 보낸편지함 날짜 칸이 "보내는 중"이다
|
||||
*/
|
||||
async function runScheduled(kind: 'send' | 'edit' | 'cancel') {
|
||||
if (working.value) return
|
||||
working.value = kind
|
||||
let done = false
|
||||
try {
|
||||
done =
|
||||
kind === 'edit'
|
||||
? await scheduledActions.editScheduled(props.message.id)
|
||||
: await scheduledActions.cancelScheduled(props.message, props.layout)
|
||||
if (kind === 'send') done = await scheduledActions.sendNow(props.message, props.layout)
|
||||
else if (kind === 'edit') done = await scheduledActions.editScheduled(props.message.id)
|
||||
else done = await scheduledActions.cancelScheduled(props.message, props.layout)
|
||||
} finally {
|
||||
working.value = null
|
||||
}
|
||||
@@ -103,26 +105,39 @@ async function unblock() {
|
||||
class="reader__notice-icon"
|
||||
/>
|
||||
<span class="reader__notice-text reader__notice-text--strong">{{ SEND_LATER_TEXT.notice(scheduledTime) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="reader__notice-action"
|
||||
:aria-disabled="working ? 'true' : undefined"
|
||||
:aria-busy="working === 'edit' ? 'true' : undefined"
|
||||
data-scheduled-edit
|
||||
@click="runScheduled('edit')"
|
||||
>
|
||||
{{ SEND_LATER_TEXT.edit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="reader__notice-action"
|
||||
:aria-disabled="working ? 'true' : undefined"
|
||||
:aria-busy="working === 'cancel' ? 'true' : undefined"
|
||||
data-scheduled-cancel
|
||||
@click="runScheduled('cancel')"
|
||||
>
|
||||
{{ SEND_LATER_TEXT.cancelSchedule }}
|
||||
</button>
|
||||
<!-- 단추 셋은 한 묶음 — 줄이 좁으면 묶음째 다음 줄 오른쪽으로 (15단계 — 글이 한 글자 폭으로 눌리지 않게) -->
|
||||
<span class="reader__notice-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="reader__notice-action"
|
||||
:aria-disabled="working ? 'true' : undefined"
|
||||
:aria-busy="working === 'send' ? 'true' : undefined"
|
||||
data-scheduled-send
|
||||
@click="runScheduled('send')"
|
||||
>
|
||||
{{ SEND_LATER_TEXT.sendNow }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="reader__notice-action"
|
||||
:aria-disabled="working ? 'true' : undefined"
|
||||
:aria-busy="working === 'edit' ? 'true' : undefined"
|
||||
data-scheduled-edit
|
||||
@click="runScheduled('edit')"
|
||||
>
|
||||
{{ SEND_LATER_TEXT.edit }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="reader__notice-action"
|
||||
:aria-disabled="working ? 'true' : undefined"
|
||||
:aria-busy="working === 'cancel' ? 'true' : undefined"
|
||||
data-scheduled-cancel
|
||||
@click="runScheduled('cancel')"
|
||||
>
|
||||
{{ SEND_LATER_TEXT.cancelSchedule }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -216,6 +231,7 @@ async function unblock() {
|
||||
|
||||
/* 예약한 메일 — 청록 (디자인 원본 MailSendLater: 바탕 #E3F2F3 · 글 #0A5F67 · 단추 테두리 #9ED6DB) */
|
||||
.reader__notice--brand {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-8);
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
@@ -223,6 +239,19 @@ async function unblock() {
|
||||
--reader-notice-button-hover: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
/* 단추 셋 — [지금 보내기] [고치기] [예약 취소] (15단계). 넓으면 글 오른쪽 한 줄, 좁으면 묶음째 다음 줄 오른쪽 */
|
||||
.reader__notice-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: var(--space-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 글이 이 폭보다 좁아지면 단추 묶음이 다음 줄로 간다 */
|
||||
.reader__notice--brand .reader__notice-text {
|
||||
flex-basis: 12rem;
|
||||
}
|
||||
|
||||
/* 차단한 보낸 사람 — 회색 (디자인 원본 MailReadTools: 바탕 #F2F4F7 · 글 #344054 · 단추 테두리 #D0D5DD) */
|
||||
.reader__notice--muted {
|
||||
background: var(--color-surface-muted);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// - 그 밖은 「스팸 신고」 — 스팸메일함으로 옮기고 스팸 표시. 알림에 [되돌리기](원래 메일함 · 원래 표시로)
|
||||
// - 옮길 메일함(스팸메일함 · 받은편지함)을 모르면(메일함 목록을 아직 못 받음) 그리지 않는다
|
||||
// - 옮긴 뒤: 삭제와 같다 — 분할 보기는 다음 메일, 목록 보기는 목록으로 (leaveRemoved)
|
||||
// - 읽기 칸이 좁으면 아이콘만 (15단계 — MailToolButton 의 collapsible · MessageReader 의 컨테이너 쿼리)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
@@ -49,6 +50,7 @@ async function onClick() {
|
||||
:label="inJunk ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report"
|
||||
:tone="inJunk ? 'accent' : 'default'"
|
||||
:disabled="working"
|
||||
collapsible
|
||||
data-reader-spam
|
||||
@click="onClick"
|
||||
/>
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
// 고를 수 없다 — 고르기 상자는 꺼진 모양(aria-disabled · 까닭은 title), Ctrl · Shift + 클릭도 고르지 않는다 (옮기거나 지워도 엔진은 그 시각에 보낸다)
|
||||
// - 검색 결과(14단계)는 제목의 맞은 말을 <mark>(HighlightedText — searchHighlight 의 위치)로 그린다. 행 높이(38)는 그대로 — 미리 보기 줄은 분할 보기만
|
||||
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
|
||||
// 끌어다 놓기(15단계 — 디자인 원본 ListDrag15): 줄을 메일함 패널로 끌어 옮긴다 — 고른 줄이면 고른 것 모두, 아니면 이 줄만.
|
||||
// 보내기를 기다리는 메일의 줄은 끌 수 없다. 줄의 링크가 끌어 가던 메일 주소 대신 우리 끌기 자료만 싣는다 (useMailRowDrag)
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import HighlightedText from '@/components/mail/HighlightedText.vue'
|
||||
import MailCheckbox from '@/components/mail/MailCheckbox.vue'
|
||||
import { useMailRowDrag } from '@/composables/useMailDrag'
|
||||
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
|
||||
@@ -45,6 +48,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const drag = useMailRowDrag()
|
||||
|
||||
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))
|
||||
@@ -80,8 +85,11 @@ function onMouseDown(event: MouseEvent) {
|
||||
<tr
|
||||
:class="['mail-table__row', { 'is-unread': unread, 'is-checked': checked }]"
|
||||
:data-row-id="message.id"
|
||||
:draggable="!held"
|
||||
@click="onRowClick"
|
||||
@mousedown="onMouseDown"
|
||||
@dragstart="(event: DragEvent) => drag.start(event, message.id)"
|
||||
@dragend="drag.end"
|
||||
>
|
||||
<td class="mail-table__check">
|
||||
<!-- 폭 32px · 행 높이를 채우는 고르기 상자 — 44px 기준의 의도된 예외 (디자인 README "3단계", 별 칸과 같다) -->
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯
|
||||
// 꺼진 항목(disabled)은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴 권고).
|
||||
// 누를 수 없는 까닭은 이름 아래 작은 글(note)이고 항목이 aria-describedby 로 가리킨다 — 꺼진 항목이어도 그 글은 흐리지 않는다
|
||||
// 들여 쓴 항목(depth — 15단계 [이동 ▾]의 메일함 나무)은 메일함 패널과 같은 10 · 28 · 44 · 56 에서 시작하고, 화면 낭독 이름이 따로 있으면(ariaLabel) 그것으로 읽힌다
|
||||
import { useId } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -43,6 +44,14 @@ function noteId(itemId: string): string {
|
||||
return `${menuId}-${itemId}-note`
|
||||
}
|
||||
|
||||
/** 들여쓰기는 3단계까지 — 그 아래는 같은 깊이로 (메일함 패널과 같다) */
|
||||
const MAX_MENU_DEPTH = 3
|
||||
|
||||
function depthClass(depth: number | undefined): string | undefined {
|
||||
if (!depth || depth <= 0) return undefined
|
||||
return `menu__item--depth-${Math.min(depth, MAX_MENU_DEPTH)}`
|
||||
}
|
||||
|
||||
/** 꺼진 항목은 골라도 아무것도 하지 않는다 (메뉴도 닫지 않는다) */
|
||||
function select(item: EditorMenuItem, event: MouseEvent) {
|
||||
if (item.separator || item.disabled) return
|
||||
@@ -70,8 +79,13 @@ function select(item: EditorMenuItem, event: MouseEvent) {
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger', 'menu__item--noted': item.note }]"
|
||||
:class="[
|
||||
'menu__item',
|
||||
depthClass(item.depth),
|
||||
{ 'menu__item--danger': item.tone === 'danger', 'menu__item--noted': item.note },
|
||||
]"
|
||||
:role="item.role ?? 'menuitem'"
|
||||
:aria-label="item.ariaLabel"
|
||||
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
|
||||
:aria-haspopup="item.opens"
|
||||
:aria-disabled="item.disabled ? 'true' : undefined"
|
||||
@@ -154,6 +168,19 @@ function select(item: EditorMenuItem, event: MouseEvent) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
/* 들여 쓴 항목 — 메일함 패널과 같은 왼쪽 안쪽 (MailboxPanelRow) */
|
||||
.menu__item--depth-1 {
|
||||
padding-left: var(--space-28);
|
||||
}
|
||||
|
||||
.menu__item--depth-2 {
|
||||
padding-left: var(--space-44);
|
||||
}
|
||||
|
||||
.menu__item--depth-3 {
|
||||
padding-left: var(--space-56);
|
||||
}
|
||||
|
||||
.menu__item[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
|
||||
import EditorMenu from './EditorMenu.vue'
|
||||
|
||||
// 15단계 — 메뉴 자리의 좌우 바꾸기 (디자인 원본 Reader15 2-4): 바라는 끝에 맞춰 그린 메뉴가 보이는 영역 밖으로 넘치면 반대 끝에 맞춘다.
|
||||
// EditorPopup 을 쓰는 메뉴 · 팝오버 모두에 걸린다 — 여기서는 EditorMenu 로 본다
|
||||
|
||||
function rect(left: number, width: number, top = 0, height = 40): DOMRect {
|
||||
return { left, right: left + width, width, top, bottom: top + height, height, x: left, y: top, toJSON: () => ({}) } as DOMRect
|
||||
}
|
||||
|
||||
/** 단추와 메뉴를 감싼 칸 — 실제 쓰는 곳(MailBulkMoveButton 등)과 같은 모양 */
|
||||
function host(align: 'start' | 'end') {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
const trigger = ref<HTMLButtonElement | null>(null)
|
||||
return () =>
|
||||
h('div', { class: 'host' }, [
|
||||
h('button', { ref: trigger, type: 'button', class: 'trigger' }, '이동'),
|
||||
trigger.value
|
||||
? h(EditorMenu, {
|
||||
label: '옮길 메일함',
|
||||
items: [
|
||||
{ id: 'a', label: '보낸편지함' },
|
||||
{ id: 'b', label: '스팸메일함' },
|
||||
],
|
||||
trigger: trigger.value,
|
||||
align,
|
||||
})
|
||||
: null,
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('EditorPopup — 좌우 자리', () => {
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.restoreAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
/** 화면 폭 360 — 단추(40)는 triggerLeft, 메뉴 폭 200 */
|
||||
function layout(triggerLeft: number) {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(360)
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
if (this.matches('.trigger')) return rect(triggerLeft, 40)
|
||||
if (this.matches('[role="menu"]')) return rect(triggerLeft, 200, 44, 120)
|
||||
return rect(0, 0)
|
||||
})
|
||||
}
|
||||
|
||||
async function open(align: 'start' | 'end') {
|
||||
wrapper = mount(host(align), { attachTo: document.body })
|
||||
// 첫 그리기에서 단추 ref 를 받고, 다음 그리기에서 메뉴를 그린다
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
return wrapper.get('[role="menu"]')
|
||||
}
|
||||
|
||||
it('왼쪽 끝에 맞춘 메뉴가 화면 오른쪽 밖으로 넘치면 단추 오른쪽 끝에 맞춘다', async () => {
|
||||
layout(240)
|
||||
const menu = await open('start')
|
||||
|
||||
expect(menu.classes()).toContain('editor-popup--end')
|
||||
expect(menu.classes()).not.toContain('editor-popup--start')
|
||||
})
|
||||
|
||||
it('다 들어가면 바라는 끝 그대로', async () => {
|
||||
layout(60)
|
||||
const menu = await open('start')
|
||||
|
||||
expect(menu.classes()).toContain('editor-popup--start')
|
||||
})
|
||||
|
||||
it('오른쪽 끝에 맞춘 메뉴(⋯)가 화면 왼쪽 밖으로 넘치면 단추 왼쪽 끝에 맞춘다', async () => {
|
||||
layout(80)
|
||||
const menu = await open('end')
|
||||
|
||||
expect(menu.classes()).toContain('editor-popup--start')
|
||||
})
|
||||
})
|
||||
@@ -5,8 +5,13 @@
|
||||
// - 메뉴(role=menu): 위·아래 화살표 · Home · End 로 항목을 옮기고, Tab 은 메뉴를 닫는다
|
||||
// - 마우스로 열었으면(focusContent) 항목이 아니라 팝오버 자체에 초점을 둔다 — 고른 항목에 키보드 고리가 뜨지 않게.
|
||||
// 화살표를 누르면 그때 항목으로 들어간다 (키보드로 열면 처음부터 항목에 초점)
|
||||
// - 맞출 끝(align)은 바라는 쪽이다 — 그린 직후(초점을 주기 전) 재어, 보이는 영역 밖으로 넘치면 반대 끝에 맞춘다
|
||||
// (15단계 — 좁은 폭의 읽기 화면 [이동 ▾]처럼 왼쪽 끝에 맞춘 메뉴가 화면 오른쪽 밖으로 넘치면 단추 오른쪽 끝에. usePopupPlacement 의 measurePopupAlign)
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { measurePopupAlign } from '@/composables/usePopupPlacement'
|
||||
import type { PopupAlign } from '@/shared/utils/popup-placement'
|
||||
|
||||
interface Props {
|
||||
role: 'menu' | 'dialog'
|
||||
label: string
|
||||
@@ -32,6 +37,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
/** 실제로 맞춘 끝 — 바라는 끝(align)으로 그린 뒤 넘치면 반대 끝 */
|
||||
const placedAlign = ref<PopupAlign>(props.align)
|
||||
|
||||
/**
|
||||
* 화살표로 오가는 항목 — 꺼진 메뉴 항목은 aria-disabled 라 여기에 든다(초점은 받되 고를 수 없다 — WAI-ARIA 메뉴, EditorMenu).
|
||||
@@ -105,8 +112,16 @@ function onFocusOut(event: FocusEvent) {
|
||||
if (isOutside(event.relatedTarget)) emit('close', false)
|
||||
}
|
||||
|
||||
/** 바라는 끝으로 그린 팝오버를 재어 넘치면 반대 끝으로 — 초점을 주기 전에 (넘친 자리에 초점이 가 화면이 옆으로 밀리지 않게) */
|
||||
function placeSide() {
|
||||
const root = rootRef.value
|
||||
if (!root || !props.trigger) return
|
||||
placedAlign.value = measurePopupAlign(props.trigger, root, props.align)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
placeSide()
|
||||
void nextTick(focusInitial)
|
||||
})
|
||||
|
||||
@@ -120,7 +135,7 @@ onBeforeUnmount(() => {
|
||||
ref="rootRef"
|
||||
:role="role"
|
||||
:aria-label="label"
|
||||
:class="['editor-popup', `editor-popup--${align}`, `editor-popup--${role}`]"
|
||||
:class="['editor-popup', `editor-popup--${placedAlign}`, `editor-popup--${role}`]"
|
||||
tabindex="-1"
|
||||
data-editor-popup
|
||||
@keydown="onKeydown"
|
||||
|
||||
@@ -18,6 +18,12 @@ export interface EditorMenuEntry {
|
||||
disabled?: boolean
|
||||
/** 이름 아래 작은 글 — 누를 수 없는 까닭 등 (읽기 화면 ⋯ 「이런 메일로 필터 만들기」 — 필터 50개). 꺼져도 흐리지 않는다 */
|
||||
note?: string
|
||||
/**
|
||||
* 화면 낭독 이름 — 보이는 이름과 다르게 읽힐 때만 (15단계 [이동 ▾]의 들여 쓴 메일함 "거래처 안의 2025" — 같은 이름이 둘이어도 가르게)
|
||||
*/
|
||||
ariaLabel?: string
|
||||
/** 들여쓰기 단계 — 메일함 패널처럼 왼쪽 안쪽 10 · 28 · 44 · 56 (3단계까지, 15단계 [이동 ▾]의 메일함 나무) */
|
||||
depth?: number
|
||||
/** danger: 되돌릴 수 없는 일 — 빨간 글자 (메일함 ⋯ 메뉴의 지우기 · 비우기) */
|
||||
tone?: 'danger'
|
||||
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// [이동 ▾] 메뉴의 항목 — 목록 도구 줄(MailBulkMoveButton)과 읽기 화면(MessageMoveButton)이 같은 모양을 쓴다
|
||||
// (15단계 — 디자인 원본 Reader15 "1 · 이 메일이 든 메일함에 하위 메일함이 있을 때"). 옮길 곳을 고르는 규칙은 shared/utils/mail-move-targets.ts
|
||||
// - 메일함 패널처럼 깊이만큼 들여 쓰고(10 · 28 · 44 · 56) 앞에 패널과 같은 아이콘, 기본 메일함과 내 메일함 사이에 가는 선
|
||||
// - 들여 쓴 메일함은 화면 낭독이 위 메일함부터 읽는다("거래처 안의 2025")
|
||||
// - 메일이 든 메일함을 남긴 줄은 고를 수 없고(aria-disabled) 이름 아래에 까닭(hereNote)을 적는다
|
||||
import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import type { MailMoveTarget } from '@/shared/utils/mail-move-targets'
|
||||
import { mailboxIcon } from '@/shared/utils/mailbox'
|
||||
|
||||
/** 기본 메일함과 내 메일함 사이의 선 — 메뉴 안에 하나뿐이다(두 묶음은 이어져 있다) */
|
||||
const GROUP_SEPARATOR_ID = 'move-menu-group-separator'
|
||||
|
||||
export function moveMenuItems(rows: readonly MailMoveTarget[], hereNote: string): EditorMenuItem[] {
|
||||
const items: EditorMenuItem[] = []
|
||||
rows.forEach((row, index) => {
|
||||
const previous = rows[index - 1]
|
||||
if (previous && previous.group !== row.group) items.push(menuSeparator(GROUP_SEPARATOR_ID))
|
||||
items.push({
|
||||
id: row.mailbox.id,
|
||||
label: row.mailbox.name,
|
||||
ariaLabel: row.spokenName === row.mailbox.name ? undefined : row.spokenName,
|
||||
icon: mailboxIcon(row.mailbox),
|
||||
depth: row.depth,
|
||||
disabled: row.disabled,
|
||||
note: row.here ? hereNote : undefined,
|
||||
})
|
||||
})
|
||||
return items
|
||||
}
|
||||
@@ -5,5 +5,7 @@ export type MailboxDialogRequest =
|
||||
/** 새 메일함 — parent 가 없으면 맨 위 ("내 메일함"), 있으면 그 안 */
|
||||
| { kind: 'create'; parent: Mailbox | null }
|
||||
| { kind: 'rename'; mailbox: Mailbox }
|
||||
/** 메일함 옮기기 (15단계) — 옮길 곳을 고르는 창 */
|
||||
| { kind: 'move'; mailbox: Mailbox }
|
||||
| { kind: 'delete'; mailbox: Mailbox }
|
||||
| { kind: 'empty'; mailbox: Mailbox }
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { restoreApiAdapter } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import AppTopBar from './AppTopBar.vue'
|
||||
|
||||
// 상단 바 — 15단계 좁은 폭(디자인 원본 ListDrag15 "4. 상단 바 · 휴대폰 폭 360"): 이름 · 주소 칸만 줄고 넘치면 "…", 전체는 마우스를 올리면 보인다.
|
||||
// 줄 · 숨김(480 이하에서 주소 감춤)은 CSS 라 여기서는 차례 · 이름 · title 만 본다
|
||||
|
||||
describe('AppTopBar — 계정 칸', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountBar() {
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox')
|
||||
await router.isReady()
|
||||
return mount(AppTopBar, { props: { title: '메일' }, global: { plugins: [pinia, router] } })
|
||||
}
|
||||
|
||||
it('차례는 그대로 — 모듈 이름 · 검색 칸 · 계정(머리글자 · 이름과 주소 · [로그아웃])', async () => {
|
||||
const wrapper = await mountBar()
|
||||
|
||||
const children = [...wrapper.get('.top-bar').element.children].map((child) => child.className)
|
||||
expect(children).toEqual(['top-bar__title', 'top-bar__search', 'top-bar__account'])
|
||||
const account = [...wrapper.get('.top-bar__account').element.children].map((child) => child.className)
|
||||
expect(account).toEqual(['top-bar__avatar', 'top-bar__who', 'top-bar__logout'])
|
||||
expect(wrapper.get('.top-bar__logout').text()).toBe('로그아웃')
|
||||
})
|
||||
|
||||
it('이름 · 주소가 줄어 "…"가 되어도 마우스를 올리면 다 보인다 — "김아라 (ara.kim@arasystem.it.kr)"', async () => {
|
||||
const wrapper = await mountBar()
|
||||
|
||||
const who = wrapper.get('.top-bar__who')
|
||||
expect(who.attributes('title')).toBe('김아라 (ara.kim@arasystem.it.kr)')
|
||||
expect(who.get('.top-bar__name').text()).toBe('김아라')
|
||||
expect(who.get('.top-bar__email').text()).toBe('ara.kim@arasystem.it.kr')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 바 — 모듈 이름 · 검색 칸 · 로그인한 직원 · 로그아웃
|
||||
// 검색 칸은 모듈이 search 슬롯으로 넣는다(메일: "메일 검색" — MailSearchBox). 넣지 않은 모듈은 비활성 "통합 검색"(준비 중)이다.
|
||||
// 좁은 폭(15단계 — 디자인 원본 ListDrag15 "4. 상단 바 · 휴대폰 폭 360"): [로그아웃] · 머리글자는 줄지도 꺾이지도 않고 이름 · 주소 칸만 준다 —
|
||||
// 넘치면 끝을 "…"로 줄이고 전체는 마우스를 올리면 보인다(title). 화면 폭 480 이하에서는 주소를 감추고 이름만 둔다.
|
||||
// 줄 수와 차례(모듈 이름 · 검색 칸 · 계정)는 그대로다 — 계정 칸을 첫 줄로 올리지 않는다(Tab 차례)
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -23,6 +26,11 @@ const { logout } = useAuth()
|
||||
|
||||
const loggingOut = ref<boolean>(false)
|
||||
const initial = computed(() => initialOf(authStore.user?.name, authStore.user?.email ?? ''))
|
||||
/** 줄어든 이름 · 주소의 전체 — "김아라 (ara.kim@arasystem.it.kr)" */
|
||||
const accountTitle = computed(() => {
|
||||
const user = authStore.user
|
||||
return user ? `${user.name} (${user.email})` : ''
|
||||
})
|
||||
|
||||
async function onLogout() {
|
||||
if (loggingOut.value) return
|
||||
@@ -68,7 +76,10 @@ async function onLogout() {
|
||||
class="top-bar__avatar"
|
||||
aria-hidden="true"
|
||||
>{{ initial }}</span>
|
||||
<div class="top-bar__who">
|
||||
<div
|
||||
class="top-bar__who"
|
||||
:title="accountTitle"
|
||||
>
|
||||
<span class="top-bar__name">{{ authStore.user.name }}</span>
|
||||
<span class="top-bar__email">{{ authStore.user.email }}</span>
|
||||
</div>
|
||||
@@ -135,12 +146,16 @@ async function onLogout() {
|
||||
|
||||
.top-bar__account {
|
||||
margin-left: auto;
|
||||
/* 줄이 모자라면 이름 · 주소 칸이 준다 (줄을 넘지 않는다) */
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.top-bar__avatar {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -153,11 +168,20 @@ async function onLogout() {
|
||||
}
|
||||
|
||||
.top-bar__who {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
/* 넘치면 끝을 "…"로 — 전체는 칸의 title */
|
||||
.top-bar__name,
|
||||
.top-bar__email {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.top-bar__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -167,7 +191,10 @@ async function onLogout() {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* [로그아웃]은 줄지도 꺾이지도 않는다 */
|
||||
.top-bar__logout {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
@@ -183,4 +210,11 @@ async function onLogout() {
|
||||
.top-bar__logout:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
/* 화면 폭 480 이하 — 주소를 감추고 이름만 (tokens.css 화면 폭 기준) */
|
||||
@media (max-width: 30rem) {
|
||||
.top-bar__email {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { notifyApiError } from './useApi'
|
||||
import type { MailDragPayload } from './useMailDrag'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { listLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
|
||||
import { listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
|
||||
import { hereMailboxIds, junkMarkForMove, mailMoveTargets, type MailMoveTarget } from '@/shared/utils/mail-move-targets'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
|
||||
|
||||
// 여러 통 처리 — 도구 줄의 단추가 하는 일 (계약 2절 "여러 통 한꺼번에" · 디자인 README "3단계 › 여러 통 처리")
|
||||
// - 읽음 · 안 읽음 · 중요 표시: 목록이 바로 바뀌므로 알리지 않는다 (서버가 바꾸지 못한 메일만 되돌린다)
|
||||
@@ -16,6 +18,10 @@ import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/ma
|
||||
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때.
|
||||
// 옮기기 · 삭제 · 스팸 알림은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 "4통"은 대화 전체라서 (2026-11-06 실사용 시험).
|
||||
// 휴지통의 영구 삭제도 확인 창 · 알림이 "휴지통에 있는 메일 2통을 영구 삭제할까요? · …했습니다." (2026-11-07 — conversationFrom)
|
||||
// - 15단계: [이동 ▾]의 옮길 곳은 메일함 패널처럼 들여 쓴 나무(mail-move-targets.ts — 고른 메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 줄로 남긴다).
|
||||
// 스팸메일함으로 옮기면 [스팸 신고]와 같이 스팸 표시를, 스팸메일함에서 휴지통 밖으로 꺼내면 [스팸 아님]과 같이 스팸 아님 표시를 함께 보낸다(junkMarkForMove).
|
||||
// 목록 줄을 메일함 패널에 끌어다 놓은 것(moveDragged — 휴지통에 놓으면 [삭제])도 같은 길 · 같은 알림이다.
|
||||
// 알림의 메일함 이름은 같은 이름이 다른 곳에도 있을 때만 "거래처 › 2025" (useMailMoveFeedback)
|
||||
|
||||
export function useBulkMailActions() {
|
||||
const mailStore = useMailStore()
|
||||
@@ -40,18 +46,28 @@ export function useBulkMailActions() {
|
||||
* 대화로 묶은 목록이면 지금 메일함의 이름 — 줄의 통 수는 대화 전체라 알림 · 영구 삭제 확인이 그 메일함의 메일임을 밝힌다
|
||||
* ("받은편지함에 있는 메일 2통을 …" · "휴지통에 있는 메일 2통을 영구 삭제할까요?"). 묶지 않은 목록이면 null (지금 글 그대로)
|
||||
*/
|
||||
const conversationFrom = computed<string | null>(() =>
|
||||
mailStore.isConversationList ? (mailStore.currentMailbox?.name ?? null) : null,
|
||||
)
|
||||
/** 이동 메뉴 — 지금 메일함 · 임시보관함 · 휴지통을 뺀 메일함 (메일함 패널 순서) */
|
||||
const moveTargets = computed<Mailbox[]>(() =>
|
||||
mailStore.orderedMailboxes
|
||||
.map((node) => node.mailbox)
|
||||
.filter(
|
||||
(mailbox) =>
|
||||
mailbox.id !== mailStore.currentMailboxId && mailbox.role !== 'drafts' && mailbox.role !== 'trash',
|
||||
),
|
||||
)
|
||||
const conversationFrom = computed<string | null>(() => {
|
||||
const mailbox = mailStore.isConversationList ? mailStore.currentMailbox : null
|
||||
return mailbox ? feedback.placeName(mailbox) : null
|
||||
})
|
||||
/**
|
||||
* 이동 메뉴 — 메일함 패널 차례 · 깊이. 고른 메일이 든 메일함(목록이 가리키는 메일함 · 고른 메일이 모두 든 곳) · 임시보관함 · 휴지통은
|
||||
* 빼고, 그중 하위 메일함이 있는 것은 고를 수 없는 줄로 남긴다 (mail-move-targets.ts)
|
||||
*/
|
||||
const moveTargets = computed<MailMoveTarget[]>(() => {
|
||||
const scopeId = scopeMailboxIdOf(mailStore.listSource)
|
||||
const here = mailStore.isConversationList
|
||||
? hereMailboxIds([], scopeId)
|
||||
: hereMailboxIds(mailStore.checkedMessages, scopeId)
|
||||
return mailMoveTargets(mailStore.mailboxes, here)
|
||||
})
|
||||
|
||||
/** 옮기며 함께 보낼 스팸 표시 — 부르는 쪽이 정했으면(스팸 신고 · 스팸 아님) 그대로, 아니면 옮길 곳과 지금 메일함으로 (junkMarkForMove) */
|
||||
function junkOptionsFor(mailbox: Mailbox, options: MoveOptions): MoveOptions {
|
||||
if (options.isJunk !== undefined) return options
|
||||
const isJunk = junkMarkForMove(mailbox, inJunk.value)
|
||||
return isJunk === undefined ? {} : { isJunk }
|
||||
}
|
||||
|
||||
async function markRead(isRead: boolean): Promise<void> {
|
||||
await mailStore.patchChecked({ isRead })
|
||||
@@ -71,15 +87,39 @@ export function useBulkMailActions() {
|
||||
await router.replace(listLocationOf(source))
|
||||
}
|
||||
|
||||
/** 옮긴 뒤 — 열어 둔 메일이 빠졌으면 목록으로, 알림(+ 되돌리기) */
|
||||
async function settleMove(result: BulkMoveResult, mailbox: Mailbox, from: string | null): Promise<void> {
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announceMove(result, mailbox, { from })
|
||||
}
|
||||
|
||||
async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
// 대화로 묶은 목록은 줄의 통 수가 대화 전체라 알림이 지금 메일함의 메일임을 밝힌다 — 시작할 때의 메일함 (기다리는 사이 목록이 바뀔 수 있다)
|
||||
const from = conversationFrom.value
|
||||
const moveOptions = junkOptionsFor(mailbox, options)
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.moveChecked(mailbox.id, options)
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announceMove(result, mailbox.name, { from })
|
||||
const result = await mailStore.moveChecked(mailbox.id, moveOptions)
|
||||
await settleMove(result, mailbox, from)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 줄을 메일함 패널에 끌어다 놓았다 (15단계 — useMailDrag) — 놓은 메일함이 휴지통이면 [삭제], 스팸메일함이면 [스팸 신고]와 같고, 그 밖은 [이동 ▾]와 같다.
|
||||
* 고른 것을 끌었으면 옮긴 뒤 고른 것을 푼다(하나라도 옮겼을 때 — 도구 줄과 같다). 고르지 않은 줄을 끌었으면 고른 것은 그대로 둔다
|
||||
*/
|
||||
async function moveDragged(drag: MailDragPayload, mailbox: Mailbox): Promise<void> {
|
||||
if (busy.value || drag.targets.length === 0) return
|
||||
const from = drag.kind === 'conversations' ? conversationFrom.value : null
|
||||
const moveOptions = junkOptionsFor(mailbox, {})
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.moveMessages(drag.targets, mailbox.id, moveOptions)
|
||||
if (drag.fromSelection && !result.stale && result.doneIds.length > 0) mailStore.clearChecked()
|
||||
await settleMove(result, mailbox, from)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
@@ -134,6 +174,7 @@ export function useBulkMailActions() {
|
||||
markRead,
|
||||
toggleFlagged,
|
||||
moveTo,
|
||||
moveDragged,
|
||||
moveToTrash,
|
||||
moveSpam,
|
||||
purge,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue'
|
||||
|
||||
// 같은 대화 칸의 펼친 안([data-conversation-panel]) 가운데 화면에 들어온 것을 알린다
|
||||
// (15단계 — 디자인 원본 Reader15 "6": [모두 펼치기]의 본문은 화면에 들어오는 줄부터 하나씩 받는다 — 받는 차례는 스토어의 noteConversationPanelVisible).
|
||||
// - IntersectionObserver 하나로 칸 안의 펼친 안을 모두 본다. 칸을 다시 그릴 때마다(watchKey) 새로 생긴 안은 보기 시작하고, 사라진 안은 나간 것으로 알린다
|
||||
// - 보이는 곳은 화면이다 — 읽기 칸처럼 스크롤되는 조상이 잘라 낸 곳은 보이지 않는 것으로 친다(IntersectionObserver 의 기본 뿌리가 그렇게 잰다)
|
||||
// - IntersectionObserver 가 없는 브라우저 · 시험 환경은 펼친 안이 생기면 들어온 것으로 본다(받는 것은 그래도 하나씩이다)
|
||||
// - 칸이 사라지면(읽기 화면을 떠남) 보던 안을 모두 나간 것으로 알린다
|
||||
|
||||
/** 펼친 안의 표시 — 값은 그 줄의 메일 id (ConversationMessagePanel) */
|
||||
const PANEL_SELECTOR = '[data-conversation-panel]'
|
||||
|
||||
export interface ConversationPanelVisibilityOptions {
|
||||
/** 칸 — 그 안의 펼친 안을 본다 (칸이 없으면 null) */
|
||||
root: Ref<HTMLElement | null>
|
||||
/** 다시 그릴 때 바뀌는 값 — 바뀌면 펼친 안을 다시 센다 */
|
||||
watchKey: () => unknown
|
||||
onChange: (id: string, visible: boolean) => void
|
||||
}
|
||||
|
||||
export function useConversationPanelVisibility(options: ConversationPanelVisibilityOptions): void {
|
||||
/** 보고 있는 안 — 요소마다 그 줄의 메일 id */
|
||||
const observed = new Map<Element, string>()
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
function report(entries: IntersectionObserverEntry[]) {
|
||||
for (const entry of entries) {
|
||||
const id = observed.get(entry.target)
|
||||
if (id !== undefined) options.onChange(id, entry.isIntersecting)
|
||||
}
|
||||
}
|
||||
|
||||
function forget(element: Element, id: string) {
|
||||
observed.delete(element)
|
||||
observer?.unobserve(element)
|
||||
options.onChange(id, false)
|
||||
}
|
||||
|
||||
/** 지금 칸 안의 펼친 안과 맞춘다 — 사라진 안은 나간 것으로, 새 안은 보기 시작한다 */
|
||||
function sync() {
|
||||
const panels = new Map<Element, string>()
|
||||
for (const element of options.root.value?.querySelectorAll<HTMLElement>(PANEL_SELECTOR) ?? []) {
|
||||
const id = element.dataset.conversationPanel
|
||||
if (id) panels.set(element, id)
|
||||
}
|
||||
for (const [element, id] of [...observed]) {
|
||||
if (panels.get(element) !== id) forget(element, id)
|
||||
}
|
||||
for (const [element, id] of panels) {
|
||||
if (observed.has(element)) continue
|
||||
observed.set(element, id)
|
||||
if (observer) observer.observe(element)
|
||||
else options.onChange(id, true)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof IntersectionObserver !== 'undefined') observer = new IntersectionObserver(report)
|
||||
sync()
|
||||
})
|
||||
|
||||
watch(options.watchKey, sync, { flush: 'post' })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
const left = [...observed.values()]
|
||||
observed.clear()
|
||||
for (const id of left) options.onChange(id, false)
|
||||
})
|
||||
}
|
||||
@@ -192,3 +192,72 @@ describe('useMail 이미 받은 메일에 적용 — POST /mail-filter-runs (계
|
||||
expect(requests[0]?.config.silent).toEqual(['REQ_001'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMail — 15단계 메일 · 메일함 다루기 (계약 2절 "메일함 관리" · "보낸 사람 차단" · "보내기 취소 · 예약 발송")', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
it('메일함 옮기기는 PATCH /mailboxes/{id} 에 parentId 만 — 맨 위는 null 을 그대로 싣는다', async () => {
|
||||
const requests = installMockApi(() => ok({ id: 'c25' }))
|
||||
|
||||
await useMail().moveMailbox('c/25', { parentId: 'p' }, { silent: ['MAIL_009'] })
|
||||
await useMail().moveMailbox('c25', { parentId: null })
|
||||
|
||||
expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual(['PATCH /mailboxes/c%2F25', 'PATCH /mailboxes/c25'])
|
||||
expect(requests.map((item) => item.data)).toEqual([{ parentId: 'p' }, { parentId: null }])
|
||||
expect(requests[0]?.config.silent).toEqual(['MAIL_009'])
|
||||
})
|
||||
|
||||
it('하위까지 지우기는 ?withChildren=true — 주지 않으면 쿼리 없이 (옛 화면처럼 하위가 있으면 서버가 막는다)', async () => {
|
||||
const requests = installMockApi(() => ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
|
||||
|
||||
await useMail().deleteMailbox('c', { withChildren: true, silent: ['RES_001'] })
|
||||
await useMail().deleteMailbox('c')
|
||||
|
||||
expect(requests.map((item) => [item.method, item.url, item.params])).toEqual([
|
||||
['DELETE', '/mailboxes/c', { withChildren: true }],
|
||||
['DELETE', '/mailboxes/c', {}],
|
||||
])
|
||||
expect(requests[0]?.config.silent).toEqual(['RES_001'])
|
||||
// withChildren 은 요청 설정으로 새지 않는다
|
||||
expect(Object.keys(requests[0]?.config ?? {})).not.toContain('withChildren')
|
||||
})
|
||||
|
||||
it('차단 정리는 POST /mail-block-runs { sender, cursor } — 필터 적용과 같은 시간 한도', async () => {
|
||||
const run = { scanned: 1200, matched: 12, remaining: 3600, cursor: 'b1' }
|
||||
const requests = installMockApi(() => ok(run))
|
||||
|
||||
await expect(useMail().runSenderBlock({ sender: '@example.com', cursor: null }, { silent: true })).resolves.toEqual(run)
|
||||
|
||||
expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual(['POST /mail-block-runs'])
|
||||
expect(requests[0]?.data).toEqual({ sender: '@example.com', cursor: null })
|
||||
expect(requests[0]?.config.timeout).toBe(MAIL_FILTER_RUN_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['보내기 취소를 기다리는 중이면 그대로', { id: 's/1', pendingSend: { kind: 'undo', sendAt: '2026-11-09T05:00:10.000Z' }, rejectedRecipients: [] }],
|
||||
['바로 보냈으면 null', { id: 's/1', pendingSend: null, rejectedRecipients: [NOBODY] }],
|
||||
])('지금 보내기는 POST /scheduled-messages/{id}/release (본문 없음) — %s', async (_label, reply) => {
|
||||
const requests = installMockApi(() => ok(reply))
|
||||
|
||||
await expect(useMail().releaseScheduledMessage('s/1')).resolves.toEqual(reply)
|
||||
|
||||
expect(requests.map((item) => `${item.method} ${item.url}`)).toEqual(['POST /scheduled-messages/s%2F1/release'])
|
||||
expect(requests[0]?.data).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['칸이 없는 응답', {}],
|
||||
['모양이 틀린 칸', { id: 7, pendingSend: { kind: 'later', sendAt: '곧' }, rejectedRecipients: 'x' }],
|
||||
['빈 응답', null],
|
||||
])('지금 보내기 응답은 늘 값으로 맞춘다 — %s 은 빈 배열 · null · 요청한 id', async (_label, reply) => {
|
||||
installMockApi(() => ok(reply))
|
||||
|
||||
await expect(useMail().releaseScheduledMessage('s1')).resolves.toEqual({ id: 's1', rejectedRecipients: [], pendingSend: null })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MAIL_SEND_TIMEOUT_MS,
|
||||
MAIL_UPLOAD_TIMEOUT_MS,
|
||||
} from '@/shared/constants/mail'
|
||||
import { MAIL_BLOCK_RUN_TIMEOUT_MS } from '@/shared/constants/mail-block'
|
||||
import { MAIL_CONVERSATION_TIMEOUT_MS } from '@/shared/constants/mail-conversation'
|
||||
import type {
|
||||
Attachment,
|
||||
@@ -19,6 +20,8 @@ import type {
|
||||
Draft,
|
||||
DraftPayload,
|
||||
DraftSaved,
|
||||
MailBlockRun,
|
||||
MailBlockRunRequest,
|
||||
MailFilterRun,
|
||||
MailFilterRunRequest,
|
||||
MailFilterSet,
|
||||
@@ -32,6 +35,7 @@ import type {
|
||||
MailboxBulkResult,
|
||||
MailboxCreatePayload,
|
||||
MailboxDeletion,
|
||||
MailboxMovePayload,
|
||||
MailboxRenamePayload,
|
||||
MessageDeletionRequest,
|
||||
MessageDeletionResult,
|
||||
@@ -46,6 +50,7 @@ import type {
|
||||
RecipientSuggestionQuery,
|
||||
SaveSignaturePayload,
|
||||
ScheduledMessageCancel,
|
||||
ReleasedSend,
|
||||
SearchIndexState,
|
||||
SendMessagePayload,
|
||||
SentMessage,
|
||||
@@ -70,6 +75,11 @@ export interface GetMessageOptions extends MailRequestOptions {
|
||||
highlight?: MessageHighlightQuery | null
|
||||
}
|
||||
|
||||
/** 메일함 지우기의 옵션 — withChildren 이면 하위 메일함까지 (15단계 — `?withChildren=true`) */
|
||||
export interface DeleteMailboxOptions extends MailRequestOptions {
|
||||
withChildren?: boolean
|
||||
}
|
||||
|
||||
export interface SendMessageOptions extends MailRequestOptions {
|
||||
/**
|
||||
* 중복 발송 방지 키 (계약 2절) — 쓰기 화면마다 하나 만들고, 실패 뒤 다시 보낼 때도 같은 값을 넘긴다.
|
||||
@@ -87,6 +97,12 @@ type SentMessageReply = Omit<SentMessage, 'rejectedRecipients' | 'pendingSend'>
|
||||
pendingSend?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /scheduled-messages/{id}/release 가 실제로 주는 모양 (15단계 — 계약의 ReleasedSend). 모양이 틀리면(응답을 바꾼 게이트웨이 등)
|
||||
* releaseScheduledMessage 가 빈 배열 · null · 요청한 id 로 맞춘다
|
||||
*/
|
||||
type ScheduledReleaseReply = { id?: unknown; rejectedRecipients?: unknown; pendingSend?: unknown } | null
|
||||
|
||||
/** GET · PUT /mail-vacation 이 실제로 주는 모양 — 7단계 앞의 서버는 isActive 가 없다 (vacationStateOf 가 맞춘다) */
|
||||
type MailVacationReply = MailVacation & { isActive?: unknown }
|
||||
|
||||
@@ -153,13 +169,25 @@ export function useMail() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다. 한 요청에 다 옮기지 못하면 deleted: false 다 (같은 요청을 다시 보낸다).
|
||||
* 하위 메일함이 있으면 400 VAL_001 (화면이 먼저 막는다)
|
||||
* 메일함 옮기기 (15단계) — 같은 PATCH 에 parentId 만 보낸다(null 은 맨 위). 하위 메일함과 안의 메일이 함께 간다.
|
||||
* 새 상위 안에 같은 이름(대소문자 무시)이면 409 MAIL_009, 자기 자신 · 하위 아래 · 둘 수 없는 자리는 400 VAL_001, 없는 메일함 · 상위는 404 RES_001,
|
||||
* 그 메일함의 오래 걸리는 처리가 진행 중이면 429 REQ_001
|
||||
*/
|
||||
function deleteMailbox(id: string, options?: MailRequestOptions): Promise<MailboxDeletion> {
|
||||
function moveMailbox(id: string, payload: MailboxMovePayload, options?: MailRequestOptions): Promise<Mailbox> {
|
||||
return api.patch<Mailbox, Mailbox>(`/mailboxes/${segment(id)}`, { parentId: payload.parentId }, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 지우기 — 안의 메일을 휴지통으로 옮긴 뒤 지운다. 한 요청에 다 옮기지 못하면 deleted: false 다 (같은 요청을 다시 보낸다).
|
||||
* 하위 메일함이 있으면 withChildren(?withChildren=true — 15단계)일 때만 하위까지 깊은 것부터 지운다. 없으면 400 VAL_001
|
||||
* (확인 창이 하위까지 지울지 물은 뒤에만 켠다 — 옛 화면이 모르고 하위를 지우지 않게)
|
||||
*/
|
||||
function deleteMailbox(id: string, options: DeleteMailboxOptions = {}): Promise<MailboxDeletion> {
|
||||
const { withChildren, ...requestOptions } = options
|
||||
return api.delete<MailboxDeletion, MailboxDeletion>(`/mailboxes/${segment(id)}`, {
|
||||
timeout: MAIL_MAILBOX_OPERATION_TIMEOUT_MS,
|
||||
...options,
|
||||
...requestOptions,
|
||||
...(withChildren ? { params: { withChildren: true } } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -229,6 +257,20 @@ export function useMail() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단한 보낸 사람(주소 · 도메인)의 메일 가운데 받은편지함에 이미 있는 것을 스팸메일함으로 옮긴다 (15단계 — 차단 창의 새 칸).
|
||||
* 필터 적용과 같은 규칙이다: 한 요청은 25초 안에 끝나고 cursor 를 돌려준다 — null 이 될 때까지 같은 sender 에 그 cursor 를 넣어 다시 보낸다
|
||||
* (부르는 쪽 — mail-block-run.store). 같은 직원의 받은편지함 정리(필터 적용 · 이것)가 다른 곳에서 진행 중이면 429 REQ_001,
|
||||
* 차단 목록에 없는 sender 는 404 RES_001, cursor 가 틀리면 400 VAL_001, 하나도 확인하지 못했으면 503 MAIL_001
|
||||
*/
|
||||
function runSenderBlock(request: MailBlockRunRequest, options?: MailRequestOptions): Promise<MailBlockRun> {
|
||||
return api.post<MailBlockRun, MailBlockRun>(
|
||||
'/mail-block-runs',
|
||||
{ sender: request.sender, cursor: request.cursor },
|
||||
{ timeout: MAIL_BLOCK_RUN_TIMEOUT_MS, ...options },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 메일 확인 — 메일함이나 메일이 하나라도 바뀌면 state 가 달라진다 (계약 2절 "새 메일 확인").
|
||||
* 메일을 읽지 않는 가벼운 조회라 화면이 보이는 동안 1분마다 부른다 (useMailStatePolling)
|
||||
@@ -447,6 +489,28 @@ export function useMail() {
|
||||
return api.delete<ScheduledMessageCancel, ScheduledMessageCancel>(`/scheduled-messages/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 예약한 메일을 지금 보낸다 (15단계 — 읽기 화면 예약 안내 줄의 [지금 보내기]). id 는 보낸 사본의 메일 id(예약 취소와 같다), 본문 없음.
|
||||
* 서버가 예약을 거두고 같은 메일을 다시 제출한다 — 보내기 취소 설정의 초만큼 붙잡았다 보내면 pendingSend { kind: 'undo' }, 바로 보냈으면 null.
|
||||
* 예약 시각이 막 지나 이미 보낸 메일 · 이미 지금 보내기를 한 메일도 200 이다(오류가 아니다). 예약이 아닌 메일 · 없는 메일은 404 RES_001.
|
||||
* 다시 제출하지 못하면 503 MAIL_001 — 엔진이 거절했으면 임시보관함으로 되돌렸고, 결과를 모르면 메일은 보낸편지함에 그대로이며 같은 요청을 다시 보내면
|
||||
* 서버가 이어 낸다(어느 쪽인지는 그 메일을 다시 읽어 가른다 — stores/mail/scheduled.ts). 받는 사람이 모두 받지 않으면 400 MAIL_010 · 503 MAIL_011(임시보관함으로 되돌렸다),
|
||||
* 같은 메일의 취소 · 지금 보내기가 진행 중이면 429 REQ_001. 발송처럼 한도는 MAIL_SEND_TIMEOUT_MS (서버는 54초 남짓 안에 답한다).
|
||||
* 받는 사람별 결과(rejectedRecipients)와 pendingSend 는 발송과 같은 규칙으로 늘 값으로 맞춘다 — 없거나 모양이 틀리면 빈 배열 · null, id 가 없으면 요청한 id
|
||||
*/
|
||||
async function releaseScheduledMessage(id: string, options?: MailRequestOptions): Promise<ReleasedSend> {
|
||||
const released = await api.post<ScheduledReleaseReply, ScheduledReleaseReply>(
|
||||
`/scheduled-messages/${segment(id)}/release`,
|
||||
undefined,
|
||||
{ timeout: MAIL_SEND_TIMEOUT_MS, ...options },
|
||||
)
|
||||
return {
|
||||
id: typeof released?.id === 'string' && released.id !== '' ? released.id : id,
|
||||
rejectedRecipients: Array.isArray(released?.rejectedRecipients) ? released.rejectedRecipients : [],
|
||||
pendingSend: pendingSendOf(released?.pendingSend),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 한 개 업로드 (multipart `file`).
|
||||
* 형식·크기 오류(FILE_001·FILE_002)는 파일 목록에 직접 표시하므로 그 코드만 전역 알림을 끈다.
|
||||
@@ -505,6 +569,7 @@ export function useMail() {
|
||||
getQuota,
|
||||
createMailbox,
|
||||
renameMailbox,
|
||||
moveMailbox,
|
||||
deleteMailbox,
|
||||
markMailboxRead,
|
||||
emptyMailbox,
|
||||
@@ -513,6 +578,7 @@ export function useMail() {
|
||||
getFilters,
|
||||
saveFilters,
|
||||
runFilter,
|
||||
runSenderBlock,
|
||||
messageSourceUrl,
|
||||
downloadMessageSource,
|
||||
getMailState,
|
||||
@@ -527,6 +593,7 @@ export function useMail() {
|
||||
sendMessage,
|
||||
listScheduledMessages,
|
||||
cancelScheduledMessage,
|
||||
releaseScheduledMessage,
|
||||
uploadAttachment,
|
||||
attachmentDownloadUrl,
|
||||
downloadAttachment,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { conversationActionIdsOf, isConversationHeld } from '@/shared/utils/mail-conversation-list'
|
||||
import { scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
|
||||
import { canDropMailOn, hereMailboxIds } from '@/shared/utils/mail-move-targets'
|
||||
import { isHeldMessage } from '@/shared/utils/pending-send'
|
||||
import { createUuid } from '@/shared/utils/uuid'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 목록 줄을 메일함 패널로 끌어다 놓기 (15단계 — 디자인 원본 ListDrag15 · 디자인 README "15단계")
|
||||
// - 끄는 것: 고른 줄을 끌면 고른 것 모두, 고르지 않은 줄을 끌면 그 줄만(고른 것은 그대로). 대화로 묶은 목록은 그 대화에서 지금 메일함에 든 메일
|
||||
// (붙잡힌 메일은 빼고 — [이동 ▾]와 같다). 보내기를 기다리는 메일의 줄 · 그런 메일뿐인 대화 줄은 끌 수 없다
|
||||
// - 끄는 동안 마우스 오른쪽 아래에 "메일 N통" · "대화 N개" 표(MailDragChip — setDragImage). 끈 줄은 흐리게 하지 않는다
|
||||
// - 놓을 곳은 메일함 패널의 줄(MailboxPanelRow) — [이동 ▾]의 옮길 곳 + 휴지통([삭제]와 같다). 메일이 이미 든 메일함 · 임시보관함 · 「예약됨」은 놓을 수 없다
|
||||
// (브라우저의 금지 커서 — dragover 를 받지 않는다)
|
||||
// - 끄는 것은 우리 끌기 자료(MAIL_DRAG_TYPE)뿐이다 — 줄의 링크가 끌어 가던 메일 주소(URL)는 지운다. 다른 창 · 바깥 프로그램에 놓아도 아무 일 없다
|
||||
// - 끄는 동안의 자료는 이 탭의 메모리에만 둔다(dragover 에서는 dataTransfer 의 값을 읽을 수 없다) — 놓을 때 dataTransfer 의 표와 견줘 같은 끌기인지 본다
|
||||
// - 마우스로만 한다 — 키보드 · 터치는 [이동 ▾](옮길 곳 · 알림이 같다)
|
||||
|
||||
/** 우리 끌기 자료의 종류 — 값은 이 끌기의 표(token) */
|
||||
export const MAIL_DRAG_TYPE = 'application/x-arasystem-mail'
|
||||
|
||||
/** 옮길 메일 하나 — id 와 되돌릴 메일함을 정할 메일함 (스토어의 moveMessages 와 같은 꼴) */
|
||||
export interface MailDragTarget {
|
||||
id: string
|
||||
mailboxIds: string[]
|
||||
}
|
||||
|
||||
/** 끄는 동안의 자료 */
|
||||
export interface MailDragPayload {
|
||||
/** 이 끌기의 표 — dataTransfer 에 실은 값과 같아야 놓기를 받는다 */
|
||||
token: string
|
||||
/** 끈 것 — 메일 줄 · 대화 줄 */
|
||||
kind: 'messages' | 'conversations'
|
||||
/** 표의 수 — 메일 수 또는 대화 수 */
|
||||
count: number
|
||||
targets: MailDragTarget[]
|
||||
/** 놓을 수 없는 메일함 — 메일이 이미 든 곳 (mail-move-targets.ts 의 hereMailboxIds) */
|
||||
hereIds: ReadonlySet<string>
|
||||
/** 고른 것을 끌었다 — 옮기면 고른 것을 푼다 */
|
||||
fromSelection: boolean
|
||||
}
|
||||
|
||||
/** 지금 끄는 것 — 놓거나 그만두면 null */
|
||||
const current = shallowRef<MailDragPayload | null>(null)
|
||||
/** 마우스를 따라오는 표 — MailDragChip 이 그린다 (setDragImage 는 그려 둔 요소의 모습을 찍는다) */
|
||||
let chipElement: HTMLElement | null = null
|
||||
|
||||
/** 메일을 끄는 중인가 — 끌기가 끝나면(놓음 · 그만둠) 메일함 줄이 켜 둔 점선을 끈다 (MailboxPanelRow) */
|
||||
export const mailDragging = computed<boolean>(() => current.value !== null)
|
||||
|
||||
/** MailDragChip 이 표 요소를 알린다 (사라질 때 null) */
|
||||
export function registerMailDragChip(element: HTMLElement | null): void {
|
||||
chipElement = element
|
||||
}
|
||||
|
||||
/** 지금 끄는 메일 — 우리 끌기이고 이 탭에서 시작했을 때만 (다른 탭 · 바깥에서 끌어 온 것은 null) */
|
||||
export function activeMailDrag(event: DragEvent): MailDragPayload | null {
|
||||
const types = event.dataTransfer?.types
|
||||
if (!types || !Array.from(types).includes(MAIL_DRAG_TYPE)) return null
|
||||
return current.value
|
||||
}
|
||||
|
||||
/** 놓은 끌기 — 표가 같을 때만 자료를 돌려주고 끌기를 끝낸다 */
|
||||
export function takeMailDrop(event: DragEvent): MailDragPayload | null {
|
||||
const payload = activeMailDrag(event)
|
||||
if (!payload || event.dataTransfer?.getData(MAIL_DRAG_TYPE) !== payload.token) return null
|
||||
current.value = null
|
||||
return payload
|
||||
}
|
||||
|
||||
/** 끌기가 끝났다 — 놓았든 그만뒀든 (끈 줄의 dragend) */
|
||||
export function endMailDrag(): void {
|
||||
current.value = null
|
||||
}
|
||||
|
||||
/** 그 메일함에 지금 끄는 메일을 놓을 수 있는가 */
|
||||
export function canDropDragOn(payload: MailDragPayload, mailbox: Pick<Mailbox, 'id' | 'role'>): boolean {
|
||||
return canDropMailOn(mailbox, payload.hereIds)
|
||||
}
|
||||
|
||||
/** 표에 수 · 아이콘을 넣는다 — 끌기를 시작하는 그 순간에 그려져 있어야 해서 화면 갱신을 기다리지 않고 바로 고친다 */
|
||||
function paintChip(chip: HTMLElement, payload: MailDragPayload) {
|
||||
const count = formatCount(payload.count)
|
||||
chip.dataset.kind = payload.kind
|
||||
const label = chip.querySelector<HTMLElement>('[data-drag-chip-text]')
|
||||
if (label) {
|
||||
label.textContent =
|
||||
payload.kind === 'conversations' ? MAIL_BULK_TEXT.dragConversations(count) : MAIL_BULK_TEXT.dragMessages(count)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 끌기를 시작한다 — 링크가 실은 주소를 지우고 우리 자료만 싣는다. 표가 있으면 마우스를 따라오게 한다
|
||||
* (표 요소의 왼쪽 위가 마우스 자리다 — 표는 그 안에서 오른쪽 아래로 비켜 그려 둔다, MailDragChip)
|
||||
*/
|
||||
function beginMailDrag(event: DragEvent, payload: MailDragPayload) {
|
||||
const transfer = event.dataTransfer
|
||||
if (!transfer) return
|
||||
transfer.clearData()
|
||||
transfer.setData(MAIL_DRAG_TYPE, payload.token)
|
||||
transfer.effectAllowed = 'move'
|
||||
if (chipElement) {
|
||||
paintChip(chipElement, payload)
|
||||
transfer.setDragImage(chipElement, 0, 0)
|
||||
}
|
||||
current.value = payload
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 줄의 끌기 (MessageListItem · MessageTableRow · ConversationListItem · ConversationTableRow).
|
||||
* 줄 요소에 :draggable="!held"(보내기를 기다리는 메일의 줄은 끌 수 없다) · @dragstart="drag.start($event, id)" · @dragend="drag.end" 를 단다.
|
||||
* 끌 수 없는 줄에서 시작한 끌기(줄 안의 링크를 끈 것)는 dragstart 에서 막는다
|
||||
*/
|
||||
export function useMailRowDrag() {
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/** 끌 수 있는 줄인가 — 보내기를 기다리는 메일의 줄 · 그런 메일뿐인 대화 줄은 아니다 */
|
||||
function canDrag(rowId: string): boolean {
|
||||
if (mailStore.isConversationList) {
|
||||
const row = mailStore.conversations.find((entry) => entry.id === rowId)
|
||||
return Boolean(row && !isConversationHeld(row))
|
||||
}
|
||||
const message = mailStore.messages.find((entry) => entry.id === rowId)
|
||||
return Boolean(message && !isHeldMessage(message))
|
||||
}
|
||||
|
||||
/** 그 줄을 끌면 옮길 것 — 고른 줄이면 고른 것 모두 */
|
||||
function payloadOf(rowId: string): MailDragPayload | null {
|
||||
if (!canDrag(rowId)) return null
|
||||
const fromSelection = mailStore.checkedIds.has(rowId)
|
||||
const scopeId = scopeMailboxIdOf(mailStore.listSource)
|
||||
let kind: MailDragPayload['kind']
|
||||
let count: number
|
||||
let targets: MailDragTarget[]
|
||||
if (mailStore.isConversationList) {
|
||||
const rows = fromSelection
|
||||
? mailStore.checkedConversations
|
||||
: mailStore.conversations.filter((entry) => entry.id === rowId)
|
||||
kind = 'conversations'
|
||||
count = rows.length
|
||||
targets = conversationActionIdsOf(rows).map((id) => ({ id, mailboxIds: scopeId ? [scopeId] : [] }))
|
||||
} else {
|
||||
const messages = fromSelection
|
||||
? mailStore.checkedMessages
|
||||
: mailStore.messages.filter((entry) => entry.id === rowId && !isHeldMessage(entry))
|
||||
kind = 'messages'
|
||||
count = messages.length
|
||||
targets = messages.map((message) => ({ id: message.id, mailboxIds: [...message.mailboxIds] }))
|
||||
}
|
||||
if (targets.length === 0) return null
|
||||
return { token: createUuid(), kind, count, targets, hereIds: hereMailboxIds(targets, scopeId), fromSelection }
|
||||
}
|
||||
|
||||
function start(event: DragEvent, rowId: string) {
|
||||
const payload = payloadOf(rowId)
|
||||
if (!payload) {
|
||||
// 끌 수 없는 줄 — 링크의 주소 끌기도 막는다
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
beginMailDrag(event, payload)
|
||||
}
|
||||
|
||||
return { canDrag, start, end: endMailDrag }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { SEARCH_SCOPE_ALL } from '@/shared/constants/mail'
|
||||
import { MAIL_FILTER_RUN_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { Mailbox, MailFilterRule, MailFilterRun, MailFilterRunRequest } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
|
||||
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore, type Toast } from '@/stores/toast.store'
|
||||
@@ -237,7 +238,7 @@ describe('useMailFilterRun', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts).toMatchObject([
|
||||
{ kind: 'error', message: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: null },
|
||||
{ kind: 'error', message: '다른 창에서 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.', action: null },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -252,7 +253,7 @@ describe('useMailFilterRun', () => {
|
||||
expect(useToastStore().toasts).toHaveLength(1)
|
||||
expect(toast).toMatchObject({
|
||||
kind: 'error',
|
||||
message: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
message: '다른 창에서 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
action: { label: '다시 시도' },
|
||||
})
|
||||
press(toast)
|
||||
@@ -327,7 +328,8 @@ describe('useMailFilterRun', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(runs()).toHaveLength(3)
|
||||
const busy = '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.'
|
||||
// 차단 정리와 함께 쓰는 공용 글 (15단계에 "다른 필터로 …"에서 바꿨다)
|
||||
const busy = '받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.'
|
||||
expect(shown()).toEqual([
|
||||
['progress', '‘둘’ 필터로 받은편지함을 정리하는 중 · 0 / 8,412통 확인'],
|
||||
['error', busy],
|
||||
@@ -349,6 +351,27 @@ describe('useMailFilterRun', () => {
|
||||
expect(shown()).toEqual([['success', '‘하나’ 필터로 받은편지함의 메일 37통을 정리했습니다.']])
|
||||
})
|
||||
|
||||
it('이 탭에서 차단할 때 받은 메일을 옮기는 중(15단계)에도 시작하지 않고 공용 글로 알린다 — 정리가 끝난 뒤 [다시 시도]로 보낸다', async () => {
|
||||
replies(step(8412, 3, 0, null))
|
||||
const blockRun = useMailBlockRunStore()
|
||||
blockRun.running = true
|
||||
|
||||
expect(useMailFilterRun().start(rule())).toBe(false)
|
||||
await flushPromises()
|
||||
expect(runs()).toEqual([])
|
||||
const busy = withAction()
|
||||
expect(busy).toMatchObject({
|
||||
kind: 'error',
|
||||
message: '받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
action: { label: '다시 시도' },
|
||||
})
|
||||
|
||||
blockRun.running = false
|
||||
press(busy)
|
||||
await flushPromises()
|
||||
expect(runs()).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('끝난 뒤 개수 · 목록 맞추기 (4단계 오래 걸리는 처리와 같은 결)', () => {
|
||||
it('끝나면 메일함 개수 · 사용량을 다시 받고, 받은편지함 목록을 보고 있으면 그 목록도 맞춘다', async () => {
|
||||
viewing('inbox')
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/shared/utils/mail-filter-run'
|
||||
import { listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
|
||||
import { useMailFilterRunStore, type MailFilterRunOutcome } from '@/stores/mail-filter-run.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import type { ToastAction } from '@/stores/toast.store'
|
||||
@@ -28,8 +29,9 @@ import type { ToastAction } from '@/stores/toast.store'
|
||||
// 실패 까닭(서버 · 연결)은 스토어가 사전 문구로 알렸다 — 진행 알림은 결과로만 바뀌고 같은 오류를 다시 띄우지 않는다(4단계와 같다).
|
||||
// 단추는 띄운 세션에서만 움직인다 (useToast)
|
||||
// - 끝났거나 이번에 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다(4단계 settle 과 같은 결) — 실시간 연결이 없는 탭에서 1분까지 옛 개수가 보이지 않게
|
||||
// - 이 탭에서 다른 적용이 돌고 있으면 시작하지 않고 "다른 필터를 … 끝난 뒤 다시 시도해 주세요." + 같은 [다시 시도](같은 자리 · 같은 수) —
|
||||
// 앞 적용이 끝난 뒤 다시 누를 수 있다 (고치기 창은 그동안 칸을 끈다 — 이 길은 앞 실패 알림의 [다시 시도]에서 온다)
|
||||
// - 이 탭에서 받은편지함을 정리하는 중이면(다른 필터 적용 · 차단 정리 — 15단계 inboxBusy) 시작하지 않고
|
||||
// "받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요."(공용 글) + 같은 [다시 시도](같은 자리 · 같은 수) —
|
||||
// 앞 정리가 끝난 뒤 다시 누를 수 있다 (고치기 창은 그동안 칸을 끈다 — 이 길은 앞 실패 알림의 [다시 시도]에서 온다)
|
||||
// - 그동안 다른 화면으로 가도 이어 한다 — 스토어가 돌리고, 이 함수가 만든 단추도 화면이 사라진 뒤에 움직인다. 세션이 바뀌면 멈추고 알림을 닫는다
|
||||
|
||||
/** 시작할 자리 — 처음부터(cursor null · 수 0) 또는 앞 실패의 자리 */
|
||||
@@ -39,6 +41,8 @@ type FailedOutcome = Extract<MailFilterRunOutcome, { status: 'failed' }>
|
||||
|
||||
export function useMailFilterRun() {
|
||||
const runStore = useMailFilterRunStore()
|
||||
/** 이 탭에서 받은편지함을 정리하는 중인지 — 필터 적용 · 차단 정리 (inboxBusy) */
|
||||
const inboxCleanup = useMailBlockRunStore()
|
||||
const mailStore = useMailStore()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
@@ -91,11 +95,11 @@ export function useMailFilterRun() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행 알림을 띄우고 끝날 때까지 보낸다. 이 탭에서 다른 적용이 돌고 있으면 시작하지 않고 알린다 —
|
||||
* 같은 자리 · 같은 수의 [다시 시도]를 붙여 앞 적용이 끝난 뒤 다시 누를 수 있게
|
||||
* 진행 알림을 띄우고 끝날 때까지 보낸다. 이 탭에서 받은편지함을 정리하는 중이면(다른 필터 적용 · 차단 정리) 시작하지 않고 알린다 —
|
||||
* 같은 자리 · 같은 수의 [다시 시도]를 붙여 앞 정리가 끝난 뒤 다시 누를 수 있게
|
||||
*/
|
||||
async function execute(rule: MailFilterRule, request: MailFilterRunRule, from: RunFrom): Promise<void> {
|
||||
if (runStore.running) {
|
||||
if (inboxCleanup.inboxBusy) {
|
||||
toast.error(MAIL_FILTER_RUN_TEXT.busy, retryAction(rule, request, from))
|
||||
return
|
||||
}
|
||||
@@ -120,13 +124,13 @@ export function useMailFilterRun() {
|
||||
|
||||
/**
|
||||
* 저장한 규칙(응답 목록의 그 규칙 — 앞뒤 공백을 지운 값)을 받은편지함에 적용한다. 진행 알림이 저장 알림을 대신하면 true.
|
||||
* 이미 받은 메일에 할 일이 없으면(고치기 창의 칸과 같은 판정 — 칸이 꺼져 있었다) 보내지 않는다. 이 탭에서 다른 적용이 돌고 있으면
|
||||
* 이미 받은 메일에 할 일이 없으면(고치기 창의 칸과 같은 판정 — 칸이 꺼져 있었다) 보내지 않는다. 이 탭에서 받은편지함을 정리하는 중이면
|
||||
* 그렇게 알리기만 한다([다시 시도] 단추와 함께). 둘 다 false — 부르는 쪽이 "필터를 저장했습니다."를 띄운다
|
||||
*/
|
||||
function start(rule: MailFilterRule): boolean {
|
||||
const request = filterRunRuleOf(rule, mailStore.mailboxes)
|
||||
if (!request) return false
|
||||
const started = !runStore.running
|
||||
const started = !inboxCleanup.inboxBusy
|
||||
void execute(rule, request, fromStart())
|
||||
return started
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useToast } from './useToast'
|
||||
import { DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import {
|
||||
movedMessageText,
|
||||
notSpamMovedText,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
type BulkOrigin,
|
||||
type BulkTextOptions,
|
||||
} from '@/shared/utils/mail-bulk'
|
||||
import { mailboxDisplayName } from '@/shared/utils/mailbox'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
@@ -21,16 +23,24 @@ import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOption
|
||||
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
|
||||
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
|
||||
* 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast)
|
||||
* 메일함 이름은 같은 이름의 메일함이 다른 곳에도 있을 때만 위 메일함 이름을 붙인다 — "메일 1통을 거래처 › 2025로 옮겼습니다." (15단계 — mailbox.ts 의 mailboxDisplayName,
|
||||
* 목록 · 읽기 화면의 [이동 ▾] · 끌어다 놓기 · 되돌리기 알림이 모두 같다)
|
||||
*/
|
||||
export function useMailMoveFeedback() {
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** 알림에 쓰는 메일함 이름 — 같은 이름이 다른 곳에도 있으면 "거래처 › 2025" */
|
||||
function placeName(mailbox: Pick<Mailbox, 'id' | 'name'>): string {
|
||||
return mailboxDisplayName(mailStore.mailboxes, mailbox)
|
||||
}
|
||||
|
||||
/** 되돌린 메일함의 이름 — 한 메일함으로 되돌릴 때만(여러 곳이면 null), 메일함 목록에 없으면 null */
|
||||
function restoredPlaceOf(origins: readonly BulkOrigin[]): string | null {
|
||||
if (origins.length !== 1) return null
|
||||
const mailboxId = origins[0]?.mailboxId
|
||||
return mailStore.mailboxes.find((mailbox) => mailbox.id === mailboxId)?.name ?? null
|
||||
const mailbox = mailStore.mailboxes.find((candidate) => candidate.id === mailboxId)
|
||||
return mailbox ? placeName(mailbox) : null
|
||||
}
|
||||
|
||||
async function undo(origins: readonly BulkOrigin[], options: MoveOptions): Promise<void> {
|
||||
@@ -40,13 +50,17 @@ export function useMailMoveFeedback() {
|
||||
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped, restoredPlaceOf(origins)))
|
||||
}
|
||||
|
||||
/** textOptions.from — 대화로 묶은 목록에서 줄에 한 일이면 그 목록의 메일함 이름 ("받은편지함에 있는 메일 2통을 …") */
|
||||
function announceMove(result: BulkMoveResult, mailboxName: string, textOptions: BulkTextOptions = {}) {
|
||||
/**
|
||||
* target — 옮긴 메일함(알림의 이름은 placeName).
|
||||
* textOptions.from — 대화로 묶은 목록에서 줄에 한 일이면 그 목록의 메일함 이름 ("받은편지함에 있는 메일 2통을 …")
|
||||
*/
|
||||
function announceMove(result: BulkMoveResult, target: Pick<Mailbox, 'id' | 'name'>, textOptions: BulkTextOptions = {}) {
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done === 0 && skipped === 0) return
|
||||
const origins = result.origins
|
||||
const undoOptions: MoveOptions = result.isJunk === undefined ? {} : { isJunk: !result.isJunk }
|
||||
const mailboxName = placeName(target)
|
||||
const text =
|
||||
result.isJunk === false
|
||||
? notSpamMovedText(done, mailboxName, skipped, textOptions)
|
||||
@@ -64,5 +78,5 @@ export function useMailMoveFeedback() {
|
||||
if (done > 0 || skipped > 0) toast.success(purgedMessageText(done, skipped, textOptions))
|
||||
}
|
||||
|
||||
return { announceMove, announcePurge }
|
||||
return { announceMove, announcePurge, placeName }
|
||||
}
|
||||
|
||||
@@ -8,13 +8,20 @@ import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { MAILBOX_RESULT_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { isSearchRouteName, listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
|
||||
import { initialOperationTotal, mailboxOperationCopy, type MailboxOperationKind } from '@/shared/utils/mailbox-operation'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { descendantMailboxes } from '@/shared/utils/mailbox'
|
||||
import {
|
||||
initialOperationTotal,
|
||||
mailboxMoveText,
|
||||
mailboxOperationCopy,
|
||||
type MailboxOperationKind,
|
||||
} from '@/shared/utils/mailbox-operation'
|
||||
import { useMailStore, type MailboxOperationOptions } from '@/stores/mail.store'
|
||||
|
||||
// 메일함 관리 — 메일함 패널의 ⋯ 메뉴 · 대화상자와 휴지통 / 스팸메일함 목록 머리의 「비우기」가 하는 일 (계약 2절 "메일함 관리" · 디자인 README "4단계")
|
||||
// - 만들기 · 이름 바꾸기: 대화상자가 칸 안에 보일 실패(같은 이름 MAIL_009 · 형식 VAL_001)는 알리지 않고 돌려준다. 그 밖은 인터셉터가 알린다.
|
||||
// 성공하면 짧은 알림 ("‘협력사’ 메일함을 만들었습니다." · "메일함 이름을 바꿨습니다.") — 메일함 패널에 바로 나타난다.
|
||||
// 기다리는 사이 세션이 바뀌었으면 아무것도 하지 않는다 (canceled)
|
||||
// - 옮기기(15단계): 같은 이름(MAIL_009)만 창이 칸 아래에 보인다. 옮기면 "‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다." + [되돌리기](원래 있던 곳으로)
|
||||
// - 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 알림이 "…하는 중 · 600 / 1,234통"(돌아가는 아이콘)으로 남았다가 끝나면 결과로 바뀐다.
|
||||
// 처리하는 동안 다른 일을 해도 된다. 도중에 실패하면 거기까지 처리한 수를 알린다(실패 까닭은 인터셉터가 알렸다 — 하나도 못 했으면 그것만).
|
||||
// 다른 탭이 먼저 해서 처리할 것이 없었으면 그렇게 알린다("…에 안 읽은 메일이 없습니다." · "…이 이미 비어 있습니다.").
|
||||
@@ -25,6 +32,8 @@ import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/** 이름 대화상자가 칸 안에 보이는 실패 — 같은 이름 · 형식 */
|
||||
const NAME_INLINE_CODES: readonly ErrorCode[] = ['MAIL_009', 'VAL_001']
|
||||
/** 옮기기 창이 칸 아래에 보이는 실패 — 그사이 옮길 곳에 같은 이름이 생겼다 (15단계 — 보통은 창이 먼저 막는다) */
|
||||
const MOVE_INLINE_CODES: readonly ErrorCode[] = ['MAIL_009']
|
||||
/** 메일함 패널의 지금 메일함 항목 (MailboxPanel 의 data-mailbox-panel) */
|
||||
const CURRENT_MAILBOX_LINK = '[data-mailbox-panel] a[aria-current="page"]'
|
||||
|
||||
@@ -86,6 +95,46 @@ export function useMailboxActions() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 새 상위의 메일함 — 맨 위(null)면 null. 목록에 없으면(그사이 지워짐) null 로 보고 이름 없이 알린다 */
|
||||
function parentOf(parentId: string | null): Mailbox | null {
|
||||
return parentId === null ? null : (mailStore.mailboxes.find((mailbox) => mailbox.id === parentId) ?? null)
|
||||
}
|
||||
|
||||
/**
|
||||
* [되돌리기] — 원래 있던 곳으로 다시 옮긴다. 실패(그사이 원래 자리에 같은 이름이 생김 · 원래 상위가 지워짐 등)는 인터셉터가 알린다.
|
||||
* 기다리는 사이 세션이 바뀌었으면 알리지 않는다 (단추는 띄운 세션에서만 움직인다 — useToast)
|
||||
*/
|
||||
async function undoMove(mailbox: Mailbox, originalParentId: string | null): Promise<void> {
|
||||
const into = parentOf(originalParentId)
|
||||
try {
|
||||
const moved = await mailStore.moveMailbox(mailbox.id, originalParentId)
|
||||
if (moved) toast.success(mailboxMoveText(moved.name, into, { restored: true }))
|
||||
} catch {
|
||||
// 인터셉터가 알렸다
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 옮기기 (15단계 — 디자인 원본 Mailbox15). 같은 이름(MAIL_009)은 창이 칸 아래에 보이도록 알리지 않고 돌려준다 — 그 밖은 인터셉터가 알린다.
|
||||
* 옮겼으면 "‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다." + [되돌리기](원래 있던 곳으로) — 메일함 패널에 바로 나타나고, 보던 메일함이면 그대로 본다(id 가 같다)
|
||||
*/
|
||||
async function move(mailbox: Mailbox, parentId: string | null): Promise<MailboxNameResult> {
|
||||
const originalParentId = mailbox.parentId
|
||||
const into = parentOf(parentId)
|
||||
try {
|
||||
const moved = await mailStore.moveMailbox(mailbox.id, parentId, { silent: MOVE_INLINE_CODES })
|
||||
if (!moved) return { status: 'canceled' }
|
||||
toast.success(mailboxMoveText(moved.name, into), {
|
||||
label: MAILBOX_RESULT_TEXT.undo,
|
||||
run: () => void undoMove(moved, originalParentId),
|
||||
})
|
||||
return { status: 'saved', mailbox: moved }
|
||||
} catch (error) {
|
||||
const apiError = toApiError(error)
|
||||
return { status: 'failed', error: apiError, inline: MOVE_INLINE_CODES.includes(apiError.code) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 지금 화면이 그 메일함(의 목록 · 메일 · 그 안의 검색)을 보고 있는가 */
|
||||
function isViewing(mailboxId: string): boolean {
|
||||
const route = router.currentRoute.value
|
||||
@@ -94,8 +143,9 @@ export function useMailboxActions() {
|
||||
}
|
||||
|
||||
/** 끝난 뒤의 이동 (위 규칙) */
|
||||
async function moveAway(kind: MailboxOperationKind, mailboxId: string): Promise<void> {
|
||||
if (!isViewing(mailboxId)) return
|
||||
async function moveAway(kind: MailboxOperationKind, mailboxIds: readonly string[]): Promise<void> {
|
||||
// 하위까지 지웠으면 하위 메일함을 보고 있어도 받은편지함으로 (15단계)
|
||||
if (!mailboxIds.some(isViewing)) return
|
||||
if (kind === 'delete') {
|
||||
await replaceWithMailbox(mailStore.defaultMailbox?.id ?? null)
|
||||
return
|
||||
@@ -113,12 +163,21 @@ export function useMailboxActions() {
|
||||
document.querySelector<HTMLElement>(CURRENT_MAILBOX_LINK)?.focus()
|
||||
}
|
||||
|
||||
async function runOperation(kind: MailboxOperationKind, mailbox: Mailbox): Promise<void> {
|
||||
async function runOperation(
|
||||
kind: MailboxOperationKind,
|
||||
mailbox: Mailbox,
|
||||
options: MailboxOperationOptions = {},
|
||||
): Promise<void> {
|
||||
if (isRunning(kind, mailbox.id) || isBlocked(kind, mailbox)) return
|
||||
const copy = mailboxOperationCopy(kind, mailbox.name)
|
||||
const progress = toast.progress(copy.progress(0, initialOperationTotal(kind, mailbox)))
|
||||
const outcome = await mailStore.runMailboxOperation(kind, mailbox, (step) =>
|
||||
progress.update(copy.progress(step.processed, step.total)),
|
||||
// 하위까지 지우면 첫 응답 전의 전체 수도 나무 전체의 메일 수다 (15단계)
|
||||
const descendants = options.withChildren ? descendantMailboxes(mailStore.mailboxes, mailbox.id) : []
|
||||
const progress = toast.progress(copy.progress(0, initialOperationTotal(kind, mailbox, descendants)))
|
||||
const outcome = await mailStore.runMailboxOperation(
|
||||
kind,
|
||||
mailbox,
|
||||
(step) => progress.update(copy.progress(step.processed, step.total)),
|
||||
options,
|
||||
)
|
||||
if (outcome.status === 'canceled') {
|
||||
progress.dismiss()
|
||||
@@ -132,7 +191,7 @@ export function useMailboxActions() {
|
||||
}
|
||||
// 지우기는 "안의 메일 N통은 휴지통에 있습니다" — 다른 메일함에도 있어 이 메일함에서만 뺀 메일은 세지 않는다
|
||||
progress.succeed(copy.done(kind === 'delete' ? outcome.movedToTrash : outcome.processed))
|
||||
await moveAway(kind, mailbox.id)
|
||||
await moveAway(kind, [mailbox.id, ...descendants.map((child) => child.id)])
|
||||
if (kind !== 'read') await recoverFocus()
|
||||
}
|
||||
|
||||
@@ -146,10 +205,13 @@ export function useMailboxActions() {
|
||||
return runOperation('empty', mailbox)
|
||||
}
|
||||
|
||||
/** 메일함 지우기(안의 메일은 휴지통으로) — 확인 대화상자를 거친 뒤에만 부른다. 하위 메일함이 있으면 대화상자가 먼저 막는다 */
|
||||
function remove(mailbox: Mailbox): Promise<void> {
|
||||
return runOperation('delete', mailbox)
|
||||
/**
|
||||
* 메일함 지우기(안의 메일은 휴지통으로) — 확인 대화상자를 거친 뒤에만 부른다. 하위 메일함이 있으면 확인 창이 하위까지 지울지 묻고
|
||||
* withChildren 으로 부른다 (15단계 — 진행 · 결과 알림은 지금 지우기 그대로, 수는 나무 전체)
|
||||
*/
|
||||
function remove(mailbox: Mailbox, options: MailboxOperationOptions = {}): Promise<void> {
|
||||
return runOperation('delete', mailbox, options)
|
||||
}
|
||||
|
||||
return { isRunning, isBlocked, create, rename, markAllRead, empty, remove }
|
||||
return { isRunning, isBlocked, create, rename, move, markAllRead, empty, remove }
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useRouter } from 'vue-router'
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useMailMoveFeedback } from './useMailMoveFeedback'
|
||||
import { useMailNavigation } from './useMailNavigation'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { listLocationOf, messageLocationOf, sameListSource, type MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { junkMarkForMove } from '@/shared/utils/mail-move-targets'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 읽기 화면의 「삭제」 · 「영구 삭제」 · 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통
|
||||
@@ -17,6 +18,8 @@ import { useMailStore } from '@/stores/mail.store'
|
||||
// - 「영구 삭제」: 확인 대화상자를 거친 뒤 POST /message-deletions — "메일 1통을 영구 삭제했습니다."
|
||||
// - 「스팸 신고」: 스팸메일함으로 옮기며 스팸 표시(isJunk: true) — 스팸메일함에 든 메일이면 「스팸 아님」: 받은편지함으로 · isJunk: false.
|
||||
// 알림에 [되돌리기] (원래 메일함 · 원래 표시로)
|
||||
// - 「이동 ▾」(15단계 — 디자인 원본 Reader15): 고른 메일함으로 옮긴다 — "메일 1통을 거래처 › 2025로 옮겼습니다." + [되돌리기].
|
||||
// 스팸메일함으로 옮기면 [스팸 신고]와 같이 스팸 표시를, 스팸메일함에서 휴지통 밖으로 꺼내면 [스팸 아님]과 같이 스팸 아님 표시를 함께 보낸다(junkMarkForMove)
|
||||
// - DELETE /messages/{id} 는 쓰지 않는다 — 메일함 정보가 낡았으면(그사이 다른 창이 휴지통으로 옮겼으면) 확인 없이 영구 삭제하기 때문이다.
|
||||
// 이 길에서는 낡은 정보가 해롭지 않다: 이미 휴지통에 있는 메일을 휴지통으로 옮기면 그대로이고, 휴지통을 떠난 메일의 영구 삭제는
|
||||
// 서버가 그대로 둔다(skippedIds — 목록을 다시 맞춘다)
|
||||
@@ -69,7 +72,19 @@ export function useMessageRemoval() {
|
||||
}
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const result = await mailStore.moveMessages([message], trash.mailbox.id)
|
||||
feedback.announceMove(result, trash.mailbox.name)
|
||||
feedback.announceMove(result, trash.mailbox)
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
/**
|
||||
* 「이동 ▾」로 고른 메일함으로 옮긴다 — 스팸메일함으로 · 스팸메일함에서 꺼내면 스팸 표시도 함께(위 규칙).
|
||||
* 알림은 [삭제]와 같은 길이다 (되돌리기는 원래 메일함 · 원래 표시로)
|
||||
*/
|
||||
async function moveTo(message: MessageSummary, mailbox: Mailbox): Promise<MessageRemovalOutcome> {
|
||||
const isJunk = junkMarkForMove(mailbox, isInJunk(message))
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const result = await mailStore.moveMessages([message], mailbox.id, isJunk === undefined ? {} : { isJunk })
|
||||
feedback.announceMove(result, mailbox)
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
@@ -91,7 +106,7 @@ export function useMessageRemoval() {
|
||||
}
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
const result = await mailStore.moveMessages([message], target.mailbox.id, { isJunk: reportsSpam })
|
||||
feedback.announceMove(result, target.mailbox.name)
|
||||
feedback.announceMove(result, target.mailbox)
|
||||
return outcomeOf(message.id, nextId, result.doneIds.includes(message.id), result.stale)
|
||||
}
|
||||
|
||||
@@ -122,5 +137,5 @@ export function useMessageRemoval() {
|
||||
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
|
||||
}
|
||||
|
||||
return { deletesPermanently, isInJunk, moveToTrash, purge, moveSpam, stillShowing, leaveRemoved }
|
||||
return { deletesPermanently, isInJunk, moveToTrash, moveTo, purge, moveSpam, stillShowing, leaveRemoved }
|
||||
}
|
||||
|
||||
@@ -1,27 +1,68 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
|
||||
import { opensUpward } from '@/shared/utils/popup-placement'
|
||||
import { opensUpward, popupAlignFor, type PopupAlign } from '@/shared/utils/popup-placement'
|
||||
|
||||
// 펼침 메뉴를 아래로 열지 위로 열지 — 메뉴를 그린 직후(초점을 주기 전) 아래로 그린 메뉴를 재어, 보이는 영역 아래가 모자라면 위로 연다
|
||||
// (판단은 순수 함수 popup-placement.ts 의 opensUpward, 여기서는 재기만 한다).
|
||||
// 쓰는 곳: 메일함 · 주소록 그룹 줄의 ⋯ (MailboxMoreButton — 스크롤되는 패널 안), 쓰기 화면의 [보내기 ▾] (ComposeFooter — 화면 맨 아래 단추 줄.
|
||||
// 아래로 열면 메뉴가 화면 밖에 생기고 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰)
|
||||
// 메뉴는 EditorPopup 이 연 뒤 다음 틱에 초점을 준다 — 그 전에 위아래가 정해진다 (같은 그리기 차례 안의 post 감시)
|
||||
// 좌우(15단계): 메뉴를 단추의 왼쪽 끝에 맞춰 열었는데 보이는 영역 오른쪽 밖으로 넘치면 단추의 오른쪽 끝에 맞춘다 —
|
||||
// EditorPopup 이 그린 직후 스스로 잰다(measurePopupAlign — 같은 부품을 쓰는 메뉴 · 팝오버 모두). 판단은 popup-placement.ts 의 popupAlignFor
|
||||
|
||||
/** 잘라 내는 overflow — 이 조상 밖은 보이지 않는다 */
|
||||
const CLIPPING_OVERFLOW = new Set(['auto', 'scroll', 'hidden', 'clip', 'overlay'])
|
||||
|
||||
/** 요소가 보일 수 있는 세로 영역 — 화면과 잘라 내는 조상(스크롤되는 칸 등)들이 겹치는 부분 */
|
||||
function visibleArea(element: Element): { top: number; bottom: number } {
|
||||
let top = 0
|
||||
let bottom = window.innerHeight
|
||||
interface VisibleArea {
|
||||
top: number
|
||||
bottom: number
|
||||
left: number
|
||||
right: number
|
||||
}
|
||||
|
||||
/** 화면 폭 — 세로 스크롤 막대는 뺀다(막대 밑의 메뉴는 보이지 않는다). 재지 못하면(시험 환경 등) 창 폭 */
|
||||
function viewportWidth(): number {
|
||||
return document.documentElement.clientWidth || window.innerWidth
|
||||
}
|
||||
|
||||
/** 요소가 보일 수 있는 영역 — 화면과 잘라 내는 조상(스크롤되는 칸 등)들이 겹치는 부분. 세로 · 가로를 따로 본다 */
|
||||
function visibleArea(element: Element): VisibleArea {
|
||||
const area: VisibleArea = { top: 0, bottom: window.innerHeight, left: 0, right: viewportWidth() }
|
||||
for (let node = element.parentElement; node; node = node.parentElement) {
|
||||
if (!CLIPPING_OVERFLOW.has(getComputedStyle(node).overflowY)) continue
|
||||
const style = getComputedStyle(node)
|
||||
const clipsY = CLIPPING_OVERFLOW.has(style.overflowY)
|
||||
const clipsX = CLIPPING_OVERFLOW.has(style.overflowX)
|
||||
if (!clipsY && !clipsX) continue
|
||||
const rect = node.getBoundingClientRect()
|
||||
top = Math.max(top, rect.top)
|
||||
bottom = Math.min(bottom, rect.bottom)
|
||||
if (clipsY) {
|
||||
area.top = Math.max(area.top, rect.top)
|
||||
area.bottom = Math.min(area.bottom, rect.bottom)
|
||||
}
|
||||
if (clipsX) {
|
||||
area.left = Math.max(area.left, rect.left)
|
||||
area.right = Math.min(area.right, rect.right)
|
||||
}
|
||||
}
|
||||
return { top, bottom }
|
||||
return area
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴를 단추의 어느 끝에 맞출지 잰다 — 바라는 끝(preferred)으로 그린 메뉴의 폭으로, 보이는 영역 밖으로 넘치면 덜 넘치는 반대 끝 (15단계).
|
||||
* 메뉴는 단추를 감싼 칸(position: relative) 안에서 그 칸의 끝 — 곧 단추의 끝 — 에 맞춰 열린다 (EditorPopup)
|
||||
*/
|
||||
export function measurePopupAlign(trigger: HTMLElement, popup: HTMLElement, preferred: PopupAlign): PopupAlign {
|
||||
const buttonRect = trigger.getBoundingClientRect()
|
||||
const area = visibleArea(trigger)
|
||||
return popupAlignFor(
|
||||
{
|
||||
triggerLeft: buttonRect.left,
|
||||
triggerRight: buttonRect.right,
|
||||
popupWidth: popup.getBoundingClientRect().width,
|
||||
areaLeft: area.left,
|
||||
areaRight: area.right,
|
||||
},
|
||||
preferred,
|
||||
)
|
||||
}
|
||||
|
||||
export interface PopupPlacementOptions {
|
||||
|
||||
@@ -3,10 +3,14 @@ import { isNavigationFailure, useRouter, type RouteLocationRaw } from 'vue-route
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useMessageRemoval } from './useMessageRemoval'
|
||||
import { useToast } from './useToast'
|
||||
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { MAIL_PARTIAL_SEND_TEXT, MAIL_SENT_ACTION_LABEL, MAIL_SENT_MESSAGE } from '@/shared/constants/mail'
|
||||
import { SEND_LATER_TEXT, UNDO_SEND_DEADLINE_MARGIN_MS, UNDO_SEND_MAX_MS } from '@/shared/constants/mail-send-later'
|
||||
import type { MessageSummary, ReleasedSend } from '@/shared/types/mail'
|
||||
import { partialSendMessage } from '@/shared/utils/mail-send-result'
|
||||
import { undoToastDeadline } from '@/shared/utils/pending-send'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailStore, type ReleaseOutcome } from '@/stores/mail.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
|
||||
/**
|
||||
* 붙잡아 둔 발송을 다루는 일 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계") — 모두 DELETE /scheduled-messages/{id} 한 길이다.
|
||||
@@ -17,6 +21,8 @@ import { useMailStore } from '@/stores/mail.store'
|
||||
* 두 경우 모두 쓰기 화면으로 가지 못했으면(지금 화면의 나가기 확인에서 머물기로 함 등) 그 자리에 두고
|
||||
* "…취소하고 임시보관함으로 옮겼습니다." + [고치기](그 메일로 쓰기 화면) — 취소는 이미 됐다
|
||||
* - [예약 취소](예약 안내 줄): 임시보관함으로 + "예약을 취소하고 임시보관함으로 옮겼습니다." + [고치기] — 삭제처럼 목록에서 빠진 뒤의 이동
|
||||
* - [지금 보내기](예약 안내 줄 — 15단계): POST /scheduled-messages/{id}/release(취소가 아니다) → 쓰기 화면의 보낸 뒤 알림과 같은 알림 + [예약 취소]와 같은 이동.
|
||||
* 보내지 못했거나 결과를 모르면(503 MAIL_001 · SYS_001) 스토어가 그 메일을 다시 받아 가른 결과대로 알린다 (sendNow)
|
||||
* - [삭제](아직 보내지 않은 메일 — 보내는 중 · 예약, 확인 창 뒤): 발송을 취소한 뒤 휴지통으로 + "보내지 않고 휴지통으로 옮겼습니다."
|
||||
* 같은 메일의 취소가 이미 진행 중이면(빠르게 두 번 누름 · 두 곳에서 누름) 두 번째는 아무것도 하지 않는다 (스토어도 요청을 하나만 보낸다).
|
||||
* 세션은 일을 시작할 때 잡는다 — 기다리는 사이 바뀌었으면(로그아웃 · 다른 계정) 이동 · 알림을 하지 않는다(앞 사용자의 메일을 새 세션에 열지 않게).
|
||||
@@ -26,6 +32,7 @@ export function useScheduledMessageActions() {
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const mailStore = useMailStore()
|
||||
const settingsStore = useMailSettingsStore()
|
||||
const toast = useToast()
|
||||
const removal = useMessageRemoval()
|
||||
|
||||
@@ -90,6 +97,90 @@ export function useScheduledMessageActions() {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금 보낸 뒤의 알림 — 쓰기 화면의 보낸 뒤 알림과 같다 (디자인 원본 Reader15 "[지금 보내기]를 누른 뒤").
|
||||
* 보내기 취소를 기다리는 중이면 "메일을 보냈습니다." + [보내기 취소](마감 알림 — 남은 시간 막대, 설정한 초로 센다),
|
||||
* 바로 보냈거나 이미 보낸 메일이면 "메일을 보냈습니다." + [보낸편지함 보기]. 받는 사람 일부가 받지 못하면 그 문장(오류 알림 모양)으로
|
||||
*/
|
||||
function notifyReleased(messageId: string, released: ReleasedSend) {
|
||||
const pending = released.pendingSend
|
||||
const now = Date.now()
|
||||
const expiresAt =
|
||||
pending?.kind === 'undo'
|
||||
? undoToastDeadline({
|
||||
sendAt: pending.sendAt,
|
||||
now,
|
||||
undoSendSeconds: settingsStore.knownUndoSendSeconds,
|
||||
marginMs: UNDO_SEND_DEADLINE_MARGIN_MS,
|
||||
maxMs: UNDO_SEND_MAX_MS,
|
||||
})
|
||||
: Number.NaN
|
||||
if (pending?.kind === 'undo' && expiresAt > now) {
|
||||
const undo = { label: SEND_LATER_TEXT.undo, run: () => void undoSend(messageId) }
|
||||
const partial = partialSendMessage(released.rejectedRecipients, { pendingKind: 'undo' })
|
||||
if (partial) toast.error(partial, undo, { expiresAt })
|
||||
else toast.success(MAIL_SENT_MESSAGE, undo, { expiresAt })
|
||||
return
|
||||
}
|
||||
const sentMailbox = mailStore.mailboxByRole('sent')
|
||||
const sentLocation = sentMailbox ? { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } : null
|
||||
const partial = partialSendMessage(released.rejectedRecipients)
|
||||
if (partial) {
|
||||
toast.error(partial, sentLocation ? { label: MAIL_PARTIAL_SEND_TEXT.action, to: sentLocation } : undefined)
|
||||
return
|
||||
}
|
||||
toast.success(MAIL_SENT_MESSAGE, sentLocation ? { label: MAIL_SENT_ACTION_LABEL, to: sentLocation } : undefined)
|
||||
}
|
||||
|
||||
/** 지금 보내지 못해 임시보관함으로 되돌아갔다 — "지금 보내지 못해 임시보관함으로 옮겼습니다." + [임시보관함 보기] */
|
||||
function notifyReverted() {
|
||||
const drafts = mailStore.mailboxByRole('drafts')
|
||||
const draftsLocation = drafts ? { name: 'mail-mailbox', params: { mailboxId: drafts.id } } : null
|
||||
toast.error(
|
||||
SEND_LATER_TEXT.sendNowReverted,
|
||||
draftsLocation ? { label: SEND_LATER_TEXT.viewDrafts, to: draftsLocation } : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [지금 보내기](예약 안내 줄 — 15단계) — 예약을 거두고 같은 메일을 지금 보낸다([보내기]를 누른 것과 같다 — 보내기 취소 설정의 초만큼 붙잡았다 보낸다).
|
||||
* 확인 창은 두지 않는다. 보낸 메일은 예약됨에서 빠지므로 분할 보기는 다음 예약 메일을, 목록 보기는 예약됨 목록을 연다([예약 취소]와 같다).
|
||||
* 보낸편지함에서 연 메일이면 그 자리에 둔다(날짜 칸이 "보내는 중"이 된다).
|
||||
* 보내지 못했거나 결과를 모르면(503 MAIL_001 · SYS_001 — 스토어가 그 메일을 다시 받아 가른다):
|
||||
* - 임시보관함으로 되돌아갔다: "…임시보관함으로 옮겼습니다." + [임시보관함 보기] (예약됨을 떠났다 — 이동은 위와 같다)
|
||||
* - 예약이 그대로다(확인했다): "지금 보내지 못했습니다. 다시 시도해 주세요." + [다시 시도](같은 요청 — 안내 줄은 그대로 둔다)
|
||||
* - 예약만 풀렸다(보낸편지함에 그대로 · 기다리는 발송이 없다): "메일을 보냈는지 확인하지 못했습니다. …" + [다시 시도] —
|
||||
* 이미 나갔을 수 있어(보내기 취소 0초 · 응답만 잃음) 못 보냈다고 단정하지 않는다. 예약이 아닌 것은 확인했으니 안내 줄이 사라지고 예약됨에서도 빠진다(이동은 위와 같다)
|
||||
* - 다시 받지 못했다: 같은 "확인하지 못했습니다" 글 + [다시 시도] — 안내 줄은 마지막으로 안 그대로 둔다
|
||||
* - 다시 낸 발송이 보내기 취소를 기다린다: 보낸 것과 같다
|
||||
* [다시 시도]는 같은 요청이다 — 이미 보낸 메일이면 서버가 200 으로 답하므로 두 통이 가지 않는다.
|
||||
* 안내 줄이 사라지면(보냄 · 되돌림 · 예약만 풀림) true — 그 밖의 실패(인터셉터가 알렸다) · 예약이 그대로 · 다시 받지 못함 · 그사이 세션이 바뀌었으면 false
|
||||
*/
|
||||
async function sendNow(message: MessageSummary, layout: 'split' | 'list'): Promise<boolean> {
|
||||
if (mailStore.isReleasingPendingSend(message.id) || mailStore.isCancellingPendingSend(message.id)) return false
|
||||
const session = authStore.generation
|
||||
const source = mailStore.listSource
|
||||
const nextId = mailStore.neighborAfterRemoval(message.id)
|
||||
let outcome: ReleaseOutcome
|
||||
try {
|
||||
outcome = await mailStore.releasePendingSend(message.id)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (authStore.generation !== session) return false
|
||||
const retry = { label: SEND_LATER_TEXT.retry, run: () => void sendNow(message, layout) }
|
||||
if (outcome.status === 'retryable' || outcome.status === 'unknown') {
|
||||
toast.error(outcome.status === 'retryable' ? SEND_LATER_TEXT.sendNowFailed : SEND_LATER_TEXT.sendNowUnconfirmed, retry)
|
||||
return false
|
||||
}
|
||||
if (outcome.status === 'unscheduled') toast.error(SEND_LATER_TEXT.sendNowUnconfirmed, retry)
|
||||
else if (outcome.status === 'reverted') notifyReverted()
|
||||
else notifyReleased(message.id, outcome.released)
|
||||
const removed = removedFromList(message.id)
|
||||
await removal.leaveRemoved({ removed, nextId: removed ? nextId : null }, source, layout, message.id)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* [삭제] — 아직 보내지 않은 메일(보내는 중 · 예약)의 확인 창에서 「삭제」를 누른 뒤에만 부른다.
|
||||
* 발송을 취소한 뒤 휴지통으로 옮긴다 (취소하지 않고 옮기면 엔진은 그 시각에 그대로 보낸다)
|
||||
@@ -114,5 +205,5 @@ export function useScheduledMessageActions() {
|
||||
return true
|
||||
}
|
||||
|
||||
return { undoSend, editScheduled, cancelScheduled, deletePendingSend }
|
||||
return { undoSend, editScheduled, cancelScheduled, sendNow, deletePendingSend }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notifyApiError } from './useApi'
|
||||
import { useSenderBlockRun } from './useSenderBlockRun'
|
||||
import { useToast } from './useToast'
|
||||
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
@@ -23,6 +24,7 @@ export interface SenderBlockOutcome {
|
||||
* 보낸 사람 차단 · 풀기와 그 알림 (계약 2절 "보낸 사람 차단" · 디자인 README "9단계 › 읽기 화면 · 설정 › 차단한 주소").
|
||||
* - 차단(읽기 화면의 확인 창): POST /mail-blocks → 「이 메일도 스팸메일함으로 옮기기」를 켰으면 그 메일을 스팸메일함으로(isJunk: true — 스팸 신고와 같은 길) →
|
||||
* "‘주소’을(를) 차단하고 이 메일을 스팸메일함으로 옮겼습니다." + [되돌리기](차단을 풀고 메일을 원래 메일함으로 — 받은편지함에서 막았으면 받은편지함)
|
||||
* 「받은편지함에 있는 이 보낸 사람의 메일도 …」(15단계)를 켰으면 그 알림 대신 받은편지함 정리의 진행 알림 하나 (useSenderBlockRun — [되돌리기] 없음)
|
||||
* - 풀기(⋯ 「보낸 사람 차단 풀기」 · 스팸메일함의 [차단 풀기] · 설정의 [차단 풀기]): DELETE → "‘주소’ 차단을 풀었습니다." + [되돌리기](다시 차단).
|
||||
* 이미 받은 메일은 옮기지 않는다. 다른 곳에서 이미 풀었으면(404) 푼 것과 같다 (스토어가 가른다)
|
||||
* 요청 실패는 인터셉터가 알린다 — 여기서는 알리지 않는다. 되돌리기는 알림을 띄운 세션에서만 움직인다 (useToast).
|
||||
@@ -33,6 +35,7 @@ export function useSenderBlock() {
|
||||
const blockStore = useMailBlockStore()
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
const blockRun = useSenderBlockRun()
|
||||
|
||||
/** 지금 세션에서 시작한 일이 그대로인지 — 시작할 때 잡아 둔 세대와 견준다 */
|
||||
function sessionGuard(): () => boolean {
|
||||
@@ -54,9 +57,16 @@ export function useSenderBlock() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단 — target 은 주소 또는 "@도메인". moveToJunk 면 그 메일을 스팸메일함으로 옮긴다(이미 스팸메일함이면 옮기지 않는다)
|
||||
* 차단 — target 은 주소 또는 "@도메인". moveToJunk 면 그 메일을 스팸메일함으로 옮긴다(이미 스팸메일함이면 옮기지 않는다).
|
||||
* cleanInbox(15단계 — 차단 창의 새 칸)면 받은편지함에 이미 있는 그 보낸 사람(도메인)의 메일도 옮기기 시작한다 — 차단 알림 대신 진행 알림 하나
|
||||
* (useSenderBlockRun — [되돌리기] 없음, 기다리지 않는다). 이 메일을 먼저 옮겼으면(받은편지함에 있었다) 그 한 통도 옮긴 수에 넣는다
|
||||
*/
|
||||
async function block(message: MessageSummary, target: string, moveToJunk: boolean): Promise<SenderBlockOutcome> {
|
||||
async function block(
|
||||
message: MessageSummary,
|
||||
target: string,
|
||||
moveToJunk: boolean,
|
||||
cleanInbox = false,
|
||||
): Promise<SenderBlockOutcome> {
|
||||
const sameSession = sessionGuard()
|
||||
let address: string
|
||||
try {
|
||||
@@ -68,6 +78,8 @@ export function useSenderBlock() {
|
||||
let origins: BulkOrigin[] = []
|
||||
let moved = false
|
||||
let stale = false
|
||||
const inboxId = mailStore.mailboxByRole('inbox')?.id
|
||||
const wasInInbox = inboxId !== undefined && message.mailboxIds.includes(inboxId)
|
||||
if (moveToJunk) {
|
||||
const junk = await mailStore.resolveMailboxByRole('junk')
|
||||
if ('error' in junk) {
|
||||
@@ -80,6 +92,10 @@ export function useSenderBlock() {
|
||||
}
|
||||
}
|
||||
if (!sameSession()) return { blocked: true, moved: false, stale }
|
||||
if (cleanInbox) {
|
||||
blockRun.start(address, { alreadyMoved: moved && wasInInbox ? 1 : 0 })
|
||||
return { blocked: true, moved, stale }
|
||||
}
|
||||
const particle = objectParticle(address)
|
||||
toast.success(
|
||||
moved ? MAIL_BLOCK_TEXT.blockedAndMoved(address, particle) : MAIL_BLOCK_TEXT.blocked(address, particle),
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MailBlockRun, MailBlockRunRequest, Mailbox } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore, type Toast } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { useSenderBlockRun } from './useSenderBlockRun'
|
||||
|
||||
// 차단할 때 받은편지함의 메일도 스팸메일함으로 — 알림 하나가 진행을 보이다 결과로 · [다시 시도] · 끝난 뒤 개수 · 목록 맞추기
|
||||
// (디자인 원본 Mailbox15 "차단한 뒤 알림" · 계약 2절 "보낸 사람 차단" — 15단계)
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const SENDER = 'news@example.com'
|
||||
|
||||
function mailbox(id: string, name: string, role: Mailbox['role'] = null, totalCount = 0): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount: 0 }
|
||||
}
|
||||
|
||||
const MAILBOXES: Mailbox[] = [mailbox('inbox', '받은편지함', 'inbox', 4800), mailbox('junk', '스팸메일함', 'junk', 5)]
|
||||
|
||||
function step(scanned: number, matched: number, remaining: number, cursor: string | null): MockResult {
|
||||
const run: MailBlockRun = { scanned, matched, remaining, cursor }
|
||||
return ok(run)
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
return { promise, finish }
|
||||
}
|
||||
|
||||
describe('useSenderBlockRun', () => {
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().startSession(USER)
|
||||
useMailStore().mailboxes = MAILBOXES.map((item) => ({ ...item }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
function replies(...results: (MockResult | Promise<MockResult>)[]) {
|
||||
let next = 0
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/mail-block-runs') {
|
||||
const result = results[Math.min(next, results.length - 1)] as MockResult | Promise<MockResult>
|
||||
next += 1
|
||||
return result
|
||||
}
|
||||
if (request.url === '/mailboxes') {
|
||||
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
|
||||
}
|
||||
if (request.url === '/messages') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
return ok(null)
|
||||
})
|
||||
}
|
||||
|
||||
const runs = () => requests.filter((request) => request.url === '/mail-block-runs')
|
||||
const bodies = () => runs().map((request) => request.data as MailBlockRunRequest)
|
||||
const shown = () => useToastStore().toasts.map((toast) => [toast.kind, toast.message])
|
||||
const withAction = () => useToastStore().toasts.find((toast) => toast.action !== null)
|
||||
const listRequests = () => requests.filter((request) => request.method === 'GET' && request.url === '/messages')
|
||||
|
||||
function press(toast: Toast | undefined) {
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
}
|
||||
|
||||
it('진행 알림이 남았다가 끝나면 그 자리에서 결과로 — "…차단하고 받은편지함의 메일 37통을 …옮겼습니다." + [스팸메일함 보기] · [되돌리기] 없음', async () => {
|
||||
const second = deferred()
|
||||
replies(step(1200, 10, 3600, 'b1'), second.promise)
|
||||
|
||||
useSenderBlockRun().start(SENDER)
|
||||
expect(shown()).toEqual([
|
||||
['progress', '‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 0 / 4,800통 확인'],
|
||||
])
|
||||
await flushPromises()
|
||||
expect(shown()).toEqual([
|
||||
['progress', '‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인'],
|
||||
])
|
||||
|
||||
second.finish(step(3600, 27, 0, null))
|
||||
await flushPromises()
|
||||
expect(bodies()).toEqual([
|
||||
{ sender: SENDER, cursor: null },
|
||||
{ sender: SENDER, cursor: 'b1' },
|
||||
])
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(useToastStore().toasts).toHaveLength(1)
|
||||
expect(toast).toMatchObject({
|
||||
kind: 'success',
|
||||
message: '‘news@example.com’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다.',
|
||||
action: { label: '스팸메일함 보기', to: { name: 'mail-mailbox', params: { mailboxId: 'junk' } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('먼저 옮긴 이 메일(받은편지함)도 옮긴 수에 넣는다 · 옮길 메일이 없으면 "…옮길 메일이 없습니다."(단추 없음)', async () => {
|
||||
replies(step(4800, 0, 0, null))
|
||||
|
||||
useSenderBlockRun().start(SENDER, { alreadyMoved: 1 })
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts).toMatchObject([
|
||||
{ kind: 'success', message: '‘news@example.com’을(를) 차단하고 받은편지함의 메일 1통을 스팸메일함으로 옮겼습니다.' },
|
||||
])
|
||||
|
||||
useToastStore().clear()
|
||||
useSenderBlockRun().start('@example.com')
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts).toMatchObject([
|
||||
{ kind: 'success', message: '‘@example.com’을(를) 차단했습니다. 받은편지함에는 옮길 메일이 없습니다.', action: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('도중에 멈추면 멈춤 문구 + [다시 시도] — 그 cursor 로 이어서 수를 이어 센다 (까닭은 사전 문구 알림)', async () => {
|
||||
replies(step(1200, 12, 3600, 'b1'), fail(503, 'MAIL_001'), step(3600, 25, 0, null))
|
||||
|
||||
useSenderBlockRun().start(SENDER)
|
||||
await flushPromises()
|
||||
expect(shown()).toContainEqual(['error', ERROR_CODE_MESSAGES.MAIL_001])
|
||||
const stopped = withAction()
|
||||
expect(stopped).toMatchObject({
|
||||
kind: 'error',
|
||||
message:
|
||||
'‘news@example.com’을(를) 차단했습니다. 받은편지함은 4,800통 가운데 1,200통까지 확인해 12통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요.',
|
||||
action: { label: '다시 시도' },
|
||||
})
|
||||
|
||||
useToastStore().clear()
|
||||
press(stopped)
|
||||
await flushPromises()
|
||||
expect(bodies().map((body) => body.cursor)).toEqual([null, 'b1', 'b1'])
|
||||
expect(useToastStore().toasts).toMatchObject([
|
||||
{ message: '‘news@example.com’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다.' },
|
||||
])
|
||||
})
|
||||
|
||||
it('처음 요청부터 실패하면 "…차단했지만 받은편지함의 메일은 옮기지 못했습니다." + [다시 시도] — 처음부터 (먼저 옮긴 수는 그대로)', async () => {
|
||||
replies(fail(500, 'SYS_001'), step(4800, 3, 0, null))
|
||||
|
||||
useSenderBlockRun().start(SENDER, { alreadyMoved: 1 })
|
||||
await flushPromises()
|
||||
const failed = withAction()
|
||||
expect(failed).toMatchObject({
|
||||
kind: 'error',
|
||||
message: '‘news@example.com’을(를) 차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요.',
|
||||
action: { label: '다시 시도' },
|
||||
})
|
||||
|
||||
useToastStore().clear()
|
||||
press(failed)
|
||||
await flushPromises()
|
||||
expect(bodies().map((body) => body.cursor)).toEqual([null, null])
|
||||
expect(useToastStore().toasts).toMatchObject([
|
||||
{ message: '‘news@example.com’을(를) 차단하고 받은편지함의 메일 4통을 스팸메일함으로 옮겼습니다.' },
|
||||
])
|
||||
})
|
||||
|
||||
it('다른 창 · PC 에서 받은편지함이나 스팸메일함을 정리하는 중이면(429) 그 알림 하나 — 처음 요청이어도 [다시 시도]를 둔다 (차단한 뒤에는 다시 고를 길이 없다)', async () => {
|
||||
replies(fail(429, 'REQ_001'), step(4800, 2, 0, null))
|
||||
|
||||
useSenderBlockRun().start(SENDER)
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts).toHaveLength(1)
|
||||
const busy = withAction()
|
||||
expect(busy).toMatchObject({
|
||||
kind: 'error',
|
||||
message: '‘news@example.com’을(를) 차단했습니다. 다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
action: { label: '다시 시도' },
|
||||
})
|
||||
|
||||
press(busy)
|
||||
await flushPromises()
|
||||
expect(bodies().map((body) => body.cursor)).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('이 탭에서 필터를 적용하는 중이면 시작하지 않고 알린다 — 그 알림의 [다시 시도]로 앞 정리가 끝난 뒤 다시 한다', async () => {
|
||||
replies(step(4800, 1, 0, null))
|
||||
const filterRun = useMailFilterRunStore()
|
||||
filterRun.running = true
|
||||
|
||||
useSenderBlockRun().start(SENDER)
|
||||
await flushPromises()
|
||||
expect(runs()).toEqual([])
|
||||
const busy = withAction()
|
||||
expect(busy).toMatchObject({
|
||||
kind: 'error',
|
||||
message: '‘news@example.com’을(를) 차단했습니다. 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
action: { label: '다시 시도' },
|
||||
})
|
||||
|
||||
filterRun.running = false
|
||||
press(busy)
|
||||
await flushPromises()
|
||||
expect(runs()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('이 탭에서 스팸메일함을 비우는 중이면 시작하지 않고 그 까닭으로 알린다 — 비우기가 끝난 뒤 그 알림의 [다시 시도]로 한다', async () => {
|
||||
const emptying = deferred()
|
||||
requests = installMockApi((request) => {
|
||||
if (request.method === 'DELETE' && request.url === '/mailboxes/junk/messages') return emptying.promise
|
||||
if (request.url === '/mail-block-runs') return step(4800, 1, 0, null)
|
||||
if (request.url === '/mailboxes') {
|
||||
return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: MAILBOXES.length } })
|
||||
}
|
||||
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
})
|
||||
const mail = useMailStore()
|
||||
const junk = mail.mailboxByRole('junk')
|
||||
if (!junk) throw new Error('스팸메일함이 없다')
|
||||
const empty = mail.runMailboxOperation('empty', junk, () => undefined)
|
||||
|
||||
useSenderBlockRun().start(SENDER)
|
||||
await flushPromises()
|
||||
expect(runs()).toEqual([])
|
||||
const busy = withAction()
|
||||
expect(busy).toMatchObject({
|
||||
kind: 'error',
|
||||
message: '‘news@example.com’을(를) 차단했습니다. 스팸메일함을 비우는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
action: { label: '다시 시도' },
|
||||
})
|
||||
|
||||
emptying.finish(ok({ processed: 5, remaining: 0 }))
|
||||
await empty
|
||||
press(busy)
|
||||
await flushPromises()
|
||||
expect(runs()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('그사이 차단을 풀었으면(404 RES_001) 사전 문구만 — 진행 알림은 닫는다', async () => {
|
||||
replies(fail(404, 'RES_001'))
|
||||
|
||||
useSenderBlockRun().start(SENDER)
|
||||
await flushPromises()
|
||||
expect(shown()).toEqual([['error', ERROR_CODE_MESSAGES.RES_001]])
|
||||
})
|
||||
|
||||
it('끝나면 개수를 다시 받고, 받은편지함 · 스팸메일함 목록을 보고 있으면 그 목록도 맞춘다 — 다른 메일함이면 개수만', async () => {
|
||||
replies(step(4800, 2, 0, null))
|
||||
const mailStore = useMailStore()
|
||||
mailStore.listSource = { kind: 'mailbox', mailboxId: 'junk' }
|
||||
mailStore.listStatus = 'ready'
|
||||
|
||||
useSenderBlockRun().start(SENDER)
|
||||
await flushPromises()
|
||||
expect(requests.some((request) => request.url === '/mailboxes')).toBe(true)
|
||||
expect(listRequests().map((request) => request.params.mailboxId)).toEqual(['junk'])
|
||||
|
||||
mailStore.listSource = { kind: 'mailbox', mailboxId: 'other' }
|
||||
useSenderBlockRun().start(SENDER)
|
||||
await flushPromises()
|
||||
expect(listRequests()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('세션이 바뀌면 진행 알림을 닫고 다음 요청을 보내지 않는다', async () => {
|
||||
const first = deferred()
|
||||
replies(first.promise, step(10, 0, 0, null))
|
||||
|
||||
useSenderBlockRun().start(SENDER)
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
first.finish(step(1200, 12, 3600, 'b1'))
|
||||
await flushPromises()
|
||||
|
||||
expect(runs()).toHaveLength(1)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useToast, type ProgressToast } from './useToast'
|
||||
import { MAIL_BLOCK_TEXT } from '@/shared/constants/mail-block'
|
||||
import { listLocationOf, scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
|
||||
import { mailBlockRunCopy, type MailBlockRunCopy, type MailBlockRunCounts } from '@/shared/utils/mail-block'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailBlockRunStore, type MailBlockRunOutcome } from '@/stores/mail-block-run.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import type { ToastAction } from '@/stores/toast.store'
|
||||
|
||||
// 차단할 때 받은편지함에 이미 있는 그 보낸 사람의 메일도 스팸메일함으로 — 진행 · 결과 알림
|
||||
// (디자인 README "15단계 › 보낸 사람 차단 창" · 디자인 원본 Mailbox15 "차단한 뒤 알림" — 7단계 필터 적용 useMailFilterRun 과 같은 결)
|
||||
// - 차단 창에서 새 칸을 켜고 [차단]하면 차단 알림을 따로 띄우지 않는다. 알림 하나가 "‘…’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 ·
|
||||
// 1,200 / 4,800통 확인"(첫 응답 전의 전체는 받은편지함의 메일 수 — 모르면 수를 뺀다)으로 남았다가 끝나면 그 자리에서 결과로 바뀐다. [되돌리기]는 없다
|
||||
// - 끝: "‘…’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다." + [스팸메일함 보기] · 옮긴 메일이 없으면 "…옮길 메일이 없습니다."(단추 없음)
|
||||
// - 멈춤(이어 할 자리가 있다): "…4,800통 가운데 1,200통까지 확인해 12통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요." + [다시 시도](그 cursor 부터 — 수는 이어 센다)
|
||||
// - 처음 요청부터 실패: "…차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요." + [다시 시도](처음부터)
|
||||
// - 429 REQ_001(다른 창 · PC 에서 받은편지함이나 스팸메일함을 정리하는 중 — 서버의 차단 정리는 스팸메일함 비우기와도 잠금을 나눈다):
|
||||
// "…차단했습니다. 다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. …" + [다시 시도] —
|
||||
// 차단한 뒤에는 ⋯ 메뉴가 「차단 풀기」로 바뀌어 다시 고를 길이 이것뿐이라 처음 요청이어도 단추를 둔다(필터 적용과 다르다)
|
||||
// - 그사이 차단을 풀었으면(404 RES_001 — 사전 문구로 알렸다) 진행 알림만 닫는다
|
||||
// 실패 까닭(서버 · 연결)은 스토어가 사전 문구로 알렸다 — 진행 알림은 결과로만 바뀌고 같은 오류를 다시 띄우지 않는다. 단추는 띄운 세션에서만 움직인다 (useToast)
|
||||
// - 이 메일을 「이 메일도 …」 칸으로 먼저 옮겼으면(받은편지함의 메일) 그 한 통도 옮긴 수에 넣는다 (alreadyMoved — 서버는 그 뒤에 받은편지함을 본다)
|
||||
// - 끝났거나 이번에 나아간 뒤 멈췄으면 개수 · 목록(받은편지함 · 스팸메일함을 보고 있으면)을 맞춘다
|
||||
// - 이 탭에서 받은편지함을 정리하는 중(필터 적용 · 다른 차단 정리)이면 시작하지 않고 "…받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요." +
|
||||
// 같은 [다시 시도](같은 자리 · 같은 수), 스팸메일함을 비우는 중이면 "…스팸메일함을 비우는 중입니다. …" + 같은 [다시 시도] (스토어의 startBlock) —
|
||||
// 차단 창은 그동안 새 칸을 끄므로 이 길은 앞 알림의 [다시 시도]에서 온다
|
||||
// - 그동안 다른 화면으로 가도 이어 한다 — 스토어가 돌린다. 세션이 바뀌면 멈추고 알림을 닫는다
|
||||
|
||||
/** 시작할 자리 — 처음부터(cursor null · 수 0 — 먼저 옮긴 이 메일은 센다) 또는 앞 실패의 자리 */
|
||||
type RunFrom = MailBlockRunCounts & { cursor: string | null }
|
||||
|
||||
type FailedOutcome = Extract<MailBlockRunOutcome, { status: 'failed' }>
|
||||
|
||||
export interface SenderBlockRunOptions {
|
||||
/** 차단 창의 「이 메일도 …」로 먼저 옮긴 받은편지함의 메일 수 (0 · 1) — 옮긴 수에 더한다 */
|
||||
alreadyMoved?: number
|
||||
}
|
||||
|
||||
export function useSenderBlockRun() {
|
||||
const runStore = useMailBlockRunStore()
|
||||
const mailStore = useMailStore()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** 처음부터 — 진행 알림의 전체는 받은편지함의 메일 수 (메일함을 아직 받지 않았으면 0 — 수를 뺀다) */
|
||||
function fromStart(alreadyMoved: number): RunFrom {
|
||||
const inboxTotal = Math.max(0, mailStore.mailboxByRole('inbox')?.totalCount ?? 0)
|
||||
return { cursor: null, scanned: 0, matched: Math.max(0, alreadyMoved), total: inboxTotal }
|
||||
}
|
||||
|
||||
/** 끝 알림의 [스팸메일함 보기] — 옮긴 메일이 있고 스팸메일함을 알 때만 */
|
||||
function viewJunkAction(matched: number): ToastAction | undefined {
|
||||
const junk = matched > 0 ? mailStore.mailboxByRole('junk') : null
|
||||
if (!junk) return undefined
|
||||
return { label: MAIL_BLOCK_TEXT.viewJunk, to: listLocationOf({ kind: 'mailbox', mailboxId: junk.id }) }
|
||||
}
|
||||
|
||||
function retryAction(sender: string, from: RunFrom): ToastAction {
|
||||
return { label: MAIL_BLOCK_TEXT.runRetry, run: () => void execute(sender, from) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 옮긴 뒤 개수 · 목록을 맞춘다 (필터 적용의 syncAfterRun 과 같은 결) — 메일함 개수 · 사용량을 다시 받고,
|
||||
* 보이는 목록이 받은편지함 · 스팸메일함(그 안의 검색 포함)이면 그 목록도 맞춘다. 전체 메일함 검색은 건드리지 않는다
|
||||
*/
|
||||
function syncAfterRun() {
|
||||
mailStore.syncCounts()
|
||||
const scope = scopeMailboxIdOf(mailStore.listSource)
|
||||
if (scope === null) return
|
||||
const touched = [mailStore.mailboxByRole('inbox')?.id, mailStore.mailboxByRole('junk')?.id]
|
||||
if (touched.includes(scope)) void mailStore.syncVisibleList({ includeSearch: true })
|
||||
}
|
||||
|
||||
/** 멈췄다 — 이어 할 자리가 있으면 그 자리에서, 없으면 처음부터 다시 하는 단추 (다른 창에서 하는 중일 때도 단추를 둔다) */
|
||||
function settleFailure(
|
||||
sender: string,
|
||||
copy: MailBlockRunCopy,
|
||||
from: RunFrom,
|
||||
outcome: FailedOutcome,
|
||||
progress: ProgressToast,
|
||||
) {
|
||||
const { cursor, scanned, matched, total } = outcome
|
||||
const resume = cursor !== null ? retryAction(sender, { cursor, scanned, matched, total }) : null
|
||||
if (outcome.error.code === 'RES_001') progress.dismiss()
|
||||
else if (outcome.error.code === 'REQ_001') progress.fail(copy.otherWindow, resume ?? retryAction(sender, from))
|
||||
else if (resume) progress.fail(copy.stopped(outcome), resume)
|
||||
else progress.fail(copy.notMoved, retryAction(sender, from))
|
||||
}
|
||||
|
||||
/** 진행 알림을 띄우고 끝날 때까지 보낸다. 이 탭에서 받은편지함을 정리하거나 스팸메일함을 비우는 중이면 시작하지 않고 알린다 */
|
||||
async function execute(sender: string, from: RunFrom): Promise<void> {
|
||||
const copy = mailBlockRunCopy(sender)
|
||||
const block = runStore.startBlock
|
||||
if (block !== null) {
|
||||
toast.error(block === 'junk' ? copy.junkBusy : copy.busy, retryAction(sender, from))
|
||||
return
|
||||
}
|
||||
const session = authStore.generation
|
||||
const progress = toast.progress(copy.progress(from))
|
||||
const outcome = await runStore.run({ ...from, sender }, (tally) => progress.update(copy.progress(tally)))
|
||||
// 세션이 바뀌었다(진행 알림은 이미 닫혔다 — 다음 사람의 목록을 맞추지 않는다) · 이 탭의 다른 정리와 겹쳤다(위에서 막는다)
|
||||
if (outcome.status === 'canceled' || outcome.status === 'busy' || authStore.generation !== session) {
|
||||
progress.dismiss()
|
||||
return
|
||||
}
|
||||
if (outcome.status === 'done') {
|
||||
progress.succeed(copy.done(outcome.matched), viewJunkAction(outcome.matched))
|
||||
syncAfterRun()
|
||||
return
|
||||
}
|
||||
settleFailure(sender, copy, from, outcome, progress)
|
||||
// 이번에 나아간 것이 있으면 — 거기까지 바뀐 개수 · 목록을 맞춘다
|
||||
if (outcome.scanned > from.scanned) syncAfterRun()
|
||||
}
|
||||
|
||||
/**
|
||||
* 차단(그 값 그대로 — 소문자 주소 또는 "@도메인")한 뒤 받은편지함을 정리하기 시작한다 — 기다리지 않는다(알림이 진행 · 결과를 보인다).
|
||||
* 차단 창이 새 칸을 켰을 때만 부른다
|
||||
*/
|
||||
function start(sender: string, options: SenderBlockRunOptions = {}): void {
|
||||
void execute(sender, fromStart(options.alreadyMoved ?? 0))
|
||||
}
|
||||
|
||||
return { start }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { defineComponent, h, type PropType } from 'vue'
|
||||
|
||||
import { dragEvent, fakeDataTransfer } from '@/test-utils/drag'
|
||||
import { useIgnoredWindowFileDrop, useWindowFileDrop } from './useWindowFileDrop'
|
||||
|
||||
// 15단계 — 메일 화면에 파일을 잘못 놓으면 브라우저가 그 파일을 열어 웹메일을 떠났다. 메일 화면(MailLayout)은 파일 놓기를 무시하고,
|
||||
// 쓰기 화면(useWindowFileDrop — 같은 메일 화면 안)이 떠 있으면 그 화면이 지금처럼 첨부로 받는다
|
||||
|
||||
/** 메일 화면(파일 놓기 무시) — receive 를 주면 쓰기 화면(파일을 첨부로 받는다) */
|
||||
const Screen = defineComponent({
|
||||
props: {
|
||||
receive: { type: Function as PropType<((files: File[]) => void) | null>, default: null },
|
||||
},
|
||||
setup(props) {
|
||||
if (props.receive) useWindowFileDrop(props.receive)
|
||||
else useIgnoredWindowFileDrop()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
describe('useIgnoredWindowFileDrop — 메일 화면의 파일 놓기 무시', () => {
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) wrapper.unmount()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function mountScreen(receive: ((files: File[]) => void) | null = null) {
|
||||
const wrapper = mount(Screen, { props: { receive }, attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('파일을 끌어 오면 놓을 수 없는 모양(dropEffect none)이고, 놓아도 브라우저가 열지 않는다(기본 동작을 막는다)', () => {
|
||||
mountScreen()
|
||||
const transfer = fakeDataTransfer([new File(['x'], '보고서.pdf')])
|
||||
|
||||
const over = dragEvent('dragover', transfer)
|
||||
document.body.dispatchEvent(over)
|
||||
expect(over.defaultPrevented).toBe(true)
|
||||
expect(transfer.dropEffect).toBe('none')
|
||||
|
||||
const drop = dragEvent('drop', transfer)
|
||||
document.body.dispatchEvent(drop)
|
||||
expect(drop.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('파일이 아닌 끌기(글 · 메일 끌기)는 건드리지 않는다', () => {
|
||||
mountScreen()
|
||||
const transfer = fakeDataTransfer()
|
||||
transfer.setData('text/plain', '글')
|
||||
|
||||
const over = dragEvent('dragover', transfer)
|
||||
document.body.dispatchEvent(over)
|
||||
expect(over.defaultPrevented).toBe(false)
|
||||
const drop = dragEvent('drop', transfer)
|
||||
document.body.dispatchEvent(drop)
|
||||
expect(drop.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('쓰기 화면이 떠 있으면(나중에 걸려도 먼저 걸려도) 쓰기 화면이 파일을 첨부로 받는다', () => {
|
||||
// 메일 화면이 먼저 뜨고 쓰기 화면이 뒤에 뜬다 — 창 처리기는 메일 화면 것이 먼저 돈다
|
||||
mountScreen()
|
||||
const onFiles = vi.fn()
|
||||
const compose = mountScreen(onFiles)
|
||||
const report = new File(['x'], '보고서.pdf')
|
||||
const transfer = fakeDataTransfer([report])
|
||||
|
||||
document.body.dispatchEvent(dragEvent('dragover', transfer))
|
||||
expect(transfer.dropEffect).toBe('copy')
|
||||
document.body.dispatchEvent(dragEvent('drop', transfer))
|
||||
expect(onFiles).toHaveBeenCalledWith([report])
|
||||
|
||||
// 쓰기 화면을 떠나면 다시 무시한다
|
||||
compose.unmount()
|
||||
mounted.splice(mounted.indexOf(compose), 1)
|
||||
const later = fakeDataTransfer([report])
|
||||
const over = dragEvent('dragover', later)
|
||||
document.body.dispatchEvent(over)
|
||||
expect(over.defaultPrevented).toBe(true)
|
||||
expect(later.dropEffect).toBe('none')
|
||||
expect(onFiles).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,13 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { isFileDrag } from '@/shared/utils/editor-files'
|
||||
|
||||
/**
|
||||
* 지금 떠 있는 파일 받는 곳의 수 (쓰기 화면 · 서명 설정 — useWindowFileDrop).
|
||||
* 메일 화면의 "파일 놓기 무시"(useIgnoredWindowFileDrop)는 받는 곳이 없을 때만 막는다 — 쓰기 화면도 메일 화면 안이라
|
||||
* 둘의 창 처리기가 함께 걸린다. 어느 쪽이 먼저 걸렸는지와 상관없이 쓰기 화면의 첨부 놓기가 이기게 (15단계)
|
||||
*/
|
||||
let activeReceivers = 0
|
||||
|
||||
/**
|
||||
* 화면 어디에 놓아도 파일을 받는다 — 이 화면이 떠 있는 동안 window 에서 파일 끌기를 받는다
|
||||
* (디자인 README "본문 이미지 → 첨부 파일": 쓰기 화면 어디에 놓아도 첨부로 올린다).
|
||||
@@ -46,6 +53,46 @@ export function useWindowFileDrop(onFiles: (files: File[]) => void) {
|
||||
['drop', onDrop],
|
||||
] as const
|
||||
|
||||
onMounted(() => {
|
||||
activeReceivers += 1
|
||||
for (const [type, listener] of listeners) window.addEventListener(type, listener)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
activeReceivers = Math.max(0, activeReceivers - 1)
|
||||
for (const [type, listener] of listeners) window.removeEventListener(type, listener)
|
||||
})
|
||||
|
||||
return { dragging }
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 화면이 떠 있는 동안 창에 놓은 파일을 받지 않고 버린다 (15단계 — 메일 화면 · MailLayout).
|
||||
* 받지 않은 곳에 놓으면 브라우저가 그 파일을 열어 웹메일을 떠난다 — 메일 목록 · 읽기 화면에 파일을 잘못 놓아도 아무 일 없게.
|
||||
* - 놓는 동안 마우스 커서는 놓을 수 없는 모양이다(dropEffect none — 메일함 패널도 파일은 받지 않는다)
|
||||
* - 파일을 받는 화면(쓰기 화면 — useWindowFileDrop)이 떠 있으면 아무것도 하지 않는다(그 화면의 첨부 놓기에 맡긴다)
|
||||
* - 글 · 메일 끌기(목록 줄을 메일함 패널로 — useMailDrag)는 건드리지 않는다
|
||||
*/
|
||||
export function useIgnoredWindowFileDrop() {
|
||||
function ignores(event: DragEvent): boolean {
|
||||
return activeReceivers === 0 && !event.defaultPrevented && isFileDrag(event)
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
if (!ignores(event)) return
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'none'
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
if (ignores(event)) event.preventDefault()
|
||||
}
|
||||
|
||||
const listeners = [
|
||||
['dragover', onDragOver],
|
||||
['drop', onDrop],
|
||||
] as const
|
||||
|
||||
onMounted(() => {
|
||||
for (const [type, listener] of listeners) window.addEventListener(type, listener)
|
||||
})
|
||||
@@ -53,6 +100,4 @@ export function useWindowFileDrop(onFiles: (files: File[]) => void) {
|
||||
onBeforeUnmount(() => {
|
||||
for (const [type, listener] of listeners) window.removeEventListener(type, listener)
|
||||
})
|
||||
|
||||
return { dragging }
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@
|
||||
// (usePendingSendRefresh). 들어올 때 보내기 · 읽기 설정도 받아 둔다 — 쓰기 화면의 [보내기 취소] 알림이 설정한 초로 마감을 센다(못 받으면 알리지 않는다).
|
||||
// 탭이 다시 보일 때 마지막으로 받은 지 60초가 지났으면 설정을 조용히 다시 받는다 — 다른 탭에서 바꾼 묶기가 남지 않게 (useMailSettingsRefresh, 14단계)
|
||||
// 회사가 2단계 인증을 필수로 정한 유예 기간이면 상단 바 바로 아래에 노란 줄 (13단계 — TwoFactorGraceNotice)
|
||||
// 메일 화면에 파일을 잘못 놓아도 아무 일 없게 한다(15단계 — 브라우저가 파일을 열어 웹메일을 떠나지 않게). 쓰기 화면은 지금처럼 어디에 놓아도 첨부로 받는다
|
||||
// (useIgnoredWindowFileDrop — 받는 화면이 떠 있으면 그쪽에 맡긴다)
|
||||
import { provide } from 'vue'
|
||||
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
import MailContactDialogHost from '@/components/mail/MailContactDialogHost.vue'
|
||||
import MailDragChip from '@/components/mail/MailDragChip.vue'
|
||||
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
|
||||
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
|
||||
import TwoFactorGraceNotice from '@/components/mail/TwoFactorGraceNotice.vue'
|
||||
@@ -24,6 +27,7 @@ import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
|
||||
import { useMailStatePolling } from '@/composables/useMailStatePolling'
|
||||
import { useMailTabTitle } from '@/composables/useMailTabTitle'
|
||||
import { usePendingSendRefresh } from '@/composables/usePendingSendRefresh'
|
||||
import { useIgnoredWindowFileDrop } from '@/composables/useWindowFileDrop'
|
||||
import { useMailBlockStore } from '@/stores/mail-block.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
@@ -68,6 +72,8 @@ useMailStatePolling({
|
||||
useMailTabTitle()
|
||||
// 보낼 시각이 지난 붙잡은 발송 — 보이는 목록 · 열린 메일 · 예약됨 수를 다시 본다
|
||||
usePendingSendRefresh({ clock })
|
||||
// 목록 · 읽기 화면에 놓은 파일은 받지 않는다 (쓰기 화면이 떠 있으면 그 화면이 첨부로 받는다)
|
||||
useIgnoredWindowFileDrop()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -82,4 +88,6 @@ usePendingSendRefresh({ clock })
|
||||
<RouterView />
|
||||
</AppShell>
|
||||
<MailContactDialogHost />
|
||||
<!-- 목록 줄을 메일함 패널로 끄는 동안 마우스를 따라오는 표 (15단계 — 화면 밖에 그려 둔다) -->
|
||||
<MailDragChip />
|
||||
</template>
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
MessageDetail,
|
||||
} from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
@@ -953,7 +954,8 @@ describe('MailFilterPage', () => {
|
||||
|
||||
await openRule(wrapper, 'r2')
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(applyHelp(wrapper)).toBe('다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.')
|
||||
// 차단 창의 새 칸과 같은 글 (15단계에 "다른 필터로 …"에서 바꿨다)
|
||||
expect(applyHelp(wrapper)).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
|
||||
await click(wrapper.get('#mail-filter-cancel'))
|
||||
|
||||
// 다른 화면으로 가도 이어 한다
|
||||
@@ -969,6 +971,21 @@ describe('MailFilterPage', () => {
|
||||
expect(applyBox(again).attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('이 탭에서 차단할 때 받은 메일을 옮기는 동안(15단계)도 칸을 끄고 같은 까닭 — 끝나면 다시 고를 수 있다', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const blockRun = useMailBlockRunStore()
|
||||
blockRun.running = true
|
||||
|
||||
await openRule(wrapper, 'r2')
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(applyHelp(wrapper)).toBe('받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.')
|
||||
|
||||
blockRun.running = false
|
||||
await flushPromises()
|
||||
expect(applyBox(wrapper).attributes('disabled')).toBeUndefined()
|
||||
expect(applyHelp(wrapper)).toBe(MAIL_FILTER_APPLY_TEXT.help)
|
||||
})
|
||||
|
||||
it('「적용」을 고르고 저장하는 중에 창을 닫거나 화면을 떠나도 — 저장이 끝나면 적용한다', async () => {
|
||||
const reply = deferred()
|
||||
const { wrapper } = await mountPage({ save: () => reply.promise, run: () => step(8412, 37, 0, null) })
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
// 주소에서 지운다(replace — 새로 고침에 또 열리지 않게)
|
||||
// - 누른 단추가 사라져 초점을 잃으면(지운 행 · 사라진 줄 · 빈 상태의 [새 필터]) 가까운 행이나 머리의 [새 필터]로 옮긴다
|
||||
// - 창의 「받은편지함에 이미 있는 메일도 정리하기」(7단계)를 고른 채 저장하면 저장한 뒤 받은편지함에 적용한다(useMailFilterActions → useMailFilterRun).
|
||||
// 이 탭에서 적용하는 중이면 창의 그 칸을 끈다(mail-filter-run.store 의 running — 화면을 떠나도 적용은 이어진다)
|
||||
// 이 탭에서 받은편지함을 정리하는 중이면(필터 적용 · 차단할 때 받은 메일 옮기기 — 15단계) 창의 그 칸을 끈다
|
||||
// (mail-block-run.store 의 inboxBusy — 차단 창의 새 칸과 같은 규칙, 화면을 떠나도 정리는 이어진다)
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -32,12 +33,13 @@ import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mai
|
||||
import type { MailFilterRule } from '@/shared/types/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
|
||||
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
|
||||
import { useMailFilterStore } from '@/stores/mail-filter.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const filterStore = useMailFilterStore()
|
||||
const filterRunStore = useMailFilterRunStore()
|
||||
/** 이 탭에서 받은편지함을 정리하는 중인지 — 필터 적용 · 차단 정리 (inboxBusy) */
|
||||
const inboxCleanup = useMailBlockRunStore()
|
||||
const mailStore = useMailStore()
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
@@ -282,7 +284,7 @@ watch(
|
||||
:busy="filterStore.saving"
|
||||
:error="dialogError"
|
||||
:conflict="dialogConflict"
|
||||
:apply-busy="filterRunStore.running"
|
||||
:apply-busy="inboxCleanup.inboxBusy"
|
||||
@save="(rule, apply) => settleDialog(saveDialog(rule, { apply }))"
|
||||
@remove="settleDialog(removeDialog())"
|
||||
@cancel="cancelDialog"
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
// 보낸 사람 차단 (계약 2절 "보낸 사람 차단" · 디자인 README "9단계 › 읽기 화면" · "설정 · 차단한 주소", 원본 MailReadTools · SettingsSendRead)
|
||||
// 15단계 — 차단할 때 받은편지함에 이미 있는 그 보낸 사람의 메일도 옮기기 (디자인 README "15단계", 원본 Mailbox15 "보낸 사람 차단 창" · "차단한 뒤 알림")
|
||||
|
||||
import { MAIL_FILTER_RUN_TIMEOUT_MS } from '@/shared/constants/mail'
|
||||
import { INBOX_CLEANUP_TEXT, MAIL_FILTER_RUN_MAX_STALLS } from '@/shared/constants/mail-filter'
|
||||
|
||||
/** 차단할 수 있는 주소 · 도메인 수 — 넘으면 서버도 400 VAL_001 */
|
||||
export const MAIL_BLOCK_MAX = 500
|
||||
|
||||
/**
|
||||
* 받은 메일 옮기기(POST /mail-block-runs)의 요청 하나 — 서버가 필터 적용과 같은 규칙(한 요청 25초 안 · cursor)으로 하니 같은 한도를 쓴다
|
||||
*/
|
||||
export const MAIL_BLOCK_RUN_TIMEOUT_MS = MAIL_FILTER_RUN_TIMEOUT_MS
|
||||
/** 한 번도 나아가지 못한 응답이 이만큼 이어지면 멈춘다 — 필터 적용과 같은 안전장치 */
|
||||
export const MAIL_BLOCK_RUN_MAX_STALLS = MAIL_FILTER_RUN_MAX_STALLS
|
||||
|
||||
/** 도메인 차단의 앞 글자 — "@spam.example" 은 그 도메인에서 오는 메일 모두 */
|
||||
export const MAIL_BLOCK_DOMAIN_PREFIX = '@'
|
||||
|
||||
@@ -65,6 +76,19 @@ export const MAIL_BLOCK_TEXT = {
|
||||
/** 「이 도메인 전체」를 끈 까닭 — 여러 사람이 쓰는 메일 서비스 (보드에 없다) */
|
||||
scopeDomainPublic: '여러 사람이 쓰는 메일 서비스라 도메인으로 막을 수 없습니다.',
|
||||
moveToJunk: '이 메일도 스팸메일함으로 옮기기',
|
||||
/** 새 칸을 켰고 이 메일이 받은편지함에 있을 때 — 첫 칸을 켠 채 잠그고 그 아래 (15단계) */
|
||||
moveToJunkLocked: '받은편지함의 메일을 옮길 때 함께 옮깁니다.',
|
||||
/** 15단계 새 칸 — 처음 꺼짐. 「이 도메인 전체」를 고르면 도메인 글로 바뀐다 */
|
||||
cleanInboxSender: '받은편지함에 있는 이 보낸 사람의 메일도 스팸메일함으로 옮기기',
|
||||
cleanInboxDomain: '받은편지함에 있는 이 도메인의 메일도 스팸메일함으로 옮기기',
|
||||
cleanInboxNote: '옮긴 메일은 한꺼번에 되돌릴 수 없습니다.',
|
||||
/** 이 탭에서 받은편지함을 정리하는 중(필터 적용 · 다른 차단 정리) — 새 칸을 끄고 칸 아래 글을 까닭으로 (필터 고치기 창의 칸과 같은 글) */
|
||||
cleanInboxBusy: INBOX_CLEANUP_TEXT.busy,
|
||||
/**
|
||||
* 이 탭에서 스팸메일함을 비우는 중 — 새 칸을 끄고 칸 아래 글을 까닭으로 (보드에 없다 — 15단계 리뷰, 위 공용 글과 같은 결).
|
||||
* 옮겨 넣은 메일이 들어오자마자 영구 삭제되지 않게
|
||||
*/
|
||||
cleanJunkBusy: '스팸메일함을 비우는 중이라 지금은 고를 수 없습니다.',
|
||||
settingsHint: '차단은 [설정] → [차단한 주소]에서 풀 수 있습니다.',
|
||||
confirm: '차단',
|
||||
cancel: '취소',
|
||||
@@ -81,6 +105,39 @@ export const MAIL_BLOCK_TEXT = {
|
||||
unblockRestored: (address: string, particle: string) => `‘${address}’${particle} 다시 차단했습니다.`,
|
||||
undo: '되돌리기',
|
||||
|
||||
// ── 새 칸을 켠 차단 — 알림 하나가 진행을 보이다가 그 자리에서 결과로 (15단계, [되돌리기] 없음) ──
|
||||
// particle 은 objectParticle, 수는 formatCount 로 만든 글자. 확인 = 지금까지 확인한 수, 전체 = 확인 + 남은 수(첫 응답 전에는 받은편지함의 메일 수)
|
||||
runProgress: (address: string, particle: string) =>
|
||||
`‘${address}’${particle} 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중`,
|
||||
/** 진행 문구 뒤 — 전체를 아직 모르면 뺀다 */
|
||||
runProgressCount: (scanned: string, total: string) => ` · ${scanned} / ${total}통 확인`,
|
||||
runDone: (address: string, particle: string, matched: string) =>
|
||||
`‘${address}’${particle} 차단하고 받은편지함의 메일 ${matched}통을 스팸메일함으로 옮겼습니다.`,
|
||||
/** 옮길 메일이 없었다 */
|
||||
runNone: (address: string, particle: string) => `‘${address}’${particle} 차단했습니다. 받은편지함에는 옮길 메일이 없습니다.`,
|
||||
/** 도중에 멈췄다 — [다시 시도]는 멈춘 자리부터 */
|
||||
runStopped: (address: string, particle: string, total: string, scanned: string, matched: string) =>
|
||||
`‘${address}’${particle} 차단했습니다. 받은편지함은 ${total}통 가운데 ${scanned}통까지 확인해 ${matched}통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요.`,
|
||||
/** 처음 요청부터 실패했다 — [다시 시도]는 처음부터 */
|
||||
runNotMoved: (address: string, particle: string) =>
|
||||
`‘${address}’${particle} 차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요.`,
|
||||
/**
|
||||
* 429 REQ_001 — 같은 직원의 받은편지함 정리(필터 적용 · 다른 차단 정리)나 스팸메일함 비우기가 다른 창 · PC 에서 진행 중 (15단계 리뷰 —
|
||||
* 서버의 차단 정리는 스팸메일함 비우기와도 잠금을 나눈다. 필터 적용의 429 글은 받은편지함만 말한다 — INBOX_CLEANUP_TEXT.otherWindow)
|
||||
*/
|
||||
runOtherWindow: (address: string, particle: string) =>
|
||||
`‘${address}’${particle} 차단했습니다. 다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.`,
|
||||
/**
|
||||
* 이 탭에서 받은편지함을 정리하는 중(필터 적용 · 다른 차단 정리)에 [다시 시도]를 눌렀다 (보드에 없다 — 필터 적용의 busy 와 같은 공용 글).
|
||||
* 같은 [다시 시도](같은 자리 · 같은 수)를 붙여 앞 정리가 끝난 뒤 다시 누를 수 있게 한다
|
||||
*/
|
||||
runBusy: (address: string, particle: string) => `‘${address}’${particle} 차단했습니다. ${INBOX_CLEANUP_TEXT.tabBusy}`,
|
||||
/** 이 탭에서 스팸메일함을 비우는 중에 [다시 시도]를 눌렀다 (보드에 없다 — runBusy 와 같은 결, 같은 [다시 시도]를 붙인다) */
|
||||
runJunkBusy: (address: string, particle: string) =>
|
||||
`‘${address}’${particle} 차단했습니다. 스팸메일함을 비우는 중입니다. 끝난 뒤 다시 시도해 주세요.`,
|
||||
runRetry: '다시 시도',
|
||||
viewJunk: '스팸메일함 보기',
|
||||
|
||||
// ── 스팸메일함에서 읽을 때 ──
|
||||
senderNotice: '차단한 보낸 사람의 메일입니다.',
|
||||
unblock: '차단 풀기',
|
||||
|
||||
@@ -54,6 +54,12 @@ export const MAIL_CONVERSATION_TEXT = {
|
||||
bodyTitle: (who: string, time: string) => `메일 본문: ${who}, ${time}`,
|
||||
more: '더 보기',
|
||||
moreBusy: '불러오는 중…',
|
||||
/**
|
||||
* 칸 머리 오른쪽의 글자 단추 (15단계 — 디자인 원본 Reader15 "6. 같은 대화") — 하나라도 접혀 있으면 [모두 펼치기], 모두 펼쳐져 있으면(하나씩 다 펼쳐도) [모두 접기].
|
||||
* 펼친 줄 머리의 [답장]은 위 reply 와 같은 글이다
|
||||
*/
|
||||
expandAll: '모두 펼치기',
|
||||
collapseAll: '모두 접기',
|
||||
loadFailed: '같은 대화를 불러오지 못했습니다.',
|
||||
retry: '다시 시도',
|
||||
traceCode: (code: string) => `문의 코드 ${code}`,
|
||||
|
||||
@@ -166,10 +166,24 @@ export const MAIL_FILTER_DIALOG_TEXT = {
|
||||
actionsEmpty: '할 일을 하나 이상 골라 주세요.',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 받은편지함 정리 — 이미 받은 메일에 필터 적용(7단계)과 차단할 때 받은 메일 옮기기(15단계)가 함께 쓰는 글.
|
||||
* 같은 직원의 받은편지함 정리는 한 번에 하나다 — 다른 창 · PC 에서 하는 중이면 서버가 429 REQ_001 (디자인 원본 Mailbox15 "차단한 뒤 알림").
|
||||
* otherWindow 는 필터 적용의 429 글이다 — 차단 정리의 429 는 스팸메일함 비우기도 말한다(MAIL_BLOCK_TEXT.runOtherWindow — 15단계 리뷰).
|
||||
* 이 탭에서 하나라도 도는 동안은 필터 고치기 창의 칸과 차단 창의 새 칸을 끄고 그 까닭(busy)을 칸 아래에 쓴다 (디자인 원본 Mailbox15 "보낸 사람 차단 창") —
|
||||
* 차단 창의 새 칸은 이 탭에서 스팸메일함을 비우는 동안에도 끈다(MAIL_BLOCK_TEXT.cleanJunkBusy)
|
||||
*/
|
||||
export const INBOX_CLEANUP_TEXT = {
|
||||
otherWindow: '다른 창에서 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
busy: '받은편지함을 정리하는 중이라 지금은 고를 수 없습니다.',
|
||||
/** 이 탭에서 정리하는 중에 [다시 시도]를 눌렀다 — 필터 적용의 알림 · 차단 정리의 알림("…차단했습니다. " 뒤)이 함께 쓴다 */
|
||||
tabBusy: '받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates).
|
||||
* 6단계의 안내 띠("새로 오는 메일에만 적용됩니다 …") 자리에 둔다. 고를 수 없으면 설명이 그 까닭이다 —
|
||||
* 이 탭에서 다른 필터를 적용하는 중 → 옮길 메일함이 지워짐 → 받은편지함으로 옮기기만 → 전달만 (mail-filter-run.ts 의 filterApplyBlock 차례)
|
||||
* 이 탭에서 받은편지함을 정리하는 중(필터 적용 · 차단 정리 — 15단계) → 옮길 메일함이 지워짐 → 받은편지함으로 옮기기만 → 전달만 (mail-filter-run.ts 의 filterApplyBlock 차례)
|
||||
*/
|
||||
export const MAIL_FILTER_APPLY_TEXT = {
|
||||
label: '받은편지함에 이미 있는 메일도 정리하기',
|
||||
@@ -181,7 +195,8 @@ export const MAIL_FILTER_APPLY_TEXT = {
|
||||
missingMailbox: '옮길 메일함이 지워져 이미 받은 메일에 할 일이 없습니다. 다른 메일함을 골라 주세요.',
|
||||
/** 받은편지함으로 옮기기만 — 이미 받은편지함에 있는 메일이다 (7단계 리뷰로 더한 경우) */
|
||||
inboxOnly: '받은편지함으로 옮기기만 하는 필터는 이미 받은 메일에 할 일이 없습니다.',
|
||||
busy: '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤에 고를 수 있습니다.',
|
||||
/** 이 탭에서 받은편지함을 정리하는 중 — 차단 창의 새 칸과 같은 글 (15단계에 "다른 필터로 …"에서 바꿨다) */
|
||||
busy: INBOX_CLEANUP_TEXT.busy,
|
||||
/** 상자 아래 한 줄 */
|
||||
spam: '스팸 메일은 필터를 거치지 않습니다.',
|
||||
} as const
|
||||
@@ -204,13 +219,16 @@ export const MAIL_FILTER_RUN_TEXT = {
|
||||
`${quoted} 필터로 ${total}통 가운데 ${scanned}통까지 확인하고 멈췄습니다. 그동안 ${matched}통을 정리했습니다. 다시 시도해 주세요.`,
|
||||
/** 처음 요청부터 실패했다 — 필터는 저장되어 있다(새로 오는 메일에는 적용된다) */
|
||||
notApplied: '필터는 저장했지만 받은편지함은 정리하지 못했습니다. 다시 시도해 주세요.',
|
||||
/** 429 REQ_001 — 같은 직원의 적용이 다른 창 · PC 에서 진행 중 */
|
||||
otherWindow: '다른 창에서 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
/**
|
||||
* 이 탭에서 다른 필터를 적용하는 중에 [다시 시도]를 눌렀다 (보드에 없던 경우 — 2026-10-29 구현하며 정함).
|
||||
* 같은 [다시 시도](같은 자리 · 같은 수)를 붙여 앞 적용이 끝난 뒤 다시 누를 수 있게 한다
|
||||
* 429 REQ_001 — 같은 직원의 받은편지함 정리(필터 적용 · 차단할 때 받은 메일 옮기기)가 다른 창 · PC 에서 진행 중 (INBOX_CLEANUP_TEXT).
|
||||
* 차단 정리의 429 글은 스팸메일함 비우기도 말해 따로다 (MAIL_BLOCK_TEXT.runOtherWindow — 15단계 리뷰)
|
||||
*/
|
||||
busy: '다른 필터로 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
otherWindow: INBOX_CLEANUP_TEXT.otherWindow,
|
||||
/**
|
||||
* 이 탭에서 받은편지함을 정리하는 중(다른 필터 적용 · 차단 정리)에 [다시 시도]를 눌렀다 (보드에 없던 경우 — 2026-10-29 구현하며 정함,
|
||||
* 15단계에 차단 정리와 함께 쓰는 글로 바꿨다 — INBOX_CLEANUP_TEXT). 같은 [다시 시도](같은 자리 · 같은 수)를 붙여 앞 정리가 끝난 뒤 다시 누를 수 있게 한다
|
||||
*/
|
||||
busy: INBOX_CLEANUP_TEXT.tabBusy,
|
||||
retry: '다시 시도',
|
||||
} as const
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// 보내기 취소 · 예약 발송 · 예약됨 (계약 2절 "보내기 취소 · 예약 발송" · 디자인 README "9단계 › 보내기 취소 · 예약 발송", 원본 MailSendLater)
|
||||
|
||||
import { UNDO_SEND_OPTIONS } from '@/shared/constants/mail-settings'
|
||||
|
||||
/** 예약할 수 있는 가장 이른 때 — 지금부터 5분 뒤 (서버도 같은 한도로 400 VAL_001) */
|
||||
export const SCHEDULE_MIN_LEAD_MS = 5 * 60_000
|
||||
/** 예약할 수 있는 가장 늦은 때 — 7일 안 (엔진 한도 — 계약 2절) */
|
||||
@@ -76,6 +78,24 @@ export const SEND_LATER_TEXT = {
|
||||
sendAtColumn: '보낼 시각',
|
||||
/** 읽기 화면 머리 아래 청록 줄 — "10월 30일 (금) 오전 9:00에 보낼 메일입니다." */
|
||||
notice: (time: string) => `${time}에 보낼 메일입니다.`,
|
||||
/**
|
||||
* 청록 줄 맨 앞 단추 (15단계 — 디자인 원본 Reader15 "지금 보내기"). [보내기]를 누른 것과 같다 — 보내기 취소 설정의 초만큼 붙잡았다 보낸다.
|
||||
* 확인 창은 두지 않는다(보내기 취소로 되돌릴 수 있다). 알림은 쓰기 화면의 보낸 뒤 알림과 같다
|
||||
*/
|
||||
sendNow: '지금 보내기',
|
||||
/**
|
||||
* 지금 보내지 못했거나 결과를 모른다(503 MAIL_001 · SYS_001 — 15단계 리뷰). 그 메일을 다시 받아
|
||||
* 임시보관함이면 sendNowReverted + [임시보관함 보기],
|
||||
* 예약이 그대로면(확인했다 — 예약한 시각에 나간다) sendNowFailed + [다시 시도](같은 요청 — 안내 줄은 그대로 둔다),
|
||||
* 예약만 풀렸거나(기다리는 발송이 없다 — 안내 줄을 내린다) 다시 받지 못했으면(안내 줄은 마지막으로 안 그대로) sendNowUnconfirmed + [다시 시도].
|
||||
* 이 두 갈래는 이미 나갔을 수 있다(보내기 취소 0초로 바로 나감 · 응답만 잃음 · 그사이 예약 시각이 됨) — 보내지 못했다고 단정하지 않는다
|
||||
* (그 글을 믿고 손으로 다시 보내면 두 통이 간다). [다시 시도]는 안전하다 — 이미 보낸 메일이면 서버가 200 으로 답한다
|
||||
*/
|
||||
sendNowReverted: '지금 보내지 못해 임시보관함으로 옮겼습니다.',
|
||||
viewDrafts: '임시보관함 보기',
|
||||
sendNowUnconfirmed: '메일을 보냈는지 확인하지 못했습니다. [다시 시도]를 누르면 아직 보내지 않았을 때만 보냅니다.',
|
||||
sendNowFailed: '지금 보내지 못했습니다. 다시 시도해 주세요.',
|
||||
retry: '다시 시도',
|
||||
edit: '고치기',
|
||||
cancelSchedule: '예약 취소',
|
||||
canceled: '예약을 취소하고 임시보관함으로 옮겼습니다.',
|
||||
@@ -111,3 +131,8 @@ export const PENDING_RELEASE_MAX_RECHECKS = 3
|
||||
* 마감이 서버보다 늦으면 늦게 누른 [보내기 취소]가 "이미 보낸 메일"(MAIL_014)이 된다
|
||||
*/
|
||||
export const UNDO_SEND_DEADLINE_MARGIN_MS = 1_000
|
||||
/**
|
||||
* 설정을 모를 때 [보내기 취소] 알림이 남는 가장 긴 시간 — 설정의 가장 긴 값(30초). 쓰기 화면(ComposePage)과 같은 값이다
|
||||
* (15단계 예약 [지금 보내기]의 알림이 함께 쓴다 — pending-send.ts 의 undoToastDeadline)
|
||||
*/
|
||||
export const UNDO_SEND_MAX_MS = Math.max(...UNDO_SEND_OPTIONS) * 1000
|
||||
|
||||
@@ -332,6 +332,15 @@ export const MAIL_BULK_TEXT = {
|
||||
unflag: '중요 해제',
|
||||
move: '이동',
|
||||
moveMenu: '옮길 메일함',
|
||||
/**
|
||||
* [이동 ▾] 메뉴에 고를 수 없게 남긴 줄의 까닭 (15단계 — 디자인 원본 Reader15) — 메일이 든 메일함에 하위 메일함이 있으면 나무 모양을 지키려고 남긴다.
|
||||
* 읽기 화면은 그 메일, 목록 도구 줄은 고른 메일
|
||||
*/
|
||||
moveHere: '이 메일이 있는 메일함',
|
||||
moveHereChecked: '고른 메일이 있는 메일함',
|
||||
/** 목록 줄을 메일함 패널로 끄는 동안 마우스를 따라오는 표 (15단계 — 디자인 원본 ListDrag15) */
|
||||
dragMessages: (count: string) => `메일 ${count}통`,
|
||||
dragConversations: (count: string) => `대화 ${count}개`,
|
||||
delete: '삭제',
|
||||
purge: '영구 삭제',
|
||||
clear: '선택 해제',
|
||||
|
||||
@@ -12,6 +12,16 @@ export const MAILBOX_NAME_FORBIDDEN_CHAR = '/'
|
||||
*/
|
||||
export const MAILBOX_OPERATION_MAX_STALLS = 3
|
||||
|
||||
/**
|
||||
* 들여 쓴 메일함의 이름 (15단계 — 디자인 원본 Reader15 · Mailbox15). 위 메일함부터 잇는다.
|
||||
* spokenJoin: 화면 낭독 이름 "거래처 안의 2025" · displayJoin: 같은 이름이 다른 곳에도 있을 때 알림에 쓰는 "거래처 › 2025"
|
||||
* (shared/utils/mailbox.ts 의 mailboxSpokenName · mailboxDisplayName)
|
||||
*/
|
||||
export const MAILBOX_PATH_TEXT = {
|
||||
spokenJoin: ' 안의 ',
|
||||
displayJoin: ' › ',
|
||||
} as const
|
||||
|
||||
export const MAILBOX_PANEL_TEXT = {
|
||||
/** 기본 메일함 아래 묶음 이름 (12px 굵게) */
|
||||
myMailboxes: '내 메일함',
|
||||
@@ -27,6 +37,8 @@ export const MAILBOX_MENU_TEXT = {
|
||||
markRead: '모두 읽음으로 표시',
|
||||
createChild: '하위 메일함 만들기',
|
||||
rename: '이름 바꾸기',
|
||||
/** 15단계 — 내 메일함만. 창을 여는 다른 항목처럼 이름 뒤에 …를 붙이지 않는다 (디자인 원본 Mailbox15) */
|
||||
move: '옮기기',
|
||||
delete: '지우기',
|
||||
emptyTrash: '휴지통 비우기',
|
||||
emptyJunk: '스팸메일함 비우기',
|
||||
@@ -46,19 +58,55 @@ export const MAILBOX_DIALOG_TEXT = {
|
||||
deleteMessage: (count: string) => `안의 메일 ${count}통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다.`,
|
||||
deleteEmptyMessage: '빈 메일함입니다. 지운 메일함은 되돌릴 수 없습니다.',
|
||||
deleteConfirm: '지우기',
|
||||
blockedTitle: (name: string) => `‘${name}’ 메일함을 지울 수 없습니다`,
|
||||
blockedMessage: '하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요.',
|
||||
blockedConfirm: '확인',
|
||||
/**
|
||||
* 하위 메일함이 있는 메일함 지우기 (15단계 — 디자인 원본 Mailbox15 "하위 메일함까지 지우기"). children 은 모든 깊이의 하위 메일함 수,
|
||||
* count 는 하위 메일함까지 더한 메일 수 (formatCount 로 만든 글자)
|
||||
*/
|
||||
deleteTreeTitle: (name: string, children: string) => `‘${name}’ 메일함과 하위 메일함 ${children}개를 지울까요?`,
|
||||
deleteTreeMessage: (count: string) =>
|
||||
`하위 메일함에 든 메일까지 모두 ${count}통을 휴지통으로 옮깁니다. 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. 지운 메일함은 되돌릴 수 없습니다.`,
|
||||
deleteTreeEmptyMessage: '하위 메일함까지 모두 비어 있습니다. 지운 메일함은 되돌릴 수 없습니다.',
|
||||
/** "휴지통의 메일 21통을 모두 영구 삭제할까요?" */
|
||||
emptyTitle: (mailbox: string, count: string) => `${mailbox}의 메일 ${count}통을 모두 영구 삭제할까요?`,
|
||||
emptyMessage: '지운 메일은 되돌릴 수 없습니다.',
|
||||
emptyConfirm: '영구 삭제',
|
||||
} as const
|
||||
|
||||
/** 만들기 · 이름 바꾸기 알림 — 메일함 패널에 바로 나타나므로 짧게 (디자인 원본 MailActions4 "알림 문구") */
|
||||
/**
|
||||
* 메일함 옮기기 창 (15단계 — 디자인 원본 Mailbox15 "옮기기 창" · "그사이 같은 이름이 생겼을 때") — 모달 400(medium).
|
||||
* 줄의 까닭 글(지금 있는 곳 · 같은 이름)은 서버에 묻기 전에 화면이 하는 검사라 화면 상수 글이다. 묶음 이름 "내 메일함"은 MAILBOX_PANEL_TEXT
|
||||
*/
|
||||
export const MAILBOX_MOVE_TEXT = {
|
||||
title: (name: string) => `‘${name}’ 메일함을 어디로 옮길까요?`,
|
||||
/** 하위 메일함이 있을 때 · 없을 때 */
|
||||
messageWithChildren: '하위 메일함과 안의 메일도 함께 옮깁니다.',
|
||||
message: '안의 메일도 함께 옮깁니다.',
|
||||
/** 목록(radiogroup)의 이름 */
|
||||
listLabel: '옮길 곳',
|
||||
top: '맨 위',
|
||||
topNote: '다른 메일함 안에 두지 않습니다',
|
||||
current: '지금 있는 곳',
|
||||
sameName: '같은 이름의 메일함이 있습니다',
|
||||
confirm: '옮기기',
|
||||
cancel: '취소',
|
||||
} as const
|
||||
|
||||
/** 만들기 · 이름 바꾸기 · 옮기기 알림 — 메일함 패널에 바로 나타나므로 짧게 (디자인 원본 MailActions4 "알림 문구" · Mailbox15 "옮긴 뒤 알림") */
|
||||
export const MAILBOX_RESULT_TEXT = {
|
||||
created: (name: string) => `‘${name}’ 메일함을 만들었습니다.`,
|
||||
renamed: '메일함 이름을 바꿨습니다.',
|
||||
/**
|
||||
* 옮긴 곳의 이름 — 내 메일함은 따옴표로 묶고, 받은편지함 같은 기본 메일함은 묶지 않는다
|
||||
* ("‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다." · "…받은편지함 안으로 옮겼습니다.")
|
||||
*/
|
||||
quoted: (name: string) => `‘${name}’`,
|
||||
movedInto: (name: string, into: string) => `‘${name}’ 메일함을 ${into} 안으로 옮겼습니다.`,
|
||||
movedToTop: (name: string) => `‘${name}’ 메일함을 다른 메일함 밖으로 옮겼습니다.`,
|
||||
/** [되돌리기] 뒤 — 원래 있던 곳으로 */
|
||||
restoredInto: (name: string, into: string) => `‘${name}’ 메일함을 ${into} 안으로 되돌렸습니다.`,
|
||||
/** 맨 위에서 옮긴 것을 되돌렸을 때 (보드에 없다 — 맨 위로 옮긴 글과 같은 결) */
|
||||
restoredToTop: (name: string) => `‘${name}’ 메일함을 다른 메일함 밖으로 되돌렸습니다.`,
|
||||
undo: '되돌리기',
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -364,6 +364,18 @@ export interface ScheduledMessageCancel {
|
||||
draftId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /scheduled-messages/{id}/release 응답 — 예약을 거두고 같은 메일을 지금 보냈다 (15단계 "지금 보내기" — 계약 2절의 ReleasedSend).
|
||||
* 발송 응답(SentMessage)과 같은 칸이다: pendingSend 는 보내기 취소를 기다리는 동안 { kind: 'undo', sendAt }, 바로 보냈으면 null
|
||||
* (예약 시각이 막 지나 이미 보낸 메일도 null — 오류가 아니다). useMail().releaseScheduledMessage 가 칸을 늘 값으로 맞춘다
|
||||
*/
|
||||
export interface ReleasedSend {
|
||||
/** 같은 메일 id (보낸 사본) — 없으면 요청한 id */
|
||||
id: string
|
||||
rejectedRecipients: RejectedRecipient[]
|
||||
pendingSend: PendingSend | null
|
||||
}
|
||||
|
||||
// ── 보내기 · 읽기 설정 (계약 2절 "보내기 · 읽기 설정" — 9단계) ─────────────
|
||||
|
||||
/** 보내기 취소 시간(초) — 0 이면 바로 보낸다 */
|
||||
@@ -451,7 +463,19 @@ export interface MailboxRenamePayload {
|
||||
name: string
|
||||
}
|
||||
|
||||
/** DELETE /mailboxes/{id} 응답 — deleted 가 false 면 아직 옮길 메일이 남았다(같은 요청을 다시 보낸다) */
|
||||
/**
|
||||
* PATCH /mailboxes/{id} 본문 — 메일함 옮기기 (15단계). parentId: null 은 맨 위.
|
||||
* 새 상위는 만들기와 같은 자리만(맨 위 · 받은편지함 · 내 메일함 — 그 밖 400 VAL_001), 자기 자신 · 하위 아래는 400 VAL_001,
|
||||
* 새 상위 안에 같은 이름(대소문자 무시)은 409 MAIL_009, 없는 상위는 404 RES_001. 하위 메일함과 안의 메일이 함께 간다
|
||||
*/
|
||||
export interface MailboxMovePayload {
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /mailboxes/{id} 응답 — deleted 가 false 면 아직 옮길 메일이 남았다(같은 요청을 다시 보낸다).
|
||||
* 하위 메일함까지 지우면(?withChildren=true — 15단계) 수는 모두 나무 전체(그 메일함 + 모든 하위)의 것이고 deleted 는 나무를 다 지웠는가다
|
||||
*/
|
||||
export interface MailboxDeletion {
|
||||
deleted: boolean
|
||||
/**
|
||||
@@ -581,6 +605,22 @@ export interface MailFilterRunRequest extends MailFilterRunRule {
|
||||
cursor: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /mail-block-runs 본문 — 차단할 때 받은편지함에 이미 있는 그 보낸 사람(도메인)의 메일을 스팸메일함으로 (15단계).
|
||||
* sender 는 차단 목록의 값 그대로(소문자 주소 또는 "@도메인") — 차단한 뒤에만 부른다(목록에 없으면 404 RES_001)
|
||||
*/
|
||||
export interface MailBlockRunRequest {
|
||||
sender: string
|
||||
/** 처음은 null, 이어 할 때는 앞 응답의 cursor 그대로 (다른 sender 의 cursor · 깨진 cursor 는 400 VAL_001) */
|
||||
cursor: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /mail-block-runs 응답 — 필터 적용(MailFilterRun)과 같은 꼴. matched 는 스팸메일함으로 옮긴 수.
|
||||
* 같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나 — 다른 곳에서 하는 중이면 429 REQ_001
|
||||
*/
|
||||
export type MailBlockRun = MailFilterRun
|
||||
|
||||
/** POST /mail-filter-runs 응답 — 한 요청은 25초 안에 끝난다 */
|
||||
export interface MailFilterRun {
|
||||
/** 이번 요청에서 확인한 받은편지함 메일 수 */
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isPublicMailDomain,
|
||||
isUnblockableSender,
|
||||
mailBlockInputOf,
|
||||
mailBlockRunCopy,
|
||||
} from './mail-block'
|
||||
|
||||
// 보낸 사람 차단 (계약 2절 "보낸 사람 차단") — 차단 값 · 검사 · 찾기
|
||||
@@ -134,3 +135,40 @@ describe('mailBlockInputOf — 설정 › 차단한 주소의 칸 (서버보다
|
||||
expect(mailBlockInputOf('new@spam.example', OWN, full)).toEqual({ problem: 'full' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('차단할 때 받은 메일 옮기기 — 알림 문구 (15단계 — 디자인 원본 Mailbox15 "차단한 뒤 알림")', () => {
|
||||
const copy = mailBlockRunCopy('news@example.com')
|
||||
|
||||
it('하는 중 — "…차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인" · 전체를 모르면 수를 뺀다', () => {
|
||||
expect(copy.progress({ scanned: 1200, total: 4800 })).toBe(
|
||||
'‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인',
|
||||
)
|
||||
expect(copy.progress({ scanned: 0, total: 0 })).toBe(
|
||||
'‘news@example.com’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중',
|
||||
)
|
||||
})
|
||||
|
||||
it('끝 — 옮긴 수 · 옮길 메일이 없으면 그렇게', () => {
|
||||
expect(copy.done(37)).toBe('‘news@example.com’을(를) 차단하고 받은편지함의 메일 37통을 스팸메일함으로 옮겼습니다.')
|
||||
expect(copy.done(0)).toBe('‘news@example.com’을(를) 차단했습니다. 받은편지함에는 옮길 메일이 없습니다.')
|
||||
})
|
||||
|
||||
it('멈춤 · 처음부터 실패 · 다른 창에서 하는 중 · 이 탭에서 하는 중 — 모두 "차단했다"로 시작한다', () => {
|
||||
expect(copy.stopped({ scanned: 1200, matched: 12, total: 4800 })).toBe(
|
||||
'‘news@example.com’을(를) 차단했습니다. 받은편지함은 4,800통 가운데 1,200통까지 확인해 12통을 옮긴 뒤 멈췄습니다. 다시 시도해 주세요.',
|
||||
)
|
||||
expect(copy.notMoved).toBe('‘news@example.com’을(를) 차단했지만 받은편지함의 메일은 옮기지 못했습니다. 다시 시도해 주세요.')
|
||||
// 서버의 차단 정리는 스팸메일함 비우기와도 잠금을 나눈다 — 필터 적용의 429 글(받은편지함만)과 다르다 (15단계 리뷰)
|
||||
expect(copy.otherWindow).toBe(
|
||||
'‘news@example.com’을(를) 차단했습니다. 다른 창에서 받은편지함이나 스팸메일함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.',
|
||||
)
|
||||
expect(copy.busy).toBe('‘news@example.com’을(를) 차단했습니다. 받은편지함을 정리하는 중입니다. 끝난 뒤 다시 시도해 주세요.')
|
||||
expect(copy.junkBusy).toBe('‘news@example.com’을(를) 차단했습니다. 스팸메일함을 비우는 중입니다. 끝난 뒤 다시 시도해 주세요.')
|
||||
})
|
||||
|
||||
it('도메인 차단도 같은 글 — 받침에 맞춘 조사는 그 값의 끝 글자로', () => {
|
||||
expect(mailBlockRunCopy('@example.com').done(2)).toBe(
|
||||
'‘@example.com’을(를) 차단하고 받은편지함의 메일 2통을 스팸메일함으로 옮겼습니다.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,15 @@
|
||||
// 내 주소와 사내 도메인(내 주소의 도메인과 그 하위 — 서버의 MAIL_DOMAIN)은 막을 수 없고, 여러 사람이 쓰는 메일 서비스는 도메인으로 막을 수 없다.
|
||||
// 검사는 서버보다 좁게 본다(isStrictEmail) — 화면이 통과시킨 값은 서버도 받는다.
|
||||
|
||||
import { MAIL_BLOCK_DOMAIN_PREFIX, MAIL_BLOCK_MAX, PUBLIC_MAIL_DOMAINS } from '@/shared/constants/mail-block'
|
||||
import {
|
||||
MAIL_BLOCK_DOMAIN_PREFIX,
|
||||
MAIL_BLOCK_MAX,
|
||||
MAIL_BLOCK_TEXT,
|
||||
PUBLIC_MAIL_DOMAINS,
|
||||
} from '@/shared/constants/mail-block'
|
||||
import type { BlockedSender } from '@/shared/types/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { objectParticle } from '@/shared/utils/korean'
|
||||
import { isStrictEmail, normalizeEmail } from '@/shared/utils/mail-address'
|
||||
|
||||
/** 칸에 쓴 값의 문제 — 형식 · 이미 차단함 · 내 주소 · 사내 도메인 · 여러 사람이 쓰는 메일 서비스의 도메인 · 500개 */
|
||||
@@ -80,3 +87,50 @@ export function mailBlockInputOf(
|
||||
if (blocks.length >= MAIL_BLOCK_MAX) return { problem: 'full' }
|
||||
return { address: value }
|
||||
}
|
||||
|
||||
// ── 차단할 때 받은 메일 옮기기 — 알림 문구 (15단계 — 디자인 원본 Mailbox15 "차단한 뒤 알림") ─────────────
|
||||
|
||||
/** 지금까지 — 확인한 수 · 옮긴 수 · 전체(확인 + 남은 수, 첫 응답 전에는 받은편지함의 메일 수 — 모르면 0) */
|
||||
export interface MailBlockRunCounts {
|
||||
scanned: number
|
||||
matched: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface MailBlockRunCopy {
|
||||
/** 하는 중 — "‘…’을(를) 차단했습니다. 받은편지함의 메일을 스팸메일함으로 옮기는 중 · 1,200 / 4,800통 확인" (전체를 모르면 수를 뺀다) */
|
||||
progress: (counts: Pick<MailBlockRunCounts, 'scanned' | 'total'>) => string
|
||||
/** 끝남 — 옮긴 수. 0 이면 "…받은편지함에는 옮길 메일이 없습니다." */
|
||||
done: (matched: number) => string
|
||||
/** 도중에 멈춤 — 거기까지의 수 */
|
||||
stopped: (counts: MailBlockRunCounts) => string
|
||||
/** 처음 요청부터 실패 */
|
||||
notMoved: string
|
||||
/** 다른 창 · PC 에서 받은편지함이나 스팸메일함을 정리하는 중 (429) */
|
||||
otherWindow: string
|
||||
/** 이 탭에서 받은편지함을 정리하는 중에 [다시 시도] */
|
||||
busy: string
|
||||
/** 이 탭에서 스팸메일함을 비우는 중에 [다시 시도] (15단계 리뷰) */
|
||||
junkBusy: string
|
||||
}
|
||||
|
||||
/** 그 차단(주소 · "@도메인")의 알림 문구 — 모두 "차단했다"는 말로 시작한다(차단은 이미 끝났고 옮기기만 멈춘 것이다) */
|
||||
export function mailBlockRunCopy(address: string): MailBlockRunCopy {
|
||||
const particle = objectParticle(address)
|
||||
return {
|
||||
progress: ({ scanned, total }) =>
|
||||
total > 0
|
||||
? `${MAIL_BLOCK_TEXT.runProgress(address, particle)}${MAIL_BLOCK_TEXT.runProgressCount(formatCount(scanned), formatCount(total))}`
|
||||
: MAIL_BLOCK_TEXT.runProgress(address, particle),
|
||||
done: (matched) =>
|
||||
matched > 0
|
||||
? MAIL_BLOCK_TEXT.runDone(address, particle, formatCount(matched))
|
||||
: MAIL_BLOCK_TEXT.runNone(address, particle),
|
||||
stopped: ({ scanned, matched, total }) =>
|
||||
MAIL_BLOCK_TEXT.runStopped(address, particle, formatCount(total), formatCount(scanned), formatCount(matched)),
|
||||
notMoved: MAIL_BLOCK_TEXT.runNotMoved(address, particle),
|
||||
otherWindow: MAIL_BLOCK_TEXT.runOtherWindow(address, particle),
|
||||
busy: MAIL_BLOCK_TEXT.runBusy(address, particle),
|
||||
junkBusy: MAIL_BLOCK_TEXT.runJunkBusy(address, particle),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import {
|
||||
canDropMailOn,
|
||||
hasMoveTarget,
|
||||
hereMailboxIds,
|
||||
junkMarkForMove,
|
||||
mailMoveTargets,
|
||||
type MailMoveTarget,
|
||||
} from './mail-move-targets'
|
||||
|
||||
// 15단계 — 메일을 옮길 곳 (디자인 원본 Reader15 "1 · 이 메일이 든 메일함에 하위 메일함이 있을 때" · ListDrag15)
|
||||
|
||||
function box(id: string, role: Mailbox['role'], name: string, partial: Partial<Mailbox> = {}): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount: 0, unreadCount: 0, ...partial }
|
||||
}
|
||||
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
box('inbox', 'inbox', '받은편지함'),
|
||||
box('sent', 'sent', '보낸편지함'),
|
||||
box('drafts', 'drafts', '임시보관함'),
|
||||
box('junk', 'junk', '스팸메일함'),
|
||||
box('trash', 'trash', '휴지통'),
|
||||
box('clients', null, '거래처'),
|
||||
box('clients-2025', null, '2025', { parentId: 'clients' }),
|
||||
box('quotes', null, '견적', { parentId: 'clients' }),
|
||||
box('project', null, '프로젝트', { sortOrder: 1 }),
|
||||
box('project-2025', null, '2025', { parentId: 'project' }),
|
||||
]
|
||||
|
||||
/** 줄을 "이름 깊이 [고를 수 없음] [여기]" 로 — 차례 · 들여쓰기를 한눈에 */
|
||||
function shape(rows: readonly MailMoveTarget[]): string[] {
|
||||
return rows.map(
|
||||
(row) => `${row.mailbox.name} ${row.depth}${row.disabled ? ' off' : ''}${row.here ? ' here' : ''} ${row.group}`,
|
||||
)
|
||||
}
|
||||
|
||||
describe('mailMoveTargets — [이동 ▾]의 옮길 곳', () => {
|
||||
it('메일함 패널 차례 · 깊이로, 메일이 든 메일함 · 임시보관함 · 휴지통은 뺀다 (받은편지함의 메일)', () => {
|
||||
const rows = mailMoveTargets(MAILBOXES, new Set(['inbox']))
|
||||
|
||||
expect(shape(rows)).toEqual([
|
||||
'보낸편지함 0 system',
|
||||
'스팸메일함 0 system',
|
||||
'거래처 0 mine',
|
||||
'2025 1 mine',
|
||||
'견적 1 mine',
|
||||
'프로젝트 0 mine',
|
||||
'2025 1 mine',
|
||||
])
|
||||
// 들여 쓴 메일함은 위 메일함부터 읽는다 — 같은 이름이 둘이어도 가른다
|
||||
expect(rows.map((row) => row.spokenName)).toEqual([
|
||||
'보낸편지함',
|
||||
'스팸메일함',
|
||||
'거래처',
|
||||
'거래처 안의 2025',
|
||||
'거래처 안의 견적',
|
||||
'프로젝트',
|
||||
'프로젝트 안의 2025',
|
||||
])
|
||||
})
|
||||
|
||||
it('메일이 든 메일함에 하위 메일함이 있으면 고를 수 없는 줄로 남긴다 (거래처의 메일)', () => {
|
||||
const rows = mailMoveTargets(MAILBOXES, new Set(['clients']))
|
||||
|
||||
expect(shape(rows)).toEqual([
|
||||
'받은편지함 0 system',
|
||||
'보낸편지함 0 system',
|
||||
'스팸메일함 0 system',
|
||||
'거래처 0 off here mine',
|
||||
'2025 1 mine',
|
||||
'견적 1 mine',
|
||||
'프로젝트 0 mine',
|
||||
'2025 1 mine',
|
||||
])
|
||||
})
|
||||
|
||||
it('하위 메일함이 있는 휴지통(다른 메일 프로그램이 지운 폴더를 넣어 둠)은 까닭 없이 고를 수 없는 줄 — 하위 메일함은 고를 수 있다', () => {
|
||||
const rows = mailMoveTargets([...MAILBOXES, box('old', null, '옛 폴더', { parentId: 'trash' })], new Set(['inbox']))
|
||||
|
||||
expect(shape(rows).slice(0, 4)).toEqual(['보낸편지함 0 system', '스팸메일함 0 system', '휴지통 0 off system', '옛 폴더 1 system'])
|
||||
})
|
||||
|
||||
it('남긴 줄 아래에 보일 하위 메일함이 없으면 그 줄도 뺀다', () => {
|
||||
// 거래처의 하위가 모두 메일이 든 곳이고 하위가 없다 — 거래처도 남길 까닭이 없다
|
||||
const list = [box('inbox', 'inbox', '받은편지함'), box('a', null, '거래처'), box('b', null, '2025', { parentId: 'a' })]
|
||||
expect(shape(mailMoveTargets(list, new Set(['a', 'b'])))).toEqual(['받은편지함 0 system'])
|
||||
})
|
||||
|
||||
it('고를 수 있는 곳이 없으면 hasMoveTarget 이 거짓', () => {
|
||||
expect(hasMoveTarget(mailMoveTargets([box('inbox', 'inbox', '받은편지함')], new Set(['inbox'])))).toBe(false)
|
||||
expect(hasMoveTarget(mailMoveTargets(MAILBOXES, new Set(['inbox'])))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canDropMailOn — 끌어다 놓을 수 있는 메일함', () => {
|
||||
it('[이동 ▾]의 옮길 곳 + 휴지통 — 메일이 든 메일함 · 임시보관함에는 놓을 수 없다', () => {
|
||||
const here = new Set(['inbox'])
|
||||
expect(MAILBOXES.filter((mailbox) => canDropMailOn(mailbox, here)).map((mailbox) => mailbox.id)).toEqual([
|
||||
'sent',
|
||||
'junk',
|
||||
'trash',
|
||||
'clients',
|
||||
'clients-2025',
|
||||
'quotes',
|
||||
'project',
|
||||
'project-2025',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hereMailboxIds — 옮기는 메일이 이미 든 메일함', () => {
|
||||
it('목록이 가리키는 메일함과, 옮기는 메일이 모두 든 메일함', () => {
|
||||
expect([...hereMailboxIds([{ mailboxIds: ['inbox'] }, { mailboxIds: ['inbox', 'clients'] }], 'inbox')]).toEqual(['inbox'])
|
||||
// 전체 메일함 검색 — 목록이 가리키는 메일함이 없다. 메일마다 다르면 모두가 든 곳만
|
||||
expect([...hereMailboxIds([{ mailboxIds: ['inbox'] }, { mailboxIds: ['clients'] }], null)]).toEqual([])
|
||||
expect([...hereMailboxIds([{ mailboxIds: ['clients', 'project'] }], null)]).toEqual(['clients', 'project'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('junkMarkForMove — 스팸메일함으로 옮기는 세 길 같게', () => {
|
||||
it('스팸메일함으로 옮기면 스팸 표시([스팸 신고]와 같다)', () => {
|
||||
expect(junkMarkForMove({ role: 'junk' }, false)).toBe(true)
|
||||
})
|
||||
|
||||
it('스팸메일함에서 휴지통 밖으로 꺼내면 스팸 아님 표시([스팸 아님]과 같다) — 휴지통으로는 표시를 바꾸지 않는다', () => {
|
||||
expect(junkMarkForMove({ role: null }, true)).toBe(false)
|
||||
expect(junkMarkForMove({ role: 'inbox' }, true)).toBe(false)
|
||||
expect(junkMarkForMove({ role: 'trash' }, true)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('그 밖의 옮기기는 표시를 바꾸지 않는다', () => {
|
||||
expect(junkMarkForMove({ role: null }, false)).toBeUndefined()
|
||||
expect(junkMarkForMove({ role: 'trash' }, false)).toBeUndefined()
|
||||
expect(junkMarkForMove({ role: 'junk' }, true)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
// 메일을 옮길 곳 — [이동 ▾](목록 · 읽기 화면)의 메뉴와 끌어다 놓기가 함께 쓰는 규칙 (15단계 — 디자인 원본 Reader15 · ListDrag15, 순수 함수만 둔다)
|
||||
// - 옮길 곳은 메일함 패널과 같은 차례 · 깊이다(mailbox.ts 의 mailboxTree) — 같은 이름이 다른 상위 아래에 있어도 가를 수 있게 들여 쓴다
|
||||
// - 메일이 이미 든 메일함(here) · 임시보관함 · 휴지통은 옮길 곳이 아니다(휴지통으로는 [삭제]로 — 끌어다 놓기는 휴지통에도 놓을 수 있다).
|
||||
// 그 메일함에 하위 메일함이 있으면 빼지 않고 고를 수 없는 줄로 남긴다 — 하위 메일함이 누구 밑에 있는지 보이게
|
||||
// - 스팸메일함으로 옮기면 [스팸 신고]와 같이 스팸 표시를, 스팸메일함에서 휴지통 밖으로 꺼내면 [스팸 아님]과 같이 스팸 아님 표시를 함께 보낸다(junkMarkForMove)
|
||||
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { mailboxSpokenName, mailboxTree, type MailboxGroup } from '@/shared/utils/mailbox'
|
||||
|
||||
/** 옮길 곳 한 줄 */
|
||||
export interface MailMoveTarget {
|
||||
mailbox: Mailbox
|
||||
/** 들여쓰기 단계 — 뿌리 0 (패널처럼 3단계까지만 들여 쓴다) */
|
||||
depth: number
|
||||
/** 화면 낭독 이름 — 위 메일함부터 "거래처 안의 2025" (뿌리면 이름 그대로) */
|
||||
spokenName: string
|
||||
/** 고를 수 없다 — 나무 모양을 지키려고 남긴 줄 (메일이 든 메일함 · 임시보관함 · 휴지통에 하위 메일함이 있을 때) */
|
||||
disabled: boolean
|
||||
/** 메일이 이미 든 메일함 — 고를 수 없는 까닭을 이름 아래에 적는다 */
|
||||
here: boolean
|
||||
/** 기본 메일함 묶음 · 내 메일함 묶음 — 메뉴는 둘 사이에 선을 긋는다 */
|
||||
group: MailboxGroup
|
||||
}
|
||||
|
||||
/** 메일 옮기기의 옮길 곳이 아닌 메일함 — 임시보관함(임시 저장본만) · 휴지통(옮기기가 아니라 [삭제]) */
|
||||
function isNeverMoveTarget(mailbox: Pick<Mailbox, 'role'>): boolean {
|
||||
return mailbox.role === 'drafts' || mailbox.role === 'trash'
|
||||
}
|
||||
|
||||
/**
|
||||
* [이동 ▾] 메뉴의 옮길 곳 — 메일함 패널 차례. 메일이 든 메일함(hereIds) · 임시보관함 · 휴지통은 빼고, 그중 하위 메일함이 있는 것은
|
||||
* 고를 수 없는 줄로 남긴다. 남긴 줄 아래에 보일 하위 메일함이 하나도 없으면 그 줄도 뺀다
|
||||
*/
|
||||
export function mailMoveTargets(list: readonly Mailbox[], hereIds: ReadonlySet<string>): MailMoveTarget[] {
|
||||
const rows: MailMoveTarget[] = []
|
||||
for (const entry of mailboxTree(list)) {
|
||||
const here = hereIds.has(entry.mailbox.id)
|
||||
const blocked = here || isNeverMoveTarget(entry.mailbox)
|
||||
if (blocked && !entry.hasChildren) continue
|
||||
rows.push({
|
||||
mailbox: entry.mailbox,
|
||||
depth: entry.depth,
|
||||
spokenName: mailboxSpokenName(entry),
|
||||
disabled: blocked,
|
||||
here,
|
||||
group: entry.group,
|
||||
})
|
||||
}
|
||||
// 고를 수 없는 줄 가운데 바로 뒤에 더 깊은 줄(보일 하위 메일함)이 없는 것을 뒤에서부터 뺀다 — 위 줄도 함께 비면 그 줄도
|
||||
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
||||
const row = rows[index]
|
||||
if (!row?.disabled) continue
|
||||
if ((rows[index + 1]?.depth ?? -1) <= row.depth) rows.splice(index, 1)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** 고를 수 있는 옮길 곳이 있는가 — 없으면 [이동 ▾] 메뉴를 열지 않는다 */
|
||||
export function hasMoveTarget(rows: readonly MailMoveTarget[]): boolean {
|
||||
return rows.some((row) => !row.disabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* 끌어다 놓을 수 있는 메일함인가 (메일함 패널의 줄) — [이동 ▾]의 옮길 곳에 휴지통을 더한 곳(= [삭제]).
|
||||
* 메일이 이미 든 메일함 · 임시보관함에는 놓을 수 없다 (「예약됨」은 메일함이 아니라 놓을 곳이 없다)
|
||||
*/
|
||||
export function canDropMailOn(mailbox: Pick<Mailbox, 'id' | 'role'>, hereIds: ReadonlySet<string>): boolean {
|
||||
return mailbox.role !== 'drafts' && !hereIds.has(mailbox.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 옮기는 메일이 이미 든 메일함 — 목록이 가리키는 메일함(scopeId)과, 옮기는 메일이 모두 들어 있는 메일함.
|
||||
* 그곳으로 옮겨도 달라지는 것이 없다 (전체 메일함 검색에서 여러 메일함의 메일을 골랐으면 모두가 든 곳만)
|
||||
*/
|
||||
export function hereMailboxIds(
|
||||
targets: readonly Pick<MessageSummary, 'mailboxIds'>[],
|
||||
scopeId: string | null,
|
||||
): Set<string> {
|
||||
const here = new Set<string>()
|
||||
if (scopeId) here.add(scopeId)
|
||||
const [first, ...rest] = targets
|
||||
if (!first) return here
|
||||
for (const id of first.mailboxIds) {
|
||||
if (rest.every((target) => target.mailboxIds.includes(id))) here.add(id)
|
||||
}
|
||||
return here
|
||||
}
|
||||
|
||||
/**
|
||||
* 옮기며 함께 보낼 스팸 표시 — 스팸메일함으로 옮기면 스팸(true, [스팸 신고]와 같다), 스팸메일함에서 휴지통 밖으로 꺼내면 스팸 아님(false, [스팸 아님]과 같다).
|
||||
* 휴지통으로 옮기는 것은 표시를 바꾸지 않는다(undefined — 그냥 옮기기). 되돌리기는 반대 값을 보낸다 (useMailMoveFeedback)
|
||||
*/
|
||||
export function junkMarkForMove(target: Pick<Mailbox, 'role'>, fromJunk: boolean): boolean | undefined {
|
||||
if (target.role === 'junk') return fromJunk ? undefined : true
|
||||
if (fromJunk && target.role !== 'trash') return false
|
||||
return undefined
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAILBOX_OPERATION_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { initialOperationTotal, mailboxOperationCopy } from './mailbox-operation'
|
||||
import { initialOperationTotal, mailboxMoveText, mailboxOperationCopy } from './mailbox-operation'
|
||||
|
||||
// 오래 걸리는 메일함 처리의 알림 문구 (디자인 원본 MailActions4 "오래 걸리는 처리" · "알림 문구")
|
||||
|
||||
@@ -42,3 +42,27 @@ describe('mailboxOperationCopy', () => {
|
||||
expect(initialOperationTotal('delete', mailbox)).toBe(148)
|
||||
})
|
||||
})
|
||||
|
||||
describe('하위 메일함까지 지우기 · 메일함 옮기기 (15단계 — 디자인 원본 Mailbox15)', () => {
|
||||
it('하위까지 지우면 시작할 때의 전체 수에 모든 깊이의 하위 메일함 메일을 더한다 — 모두 읽음은 그대로 안 읽은 수', () => {
|
||||
const mailbox = { unreadCount: 3, totalCount: 12 }
|
||||
const descendants = [{ totalCount: 4 }, { totalCount: 24 }, { totalCount: -1 }]
|
||||
|
||||
expect(initialOperationTotal('delete', mailbox, descendants)).toBe(40)
|
||||
expect(initialOperationTotal('read', mailbox, descendants)).toBe(3)
|
||||
expect(initialOperationTotal('delete', mailbox)).toBe(12)
|
||||
})
|
||||
|
||||
it('옮긴 뒤 알림 — 내 메일함은 따옴표 · 받은편지함은 따옴표 없이 · 맨 위는 "다른 메일함 밖으로", 되돌린 뒤는 "되돌렸습니다"', () => {
|
||||
const project = { name: '프로젝트', role: null }
|
||||
const inbox = { name: '받은편지함', role: 'inbox' as const }
|
||||
|
||||
expect(mailboxMoveText('2025', project)).toBe('‘2025’ 메일함을 ‘프로젝트’ 안으로 옮겼습니다.')
|
||||
expect(mailboxMoveText('2025', inbox)).toBe('‘2025’ 메일함을 받은편지함 안으로 옮겼습니다.')
|
||||
expect(mailboxMoveText('2025', null)).toBe('‘2025’ 메일함을 다른 메일함 밖으로 옮겼습니다.')
|
||||
expect(mailboxMoveText('2025', { name: '거래처', role: null }, { restored: true })).toBe(
|
||||
'‘2025’ 메일함을 ‘거래처’ 안으로 되돌렸습니다.',
|
||||
)
|
||||
expect(mailboxMoveText('2025', null, { restored: true })).toBe('‘2025’ 메일함을 다른 메일함 밖으로 되돌렸습니다.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 오래 걸리는 메일함 처리 — 모두 읽음 · 비우기 · 메일함 지우기의 알림 문구 (계약 2절 "메일함 관리" · 디자인 원본 MailActions4 — 순수 함수만 둔다)
|
||||
// 서버는 한 요청에 500통씩(25초 안) 처리하고 남은 수를 돌려준다 — 화면은 0 이 될 때까지 다시 보내며 "…하는 중 · 600 / 1,234통"을 보인다.
|
||||
|
||||
import { MAILBOX_OPERATION_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { MAILBOX_OPERATION_TEXT, MAILBOX_RESULT_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { objectParticle, subjectParticle } from '@/shared/utils/korean'
|
||||
@@ -21,9 +21,17 @@ export interface MailboxOperationCopy {
|
||||
failed: (done: number, total: number) => string
|
||||
}
|
||||
|
||||
/** 시작할 때 아는 전체 수 — 첫 응답 전에 알림에 보인다 (그 뒤로는 서버가 돌려준 남은 수로 맞춘다) */
|
||||
export function initialOperationTotal(kind: MailboxOperationKind, mailbox: Pick<Mailbox, 'unreadCount' | 'totalCount'>): number {
|
||||
return Math.max(0, kind === 'read' ? mailbox.unreadCount : mailbox.totalCount)
|
||||
/**
|
||||
* 시작할 때 아는 전체 수 — 첫 응답 전에 알림에 보인다 (그 뒤로는 서버가 돌려준 남은 수로 맞춘다).
|
||||
* 하위 메일함까지 지우면(15단계) 하위 메일함(descendants — 모든 깊이)의 메일 수도 더한다
|
||||
*/
|
||||
export function initialOperationTotal(
|
||||
kind: MailboxOperationKind,
|
||||
mailbox: Pick<Mailbox, 'unreadCount' | 'totalCount'>,
|
||||
descendants: readonly Pick<Mailbox, 'totalCount'>[] = [],
|
||||
): number {
|
||||
if (kind === 'read') return Math.max(0, mailbox.unreadCount)
|
||||
return [mailbox, ...descendants].reduce((sum, item) => sum + Math.max(0, item.totalCount), 0)
|
||||
}
|
||||
|
||||
export function mailboxOperationCopy(kind: MailboxOperationKind, mailboxName: string): MailboxOperationCopy {
|
||||
@@ -61,3 +69,17 @@ export function mailboxOperationCopy(kind: MailboxOperationKind, mailboxName: st
|
||||
failed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 옮기기 알림 (15단계 — 디자인 원본 Mailbox15 "옮긴 뒤 알림"). into 는 새 상위 — null 이면 맨 위("다른 메일함 밖으로").
|
||||
* 내 메일함은 따옴표로 묶고 받은편지함 같은 기본 메일함은 묶지 않는다. restored 면 [되돌리기] 뒤의 글("…안으로 되돌렸습니다.")
|
||||
*/
|
||||
export function mailboxMoveText(
|
||||
name: string,
|
||||
into: Pick<Mailbox, 'name' | 'role'> | null,
|
||||
options: { restored?: boolean } = {},
|
||||
): string {
|
||||
if (!into) return options.restored ? MAILBOX_RESULT_TEXT.restoredToTop(name) : MAILBOX_RESULT_TEXT.movedToTop(name)
|
||||
const place = into.role === null ? MAILBOX_RESULT_TEXT.quoted(into.name) : into.name
|
||||
return options.restored ? MAILBOX_RESULT_TEXT.restoredInto(name, place) : MAILBOX_RESULT_TEXT.movedInto(name, place)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,19 @@ import { describe, expect, it } from 'vitest'
|
||||
import { MAILBOX_EMPTY_HINTS } from '@/shared/constants/mail'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import {
|
||||
canHoldChildMailbox,
|
||||
descendantMailboxes,
|
||||
emptyMailboxText,
|
||||
hasChildMailbox,
|
||||
mailboxActions,
|
||||
mailboxDisplayName,
|
||||
mailboxIcon,
|
||||
mailboxMoveTargets,
|
||||
mailboxNameProblem,
|
||||
mailboxSpokenName,
|
||||
mailboxTree,
|
||||
orderMailboxes,
|
||||
sameMailboxName,
|
||||
splitMailboxGroups,
|
||||
} from './mailbox'
|
||||
|
||||
@@ -121,11 +128,13 @@ describe('메일함 관리 (4단계 — 디자인 README "메일함 관리")', (
|
||||
])
|
||||
})
|
||||
|
||||
it('⋯ 메뉴 — 안 읽은 메일이 있으면 모두 읽음 · 받은편지함과 내 메일함은 하위 메일함 · 내 메일함만 이름 바꾸기 · 지우기 · 휴지통 / 스팸메일함은 비우기', () => {
|
||||
it('⋯ 메뉴 — 안 읽은 메일이 있으면 모두 읽음 · 받은편지함과 내 메일함은 하위 메일함 · 내 메일함만 이름 바꾸기 · 옮기기 · 지우기 · 휴지통 / 스팸메일함은 비우기', () => {
|
||||
expect(mailboxActions({ ...inbox, unreadCount: 3 })).toEqual(['markRead', 'createChild'])
|
||||
expect(mailboxActions(inbox)).toEqual(['createChild'])
|
||||
expect(mailboxActions(sent)).toEqual([])
|
||||
expect(mailboxActions({ ...project, unreadCount: 1 })).toEqual(['markRead', 'createChild', 'rename', 'delete'])
|
||||
// 옮기기는 이름 바꾸기 다음 · 지우기 앞 (15단계 — 디자인 원본 Mailbox15). 받은편지함 아래의 내 메일함도 옮길 수 있다
|
||||
expect(mailboxActions({ ...project, unreadCount: 1 })).toEqual(['markRead', 'createChild', 'rename', 'move', 'delete'])
|
||||
expect(mailboxActions(inboxChild)).toEqual(['createChild', 'rename', 'move', 'delete'])
|
||||
expect(mailboxActions({ ...trash, totalCount: 21 })).toEqual(['empty'])
|
||||
expect(mailboxActions(mailbox({ id: 'j', name: '스팸메일함', role: 'junk', totalCount: 5, unreadCount: 1 }))).toEqual([
|
||||
'markRead',
|
||||
@@ -135,7 +144,7 @@ describe('메일함 관리 (4단계 — 디자인 README "메일함 관리")', (
|
||||
expect(mailboxActions(trash)).toEqual([])
|
||||
})
|
||||
|
||||
it('하위 메일함이 있는가 — 지우기를 막는다', () => {
|
||||
it('하위 메일함이 있는가 — 지우기 확인 창이 하위까지 지울지 묻는다', () => {
|
||||
expect(hasChildMailbox([project, quote], 'p')).toBe(true)
|
||||
expect(hasChildMailbox([project, quote], 'q')).toBe(false)
|
||||
})
|
||||
@@ -150,3 +159,176 @@ describe('메일함 관리 (4단계 — 디자인 README "메일함 관리")', (
|
||||
expect(mailboxNameProblem('견적\u00852026')).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('메일함 나무 (15단계 — 디자인 원본 Mailbox15 · Reader15)', () => {
|
||||
// 보드의 예: 거래처 안에 2025(그 안에 정산) · 견적, 보관 안에도 2025, 프로젝트 안에 견적 — 받은편지함 아래 협력사
|
||||
const inbox = mailbox({ id: 'i', name: '받은편지함', role: 'inbox' })
|
||||
const sent = mailbox({ id: 's', name: '보낸편지함', role: 'sent' })
|
||||
const sentFolder = mailbox({ id: 'sf', name: '보낸 정리', parentId: 's' })
|
||||
const junk = mailbox({ id: 'j', name: '스팸메일함', role: 'junk' })
|
||||
const trash = mailbox({ id: 't', name: '휴지통', role: 'trash' })
|
||||
const partner = mailbox({ id: 'ip', name: '협력사', parentId: 'i' })
|
||||
const client = mailbox({ id: 'c', name: '거래처' })
|
||||
const client2025 = mailbox({ id: 'c25', name: '2025', parentId: 'c' })
|
||||
const settle = mailbox({ id: 'c25s', name: '정산', parentId: 'c25' })
|
||||
const clientQuote = mailbox({ id: 'cq', name: '견적', parentId: 'c' })
|
||||
const keep = mailbox({ id: 'b', name: '보관' })
|
||||
const keep2025 = mailbox({ id: 'b25', name: '2025', parentId: 'b' })
|
||||
const project = mailbox({ id: 'p', name: '프로젝트' })
|
||||
const projectQuote = mailbox({ id: 'pq', name: '견적', parentId: 'p' })
|
||||
const LIST = [
|
||||
project,
|
||||
projectQuote,
|
||||
keep2025,
|
||||
keep,
|
||||
settle,
|
||||
clientQuote,
|
||||
client2025,
|
||||
client,
|
||||
partner,
|
||||
trash,
|
||||
junk,
|
||||
sentFolder,
|
||||
sent,
|
||||
inbox,
|
||||
]
|
||||
|
||||
it('mailboxTree — 패널 차례 · 깊이에 위 메일함 · 하위가 있는지 · 묶음', () => {
|
||||
const entries = mailboxTree(LIST)
|
||||
|
||||
expect(entries.map((entry) => [entry.mailbox.id, entry.depth, entry.group, entry.hasChildren])).toEqual([
|
||||
['i', 0, 'system', true],
|
||||
['ip', 1, 'system', false],
|
||||
['s', 0, 'system', true],
|
||||
['sf', 1, 'system', false],
|
||||
['j', 0, 'system', false],
|
||||
['t', 0, 'system', false],
|
||||
['c', 0, 'mine', true],
|
||||
['c25', 1, 'mine', true],
|
||||
['c25s', 2, 'mine', false],
|
||||
['cq', 1, 'mine', false],
|
||||
['b', 0, 'mine', true],
|
||||
['b25', 1, 'mine', false],
|
||||
['p', 0, 'mine', true],
|
||||
['pq', 1, 'mine', false],
|
||||
])
|
||||
expect(entries.find((entry) => entry.mailbox.id === 'c25s')?.ancestors.map((item) => item.id)).toEqual(['c', 'c25'])
|
||||
expect(entries.find((entry) => entry.mailbox.id === 'c')?.ancestors).toEqual([])
|
||||
// 차례와 깊이는 패널(orderMailboxes)과 같다
|
||||
expect(entries.map((entry) => [entry.mailbox.id, entry.depth])).toEqual(
|
||||
orderMailboxes(LIST).map((node) => [node.mailbox.id, node.depth]),
|
||||
)
|
||||
})
|
||||
|
||||
it('mailboxTree — 부모를 찾을 수 없거나 순환이어도 빠뜨리지 않고 위 메일함은 나무 그대로', () => {
|
||||
const entries = mailboxTree([
|
||||
mailbox({ id: 'orphan', name: '고아', parentId: 'missing' }),
|
||||
mailbox({ id: 'a', name: '가', parentId: 'b' }),
|
||||
mailbox({ id: 'b', name: '나', parentId: 'a' }),
|
||||
])
|
||||
|
||||
expect(entries.map((entry) => entry.mailbox.id).sort()).toEqual(['a', 'b', 'orphan'])
|
||||
for (const entry of entries) expect(entry.ancestors).toHaveLength(entry.depth)
|
||||
})
|
||||
|
||||
it('mailboxSpokenName — 위 메일함부터 "거래처 안의 2025"', () => {
|
||||
const entries = mailboxTree(LIST)
|
||||
const spoken = (id: string) => {
|
||||
const entry = entries.find((item) => item.mailbox.id === id)
|
||||
return entry ? mailboxSpokenName(entry) : ''
|
||||
}
|
||||
|
||||
expect(spoken('c25')).toBe('거래처 안의 2025')
|
||||
expect(spoken('c25s')).toBe('거래처 안의 2025 안의 정산')
|
||||
expect(spoken('ip')).toBe('받은편지함 안의 협력사')
|
||||
expect(spoken('c')).toBe('거래처')
|
||||
})
|
||||
|
||||
it('mailboxDisplayName — 같은 이름이 다른 곳에도 있을 때만 "거래처 › 2025", 하나뿐이면 이름 그대로', () => {
|
||||
expect(mailboxDisplayName(LIST, client2025)).toBe('거래처 › 2025')
|
||||
expect(mailboxDisplayName(LIST, keep2025)).toBe('보관 › 2025')
|
||||
expect(mailboxDisplayName(LIST, projectQuote)).toBe('프로젝트 › 견적')
|
||||
expect(mailboxDisplayName(LIST, settle)).toBe('정산')
|
||||
expect(mailboxDisplayName(LIST, client)).toBe('거래처')
|
||||
expect(mailboxDisplayName(LIST, inbox)).toBe('받은편지함')
|
||||
})
|
||||
|
||||
it('mailboxDisplayName — 위 메일함 이름까지 같으면 가를 수 있을 때까지 올라간다 · 맨 위의 메일함은 이름 그대로', () => {
|
||||
const a = mailbox({ id: 'a', name: 'A' })
|
||||
const aYear = mailbox({ id: 'ay', name: '2025', parentId: 'a' })
|
||||
const aMonth = mailbox({ id: 'am', name: '11월', parentId: 'ay' })
|
||||
const b = mailbox({ id: 'b', name: 'B' })
|
||||
const bYear = mailbox({ id: 'by', name: '2025', parentId: 'b' })
|
||||
const bMonth = mailbox({ id: 'bm', name: '11월', parentId: 'by' })
|
||||
const rootMonth = mailbox({ id: 'rm', name: '11월' })
|
||||
const list = [a, aYear, aMonth, b, bYear, bMonth, rootMonth]
|
||||
|
||||
expect(mailboxDisplayName(list, aMonth)).toBe('A › 2025 › 11월')
|
||||
expect(mailboxDisplayName(list, bMonth)).toBe('B › 2025 › 11월')
|
||||
expect(mailboxDisplayName(list, rootMonth)).toBe('11월')
|
||||
// 목록에 없는 메일함(그사이 지워짐)은 이름 그대로
|
||||
expect(mailboxDisplayName(list, { id: 'gone', name: '11월' })).toBe('11월')
|
||||
})
|
||||
|
||||
it('mailboxDisplayName — 글자 모양이 같으면(NFC · NFD) 같은 이름으로 본다', () => {
|
||||
const a = mailbox({ id: 'a', name: 'A' })
|
||||
const b = mailbox({ id: 'b', name: 'B' })
|
||||
const composed = mailbox({ id: 'q', name: '견적', parentId: 'a' })
|
||||
const decomposed = mailbox({ id: 'n', name: '견적'.normalize('NFD'), parentId: 'b' })
|
||||
|
||||
expect(mailboxDisplayName([a, b, composed, decomposed], composed)).toBe('A › 견적')
|
||||
})
|
||||
|
||||
it('canHoldChildMailbox · sameMailboxName — 하위 메일함은 받은편지함 · 내 메일함만, 이름은 대소문자를 가리지 않는다', () => {
|
||||
expect([inbox, sent, junk, trash, client].map(canHoldChildMailbox)).toEqual([true, false, false, false, true])
|
||||
expect(canHoldChildMailbox(mailbox({ id: 'a', name: '보관함', role: 'archive' }))).toBe(false)
|
||||
expect(sameMailboxName('Report', 'report')).toBe(true)
|
||||
expect(sameMailboxName('p15-자식', 'P15-자식')).toBe(true)
|
||||
expect(sameMailboxName('2025', '2026')).toBe(false)
|
||||
})
|
||||
|
||||
it('mailboxMoveTargets — 맨 위 · 받은편지함(과 그 아래) · 내 메일함, 고를 수 없는 줄은 까닭과 함께 남긴다', () => {
|
||||
const targets = mailboxMoveTargets(LIST, client2025)
|
||||
|
||||
expect(targets.map((target) => [target.parentId, target.group, target.block])).toEqual([
|
||||
[null, 'top', null],
|
||||
['i', 'system', null],
|
||||
['ip', 'system', null],
|
||||
// 지금 있는 곳 · 옮기는 메일함과 그 하위 · 바로 아래에 같은 이름(보관 › 2025)이 있는 곳
|
||||
['c', 'mine', 'current'],
|
||||
['c25', 'mine', 'self'],
|
||||
['c25s', 'mine', 'self'],
|
||||
['cq', 'mine', null],
|
||||
['b', 'mine', 'sameName'],
|
||||
['b25', 'mine', null],
|
||||
['p', 'mine', null],
|
||||
['pq', 'mine', null],
|
||||
])
|
||||
// 보낸편지함 · 스팸메일함 · 휴지통과 그 아래(보낸 정리)는 옮길 곳이 아니다
|
||||
expect(targets.some((target) => ['s', 'sf', 'j', 't'].includes(target.parentId ?? ''))).toBe(false)
|
||||
expect(targets[3]?.entry?.mailbox.id).toBe('c')
|
||||
expect(targets[0]?.entry).toBeNull()
|
||||
})
|
||||
|
||||
it('mailboxMoveTargets — 맨 위의 메일함은 맨 위 줄이 지금 있는 곳, 같은 이름은 대소문자를 가리지 않고 본다', () => {
|
||||
const report = mailbox({ id: 'r', name: 'Report', sortOrder: 1 })
|
||||
const lowerReport = mailbox({ id: 'pr', name: 'report', parentId: 'p' })
|
||||
const targets = mailboxMoveTargets([inbox, project, report, lowerReport], report)
|
||||
|
||||
expect(targets.map((target) => [target.parentId, target.block])).toEqual([
|
||||
[null, 'current'],
|
||||
['i', null],
|
||||
['p', 'sameName'],
|
||||
['pr', null],
|
||||
['r', 'self'],
|
||||
])
|
||||
// 맨 위에 같은 이름(대소문자만 다름)이 있으면 맨 위 줄도 고를 수 없다
|
||||
const nested = mailbox({ id: 'n', name: 'REPORT', parentId: 'p' })
|
||||
expect(mailboxMoveTargets([inbox, project, report, nested], nested)[0]?.block).toBe('sameName')
|
||||
})
|
||||
|
||||
it('descendantMailboxes — 모든 깊이의 하위 메일함', () => {
|
||||
expect(descendantMailboxes(LIST, 'c').map((item) => item.id)).toEqual(['c25', 'c25s', 'cq'])
|
||||
expect(descendantMailboxes(LIST, 'c25s')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
MAILBOX_ROLE_ORDER,
|
||||
type MailboxKind,
|
||||
} from '@/shared/constants/mail'
|
||||
import { MAILBOX_NAME_FORBIDDEN_CHAR, MAILBOX_NAME_MAX_LENGTH } from '@/shared/constants/mailbox-manage'
|
||||
import {
|
||||
MAILBOX_NAME_FORBIDDEN_CHAR,
|
||||
MAILBOX_NAME_MAX_LENGTH,
|
||||
MAILBOX_PATH_TEXT,
|
||||
} from '@/shared/constants/mailbox-manage'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import type { Mailbox, MailboxRole } from '@/shared/types/mail'
|
||||
import { subjectParticle } from '@/shared/utils/korean'
|
||||
@@ -89,7 +93,140 @@ export function splitMailboxGroups(nodes: readonly MailboxNode[]): { system: Mai
|
||||
return { system, mine }
|
||||
}
|
||||
|
||||
/** 하위 메일함이 있는가 — 있으면 지울 수 없다 (서버도 400 VAL_001, 화면이 먼저 막는다) */
|
||||
// ── 메일함 나무 (15단계 — [이동 ▾] 들여쓰기 · 옮기기 창 · 알림의 "상위 › 이름") ─────────────
|
||||
|
||||
/** 메일함 패널의 두 묶음 — 기본 메일함(역할이 있는 뿌리와 그 아래) · 내 메일함(역할 없는 뿌리와 그 아래) */
|
||||
export type MailboxGroup = 'system' | 'mine'
|
||||
|
||||
/** 메일함 나무의 한 줄 — 패널과 같은 차례 · 깊이(orderMailboxes)에 위 메일함 · 하위가 있는지 · 묶음을 더했다 */
|
||||
export interface MailboxTreeEntry extends MailboxNode {
|
||||
/** 뿌리부터 바로 위 메일함까지 — 뿌리면 빈 배열 */
|
||||
ancestors: readonly Mailbox[]
|
||||
/** 하위 메일함이 있다 */
|
||||
hasChildren: boolean
|
||||
group: MailboxGroup
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함 목록 → 패널 차례로 편 나무. 차례 · 깊이는 orderMailboxes 그대로(부모를 찾을 수 없거나 순환이면 맨 위로 — 빠뜨리지 않는다),
|
||||
* 묶음은 splitMailboxGroups 와 같다(뿌리의 역할을 하위가 따라간다)
|
||||
*/
|
||||
export function mailboxTree(list: readonly Mailbox[]): MailboxTreeEntry[] {
|
||||
const nodes = orderMailboxes(list)
|
||||
const entries: MailboxTreeEntry[] = []
|
||||
/** 지금 줄 위의 메일함들 — 깊이 d 인 줄 앞에는 d 개가 남는다 (orderMailboxes 는 부모 바로 뒤에 하위를 둔다) */
|
||||
const path: Mailbox[] = []
|
||||
let group: MailboxGroup = 'mine'
|
||||
nodes.forEach((node, index) => {
|
||||
path.length = Math.min(path.length, node.depth)
|
||||
if (node.depth === 0) group = node.mailbox.role !== null ? 'system' : 'mine'
|
||||
entries.push({
|
||||
mailbox: node.mailbox,
|
||||
depth: node.depth,
|
||||
ancestors: [...path],
|
||||
hasChildren: (nodes[index + 1]?.depth ?? -1) > node.depth,
|
||||
group,
|
||||
})
|
||||
path.push(node.mailbox)
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
/** 들여 쓴 메일함의 화면 낭독 이름 — 위 메일함부터 "거래처 안의 2025" (뿌리면 이름 그대로) */
|
||||
export function mailboxSpokenName(entry: Pick<MailboxTreeEntry, 'mailbox' | 'ancestors'>): string {
|
||||
return [...entry.ancestors.map((mailbox) => mailbox.name), entry.mailbox.name].join(MAILBOX_PATH_TEXT.spokenJoin)
|
||||
}
|
||||
|
||||
/** 보기에 같은 이름인가 — 알림에서 가려야 할 이름 (글자 모양이 같으면 같다 — NFC) */
|
||||
function sameDisplayedName(a: string, b: string): boolean {
|
||||
return a.normalize('NFC') === b.normalize('NFC')
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림에 쓰는 메일함 이름 — 같은 이름의 메일함이 다른 곳에도 있을 때만 위 메일함 이름을 붙인다 ("거래처 › 2025").
|
||||
* 위 메일함까지 같으면 한 단계씩 더 올라가 가를 수 있을 때까지 붙인다("거래처 › 2025 › 정산"). 맨 위의 메일함 · 목록에 없는 메일함은 이름 그대로
|
||||
*/
|
||||
export function mailboxDisplayName(list: readonly Mailbox[], mailbox: Pick<Mailbox, 'id' | 'name'>): string {
|
||||
if (list.filter((other) => sameDisplayedName(other.name, mailbox.name)).length < 2) return mailbox.name
|
||||
const entries = mailboxTree(list)
|
||||
const own = entries.find((entry) => entry.mailbox.id === mailbox.id)
|
||||
if (!own || own.ancestors.length === 0) return mailbox.name
|
||||
const others = entries.filter(
|
||||
(entry) => entry.mailbox.id !== mailbox.id && sameDisplayedName(entry.mailbox.name, mailbox.name),
|
||||
)
|
||||
const ownPath = own.ancestors.map((ancestor) => ancestor.name)
|
||||
for (let take = 1; take <= ownPath.length; take += 1) {
|
||||
const tail = ownPath.slice(-take)
|
||||
const clash = others.some((other) => {
|
||||
const otherPath = other.ancestors.map((ancestor) => ancestor.name)
|
||||
if (otherPath.length < take) return false
|
||||
return otherPath.slice(-take).every((name, index) => sameDisplayedName(name, tail[index] ?? ''))
|
||||
})
|
||||
if (!clash) return [...tail, mailbox.name].join(MAILBOX_PATH_TEXT.displayJoin)
|
||||
}
|
||||
return [...ownPath, mailbox.name].join(MAILBOX_PATH_TEXT.displayJoin)
|
||||
}
|
||||
|
||||
/** 하위 메일함을 둘 수 있는 메일함 — 받은편지함 · 내 메일함(역할 없음). ⋯ 「하위 메일함 만들기」와 옮기기 창의 옮길 곳이 같은 규칙이다 */
|
||||
export function canHoldChildMailbox(mailbox: Pick<Mailbox, 'role'>): boolean {
|
||||
return mailbox.role === 'inbox' || mailbox.role === null
|
||||
}
|
||||
|
||||
/** 같은 자리에 둘 수 없는 이름인가 — 엔진은 대소문자를 가리지 않고 같은 이름으로 본다 (Report 와 report — 15단계 엔진 실측 E1) */
|
||||
export function sameMailboxName(a: string, b: string): boolean {
|
||||
return a.normalize('NFC').toLowerCase() === b.normalize('NFC').toLowerCase()
|
||||
}
|
||||
|
||||
/** 옮기기 창에서 고를 수 없는 까닭 — 옮기는 메일함 자신이나 그 하위 · 지금 있는 곳 · 같은 이름의 메일함이 이미 있는 곳 */
|
||||
export type MailboxMoveBlock = 'self' | 'current' | 'sameName'
|
||||
|
||||
/** 옮기기 창의 한 줄 (디자인 원본 Mailbox15 "옮기기 창") */
|
||||
export interface MailboxMoveTarget {
|
||||
/** 옮기면 새 상위 — 맨 위 줄이면 null */
|
||||
parentId: string | null
|
||||
/** 그 메일함 — 맨 위 줄이면 null */
|
||||
entry: MailboxTreeEntry | null
|
||||
/** 맨 위 줄 · 기본 메일함(받은편지함과 그 아래) · 내 메일함 */
|
||||
group: 'top' | MailboxGroup
|
||||
/** 고를 수 없으면 그 까닭 */
|
||||
block: MailboxMoveBlock | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일함을 옮길 곳 — 맨 위 · 받은편지함(과 그 아래) · 내 메일함을 패널 차례로 (디자인 원본 Mailbox15 "옮기기 창").
|
||||
* 하위 메일함을 둘 수 없는 메일함(보낸편지함 · 임시보관함 · 스팸메일함 · 휴지통 · 보관함)과 그 아래는 뺀다 — 하위 메일함을 만들 수 있는 곳과 같다.
|
||||
* 고를 수 없는 줄도 남긴다: 옮기는 메일함 자신과 그 하위(self) → 지금 있는 곳(current) → 바로 아래에 같은 이름(대소문자 무시)이 있는 곳(sameName) 차례로 본다
|
||||
*/
|
||||
export function mailboxMoveTargets(
|
||||
list: readonly Mailbox[],
|
||||
moving: Pick<Mailbox, 'id' | 'name' | 'parentId'>,
|
||||
): MailboxMoveTarget[] {
|
||||
const blockOf = (parentId: string | null, inside: boolean): MailboxMoveBlock | null => {
|
||||
if (inside) return 'self'
|
||||
if (moving.parentId === parentId) return 'current'
|
||||
const taken = list.some(
|
||||
(mailbox) => mailbox.id !== moving.id && mailbox.parentId === parentId && sameMailboxName(mailbox.name, moving.name),
|
||||
)
|
||||
return taken ? 'sameName' : null
|
||||
}
|
||||
const targets: MailboxMoveTarget[] = [{ parentId: null, entry: null, group: 'top', block: blockOf(null, false) }]
|
||||
for (const entry of mailboxTree(list)) {
|
||||
// 그 메일함과 위 메일함이 모두 하위 메일함을 둘 수 있는 곳이어야 한다 (보낸편지함 아래의 메일함 등은 뺀다)
|
||||
if (!canHoldChildMailbox(entry.mailbox) || !entry.ancestors.every(canHoldChildMailbox)) continue
|
||||
const inside = entry.mailbox.id === moving.id || entry.ancestors.some((ancestor) => ancestor.id === moving.id)
|
||||
targets.push({ parentId: entry.mailbox.id, entry, group: entry.group, block: blockOf(entry.mailbox.id, inside) })
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
/** 그 메일함 아래의 모든 하위 메일함 — 모든 깊이 (하위까지 지우기 확인 창의 "하위 메일함 2개" · 메일 수 — 15단계) */
|
||||
export function descendantMailboxes(list: readonly Mailbox[], id: string): Mailbox[] {
|
||||
return mailboxTree(list)
|
||||
.filter((entry) => entry.ancestors.some((ancestor) => ancestor.id === id))
|
||||
.map((entry) => entry.mailbox)
|
||||
}
|
||||
|
||||
/** 하위 메일함이 있는가 — 지우기 확인 창이 하위까지 지울지 묻는다 (15단계 — 서버는 withChildren=true 일 때만 지운다) */
|
||||
export function hasChildMailbox(list: readonly Mailbox[], id: string): boolean {
|
||||
return list.some((mailbox) => mailbox.parentId === id)
|
||||
}
|
||||
@@ -100,18 +237,18 @@ export function isEmptiableMailbox(mailbox: Pick<Mailbox, 'role'>): boolean {
|
||||
}
|
||||
|
||||
/** ⋯ 메뉴가 할 수 있는 일 */
|
||||
export type MailboxAction = 'markRead' | 'createChild' | 'rename' | 'delete' | 'empty'
|
||||
export type MailboxAction = 'markRead' | 'createChild' | 'rename' | 'move' | 'delete' | 'empty'
|
||||
|
||||
/**
|
||||
* 메일함마다 ⋯ 메뉴의 항목 (디자인 README "4단계 › 메일함 관리"):
|
||||
* 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기(받은편지함 · 내 메일함) · 이름 바꾸기 · 지우기(내 메일함만) ·
|
||||
* 메일함마다 ⋯ 메뉴의 항목 (디자인 README "4단계 › 메일함 관리" · "15단계 › 메일함 옮기기"):
|
||||
* 모두 읽음으로 표시(안 읽은 메일이 있을 때) · 하위 메일함 만들기(받은편지함 · 내 메일함) · 이름 바꾸기 · 옮기기 · 지우기(내 메일함만) ·
|
||||
* 휴지통 / 스팸메일함 비우기(그 메일함에서만 — 빈 메일함이면 뺀다). 할 일이 없으면 빈 목록 — ⋯ 단추를 그리지 않는다
|
||||
*/
|
||||
export function mailboxActions(mailbox: Mailbox): MailboxAction[] {
|
||||
const actions: MailboxAction[] = []
|
||||
if (mailbox.unreadCount > 0) actions.push('markRead')
|
||||
if (mailbox.role === 'inbox' || mailbox.role === null) actions.push('createChild')
|
||||
if (mailbox.role === null) actions.push('rename', 'delete')
|
||||
if (canHoldChildMailbox(mailbox)) actions.push('createChild')
|
||||
if (mailbox.role === null) actions.push('rename', 'move', 'delete')
|
||||
if (isEmptiableMailbox(mailbox) && mailbox.totalCount > 0) actions.push('empty')
|
||||
return actions
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
pendingWatchesOf,
|
||||
samePendingSend,
|
||||
undoToastDeadline,
|
||||
unsentReleaseState,
|
||||
} from './pending-send'
|
||||
|
||||
// 엔진이 붙잡아 둔 내 발송 (계약 2절 "보내기 취소 · 예약 발송" — 9단계) — 순수 함수
|
||||
@@ -88,3 +89,29 @@ describe('보낸 뒤 알림의 [보내기 취소] 마감 (리뷰 #11)', () => {
|
||||
expect(undoToastDeadline({ ...base, sendAt: at(20_000), undoSendSeconds: 0 })).toBe(NOW + 20_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('지금 보내기가 결과를 모른 채 끝난 뒤 다시 받은 메일 (15단계 리뷰)', () => {
|
||||
const scheduled = { kind: 'scheduled' as const, sendAt: at(3_600_000) }
|
||||
const undo = { kind: 'undo' as const, sendAt: at(10_000) }
|
||||
|
||||
it('임시보관함에 있으면 되돌린 것 — 붙잡은 발송보다 먼저 본다', () => {
|
||||
expect(unsentReleaseState({ mailboxIds: ['drafts'], pendingSend: null }, 'drafts')).toBe('reverted')
|
||||
expect(unsentReleaseState({ mailboxIds: ['drafts'], pendingSend: scheduled }, 'drafts')).toBe('reverted')
|
||||
})
|
||||
|
||||
it('보낸편지함 — 붙잡은 발송이 예약이면 그대로 · 보내기 취소를 기다리면 보낸 것 · 없으면 예약만 풀린 것', () => {
|
||||
expect(unsentReleaseState({ mailboxIds: ['sent'], pendingSend: scheduled }, 'drafts')).toBe('scheduled')
|
||||
expect(unsentReleaseState({ mailboxIds: ['sent'], pendingSend: undo }, 'drafts')).toBe('undo')
|
||||
expect(unsentReleaseState({ mailboxIds: ['sent'], pendingSend: null }, 'drafts')).toBe('unscheduled')
|
||||
// 모양이 틀린 붙잡은 발송은 없는 것으로 읽는다
|
||||
expect(unsentReleaseState({ mailboxIds: ['sent'], pendingSend: { kind: 'later', sendAt: 'x' } as never }, 'drafts')).toBe(
|
||||
'unscheduled',
|
||||
)
|
||||
})
|
||||
|
||||
it('다시 받지 못했으면 모른다 · 임시보관함을 모르면 붙잡은 발송이 없는 메일을 예약만 풀린 것으로', () => {
|
||||
expect(unsentReleaseState(null, 'drafts')).toBe('unknown')
|
||||
expect(unsentReleaseState({ mailboxIds: ['drafts'], pendingSend: null }, null)).toBe('unscheduled')
|
||||
expect(unsentReleaseState({ mailboxIds: undefined as never, pendingSend: scheduled }, 'drafts')).toBe('scheduled')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,3 +126,32 @@ export function undoToastDeadline(input: UndoDeadlineInput): number {
|
||||
if (seconds !== null && seconds > 0) return input.now + seconds * 1000 - input.marginMs
|
||||
return Math.min(Date.parse(input.sendAt), input.now + input.maxMs)
|
||||
}
|
||||
|
||||
// ── 지금 보내기가 결과를 모른 채 끝난 뒤 (15단계 — 계약 2절 "지금 보내기") ─────────────
|
||||
|
||||
/**
|
||||
* 지금 보내기가 503 MAIL_001 · 결과를 모르는 실패(SYS_001 — 앞단 502 · 504 · 시간 초과 · 연결 끊김)로 끝난 뒤 다시 받은 그 메일의 상태.
|
||||
* - reverted: 임시보관함에 있다 — 엔진이 거절해 되돌렸다(보내지 않았다)
|
||||
* - undo: 다시 낸 발송이 보내기 취소를 기다린다 — 보낸 것이다(같은 요청을 다시 보내도 서버가 이 상태로 200 을 준다)
|
||||
* - scheduled: 예약이 그대로다 — 예약한 시각에 나간다
|
||||
* - unscheduled: 예약은 풀렸고 기다리는 발송이 없다 — 예약한 시각에 따로 나가지는 않는다. 다시 낸 발송이 이미 나갔는지는 모른다
|
||||
* (보내기 취소 0초 · 응답만 잃음). 같은 요청을 다시 보내면 서버가 아직 보내지 않았을 때만 이어 낸다
|
||||
* - unknown: 다시 받지 못했다
|
||||
*/
|
||||
export type UnsentReleaseState = 'reverted' | 'undo' | 'scheduled' | 'unscheduled' | 'unknown'
|
||||
|
||||
/**
|
||||
* 다시 받은 메일(없으면 null — 받지 못했다) → 상태. 임시보관함을 먼저 본다(되돌린 메일은 붙잡은 발송이 없다).
|
||||
* 임시보관함 id 를 모르면 붙잡은 발송이 없는 메일을 unscheduled 로 둔다 — 되돌렸든 아니든 예약은 풀렸다
|
||||
*/
|
||||
export function unsentReleaseState(
|
||||
fresh: Pick<MessageSummary, 'mailboxIds' | 'pendingSend'> | null,
|
||||
draftsId: string | null,
|
||||
): UnsentReleaseState {
|
||||
if (fresh === null) return 'unknown'
|
||||
const mailboxIds: unknown = fresh.mailboxIds
|
||||
if (draftsId !== null && Array.isArray(mailboxIds) && mailboxIds.includes(draftsId)) return 'reverted'
|
||||
const pending = messagePendingSend(fresh)
|
||||
if (pending === null) return 'unscheduled'
|
||||
return pending.kind === 'undo' ? 'undo' : 'scheduled'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { opensUpward } from './popup-placement'
|
||||
import { opensUpward, popupAlignFor } from './popup-placement'
|
||||
|
||||
// 메일함 패널의 ⋯ 메뉴 — 아래로 열면 잘릴 때 위로 연다
|
||||
|
||||
@@ -20,3 +20,42 @@ describe('opensUpward', () => {
|
||||
expect(opensUpward({ ...base, triggerTop: 220, triggerBottom: 260, areaTop: 0, areaBottom: 320 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// 15단계 — 폭 360 의 읽기 화면 [이동 ▾](디자인 원본 Reader15 2-4): 단추 왼쪽 끝에 맞춘 메뉴가 화면 오른쪽 밖으로 넘치면 단추 오른쪽 끝에 맞춘다
|
||||
describe('popupAlignFor', () => {
|
||||
/** 폭 360 화면 · 레일 72 뒤의 읽기 칸 — [이동 ▾](40) 이 x 240~280 */
|
||||
const narrow = { triggerLeft: 240, triggerRight: 280, popupWidth: 200, areaLeft: 0, areaRight: 360 }
|
||||
|
||||
it('왼쪽 끝에 맞춰 다 들어가면 그대로', () => {
|
||||
expect(popupAlignFor({ ...narrow, triggerLeft: 100, triggerRight: 140 }, 'start')).toBe('start')
|
||||
})
|
||||
|
||||
it('왼쪽 끝에 맞추면 오른쪽 밖으로 넘치고 오른쪽 끝에 맞추면 들어가면 — 단추 오른쪽 끝에 맞춘다', () => {
|
||||
expect(popupAlignFor(narrow, 'start')).toBe('end')
|
||||
})
|
||||
|
||||
it('오른쪽 끝에 맞춘 메뉴(⋯)가 왼쪽 밖으로 넘치면 — 단추 왼쪽 끝에 맞춘다(⋯ 가 둘째 줄 맨 앞으로 내려간 폭 360)', () => {
|
||||
expect(popupAlignFor({ ...narrow, triggerLeft: 94, triggerRight: 134 }, 'end')).toBe('start')
|
||||
// 다 들어가면 바라는 끝 그대로
|
||||
expect(popupAlignFor(narrow, 'end')).toBe('end')
|
||||
})
|
||||
|
||||
it('양쪽 다 넘치면 덜 넘치는 쪽 — 똑같이 넘치면 바라는 끝', () => {
|
||||
const wide = { triggerLeft: 120, triggerRight: 160, popupWidth: 300, areaLeft: 0, areaRight: 360 }
|
||||
// 왼쪽 끝에 맞추면 오른쪽으로 60 · 오른쪽 끝에 맞추면 왼쪽으로 140 넘친다
|
||||
expect(popupAlignFor(wide, 'start')).toBe('start')
|
||||
expect(popupAlignFor(wide, 'end')).toBe('start')
|
||||
const even = { triggerLeft: 130, triggerRight: 230, popupWidth: 400, areaLeft: 0, areaRight: 360 }
|
||||
expect(popupAlignFor(even, 'start')).toBe('start')
|
||||
expect(popupAlignFor(even, 'end')).toBe('end')
|
||||
})
|
||||
|
||||
it('보이는 영역은 화면이 아니라 잘라 내는 칸일 수 있다 — 칸의 오른쪽 끝을 넘으면 넘친 것이다', () => {
|
||||
expect(popupAlignFor({ ...narrow, triggerLeft: 500, triggerRight: 540, areaLeft: 300, areaRight: 680 }, 'start')).toBe(
|
||||
'end',
|
||||
)
|
||||
expect(popupAlignFor({ ...narrow, triggerLeft: 400, triggerRight: 440, areaLeft: 300, areaRight: 680 }, 'start')).toBe(
|
||||
'start',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 펼침 메뉴의 자리 — 아래로 열면 보이는 영역을 넘을 때 위로 연다 (메일함 패널처럼 스크롤되는 칸 안의 ⋯ 메뉴, 순수 함수만 둔다)
|
||||
// 보이는 영역(화면과 스크롤 · 잘림 조상이 겹치는 부분)을 재는 일은 부르는 쪽이 한다.
|
||||
// 펼침 메뉴의 자리 — 아래로 열면 보이는 영역을 넘을 때 위로 연다 (메일함 패널처럼 스크롤되는 칸 안의 ⋯ 메뉴),
|
||||
// 단추의 왼쪽 끝에 맞춰 열면 오른쪽 밖으로 넘칠 때 단추의 오른쪽 끝에 맞춘다 (15단계 — 좁은 폭의 읽기 화면 [이동 ▾], 순수 함수만 둔다)
|
||||
// 보이는 영역(화면과 스크롤 · 잘림 조상이 겹치는 부분)을 재는 일은 부르는 쪽이 한다 (composables/usePopupPlacement.ts).
|
||||
|
||||
export interface PopupFitInput {
|
||||
/** 연 단추의 위 · 아래 (화면 좌표) */
|
||||
@@ -20,3 +21,34 @@ export function opensUpward(input: PopupFitInput): boolean {
|
||||
const above = input.triggerTop - input.areaTop - input.gap
|
||||
return input.popupHeight > below && above > below
|
||||
}
|
||||
|
||||
/** 메뉴를 단추의 어느 끝에 맞출지 — start: 메뉴 왼쪽 끝을 단추 왼쪽 끝에 · end: 메뉴 오른쪽 끝을 단추 오른쪽 끝에 */
|
||||
export type PopupAlign = 'start' | 'end'
|
||||
|
||||
export interface PopupSideInput {
|
||||
/** 연 단추의 왼쪽 · 오른쪽 (화면 좌표) */
|
||||
triggerLeft: number
|
||||
triggerRight: number
|
||||
/** 메뉴 폭 */
|
||||
popupWidth: number
|
||||
/** 보이는 영역의 왼쪽 · 오른쪽 (화면 좌표) */
|
||||
areaLeft: number
|
||||
areaRight: number
|
||||
}
|
||||
|
||||
/** 그 끝에 맞췄을 때 보이는 영역 밖으로 넘치는 폭 — start 는 오른쪽 밖, end 는 왼쪽 밖 */
|
||||
function overflowOf(input: PopupSideInput, align: PopupAlign): number {
|
||||
if (align === 'start') return Math.max(0, input.triggerLeft + input.popupWidth - input.areaRight)
|
||||
return Math.max(0, input.areaLeft - (input.triggerRight - input.popupWidth))
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴를 맞출 끝 — 바라는 끝(preferred)에 맞춰 다 보이면 그대로, 넘치면 덜 넘치는 반대 끝으로
|
||||
* (반대 끝에도 다 들지 않으면 덜 넘치는 쪽 — 위아래의 opensUpward 처럼 넓은 쪽으로. 똑같이 넘치면 바라는 끝)
|
||||
*/
|
||||
export function popupAlignFor(input: PopupSideInput, preferred: PopupAlign): PopupAlign {
|
||||
const wanted = overflowOf(input, preferred)
|
||||
if (wanted === 0) return preferred
|
||||
const other: PopupAlign = preferred === 'start' ? 'end' : 'start'
|
||||
return overflowOf(input, other) < wanted ? other : preferred
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 받은편지함 정리의 되풀이 — 이미 받은 메일에 필터 적용(7단계 — mail-filter-run.store)과 차단할 때 받은 메일 옮기기(15단계 — mail-block-run.store)가 함께 쓴다
|
||||
// (계약 2절 "이미 받은 메일에 적용" · "보낸 사람 차단" — 같은 규칙: 한 요청 25초 안 · cursor)
|
||||
// - 서버가 한 요청을 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 일에 그 cursor 를 넣어 다시 보낸다.
|
||||
// 다시 보낼 때마다 onProgress (확인 = scanned 합, 전체 = 확인 + remaining). 이어 하기([다시 시도])는 앞 실패의 cursor · 수에서 시작한다
|
||||
// - 요청은 알림을 끄고 보낸다(send 가 silent) — 실패를 알릴지는 응답이 온 때의 세션으로 정한다: 세션이 바뀐 뒤 늦게 온 실패는 알리지 않는다.
|
||||
// 화면이 따로 알리는 거절(screenNotified — 다른 창 · PC 에서 하는 중 429 REQ_001)은 알리지 않고, 그 밖은 사전 문구로 알린다
|
||||
// - 한 번도 나아가지 못한 응답(확인 0 인데 cursor 가 있다)이 maxStalls 번 이어지면 멈추고 BIZ_001 — 같은 요청을 끝없이 보내지 않게 (서버는 그럴 때 503 을 준다)
|
||||
// - 세션이 바뀌면(sameSession 이 false) 다음 요청을 보내지 않고 멈춘다(canceled). 늦게 온 응답도 쓰지 않는다
|
||||
// "한 탭에서 한 번에 하나"는 부르는 스토어가 정한다(running) — 이 함수는 되풀이만 한다
|
||||
import { getErrorMessage, notifyApiError, toApiError, type ApiError } from '@/composables/useApi'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
|
||||
/** 지금까지 — 확인한 수 · 할 일을 한 수 · 전체(확인 + 남은 수 — 어림. 첫 응답 전에는 받은편지함의 메일 수, 모르면 0) */
|
||||
export interface InboxCleanupTally {
|
||||
scanned: number
|
||||
matched: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 한 요청의 응답 — POST /mail-filter-runs · /mail-block-runs 가 같은 꼴이다 */
|
||||
export interface InboxCleanupStep {
|
||||
scanned: number
|
||||
matched: number
|
||||
remaining: number
|
||||
/** null = 끝 */
|
||||
cursor: string | null
|
||||
}
|
||||
|
||||
export type InboxCleanupOutcome =
|
||||
| ({ status: 'done' } & InboxCleanupTally)
|
||||
/** 도중에 멈췄다 — cursor 는 이어 할 자리(마지막으로 받은 cursor — 처음 요청부터 실패했으면 시작한 cursor), 수는 거기까지 */
|
||||
| ({ status: 'failed'; cursor: string | null; error: ApiError } & InboxCleanupTally)
|
||||
/** 세션이 바뀌었다 — 아무것도 알리지 않는다 */
|
||||
| { status: 'canceled' }
|
||||
|
||||
export interface InboxCleanupLoop {
|
||||
/** 시작할 자리와 수 — 처음부터(cursor null · 수 0) 또는 앞 실패의 자리 */
|
||||
start: InboxCleanupTally & { cursor: string | null }
|
||||
/** 한 요청을 보낸다 — 알림을 끄고(silent) */
|
||||
send: (cursor: string | null) => Promise<InboxCleanupStep>
|
||||
onProgress: (tally: InboxCleanupTally) => void
|
||||
/** 시작한 세션이 그대로인가 (authStore.generation) */
|
||||
sameSession: () => boolean
|
||||
/** 화면이 따로 알리는 거절 — 그 밖의 실패는 여기서 사전 문구로 알린다 */
|
||||
screenNotified: readonly ErrorCode[]
|
||||
/** 나아가지 못한 응답이 이만큼 이어지면 멈춘다 */
|
||||
maxStalls: number
|
||||
}
|
||||
|
||||
const CANCELED = { status: 'canceled' } as const
|
||||
|
||||
/** 서버가 준 수 — 음수는 0 으로 */
|
||||
function countOf(value: number): number {
|
||||
return Math.max(0, value)
|
||||
}
|
||||
|
||||
/** 끝날 때까지(cursor 가 null) 보낸다 — 세션이 바뀌면 멈춘다 */
|
||||
export async function runInboxCleanup(loop: InboxCleanupLoop): Promise<InboxCleanupOutcome> {
|
||||
let { cursor, scanned, matched, total } = loop.start
|
||||
let stalls = 0
|
||||
for (;;) {
|
||||
let step: InboxCleanupStep
|
||||
try {
|
||||
step = await loop.send(cursor)
|
||||
} catch (caught) {
|
||||
if (!loop.sameSession()) return CANCELED
|
||||
const error = toApiError(caught)
|
||||
if (!loop.screenNotified.includes(error.code)) notifyApiError(error)
|
||||
return { status: 'failed', cursor, scanned, matched, total, error }
|
||||
}
|
||||
if (!loop.sameSession()) return CANCELED
|
||||
const stepScanned = countOf(step.scanned)
|
||||
scanned += stepScanned
|
||||
matched += countOf(step.matched)
|
||||
total = scanned + countOf(step.remaining)
|
||||
if (step.cursor === null) return { status: 'done', scanned, matched, total }
|
||||
cursor = step.cursor
|
||||
stalls = stepScanned > 0 ? 0 : stalls + 1
|
||||
if (stalls >= loop.maxStalls) {
|
||||
const error: ApiError = { code: 'BIZ_001', message: getErrorMessage('BIZ_001') }
|
||||
return { status: 'failed', cursor, scanned, matched, total, error }
|
||||
}
|
||||
loop.onProgress({ scanned, matched, total })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { Mailbox, MailBlockRun, MailBlockRunRequest } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { InboxCleanupTally } from '@/stores/inbox-cleanup-run'
|
||||
import { useMailFilterRunStore } from '@/stores/mail-filter-run.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 { useMailBlockRunStore, type MailBlockRunStart } from './mail-block-run.store'
|
||||
|
||||
// 차단할 때 받은 메일 옮기기 — cursor 가 null 이 될 때까지 · 수를 이어 센다 · 이 탭의 받은편지함 정리는 한 번에 하나 · 세션이 바뀌면 멈춘다
|
||||
// (계약 2절 "보낸 사람 차단" — 15단계 POST /mail-block-runs)
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const FROM_START: MailBlockRunStart = { sender: 'news@example.com', cursor: null, scanned: 0, matched: 0, total: 4800 }
|
||||
const JUNK: Mailbox = { id: 'junk', name: '스팸메일함', role: 'junk', parentId: null, sortOrder: 0, totalCount: 40, unreadCount: 0 }
|
||||
|
||||
function step(scanned: number, matched: number, remaining: number, cursor: string | null): MockResult {
|
||||
const run: MailBlockRun = { scanned, matched, remaining, cursor }
|
||||
return ok(run)
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const promise = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
return { promise, finish }
|
||||
}
|
||||
|
||||
describe('mail-block-run.store', () => {
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().startSession(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
function replies(...results: (MockResult | Promise<MockResult>)[]) {
|
||||
let next = 0
|
||||
requests = installMockApi(() => {
|
||||
const result = results[Math.min(next, results.length - 1)] as MockResult | Promise<MockResult>
|
||||
next += 1
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
const bodies = () => requests.map((request) => request.data as MailBlockRunRequest)
|
||||
const toasts = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
|
||||
it('cursor 가 null 이 될 때까지 같은 sender 에 cursor 를 넣어 다시 보내고, 확인 · 옮긴 수를 이어 센다 — 요청은 알림을 끄고 보낸다', async () => {
|
||||
replies(step(1200, 10, 3600, 'b1'), step(3600, 27, 0, null))
|
||||
const store = useMailBlockRunStore()
|
||||
const progress: InboxCleanupTally[] = []
|
||||
|
||||
const running = store.run(FROM_START, (tally) => progress.push(tally))
|
||||
expect(store.running).toBe(true)
|
||||
expect(store.inboxBusy).toBe(true)
|
||||
const outcome = await running
|
||||
|
||||
expect(outcome).toEqual({ status: 'done', scanned: 4800, matched: 37, total: 4800 })
|
||||
expect(bodies()).toEqual([
|
||||
{ sender: 'news@example.com', cursor: null },
|
||||
{ sender: 'news@example.com', cursor: 'b1' },
|
||||
])
|
||||
expect(requests.every((request) => request.config.silent === true)).toBe(true)
|
||||
expect(progress).toEqual([{ scanned: 1200, matched: 10, total: 4800 }])
|
||||
expect(store.running).toBe(false)
|
||||
})
|
||||
|
||||
it('이 탭에서 받은편지함을 정리하는 중이면(필터 적용 · 다른 차단 정리) 보내지 않는다 — busy', async () => {
|
||||
const first = deferred()
|
||||
replies(first.promise, step(10, 0, 0, null))
|
||||
const store = useMailBlockRunStore()
|
||||
|
||||
const running = store.run(FROM_START, () => undefined)
|
||||
expect((await store.run(FROM_START, () => undefined)).status).toBe('busy')
|
||||
first.finish(step(10, 0, 0, null))
|
||||
await running
|
||||
|
||||
// 필터 적용이 도는 동안도 — 같은 직원의 받은편지함 정리는 한 번에 하나
|
||||
useMailFilterRunStore().running = true
|
||||
expect(store.inboxBusy).toBe(true)
|
||||
expect((await store.run(FROM_START, () => undefined)).status).toBe('busy')
|
||||
expect(requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('이 탭에서 스팸메일함을 비우는 동안은 보내지 않는다(startBlock junk) — 필터 적용은 막지 않는다(inboxBusy 는 그대로), 끝나면 다시 시작할 수 있다', async () => {
|
||||
const emptying = deferred()
|
||||
requests = installMockApi((request) =>
|
||||
request.method === 'DELETE' && request.url === '/mailboxes/junk/messages' ? emptying.promise : step(10, 0, 0, null),
|
||||
)
|
||||
const mail = useMailStore()
|
||||
mail.mailboxes = [JUNK]
|
||||
const store = useMailBlockRunStore()
|
||||
expect(store.startBlock).toBeNull()
|
||||
|
||||
const empty = mail.runMailboxOperation('empty', JUNK, () => undefined)
|
||||
expect(store.startBlock).toBe('junk')
|
||||
expect(store.inboxBusy).toBe(false)
|
||||
expect((await store.run(FROM_START, () => undefined)).status).toBe('busy')
|
||||
expect(requests.filter((request) => request.url === '/mail-block-runs')).toEqual([])
|
||||
|
||||
emptying.finish(ok({ processed: 40, remaining: 0 }))
|
||||
expect((await empty).status).toBe('done')
|
||||
expect(store.startBlock).toBeNull()
|
||||
expect((await store.run(FROM_START, () => undefined)).status).toBe('done')
|
||||
expect(requests.filter((request) => request.url === '/mail-block-runs')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('받은편지함 정리가 먼저 보인다 — 필터 적용 중이면 스팸메일함을 비우는 중이어도 inbox', async () => {
|
||||
const emptying = deferred()
|
||||
requests = installMockApi(() => emptying.promise)
|
||||
const mail = useMailStore()
|
||||
mail.mailboxes = [JUNK]
|
||||
const store = useMailBlockRunStore()
|
||||
|
||||
const empty = mail.runMailboxOperation('empty', JUNK, () => undefined)
|
||||
useMailFilterRunStore().running = true
|
||||
expect(store.startBlock).toBe('inbox')
|
||||
useMailFilterRunStore().running = false
|
||||
expect(store.startBlock).toBe('junk')
|
||||
|
||||
emptying.finish(ok({ processed: 40, remaining: 0 }))
|
||||
await empty
|
||||
})
|
||||
|
||||
it('도중에 실패하면 거기까지의 수와 이어 할 자리를 돌려준다 — 까닭은 사전 문구로, 429 REQ_001 은 알리지 않는다(화면이 "다른 창에서 …")', async () => {
|
||||
replies(step(1200, 12, 3600, 'b1'), fail(503, 'MAIL_001'))
|
||||
const stopped = await useMailBlockRunStore().run(FROM_START, () => undefined)
|
||||
|
||||
expect(stopped).toMatchObject({ status: 'failed', cursor: 'b1', scanned: 1200, matched: 12, total: 4800 })
|
||||
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.MAIL_001])
|
||||
|
||||
useToastStore().clear()
|
||||
replies(fail(429, 'REQ_001'))
|
||||
const busyElsewhere = await useMailBlockRunStore().run(FROM_START, () => undefined)
|
||||
expect(busyElsewhere).toMatchObject({ status: 'failed', cursor: null, error: { code: 'REQ_001' } })
|
||||
expect(toasts()).toEqual([])
|
||||
})
|
||||
|
||||
it('세션이 바뀌면 다음 요청을 보내지 않는다 — 늦게 온 응답도 쓰지 않고, 새 세션은 바로 시작할 수 있다', async () => {
|
||||
const first = deferred()
|
||||
replies(first.promise, step(10, 0, 0, null))
|
||||
const store = useMailBlockRunStore()
|
||||
|
||||
const running = store.run(FROM_START, () => undefined)
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
expect(store.running).toBe(false)
|
||||
useAuthStore().startSession(USER)
|
||||
await nextTick()
|
||||
first.finish(step(1200, 12, 3600, 'b1'))
|
||||
|
||||
expect(await running).toEqual({ status: 'canceled' })
|
||||
expect(requests).toHaveLength(1)
|
||||
await flushPromises()
|
||||
expect(store.running).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BLOCK_RUN_MAX_STALLS } from '@/shared/constants/mail-block'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { runInboxCleanup, type InboxCleanupOutcome, type InboxCleanupTally } from '@/stores/inbox-cleanup-run'
|
||||
import { useMailFilterRunStore } from '@/stores/mail-filter-run.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 차단할 때 받은편지함에 이미 있는 그 보낸 사람(도메인)의 메일을 스팸메일함으로 — 계약 2절 "보낸 사람 차단" (15단계 — POST /api/mail-block-runs)
|
||||
// - 필터 적용(7단계 — mail-filter-run.store)과 같은 결이다: 서버가 한 요청을 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지
|
||||
// 같은 sender 에 그 cursor 를 넣어 다시 보낸다(되풀이는 inbox-cleanup-run.ts). 이어 하기([다시 시도])는 앞 실패의 cursor · 수에서 시작한다
|
||||
// - 같은 직원의 받은편지함 정리(필터 적용 · 이것)는 한 번에 하나다 — 다른 탭 · PC 에서 하는 중이면 서버가 429 REQ_001(화면이 "다른 창에서 …"로 알린다),
|
||||
// 이 탭에서 필터 적용이나 다른 차단 정리가 도는 동안은 시작하지 않는다(inboxBusy — 차단 창의 새 칸을 끈다)
|
||||
// - 이 탭에서 스팸메일함을 비우는 동안도 시작하지 않는다(15단계 리뷰 — 옮겨 넣은 메일이 들어오자마자 영구 삭제되지 않게, 반대 방향은
|
||||
// mailbox-admin 의 isOperationBlocked). 서버도 요청마다 스팸메일함 처리 잠금을 함께 잡는다(다른 창에서 비우는 중이면 429 — 위와 같은 알림).
|
||||
// 필터 적용은 스팸메일함에 넣지 않으므로 막지 않는다 — inboxBusy 는 그대로이고 차단 정리만 startBlock 을 본다
|
||||
// - 그 밖의 실패 까닭은 사전 문구로 알린다 — 요청은 알림을 끄고 보내고, 응답이 온 때 세션이 그대로일 때만 알린다
|
||||
// - 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 — authStore.generation) 다음 요청을 보내지 않고 멈춘다(canceled)
|
||||
// - 화면과 상관없이 앱이 떠 있는 동안 이어 한다(다른 화면으로 가도 된다). 탭을 닫으면 멈춘다 — 이어 하지 않는다
|
||||
|
||||
/** 처음부터(cursor null · 수 0 · 전체는 받은편지함의 메일 수) 또는 앞 실패의 자리에서 */
|
||||
export interface MailBlockRunStart extends InboxCleanupTally {
|
||||
/** 차단 목록의 값 그대로 — 소문자 주소 또는 "@도메인" */
|
||||
sender: string
|
||||
cursor: string | null
|
||||
}
|
||||
|
||||
export type MailBlockRunOutcome =
|
||||
| InboxCleanupOutcome
|
||||
/** 이 탭에서 받은편지함을 정리하거나(필터 적용 · 다른 차단 정리) 스팸메일함을 비우는 중이다 — 보내지 않았다 */
|
||||
| { status: 'busy' }
|
||||
|
||||
/** 차단 정리를 지금 시작할 수 없는 까닭 — 이 탭에서 받은편지함을 정리하는 중(inbox) · 스팸메일함을 비우는 중(junk) */
|
||||
export type MailBlockRunBlock = 'inbox' | 'junk'
|
||||
|
||||
/** 화면이 알리는 거절 — 같은 직원의 받은편지함 정리나 스팸메일함 비우기가 다른 곳에서 진행 중 */
|
||||
const SCREEN_NOTIFIED: readonly ErrorCode[] = ['REQ_001']
|
||||
|
||||
export const useMailBlockRunStore = defineStore('mail-block-run', () => {
|
||||
const mailApi = useMail()
|
||||
const authStore = useAuthStore()
|
||||
const filterRunStore = useMailFilterRunStore()
|
||||
// 메일 스토어도 셋업에서 이 스토어를 부른다 — 서로 셋업에서는 읽지 않고 계산할 때만 읽는다 (Pinia 의 서로 쓰는 스토어)
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/** 이 탭에서 차단 정리를 하는 중 — 한 번에 하나 */
|
||||
const running = ref<boolean>(false)
|
||||
/** 이 탭에서 받은편지함을 정리하는 중 — 필터 적용 · 차단 정리 (같은 직원은 한 번에 하나 — 차단 창의 새 칸을 끈다) */
|
||||
const inboxBusy = computed<boolean>(() => running.value || filterRunStore.running)
|
||||
/** 이 탭에서 스팸메일함을 비우는 중 (mailbox-admin — 15단계 리뷰) — 차단 정리만 막는다 */
|
||||
const junkEmptying = computed<boolean>(() => {
|
||||
const junk = mailStore.mailboxByRole('junk')
|
||||
return junk !== null && mailStore.isMailboxOperationRunning('empty', junk.id)
|
||||
})
|
||||
/** 차단 정리를 지금 시작할 수 없는 까닭 — 없으면 null (차단 창의 새 칸 · 시작 · [다시 시도]가 함께 본다) */
|
||||
const startBlock = computed<MailBlockRunBlock | null>(() => {
|
||||
if (inboxBusy.value) return 'inbox'
|
||||
return junkEmptying.value ? 'junk' : null
|
||||
})
|
||||
|
||||
/** 끝날 때까지(cursor 가 null) 보낸다 — 세션이 바뀌면 멈춘다 */
|
||||
async function run(
|
||||
start: MailBlockRunStart,
|
||||
onProgress: (tally: InboxCleanupTally) => void,
|
||||
): Promise<MailBlockRunOutcome> {
|
||||
if (startBlock.value !== null) return { status: 'busy' }
|
||||
const session = authStore.generation
|
||||
running.value = true
|
||||
try {
|
||||
return await runInboxCleanup({
|
||||
start,
|
||||
send: (cursor) => mailApi.runSenderBlock({ sender: start.sender, cursor }, { silent: true }),
|
||||
onProgress,
|
||||
sameSession: () => authStore.generation === session,
|
||||
screenNotified: SCREEN_NOTIFIED,
|
||||
maxStalls: MAIL_BLOCK_RUN_MAX_STALLS,
|
||||
})
|
||||
} finally {
|
||||
// 세션이 바뀐 뒤 끝난 옛 정리는 새 세션의 표시를 건드리지 않는다 (아래 watch 가 이미 내렸다)
|
||||
if (authStore.generation === session) running.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃 · 세션 만료 · 다른 계정 로그인 — 하던 정리는 다음 요청을 보내지 않는다(run 이 멈춘다). 새 세션은 바로 시작할 수 있다
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
running.value = false
|
||||
},
|
||||
)
|
||||
|
||||
return { running, inboxBusy, startBlock, run }
|
||||
})
|
||||
@@ -1,23 +1,25 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { getErrorMessage, notifyApiError, toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_FILTER_RUN_MAX_STALLS } from '@/shared/constants/mail-filter'
|
||||
import type { MailFilterRun, MailFilterRunRule } from '@/shared/types/mail'
|
||||
import type { MailFilterRunRule } from '@/shared/types/mail'
|
||||
import type { MailFilterRunTally } from '@/shared/utils/mail-filter-run'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { runInboxCleanup, type InboxCleanupOutcome } from '@/stores/inbox-cleanup-run'
|
||||
|
||||
// 이미 받은 메일에 필터 적용 — 계약 2절 "이미 받은 메일에 적용 (/api/mail-filter-runs)" (7단계)
|
||||
// - 서버가 한 요청을 25초 안에 끝내고 cursor 를 돌려준다 — null 이 될 때까지 같은 규칙에 그 cursor 를 넣어 다시 보낸다.
|
||||
// 다시 보낼 때마다 onProgress (확인 = scanned 합, 전체 = 확인 + remaining). 이어 하기([다시 시도])는 앞 실패의 cursor · 수에서 시작한다
|
||||
// (되풀이는 차단 정리와 함께 쓰는 inbox-cleanup-run.ts — 15단계에서 나눴다)
|
||||
// - 한 탭에서 한 번에 하나(running — 고치기 창이 그동안 「…에도 적용」 칸을 끈다). 다른 탭 · PC 의 적용은 서버가 429 REQ_001 로 막는다 —
|
||||
// 그 알림은 화면이 "다른 창에서 …"로 한다. 그 밖의 실패 까닭은 여기서 사전 문구로 알린다(4단계 오래 걸리는 처리의 인터셉터 알림과 같은 알림 —
|
||||
// 그 알림은 화면이 "다른 창에서 …"로 한다. 그 밖의 실패 까닭은 사전 문구로 알린다(4단계 오래 걸리는 처리의 인터셉터 알림과 같은 알림 —
|
||||
// 요청은 알림을 끄고 보내고, 응답이 온 때 세션이 그대로일 때만 알린다: 세션이 바뀐 뒤 늦게 온 실패는 알리지 않는다)
|
||||
// - 한 번도 나아가지 못한 응답(확인 0 인데 cursor 가 있다)이 이어지면 멈추고 BIZ_001 — 같은 요청을 끝없이 보내지 않게 (서버는 그럴 때 503 을 준다)
|
||||
// - 세션이 바뀌면(로그아웃 · 세션 만료 · 다른 계정 — authStore.generation) 다음 요청을 보내지 않고 멈춘다(canceled). 늦게 온 응답도 쓰지 않는다
|
||||
// - 화면과 상관없이 앱이 떠 있는 동안 이어 한다(다른 화면으로 가도 된다). 탭을 닫으면 멈춘다 — 이어 하지 않는다
|
||||
// - 차단할 때 받은 메일 옮기기(15단계 — mail-block-run.store)는 이 탭에서 이것이 도는 동안 시작하지 않는다(차단 창의 새 칸을 끈다)
|
||||
|
||||
/** 처음부터(cursor null · 수 0 · 전체는 받은편지함의 메일 수) 또는 앞 실패의 자리에서 */
|
||||
export interface MailFilterRunStart extends MailFilterRunTally {
|
||||
@@ -26,22 +28,12 @@ export interface MailFilterRunStart extends MailFilterRunTally {
|
||||
}
|
||||
|
||||
export type MailFilterRunOutcome =
|
||||
| ({ status: 'done' } & MailFilterRunTally)
|
||||
/** 도중에 멈췄다 — cursor 는 이어 할 자리(마지막으로 받은 cursor — 처음 요청부터 실패했으면 null), 수는 거기까지 */
|
||||
| ({ status: 'failed'; cursor: string | null; error: ApiError } & MailFilterRunTally)
|
||||
| InboxCleanupOutcome
|
||||
/** 이 탭에서 다른 적용이 돌고 있다 — 보내지 않았다 */
|
||||
| { status: 'busy' }
|
||||
/** 세션이 바뀌었다 — 아무것도 알리지 않는다 */
|
||||
| { status: 'canceled' }
|
||||
|
||||
/** 화면이 알리는 거절 — 같은 직원의 적용이 다른 곳에서 진행 중 (그 밖의 실패는 여기서 알린다) */
|
||||
const SCREEN_NOTIFIED: readonly ErrorCode[] = ['REQ_001']
|
||||
const CANCELED = { status: 'canceled' } as const
|
||||
|
||||
/** 서버가 준 수 — 음수는 0 으로 */
|
||||
function countOf(value: number): number {
|
||||
return Math.max(0, value)
|
||||
}
|
||||
|
||||
export const useMailFilterRunStore = defineStore('mail-filter-run', () => {
|
||||
const mailApi = useMail()
|
||||
@@ -58,34 +50,16 @@ export const useMailFilterRunStore = defineStore('mail-filter-run', () => {
|
||||
if (running.value) return { status: 'busy' }
|
||||
const session = authStore.generation
|
||||
running.value = true
|
||||
let { cursor, scanned, matched, total } = start
|
||||
let stalls = 0
|
||||
try {
|
||||
for (;;) {
|
||||
let step: MailFilterRun
|
||||
try {
|
||||
// 알림을 끄고 보낸다 — 실패를 알릴지는 응답이 온 때의 세션으로 정한다 (세션 만료 대화상자는 그대로 뜬다)
|
||||
step = await mailApi.runFilter({ ...start.rule, cursor }, { silent: true })
|
||||
} catch (caught) {
|
||||
if (authStore.generation !== session) return CANCELED
|
||||
const error = toApiError(caught)
|
||||
if (!SCREEN_NOTIFIED.includes(error.code)) notifyApiError(error)
|
||||
return { status: 'failed', cursor, scanned, matched, total, error }
|
||||
}
|
||||
if (authStore.generation !== session) return CANCELED
|
||||
const stepScanned = countOf(step.scanned)
|
||||
scanned += stepScanned
|
||||
matched += countOf(step.matched)
|
||||
total = scanned + countOf(step.remaining)
|
||||
if (step.cursor === null) return { status: 'done', scanned, matched, total }
|
||||
cursor = step.cursor
|
||||
stalls = stepScanned > 0 ? 0 : stalls + 1
|
||||
if (stalls >= MAIL_FILTER_RUN_MAX_STALLS) {
|
||||
const error: ApiError = { code: 'BIZ_001', message: getErrorMessage('BIZ_001') }
|
||||
return { status: 'failed', cursor, scanned, matched, total, error }
|
||||
}
|
||||
onProgress({ scanned, matched, total })
|
||||
}
|
||||
return await runInboxCleanup({
|
||||
start,
|
||||
// 알림을 끄고 보낸다 — 실패를 알릴지는 응답이 온 때의 세션으로 정한다 (세션 만료 대화상자는 그대로 뜬다)
|
||||
send: (cursor) => mailApi.runFilter({ ...start.rule, cursor }, { silent: true }),
|
||||
onProgress,
|
||||
sameSession: () => authStore.generation === session,
|
||||
screenNotified: SCREEN_NOTIFIED,
|
||||
maxStalls: MAIL_FILTER_RUN_MAX_STALLS,
|
||||
})
|
||||
} finally {
|
||||
// 세션이 바뀐 뒤 끝난 옛 적용은 새 세션의 표시를 건드리지 않는다 (아래 watch 가 이미 내렸다)
|
||||
if (authStore.generation === session) running.value = false
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
|
||||
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailBlockRunStore } from '@/stores/mail-block-run.store'
|
||||
import { createBulkActions } from './mail/bulk'
|
||||
import { createConversationState } from './mail/conversation'
|
||||
import { createListState } from './mail/list'
|
||||
@@ -14,15 +15,16 @@ import { createSelection } from './mail/selection'
|
||||
|
||||
export type { BulkResult } from '@/shared/utils/mail-bulk'
|
||||
export type { BulkMoveResult, FlagChanges, LoadStatus, MailBulkResult, MoveOptions, SearchOrigin } from './mail/types'
|
||||
export type { MailboxOperationOutcome, MailboxOperationProgress } from './mail/mailbox-admin'
|
||||
export type { MailboxOperationOptions, MailboxOperationOutcome, MailboxOperationProgress } from './mail/mailbox-admin'
|
||||
export type { ConversationDetailState } from './mail/conversation'
|
||||
export type { ReleaseOutcome } from './mail/scheduled'
|
||||
|
||||
// 메일 도메인 스토어 — 메일함 · 사용량 · 보이는 목록(메일함 · 대화로 묶은 메일함 · 검색 결과 · 예약됨) · 고른 메일 · 열린 메일
|
||||
// 한 스토어를 기능별 모듈(stores/mail/*)로 나눠 조립한다 — 화면은 이 스토어 하나만 본다.
|
||||
// mailboxes.ts 메일함 · 사용량 | list.ts 보이는 목록 · 이 탭이 바꾼 것 | selection.ts 고른 메일
|
||||
// message.ts 열린 메일 · 읽음 · 중요 표시 · 외부 이미지 보기 | bulk.ts 여러 통 처리 (읽기 화면의 한 통 삭제 · 스팸 신고 포함)
|
||||
// mailbox-admin.ts 메일함 만들기 · 이름 바꾸기 · 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기)
|
||||
// scheduled.ts 예약됨 개수 · 붙잡은 발송 취소(보내기 취소 · 예약 취소 — 9단계) · 엔진이 풀었는지 다시 보기
|
||||
// mailbox-admin.ts 메일함 만들기 · 이름 바꾸기 · 옮기기(15단계) · 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기 — 하위까지 지우기 포함)
|
||||
// scheduled.ts 예약됨 개수 · 붙잡은 발송 취소(보내기 취소 · 예약 취소 — 9단계) · 지금 보내기(15단계) · 엔진이 풀었는지 다시 보기
|
||||
// conversation.ts 읽기 화면의 같은 대화 · 펼친 줄의 본문 (14단계)
|
||||
//
|
||||
// 오류 처리 원칙:
|
||||
@@ -43,7 +45,14 @@ export const useMailStore = defineStore('mail', () => {
|
||||
const selection = createSelection(list)
|
||||
const message = createMessageActions({ mailApi, mailboxes: mailboxState, list, opened })
|
||||
const bulk = createBulkActions({ mailApi, mailboxes: mailboxState, list, selection, opened })
|
||||
const admin = createMailboxAdmin({ mailApi, mailboxes: mailboxState, list })
|
||||
// 차단할 때 받은 메일을 옮기는 동안(15단계)은 스팸메일함 비우기를 시작하지 않는다 (mailbox-admin.ts 의 isOperationBlocked)
|
||||
const blockRunStore = useMailBlockRunStore()
|
||||
const admin = createMailboxAdmin({
|
||||
mailApi,
|
||||
mailboxes: mailboxState,
|
||||
list,
|
||||
blockCleanupRunning: () => blockRunStore.running,
|
||||
})
|
||||
const scheduled = createScheduledState({
|
||||
mailApi,
|
||||
mailboxes: mailboxState,
|
||||
@@ -227,6 +236,9 @@ export const useMailStore = defineStore('mail', () => {
|
||||
loadScheduledCount: scheduled.loadScheduledCount,
|
||||
cancelPendingSend: scheduled.cancelPendingSend,
|
||||
isCancellingPendingSend: scheduled.isCancellingPendingSend,
|
||||
// 예약 메일 지금 보내기 (15단계)
|
||||
releasePendingSend: scheduled.releasePendingSend,
|
||||
isReleasingPendingSend: scheduled.isReleasingPendingSend,
|
||||
refreshPendingSends,
|
||||
// 같은 대화 (14단계)
|
||||
conversationMessageId: conversation.conversationMessageId,
|
||||
@@ -250,6 +262,12 @@ export const useMailStore = defineStore('mail', () => {
|
||||
loadMoreConversation: conversation.loadMoreConversation,
|
||||
toggleConversationMessage: conversation.toggleConversationMessage,
|
||||
retryConversationDetail: conversation.retryConversationDetail,
|
||||
// 같은 대화 [모두 펼치기] · [모두 접기] (15단계)
|
||||
conversationExpandableIds: conversation.conversationExpandableIds,
|
||||
conversationAllExpanded: conversation.conversationAllExpanded,
|
||||
expandAllConversation: conversation.expandAllConversation,
|
||||
collapseAllConversation: conversation.collapseAllConversation,
|
||||
noteConversationPanelVisible: conversation.noteConversationPanelVisible,
|
||||
// 여러 통 처리 · 한 통 삭제
|
||||
patchChecked: bulk.patchChecked,
|
||||
toggleConversationFlag: bulk.toggleConversationFlag,
|
||||
@@ -264,6 +282,8 @@ export const useMailStore = defineStore('mail', () => {
|
||||
isMailboxOperationBlocked: admin.isOperationBlocked,
|
||||
createMailbox: admin.createMailbox,
|
||||
renameMailbox: admin.renameMailbox,
|
||||
// 메일함 옮기기 · 하위까지 지우기 (15단계 — runMailboxOperation 의 withChildren)
|
||||
moveMailbox: admin.moveMailbox,
|
||||
runMailboxOperation: admin.runMailboxOperation,
|
||||
// 쓰기 화면
|
||||
lastBrowseLocation,
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
// 같은 길(markRead — 목록 · 메일함 개수도 맞춘다)로 읽음. 이 탭이 아는 값(열린 메일 · 보이는 목록 · 이 칸이 읽음으로 바꾼 것)을 먼저 본다 —
|
||||
// 받아 둔 대화가 옛 값이어도 같은 메일을 두 번 읽음으로 바꾸지 않게. 받아 둔 본문은 접힌 줄의 것을 최근 MAIL_CONVERSATION_DETAIL_CACHE_MAX 개만 둔다.
|
||||
// 그사이 지워진 메일(본문이 404 RES_001)은 대화를 다시 받아 그 줄을 뺀다([더 보기]로 더 받은 대화는 그 줄만 뺀다)
|
||||
// 모두 펼치기 · 모두 접기 (15단계 — 디자인 원본 Reader15 "6. 같은 대화"): 모두 펼치기는 불러온 줄(지금 메일 빼고)을 모두 펼치되 본문은 바로 받지 않는다 —
|
||||
// 칸이 알려 주는 대로(noteConversationPanelVisible — 펼친 안이 화면에 들어옴 · 나감) 화면에 들어온 줄부터 하나씩(detailQueue) 받는다.
|
||||
// 100통을 한꺼번에 부르지 않는다 — 요청 수 한도(경로 · IP 마다 1분 100회, 사무실은 함께 센다)와 화면 무게. 차례가 오기 전에 화면 밖으로 나간 줄은 건너뛴다.
|
||||
// 읽음은 지금 규칙 그대로 — 본문이 화면에 보인 메일만 읽음이 된다(펼쳤어도 화면 밖이면 그대로: 받았을 때 화면 밖이었으면 들어올 때 읽음).
|
||||
// [더 보기]로 더 받은 줄은 접힌 채로 온다. 모두 접기는 펼친 줄을 모두 접는다(받아 둔 본문은 접힌 줄의 상한까지만 둔다)
|
||||
// 이 탭이 바꾼 읽음 (2026-11-07 — 칸이 안 읽은 줄을 굵게 그린다): 목록 · 열린 메일에 읽음을 적을 때마다(list.ts 의 watchLocalReads — 펼친 줄 · 읽기 화면에서
|
||||
// 연 메일 · 목록과 여러 통 처리의 읽음 · 안 읽음 표시 · 실패해 되돌림 · 서버가 돌려준 요약 · 메일함 ⋯ › 모두 읽음) 칸의 줄에도 바로 적는다.
|
||||
// 받는 동안 바꾼 것 · 받기를 시작할 때 보내던 것은 받은 값보다 이 탭의 값을 믿는다 — 받은 쪽은 그 바꿈이 서버에 닿기 전의 값일 수 있다.
|
||||
@@ -132,6 +137,15 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
|
||||
)
|
||||
/** 대화를 묻는 중 — 끝나면 칸이 다시 받기의 간격을 그때부터 센다 (실제로 물었을 때만 켜진다) */
|
||||
const conversationBusy = computed<boolean>(() => conversationInFlight.value > 0)
|
||||
/** 펼칠 수 있는 줄 — 불러온 줄에서 지금 메일을 뺀 것 (15단계 — [모두 펼치기]는 이것이 있을 때만) */
|
||||
const conversationExpandableIds = computed<string[]>(() =>
|
||||
conversationItems.value.map((item) => item.id).filter((id) => id !== conversationMessageId.value),
|
||||
)
|
||||
/** 펼칠 수 있는 줄이 모두 펼쳐져 있다 — 단추가 [모두 접기]가 된다(하나씩 다 펼쳐도) */
|
||||
const conversationAllExpanded = computed<boolean>(() => {
|
||||
const ids = conversationExpandableIds.value
|
||||
return ids.length > 0 && ids.every((id) => conversationExpandedIds.value.has(id))
|
||||
})
|
||||
|
||||
let runSeq = 0
|
||||
let conversationSeq = 0
|
||||
@@ -147,6 +161,15 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
|
||||
*/
|
||||
const localReads = new Map<string, LocalRead>()
|
||||
let localReadSeq = 0
|
||||
/**
|
||||
* 모두 펼치기 (머리말 — 15단계) — 화면에 그리지 않으므로 반응형이 아니다.
|
||||
* lazyRows: 모두 펼치기로 펼쳤고 아직 읽음을 보지 않은 줄(본문이 화면에 보일 때 읽음) · visiblePanels: 펼친 안이 화면에 들어와 있는 줄 ·
|
||||
* detailQueue: 화면에 들어온 차례대로 본문을 받을 줄 (하나씩 — pumpingDetails)
|
||||
*/
|
||||
const lazyRows = new Set<string>()
|
||||
const visiblePanels = new Set<string>()
|
||||
const detailQueue: string[] = []
|
||||
let pumpingDetails = false
|
||||
|
||||
/** 받는 일 하나의 확인 — 그 뒤 다른 일이 시작됐거나 · 다른 대화 · 세션이 바뀌었으면 false */
|
||||
function liveCheck(messageId: string): () => boolean {
|
||||
@@ -295,7 +318,10 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
|
||||
conversationPageInfo.value = gathered.pageInfo
|
||||
const ids = new Set(gathered.items.map((item) => item.id))
|
||||
const expanded = [...conversationExpandedIds.value]
|
||||
if (expanded.some((id) => !ids.has(id))) conversationExpandedIds.value = new Set(expanded.filter((id) => ids.has(id)))
|
||||
if (expanded.some((id) => !ids.has(id))) {
|
||||
for (const id of expanded) if (!ids.has(id)) forgetLazy(id)
|
||||
conversationExpandedIds.value = new Set(expanded.filter((id) => ids.has(id)))
|
||||
}
|
||||
}
|
||||
|
||||
/** 받아 둔 본문을 고친 뒤, 접힌 줄의 것이 상한을 넘으면 오래 보지 않은 것부터 버린다 (펼친 줄의 것은 늘 둔다) */
|
||||
@@ -319,6 +345,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
|
||||
|
||||
function collapse(id: string) {
|
||||
if (!conversationExpandedIds.value.has(id)) return
|
||||
forgetLazy(id)
|
||||
const next = new Set(conversationExpandedIds.value)
|
||||
next.delete(id)
|
||||
conversationExpandedIds.value = next
|
||||
@@ -366,6 +393,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
|
||||
conversationExpandedIds.value = new Set()
|
||||
conversationDetails.value = new Map()
|
||||
markedRead.clear()
|
||||
clearLazy()
|
||||
}
|
||||
const live = liveCheck(messageId)
|
||||
// 처음 받을 때는 쪽마다 보인다 — 두 통 넘게 모였거나 · 다른 메일이 있다는 수(total)를 받았거나 · 더 묻지 않으면 칸을 그리고 뒤에서 잇는다
|
||||
@@ -546,7 +574,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
|
||||
const detail = await mailApi.getMessage(id, { silent: true })
|
||||
if (stale()) return
|
||||
setDetail(id, { status: 'ready', detail })
|
||||
if (conversationExpandedIds.value.has(id)) void readExpanded(id, detail)
|
||||
readIfSeen(id, detail)
|
||||
} catch (error) {
|
||||
if (stale()) return
|
||||
const apiError = toApiError(error)
|
||||
@@ -577,6 +605,98 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
|
||||
return conversationDetails.value.get(id)?.status === 'error' ? loadConversationDetail(id) : Promise.resolve()
|
||||
}
|
||||
|
||||
// ── 모두 펼치기 · 모두 접기 (머리말 — 15단계) ─────────────────────
|
||||
|
||||
/**
|
||||
* 받은 본문을 읽음으로 — 펼쳐 있을 때만. 모두 펼치기로 펼친 줄은 본문이 화면에 보일 때만(화면 밖이면 들어올 때 — noteConversationPanelVisible).
|
||||
* 손으로 펼친 줄은 누른 그 자리라 받는 대로다 (지금 규칙 그대로)
|
||||
*/
|
||||
function readIfSeen(id: string, detail: MessageDetail) {
|
||||
if (!conversationExpandedIds.value.has(id)) return
|
||||
if (lazyRows.has(id)) {
|
||||
if (!visiblePanels.has(id)) return
|
||||
lazyRows.delete(id)
|
||||
}
|
||||
void readExpanded(id, detail)
|
||||
}
|
||||
|
||||
/** 그 줄의 모두 펼치기 몫을 잊는다 — 접었다 · 대화에서 빠졌다 */
|
||||
function forgetLazy(id: string) {
|
||||
lazyRows.delete(id)
|
||||
const index = detailQueue.indexOf(id)
|
||||
if (index >= 0) detailQueue.splice(index, 1)
|
||||
}
|
||||
|
||||
function clearLazy() {
|
||||
lazyRows.clear()
|
||||
visiblePanels.clear()
|
||||
detailQueue.length = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면에 들어온 차례대로 본문을 하나씩 받는다 — 차례가 왔을 때 접혔거나 · 화면 밖으로 나갔거나 · 이미 받았으면(받는 중 포함) 건너뛴다.
|
||||
* 대화 · 세션이 바뀌면 받은 것을 버린다(loadConversationDetail)
|
||||
*/
|
||||
async function pumpDetailQueue(): Promise<void> {
|
||||
if (pumpingDetails) return
|
||||
pumpingDetails = true
|
||||
try {
|
||||
for (let id = detailQueue.shift(); id !== undefined; id = detailQueue.shift()) {
|
||||
if (!conversationExpandedIds.value.has(id) || !visiblePanels.has(id) || conversationDetails.value.has(id)) continue
|
||||
await loadConversationDetail(id)
|
||||
}
|
||||
} finally {
|
||||
pumpingDetails = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [모두 펼치기] — 불러온 줄(지금 메일 빼고)을 모두 펼친다. 본문은 펼친 안이 화면에 들어올 때 받는다(noteConversationPanelVisible).
|
||||
* 이미 펼친 줄은 그대로 둔다(손으로 펼친 줄은 받는 대로 읽음이다)
|
||||
*/
|
||||
function expandAllConversation(): void {
|
||||
const next = new Set(conversationExpandedIds.value)
|
||||
for (const id of conversationExpandableIds.value) {
|
||||
if (next.has(id)) continue
|
||||
next.add(id)
|
||||
lazyRows.add(id)
|
||||
}
|
||||
if (next.size !== conversationExpandedIds.value.size) conversationExpandedIds.value = next
|
||||
}
|
||||
|
||||
/** [모두 접기] — 펼친 줄을 모두 접는다. 받아 둔 본문은 접힌 줄의 상한까지만 남는다 */
|
||||
function collapseAllConversation(): void {
|
||||
if (conversationExpandedIds.value.size === 0) return
|
||||
lazyRows.clear()
|
||||
detailQueue.length = 0
|
||||
conversationExpandedIds.value = new Set()
|
||||
setDetails(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* 펼친 줄의 안이 화면에 들어왔다 · 나갔다 (칸이 알린다 — MessageConversation). 들어왔는데 본문이 없으면 받을 차례에 넣고,
|
||||
* 받아 둔 본문이 있는데 모두 펼치기로 펼친 줄이면 이제 보였으니 읽음으로. 나가면 받을 차례에서 뺀다(다시 들어오면 다시 넣는다)
|
||||
*/
|
||||
function noteConversationPanelVisible(id: string, visible: boolean): void {
|
||||
if (!visible) {
|
||||
visiblePanels.delete(id)
|
||||
const index = detailQueue.indexOf(id)
|
||||
if (index >= 0) detailQueue.splice(index, 1)
|
||||
return
|
||||
}
|
||||
visiblePanels.add(id)
|
||||
if (!conversationExpandedIds.value.has(id)) return
|
||||
const state = conversationDetails.value.get(id)
|
||||
if (state?.status === 'ready') {
|
||||
// 손으로 펼친 줄은 받을 때 읽음으로 했다 — 화면에 드나들 때마다 다시 묻지 않는다(읽음이 실패한 뒤에도)
|
||||
if (lazyRows.has(id)) readIfSeen(id, state.detail)
|
||||
return
|
||||
}
|
||||
if (state || detailQueue.includes(id)) return
|
||||
detailQueue.push(id)
|
||||
void pumpDetailQueue()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
runSeq += 1
|
||||
conversationSeq += 1
|
||||
@@ -593,6 +713,7 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
|
||||
refreshAgain = false
|
||||
markedRead.clear()
|
||||
localReads.clear()
|
||||
clearLazy()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -610,6 +731,8 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
|
||||
conversationVisible,
|
||||
conversationFailed,
|
||||
conversationBusy,
|
||||
conversationExpandableIds,
|
||||
conversationAllExpanded,
|
||||
loadConversation,
|
||||
retryConversation,
|
||||
refreshConversation,
|
||||
@@ -618,6 +741,9 @@ export function createConversationState({ mailApi, list, opened, markRead, sessi
|
||||
loadMoreConversation,
|
||||
toggleConversationMessage,
|
||||
retryConversationDetail,
|
||||
expandAllConversation,
|
||||
collapseAllConversation,
|
||||
noteConversationPanelVisible,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// 메일 스토어 모듈 — 메일함 관리 (계약 2절 "메일함 관리" — 4단계, mail.store.ts 가 조립한다)
|
||||
// - 만들기 · 이름 바꾸기: 서버가 돌려준 메일함을 바로 보이고, 뒤이어 메일함 목록을 서버 기준으로 다시 맞춘다
|
||||
// 메일 스토어 모듈 — 메일함 관리 (계약 2절 "메일함 관리" — 4단계 · 15단계, mail.store.ts 가 조립한다)
|
||||
// - 만들기 · 이름 바꾸기 · 옮기기(15단계): 서버가 돌려준 메일함을 바로 보이고, 뒤이어 메일함 목록을 서버 기준으로 다시 맞춘다
|
||||
// - 하위 메일함까지 지우기(15단계): 같은 지우기에 withChildren 을 실어 보낸다 — 나무 전체를 "지우는 중"으로 두고 끝나면 나무 전체를 뺀다
|
||||
// - 오래 걸리는 처리(모두 읽음 · 비우기 · 메일함 지우기): 서버가 한 요청을 25초 안에 끝내고 남은 수를 돌려준다 —
|
||||
// 0(지우기는 deleted: true)이 될 때까지 같은 요청을 다시 보내며 진행을 알린다. 도중에 실패하면 거기까지 처리한 수를 돌려준다
|
||||
// (다시 하면 남은 것만 처리한다). 요청 실패는 인터셉터가 알린다. 그동안 다른 일을 해도 된다 — 같은 메일함의 같은 처리만 한 번에 하나
|
||||
// - 휴지통 비우기와 메일함 지우기는 함께 돌지 않는다: 비우기는 차례마다 그때의 휴지통을 다시 읽어 지우므로, 그동안 지운 메일함의 메일이
|
||||
// 휴지통에 들어가자마자 영구 삭제된다 ("안의 메일은 휴지통에 있습니다"가 거짓이 된다). 스팸메일함 비우기는 상관없다 (isOperationBlocked)
|
||||
// 휴지통에 들어가자마자 영구 삭제된다 ("안의 메일은 휴지통에 있습니다"가 거짓이 된다) (isOperationBlocked)
|
||||
// - 같은 결로 스팸메일함 비우기는 이 탭에서 차단할 때 받은 메일을 옮기는 동안(15단계) 시작하지 않는다 — 그동안 옮겨 온 메일이 들어오자마자
|
||||
// 영구 삭제되지 않게 (blockCleanupRunning — 반대로 차단 정리도 이 탭에서 스팸메일함을 비우는 동안 시작하지 않는다, mail-block-run.store 의 startBlock).
|
||||
// 다른 창 · PC 와는 막지 못한다 — 서버의 (직원, 스팸메일함) 잠금은 요청 하나 동안만이라 두 창의 비우기와 차단 정리가 요청 사이에 번갈아 돌 수 있고,
|
||||
// 그러면 옮겨 넣은 메일을 다음 비우기 요청이 지울 수 있다(알려진 한계 — 다른 창끼리의 휴지통 비우기와 메일함 지우기와 같다, 계약 2절 "보낸 사람 차단")
|
||||
// - 이미 지워진 메일함(지우기의 404 RES_001 — 다른 탭이 먼저 지웠거나, 응답이 끊긴 뒤 다시 보냈다)은 실패가 아니라 끝난 것으로 다룬다
|
||||
// - 세션이 바뀌면(로그아웃 · 다른 계정 — reset) 하던 처리는 더 보내지 않고 멈춘다 (canceled — 알리지 않는다).
|
||||
// 만들기 · 이름 바꾸기의 늦은 응답도 쓰지 않는다 — 메일함 id 는 계정마다 겹칠 수 있어 다음 사용자의 메일함을 덮게 된다
|
||||
@@ -14,6 +19,7 @@ import { getErrorMessage, toApiError, type ApiError } from '@/composables/useApi
|
||||
import type { MailRequestOptions } from '@/composables/useMail'
|
||||
import { MAILBOX_OPERATION_MAX_STALLS } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { descendantMailboxes } from '@/shared/utils/mailbox'
|
||||
import { initialOperationTotal, type MailboxOperationKind } from '@/shared/utils/mailbox-operation'
|
||||
import { scopeMailboxIdOf } from '@/shared/utils/mail-list-source'
|
||||
import type { ListState } from './list'
|
||||
@@ -24,6 +30,8 @@ interface MailboxAdminDeps {
|
||||
mailApi: MailApi
|
||||
mailboxes: MailboxesState
|
||||
list: ListState
|
||||
/** 이 탭에서 차단할 때 받은 메일을 옮기는 중인가 (mail-block-run.store 의 running — 15단계) — 그동안 스팸메일함 비우기를 막는다 */
|
||||
blockCleanupRunning?: () => boolean
|
||||
}
|
||||
|
||||
/** 진행 — 지금까지 처리한 수 / 처리한 수 + 남은 수 */
|
||||
@@ -32,6 +40,12 @@ export interface MailboxOperationProgress {
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 오래 걸리는 처리의 옵션 */
|
||||
export interface MailboxOperationOptions {
|
||||
/** 지우기 — 하위 메일함까지 (15단계 — 확인 창이 하위까지 지울지 물은 뒤에만. 서버는 ?withChildren=true 일 때만 하위가 있는 메일함을 지운다) */
|
||||
withChildren?: boolean
|
||||
}
|
||||
|
||||
export type MailboxOperationOutcome =
|
||||
/** 끝났다 — 처리한 수 (0 이면 이미 끝나 있었다) · 휴지통으로 옮긴 수 (지우기에서만) */
|
||||
| { status: 'done'; processed: number; movedToTrash: number }
|
||||
@@ -58,7 +72,7 @@ function operationKey(kind: MailboxOperationKind, mailboxId: string): string {
|
||||
return `${kind}:${mailboxId}`
|
||||
}
|
||||
|
||||
export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDeps) {
|
||||
export function createMailboxAdmin({ mailApi, mailboxes, list, blockCleanupRunning = () => false }: MailboxAdminDeps) {
|
||||
/** 돌고 있는 처리 — "종류:메일함 id" (메뉴 항목 · 비우기 단추를 그동안 끈다) */
|
||||
const runningOperations = shallowRef<ReadonlyMap<string, RunningOperation>>(new Map())
|
||||
/** 세션이 바뀔 때마다 오른다 — 하던 처리 · 늦게 온 응답은 쓰지 않고 멈춘다 */
|
||||
@@ -69,8 +83,9 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금은 시작할 수 없는 처리인가 — 휴지통을 비우는 동안의 메일함 지우기, 메일함을 지우는 동안의 휴지통 비우기 (위 설명).
|
||||
* 스팸메일함 비우기 · 모두 읽음은 막지 않는다
|
||||
* 지금은 시작할 수 없는 처리인가 — 휴지통을 비우는 동안의 메일함 지우기, 메일함을 지우는 동안의 휴지통 비우기,
|
||||
* 차단할 때 받은 메일을 옮기는 동안의 스팸메일함 비우기 (위 설명 — 메뉴 · 목록 머리 [비우기] · 확인 단추가 모두 이것을 본다).
|
||||
* 모두 읽음은 막지 않는다
|
||||
*/
|
||||
function isOperationBlocked(kind: MailboxOperationKind, mailbox: Pick<Mailbox, 'role'>): boolean {
|
||||
if (kind === 'delete') {
|
||||
@@ -80,6 +95,7 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
|
||||
if (kind === 'empty' && mailbox.role === 'trash') {
|
||||
return [...runningOperations.value.values()].some((operation) => operation.kind === 'delete')
|
||||
}
|
||||
if (kind === 'empty' && mailbox.role === 'junk') return blockCleanupRunning()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -118,10 +134,23 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
|
||||
return renamed
|
||||
}
|
||||
|
||||
async function sendStep(kind: MailboxOperationKind, mailboxId: string): Promise<OperationStep> {
|
||||
/**
|
||||
* 메일함 옮기기 (15단계) — 서버가 돌려준 메일함(새 parentId)을 바로 보이고 목록을 서버 기준으로 다시 맞춘다(하위 메일함은 parentId 가 그대로라
|
||||
* 나무를 따라 함께 옮겨 보인다). 같은 이름이면 409 MAIL_009 (예외가 그대로 올라간다). 기다리는 사이 세션이 바뀌었으면 null
|
||||
*/
|
||||
async function moveMailbox(id: string, parentId: string | null, options?: MailRequestOptions): Promise<Mailbox | null> {
|
||||
const started = epoch
|
||||
const moved = await mailApi.moveMailbox(id, { parentId }, options)
|
||||
if (started !== epoch) return null
|
||||
mailboxes.upsertMailbox(moved)
|
||||
void mailboxes.loadMailboxes({ background: true })
|
||||
return moved
|
||||
}
|
||||
|
||||
async function sendStep(kind: MailboxOperationKind, mailboxId: string, withChildren: boolean): Promise<OperationStep> {
|
||||
if (kind === 'delete') {
|
||||
// 이미 지워진 메일함(RES_001)은 끝난 것으로 다룬다 — 알림을 끄고 아래 runMailboxOperation 이 받는다
|
||||
const result = await mailApi.deleteMailbox(mailboxId, { silent: ['RES_001'] })
|
||||
const result = await mailApi.deleteMailbox(mailboxId, { silent: ['RES_001'], withChildren })
|
||||
const processed = typeof result.processed === 'number' ? result.processed : result.movedToTrash
|
||||
return { processed, movedToTrash: result.movedToTrash, remaining: result.remaining, done: result.deleted }
|
||||
}
|
||||
@@ -129,11 +158,19 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
|
||||
return { processed: result.processed, movedToTrash: 0, remaining: result.remaining, done: result.remaining <= 0 }
|
||||
}
|
||||
|
||||
/** 한 번 보낸 뒤 — 남은 수를 패널에 바로 보인다 (끝나면 서버 기준으로 다시 맞춘다) */
|
||||
function applyStep(kind: MailboxOperationKind, mailboxId: string, step: OperationStep) {
|
||||
/** 지운 메일함을 패널에서 뺀다 — 하위까지 지웠으면 나무 전체를 (15단계) */
|
||||
function removeDeleted(tree: readonly string[]) {
|
||||
for (const id of tree) mailboxes.removeMailbox(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 번 보낸 뒤 — 남은 수를 패널에 바로 보인다 (끝나면 서버 기준으로 다시 맞춘다).
|
||||
* 하위까지 지우는 동안의 남은 수는 나무 전체의 것이라 그 메일함의 수로 쓰지 않는다 — 끝나면 나무 전체를 뺀다
|
||||
*/
|
||||
function applyStep(kind: MailboxOperationKind, mailboxId: string, step: OperationStep, tree: readonly string[]) {
|
||||
if (kind === 'read') mailboxes.setCounts(mailboxId, { unreadCount: Math.max(0, step.remaining) })
|
||||
else if (kind === 'delete' && step.done) mailboxes.removeMailbox(mailboxId)
|
||||
else mailboxes.setCounts(mailboxId, { totalCount: Math.max(0, step.remaining) })
|
||||
else if (kind === 'delete' && step.done) removeDeleted(tree)
|
||||
else if (tree.length === 1) mailboxes.setCounts(mailboxId, { totalCount: Math.max(0, step.remaining) })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,32 +190,38 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
|
||||
/**
|
||||
* 오래 걸리는 처리 — 끝날 때까지(남은 수 0 · 지우기는 deleted: true) 같은 요청을 다시 보낸다. 다시 보낼 때마다 onProgress.
|
||||
* 실패는 요청마다 인터셉터가 알린다(notified). 한 번도 나아가지 못한 응답이 이어지면 멈추고 BIZ_001 로 돌려준다(아직 알리지 않았다).
|
||||
* 같은 처리가 이미 돌고 있거나 함께 돌 수 없는 처리가 돌고 있으면 시작하지 않는다 (canceled)
|
||||
* 같은 처리가 이미 돌고 있거나 함께 돌 수 없는 처리가 돌고 있으면 시작하지 않는다 (canceled).
|
||||
* 하위까지 지우기(options.withChildren — 15단계)는 시작할 때의 나무(그 메일함 + 모든 하위)를 함께 "지우는 중"으로 두고(그 메뉴들도 끈다),
|
||||
* 전체 수는 나무 전체의 메일 수로 센다. 나무 안의 메일함 하나라도 지우는 중이면 시작하지 않는다
|
||||
*/
|
||||
async function runMailboxOperation(
|
||||
kind: MailboxOperationKind,
|
||||
mailbox: Mailbox,
|
||||
onProgress: (progress: MailboxOperationProgress) => void,
|
||||
options: MailboxOperationOptions = {},
|
||||
): Promise<MailboxOperationOutcome> {
|
||||
if (isOperationRunning(kind, mailbox.id) || isOperationBlocked(kind, mailbox)) return { status: 'canceled' }
|
||||
const operation: RunningOperation = { kind, mailboxId: mailbox.id }
|
||||
const withChildren = kind === 'delete' && options.withChildren === true
|
||||
const descendants = withChildren ? descendantMailboxes(mailboxes.mailboxes.value, mailbox.id) : []
|
||||
const tree = [mailbox.id, ...descendants.map((child) => child.id)]
|
||||
if (tree.some((id) => isOperationRunning(kind, id)) || isOperationBlocked(kind, mailbox)) return { status: 'canceled' }
|
||||
const operations: RunningOperation[] = tree.map((mailboxId) => ({ kind, mailboxId }))
|
||||
const started = epoch
|
||||
markRunning(operation, true)
|
||||
for (const operation of operations) markRunning(operation, true)
|
||||
let processed = 0
|
||||
let movedToTrash = 0
|
||||
let total = initialOperationTotal(kind, mailbox)
|
||||
let total = initialOperationTotal(kind, mailbox, descendants)
|
||||
let stalls = 0
|
||||
try {
|
||||
for (;;) {
|
||||
let step: OperationStep
|
||||
try {
|
||||
step = await sendStep(kind, mailbox.id)
|
||||
step = await sendStep(kind, mailbox.id, withChildren)
|
||||
} catch (error) {
|
||||
if (started !== epoch) return { status: 'canceled' }
|
||||
const apiError = toApiError(error)
|
||||
if (kind === 'delete' && apiError.code === 'RES_001') {
|
||||
// 이미 지워진 메일함 — 다른 탭이 먼저 지웠거나 끊긴 응답 뒤에 다시 보냈다
|
||||
mailboxes.removeMailbox(mailbox.id)
|
||||
removeDeleted(tree)
|
||||
settle(kind, mailbox.id)
|
||||
return { status: 'done', processed, movedToTrash }
|
||||
}
|
||||
@@ -189,7 +232,7 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
|
||||
processed += Math.max(0, step.processed)
|
||||
movedToTrash += Math.max(0, step.movedToTrash)
|
||||
total = processed + Math.max(0, step.remaining)
|
||||
applyStep(kind, mailbox.id, step)
|
||||
applyStep(kind, mailbox.id, step, tree)
|
||||
if (step.done) break
|
||||
stalls = step.processed > 0 ? 0 : stalls + 1
|
||||
if (stalls >= MAILBOX_OPERATION_MAX_STALLS) {
|
||||
@@ -203,7 +246,7 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
|
||||
return { status: 'done', processed, movedToTrash }
|
||||
} finally {
|
||||
// 세션이 바뀐 뒤 끝난 옛 처리는 새 세션의 표시를 건드리지 않는다 (reset 이 이미 비웠다)
|
||||
if (started === epoch) markRunning(operation, false)
|
||||
if (started === epoch) for (const operation of operations) markRunning(operation, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +261,7 @@ export function createMailboxAdmin({ mailApi, mailboxes, list }: MailboxAdminDep
|
||||
isOperationBlocked,
|
||||
createMailbox,
|
||||
renameMailbox,
|
||||
moveMailbox,
|
||||
runMailboxOperation,
|
||||
reset,
|
||||
}
|
||||
|
||||
@@ -6,19 +6,48 @@
|
||||
// 같은 메일의 취소는 한 번에 하나다 — 이어 누른 두 번째가 첫 번째의 결과를 기다린다(두 번째 요청이 "이미 보냈다"(MAIL_014)로 거짓 알림을 띄우지 않게)
|
||||
// - 엔진이 붙잡은 발송을 풀어도(보냄) 새 메일 확인 · 실시간 알림의 state 는 바뀌지 않는다 — 그래서 보낼 시각이 지나면 보이는 목록 · 열린 메일 ·
|
||||
// 예약됨 수를 다시 본다(refreshPendingSends — 때는 usePendingSendRefresh 가 정한다). 취소가 MAIL_014(이미 보냄)이면 바로 다시 본다
|
||||
// - 지금 보내기(15단계)는 결과를 ReleaseOutcome 으로 돌려준다 — 503 MAIL_001 · 결과를 모르는 실패(SYS_001)는 그 메일을 다시 받아
|
||||
// 붙잡은 발송 · 메일함으로 가른다(unsentReleaseState): 임시보관함 reverted · 다시 낸 발송이 기다림 released · 예약 그대로 retryable ·
|
||||
// 예약만 풀림 unscheduled · 다시 받지 못함 unknown. 열린 메일의 안내 줄은 예약이 풀린 것을 확인했을 때(보냄 · 되돌림 · 예약만 풀림)만 내린다
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { toApiError } from '@/composables/useApi'
|
||||
import type { MailRequestOptions } from '@/composables/useMail'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { SCHEDULED_COUNT_PAGE_SIZE } from '@/shared/constants/mail-send-later'
|
||||
import type { MessageSummary, ScheduledMessageCancel } from '@/shared/types/mail'
|
||||
import { mailboxListIdOf, sameListSource } from '@/shared/utils/mail-list-source'
|
||||
import { messagePendingSend, pendingSendOf, samePendingSend } from '@/shared/utils/pending-send'
|
||||
import type { MessageSummary, ScheduledMessageCancel, ReleasedSend } from '@/shared/types/mail'
|
||||
import { mailboxListIdOf, sameListSource, type MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { messagePendingSend, pendingSendOf, samePendingSend, unsentReleaseState } from '@/shared/utils/pending-send'
|
||||
import type { ListState } from './list'
|
||||
import type { MailboxesState } from './mailboxes'
|
||||
import type { OpenedMessage } from './message'
|
||||
import type { MailApi } from './types'
|
||||
|
||||
/** 이미 보낸 메일 — 지금 보내기에서는 바라던 대로 나간 것이다 (15단계) */
|
||||
const MAIL_ALREADY_SENT: ErrorCode = 'MAIL_014'
|
||||
/** 지금 보내지 못했다 — 결과를 모르거나 임시보관함으로 되돌렸다 (15단계 — 메일이 어디 있는지 보고 화면이 알린다) */
|
||||
const MAIL_UNAVAILABLE: ErrorCode = 'MAIL_001'
|
||||
/** 결과를 모르는 실패 — 앞단의 502 · 504, 시간 초과, 연결 끊김, 서버 오류 (15단계 — MAIL_001 과 같은 갈래) */
|
||||
const UNKNOWN_FAILURE: ErrorCode = 'SYS_001'
|
||||
/** 지금 보내기에서 화면이 알리는 코드 — 인터셉터 알림을 끈다 */
|
||||
const RELEASE_SCREEN_CODES: readonly ErrorCode[] = [MAIL_ALREADY_SENT, MAIL_UNAVAILABLE, UNKNOWN_FAILURE]
|
||||
|
||||
/** 지금 보내기의 결과 (15단계 — 그 밖의 실패는 예외로 올라간다) */
|
||||
export type ReleaseOutcome =
|
||||
/** 보냈다 — 보내기 취소를 기다리거나 바로 보냈다(이미 보낸 메일 · 결과를 모른 뒤 다시 받아 보니 다시 낸 발송이 기다리는 메일 포함) */
|
||||
| { status: 'released'; released: ReleasedSend }
|
||||
/** 보내지 못해 그 메일이 임시보관함으로 되돌아갔다 — 예약됨을 떠났다 (다시 받아 보니 임시보관함) */
|
||||
| { status: 'reverted' }
|
||||
/**
|
||||
* 예약은 풀렸고 기다리는 발송이 없다(보낸편지함에 그대로) — 예약한 시각에 따로 나가지는 않지만, 이미 나갔는지는 모른다(보내기 취소 0초 · 응답만 잃음).
|
||||
* 같은 요청을 다시 보내면 서버가 아직 보내지 않았을 때만 이어 낸다
|
||||
*/
|
||||
| { status: 'unscheduled' }
|
||||
/** 보내지 못했다 — 예약이 그대로인 것을 확인했다(예약한 시각에 나간다). 같은 요청을 다시 보내면 된다 */
|
||||
| { status: 'retryable' }
|
||||
/** 그 메일을 다시 받지 못해 어떻게 됐는지 모른다 — 이미 나갔을 수 있다. 같은 요청을 다시 보내면 서버가 아직 보내지 않았을 때만 낸다 */
|
||||
| { status: 'unknown' }
|
||||
|
||||
interface ScheduledDeps {
|
||||
mailApi: MailApi
|
||||
mailboxes: MailboxesState
|
||||
@@ -36,6 +65,8 @@ export function createScheduledState({ mailApi, mailboxes, list, opened, session
|
||||
let countSeq = 0
|
||||
/** 메일마다 진행 중인 취소 — 같은 메일의 두 번째 취소는 이것을 기다린다 */
|
||||
const cancels = new Map<string, Promise<ScheduledMessageCancel>>()
|
||||
/** 메일마다 진행 중인 지금 보내기 (15단계) — 같은 메일의 두 번째는 이것을 기다린다 */
|
||||
const releases = new Map<string, Promise<ReleaseOutcome>>()
|
||||
|
||||
/** 예약한 메일 수를 다시 묻는다 — 실패는 알리지 않고 지난 값을 둔다 */
|
||||
async function loadScheduledCount(): Promise<void> {
|
||||
@@ -122,6 +153,129 @@ export function createScheduledState({ mailApi, mailboxes, list, opened, session
|
||||
return result
|
||||
}
|
||||
|
||||
/** 같은 메일의 지금 보내기가 진행 중인가 (15단계) */
|
||||
function isReleasingPendingSend(id: string): boolean {
|
||||
return releases.has(id)
|
||||
}
|
||||
|
||||
/** 지금 보내기가 끝난 뒤 — 예약됨에서 빼고 열린 메일의 붙잡은 발송을 응답으로 바꾼다. 다른 목록이면 서버 기준으로 맞추기만 한다 */
|
||||
function settleRelease(id: string, source: MailListSource | null, result: ReleasedSend) {
|
||||
// 예약됨에서는 빠진다(이제 보내기 취소를 기다리거나 보낸 메일이다). 보낸편지함 목록이면 줄은 남고 날짜 칸만 바뀐다 — 다시 맞추며 들어온다
|
||||
if (sameListSource(list.listSource.value, source) && source?.kind === 'scheduled') list.removeFromList(new Set([id]))
|
||||
const current = opened.selected.value
|
||||
if (current?.id === id) opened.selected.value = { ...current, pendingSend: result.pendingSend }
|
||||
resyncAfterRelease()
|
||||
}
|
||||
|
||||
/** 예약됨 수 · 메일함 개수 · 보이는 목록을 서버 기준으로 다시 맞춘다 — 열린 메일은 건드리지 않는다 */
|
||||
function resyncAfterRelease() {
|
||||
void list.syncVisibleList({ includeSearch: true })
|
||||
mailboxes.syncCounts()
|
||||
void loadScheduledCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* 예약이 풀린 채 그 메일이 남았다(임시보관함으로 되돌림 · 예약만 풀림) — 열린 메일의 안내 줄을 내린다(예약됨 · 보낸편지함 · 검색 어디서 열었든).
|
||||
* 그 목록을 보고 있으면 뺀다: 예약됨에서는 늘(이제 예약이 아니다), 메일함 목록에서는 보낸편지함을 떠났을 때(leftMailbox — 되돌림)만
|
||||
*/
|
||||
function settleUnscheduled(id: string, source: MailListSource | null, mailboxIds: string[] | null, leftMailbox: boolean) {
|
||||
if (
|
||||
sameListSource(list.listSource.value, source) &&
|
||||
(source?.kind === 'scheduled' || (leftMailbox && mailboxListIdOf(source) !== null))
|
||||
) {
|
||||
list.removeFromList(new Set([id]))
|
||||
}
|
||||
const current = opened.selected.value
|
||||
if (current?.id === id) opened.selected.value = { ...current, mailboxIds: mailboxIds ?? current.mailboxIds, pendingSend: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 503 MAIL_001 · 결과를 모르는 실패(SYS_001) 뒤 — 결과를 모르는 실패(메일은 보낸편지함에 그대로 · 같은 요청을 다시 보내면 이어 낸다)와
|
||||
* 엔진이 거절해 임시보관함으로 되돌린 실패가 같은 꼴로 온다. 그 메일을 다시 받아 가른다(unsentReleaseState):
|
||||
* - 임시보관함: reverted — 예약됨 · 보낸편지함 목록에서 빼고 열린 메일의 안내 줄을 내린다(예약 취소와 같다)
|
||||
* - 다시 낸 발송이 보내기 취소를 기다린다: released — 지금 보내기가 끝난 것과 같다(같은 요청을 다시 보내도 서버가 이 상태로 200 을 준다)
|
||||
* - 예약 그대로: retryable — 안내 줄은 그대로(다시 받은 시각으로만 맞춘다)
|
||||
* - 예약만 풀림(붙잡은 발송이 없다 · 보낸편지함): unscheduled — 예약이 아닌 것은 확인했으니 안내 줄을 내리고 예약됨에서 뺀다(보낸편지함 목록에는 남는다).
|
||||
* 이미 나갔는지는 모른다 — 화면은 보냈다고도 못 보냈다고도 하지 않는다
|
||||
* - 다시 받지 못함: unknown — 안내 줄은 마지막으로 안 그대로 둔다(다음 다시 맞추기가 바로잡는다). 보냈다 · 못 보냈다 · 되돌렸다고 말하지 않는다(닫힌 쪽)
|
||||
* 예약됨 수와 목록은 어느 쪽이든 다시 맞춘다
|
||||
*/
|
||||
async function settleUnsentRelease(id: string, source: MailListSource | null, session: number): Promise<ReleaseOutcome> {
|
||||
let fresh: MessageSummary | null = null
|
||||
try {
|
||||
fresh = await mailApi.getMessage(id, { silent: true })
|
||||
} catch {
|
||||
// 다시 받지 못했다 — 다시 시도할 것으로
|
||||
}
|
||||
if (sessionGeneration() !== session) return { status: 'unknown' }
|
||||
const state = unsentReleaseState(fresh, mailboxes.mailboxByRole('drafts')?.id ?? null)
|
||||
const pending = messagePendingSend(fresh)
|
||||
const mailboxIds = fresh !== null && Array.isArray(fresh.mailboxIds) ? fresh.mailboxIds : null
|
||||
if (state === 'undo' && pending) {
|
||||
const released: ReleasedSend = { id, rejectedRecipients: [], pendingSend: pending }
|
||||
settleRelease(id, source, released)
|
||||
return { status: 'released', released }
|
||||
}
|
||||
if (state === 'reverted' || state === 'unscheduled') settleUnscheduled(id, source, mailboxIds, state === 'reverted')
|
||||
if (state === 'scheduled') {
|
||||
// 예약은 살아 있다 — 다시 받은 시각으로만 맞춘다(안내 줄은 그대로)
|
||||
const current = opened.selected.value
|
||||
if (current?.id === id && !samePendingSend(pending, messagePendingSend(current))) {
|
||||
opened.selected.value = { ...current, pendingSend: pending }
|
||||
}
|
||||
}
|
||||
resyncAfterRelease()
|
||||
if (state === 'reverted') return { status: 'reverted' }
|
||||
if (state === 'unscheduled') return { status: 'unscheduled' }
|
||||
if (state === 'scheduled') return { status: 'retryable' }
|
||||
return { status: 'unknown' }
|
||||
}
|
||||
|
||||
async function sendRelease(id: string, options?: MailRequestOptions): Promise<ReleaseOutcome> {
|
||||
const session = sessionGeneration()
|
||||
const source = list.listSource.value
|
||||
let result: ReleasedSend
|
||||
try {
|
||||
// 이미 보낸 메일(MAIL_014)은 서버가 200 으로 답하기로 했지만, 혹시 409 로 오더라도 바라던 대로 나간 것이다 — 보낸 것으로 다룬다.
|
||||
// 503 MAIL_001 · 결과를 모르는 실패(SYS_001)는 화면이 그 메일을 다시 받아 보고 알린다 (settleUnsentRelease)
|
||||
result = await mailApi.releaseScheduledMessage(id, { silent: RELEASE_SCREEN_CODES, ...options })
|
||||
} catch (error) {
|
||||
if (sessionGeneration() !== session) throw error
|
||||
const { code, canceled } = toApiError(error)
|
||||
if (canceled) throw error
|
||||
if (code === MAIL_UNAVAILABLE || code === UNKNOWN_FAILURE) return settleUnsentRelease(id, source, session)
|
||||
if (code !== MAIL_ALREADY_SENT) {
|
||||
// 받는 사람이 모두 받지 않아 임시보관함으로 되돌렸거나(400 MAIL_010 · 503 MAIL_011) 그사이 예약이 사라졌다(404) ·
|
||||
// 같은 메일을 다른 곳에서 하는 중(429) — 알림은 인터셉터, 화면의 예약 표시 · 수를 다시 맞춘다
|
||||
void refreshPendingSends()
|
||||
mailboxes.syncCounts()
|
||||
throw error
|
||||
}
|
||||
result = { id, rejectedRecipients: [], pendingSend: null }
|
||||
}
|
||||
// 기다리는 사이 세션이 바뀌었으면(다른 계정 — 메일 id 가 겹칠 수 있다) 목록 · 열린 메일을 건드리지 않는다
|
||||
if (sessionGeneration() === session) settleRelease(id, source, result)
|
||||
return { status: 'released', released: result }
|
||||
}
|
||||
|
||||
/**
|
||||
* 예약한 메일을 지금 보낸다 (POST /scheduled-messages/{id}/release — 15단계). 응답의 pendingSend 가 { kind: 'undo' } 면 보내기 취소를 기다리는 중,
|
||||
* null 이면 보냈다(예약 시각이 막 지나 먼저 보낸 메일도) — released. 예약됨 · 메일함 개수 · 보이는 목록 · 열린 메일을 맞춘다.
|
||||
* 503 MAIL_001 · 결과를 모르는 실패(SYS_001)면 그 메일을 다시 받아 가른다 — 임시보관함으로 되돌렸다(reverted) · 다시 낸 발송이 기다린다(released) ·
|
||||
* 예약만 풀렸다(unscheduled) · 예약이 그대로다(retryable) · 다시 받지 못했다(unknown) (settleUnsentRelease) — 알림은 부르는 쪽(화면 글).
|
||||
* 같은 메일의 지금 보내기가 진행 중이면 새로 보내지 않고 그 결과를 함께 기다린다.
|
||||
* 그 밖의 실패(404 · 400 MAIL_010 · 503 MAIL_011 · 429)는 예외가 그대로 올라간다 — 알림은 인터셉터가 띄운다
|
||||
*/
|
||||
function releasePendingSend(id: string, options?: MailRequestOptions): Promise<ReleaseOutcome> {
|
||||
const running = releases.get(id)
|
||||
if (running) return running
|
||||
const release = sendRelease(id, options).finally(() => {
|
||||
if (releases.get(id) === release) releases.delete(id)
|
||||
})
|
||||
releases.set(id, release)
|
||||
return release
|
||||
}
|
||||
|
||||
/**
|
||||
* 붙잡은 발송을 취소한다 (DELETE /scheduled-messages/{id}) — 그 메일은 보내지 않고 임시보관함으로 옮겨진다(같은 id → draftId).
|
||||
* 예약됨 · 메일함 목록에서는 뺀다(보낸편지함을 떠났다 — 임시보관함이면 다시 맞추며 들어온다), 열린 메일이면 붙잡은 발송 표시를 내린다.
|
||||
@@ -143,6 +297,7 @@ export function createScheduledState({ mailApi, mailboxes, list, opened, session
|
||||
scheduledCount.value = 0
|
||||
nextScheduled.value = null
|
||||
cancels.clear()
|
||||
releases.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -151,6 +306,8 @@ export function createScheduledState({ mailApi, mailboxes, list, opened, session
|
||||
loadScheduledCount,
|
||||
cancelPendingSend,
|
||||
isCancellingPendingSend,
|
||||
releasePendingSend,
|
||||
isReleasingPendingSend,
|
||||
refreshPendingSends,
|
||||
reset,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* 70rem(1120px) 이상 — 레일 · 메일함 · 목록 · 본문이 한 줄에 놓이고 칸마다 따로 스크롤된다.
|
||||
* 그보다 좁으면 칸이 아래로 접히고 화면 전체가 스크롤된다.
|
||||
* 목록 보기 표(MessageTable) — 68.6875rem(1099px) 이하에서 크기 칸 숨김, 37.4375rem(599px) 이하에서 칸 축소.
|
||||
* 상단 바(AppTopBar) — 30rem(480px) 이하에서 계정 칸의 주소를 감추고 이름만 둔다 (15단계).
|
||||
* 읽기 화면 도구 줄(MessageReader)은 화면 폭이 아니라 읽기 칸 폭(컨테이너 쿼리) — 35.625rem(570px, 휴지통은 37.1875rem 595px)보다 좁으면 아이콘만 (15단계).
|
||||
*/
|
||||
:root {
|
||||
/* ── 글꼴 ─────────────────────────────── */
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// 단위 테스트 전용 — 끌어다 놓기 이벤트 (jsdom 에는 DragEvent · DataTransfer 가 없다).
|
||||
// 브라우저처럼 dragstart 에 넣은 값은 놓을 때만 읽히고(getData), 끄는 동안(dragenter · dragover)에는 종류(types)만 보인다 —
|
||||
// 테스트도 같은 DataTransfer 를 dragstart · dragover · drop 에 함께 넘긴다. 앱 코드에서 import 하지 않는다
|
||||
|
||||
export interface FakeDataTransfer {
|
||||
types: string[]
|
||||
files: File[]
|
||||
effectAllowed: string
|
||||
dropEffect: string
|
||||
setData(type: string, value: string): void
|
||||
getData(type: string): string
|
||||
/** 파일 밖의 값을 모두 지운다 (브라우저처럼 — 끌기를 시작할 때만 쓸 수 있다) */
|
||||
clearData(): void
|
||||
setDragImage(image: Element, x: number, y: number): void
|
||||
/** setDragImage 로 붙인 그림 (테스트가 본다) */
|
||||
dragImage: Element | null
|
||||
}
|
||||
|
||||
/** 메일 끌기에 쓰는 빈 DataTransfer — 파일 끌기면 files 를 넘긴다(types 에 'Files') */
|
||||
export function fakeDataTransfer(files: File[] = []): FakeDataTransfer {
|
||||
const data = new Map<string, string>()
|
||||
const transfer: FakeDataTransfer = {
|
||||
types: files.length > 0 ? ['Files'] : [],
|
||||
files,
|
||||
effectAllowed: 'uninitialized',
|
||||
dropEffect: 'none',
|
||||
dragImage: null,
|
||||
setData(type, value) {
|
||||
data.set(type, value)
|
||||
if (!transfer.types.includes(type)) transfer.types.push(type)
|
||||
},
|
||||
getData(type) {
|
||||
return data.get(type) ?? ''
|
||||
},
|
||||
clearData() {
|
||||
data.clear()
|
||||
transfer.types = transfer.types.filter((type) => type === 'Files')
|
||||
},
|
||||
setDragImage(image) {
|
||||
transfer.dragImage = image
|
||||
},
|
||||
}
|
||||
return transfer
|
||||
}
|
||||
|
||||
/** dragstart · dragenter · dragover · dragleave · drop · dragend — bubbles · cancelable, 마우스 자리(clientX · clientY)는 0 */
|
||||
export function dragEvent(
|
||||
type: string,
|
||||
dataTransfer: FakeDataTransfer,
|
||||
init: { relatedTarget?: EventTarget | null; ctrlKey?: boolean } = {},
|
||||
): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer })
|
||||
Object.defineProperty(event, 'relatedTarget', { value: init.relatedTarget ?? null })
|
||||
Object.defineProperty(event, 'clientX', { value: 0 })
|
||||
Object.defineProperty(event, 'clientY', { value: 0 })
|
||||
Object.defineProperty(event, 'ctrlKey', { value: init.ctrlKey ?? false })
|
||||
return event
|
||||
}
|
||||
Reference in New Issue
Block a user