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>
|
||||
|
||||
Reference in New Issue
Block a user