generated from ttipo/project-base
feat(frontend): 설정(비밀번호 · 부재중 응답) · 메일함 관리 · 스팸 신고 · 원문 내려받기 화면
- 설정 메뉴를 계정 · 메일 묶음으로, 비밀번호 바꾸기(칸 아래 오류 · 30초 한도)와 부재중 자동 응답(기간 · 미리 보기 · 나가기 확인),
메일함 패널의 부재중 안내(1분마다 다시 계산)
- 메일함 패널의 내 메일함 묶음 · ⋯ 메뉴(보이는 영역 안에 열기) · 대화상자(만들기 · 이름 바꾸기 · 지우기), 목록 머리 비우기
— 확인한 그 메일함만, 휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다
- 오래 걸리는 처리의 진행 알림("…하는 중 · 600 / 1,234통" → 결과), 세션이 바뀌면 멈추고 늦게 온 응답은 쓰지 않는다
- 읽기 화면 · 여러 통 도구 줄의 스팸 신고 / 스팸 아님 + 되돌리기, ⋯ › 원문 내려받기 (.eml)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명) · MailPurgeDialog(영구 삭제 확인)
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답) ·
|
||||
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
// 글(message) 대신 · 글 아래에 입력 칸을 둘 때는 기본 슬롯에 넣는다 — 글이 없으면 describedBy 로 칸의 안내를 가리킨다
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
/** 제목 아래 글 — 없으면 그리지 않는다 (입력 칸만 있는 대화상자) */
|
||||
message?: string
|
||||
/** 제목 · 글 문단의 id — aria-labelledby · aria-describedby 가 가리킨다 (화면에 하나뿐인 값) */
|
||||
titleId: string
|
||||
messageId: string
|
||||
messageId?: string
|
||||
/** 글이 없을 때 aria-describedby 가 가리킬 요소의 id (입력 칸의 안내 등) */
|
||||
describedBy?: string
|
||||
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
|
||||
role?: 'alertdialog'
|
||||
}
|
||||
@@ -23,9 +28,16 @@ interface Emits {
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
message: '',
|
||||
messageId: undefined,
|
||||
describedBy: undefined,
|
||||
role: undefined,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const descriptionId = computed(() => (props.message ? props.messageId : props.describedBy))
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
/** 열기 전에 초점이 있던 곳 — 닫으면 돌려준다 (브라우저가 돌려주지 않는 환경에서도) */
|
||||
let returnFocus: HTMLElement | null = null
|
||||
@@ -88,7 +100,7 @@ onMounted(() => {
|
||||
class="modal-dialog"
|
||||
:role="role"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="messageId"
|
||||
:aria-describedby="descriptionId"
|
||||
@cancel="onCancel"
|
||||
@close="onClose"
|
||||
>
|
||||
@@ -99,11 +111,13 @@ onMounted(() => {
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="message"
|
||||
:id="messageId"
|
||||
class="modal-dialog__message"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<slot />
|
||||
<div class="modal-dialog__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
@@ -164,16 +178,25 @@ onMounted(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action:hover) {
|
||||
:slotted(.modal-dialog__action:not(:disabled):hover) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/*
|
||||
* 아직 누를 수 없는 단추 (메일함 이름을 비웠다 · 보내는 중 · 함께 돌 수 없는 처리가 도는 중) — 마우스를 올려도 그대로.
|
||||
* 마우스를 올린 모양은 모두 :not(:disabled) 로만 그린다 — 꺼진 빨강 · 보조 단추가 브랜드색으로 바뀌지 않게
|
||||
*/
|
||||
:slotted(.modal-dialog__action:disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 되돌릴 수 없는 일 — 빨강 채움 (영구 삭제, 디자인 원본 BulkSelect) */
|
||||
:slotted(.modal-dialog__action--danger) {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--danger:hover) {
|
||||
:slotted(.modal-dialog__action--danger:not(:disabled):hover) {
|
||||
background: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
@@ -184,7 +207,7 @@ onMounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--secondary:hover) {
|
||||
:slotted(.modal-dialog__action--secondary:not(:disabled):hover) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료")
|
||||
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료" · MailActions4 "오래 걸리는 처리")
|
||||
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
|
||||
// 진행: 성공과 같은 바탕 + 돌아가는 아이콘 — 끝날 때까지 남았다가 그 자리에서 결과로 바뀐다 (toast.store 의 update)
|
||||
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { formatTraceCode } from '@/shared/utils/format'
|
||||
import { useToastStore, type Toast } from '@/stores/toast.store'
|
||||
import { useToastStore, type Toast, type ToastKind } from '@/stores/toast.store'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
|
||||
/** 종류별 아이콘 — 오류 18 · 성공 ✓ 16 · 진행 원의 3/4 (돌아간다) */
|
||||
const TOAST_ICONS: Readonly<Record<ToastKind, { name: IconName; size: number; strokeWidth: number }>> = {
|
||||
error: { name: 'alert-circle', size: 18, strokeWidth: 1.75 },
|
||||
success: { name: 'check', size: 16, strokeWidth: 2.2 },
|
||||
progress: { name: 'loader', size: 16, strokeWidth: 2 },
|
||||
}
|
||||
|
||||
/** 초점이 알림 밖으로 나갔을 때만 놓는다 — 알림 안의 다른 단추로 옮긴 것은 그대로 붙잡는다 */
|
||||
function onFocusOut(event: FocusEvent, id: number) {
|
||||
const next = event.relatedTarget
|
||||
@@ -44,9 +53,9 @@ function runAction(toast: Toast) {
|
||||
@focusout="onFocusOut($event, toast.id)"
|
||||
>
|
||||
<AppIcon
|
||||
:name="toast.kind === 'error' ? 'alert-circle' : 'check'"
|
||||
:size="toast.kind === 'error' ? 18 : 16"
|
||||
:stroke-width="toast.kind === 'error' ? 1.75 : 2.2"
|
||||
:name="TOAST_ICONS[toast.kind].name"
|
||||
:size="TOAST_ICONS[toast.kind].size"
|
||||
:stroke-width="TOAST_ICONS[toast.kind].strokeWidth"
|
||||
class="toast__icon"
|
||||
/>
|
||||
<div class="toast__body">
|
||||
@@ -121,7 +130,8 @@ function runAction(toast: Toast) {
|
||||
box-shadow: var(--shadow-toast);
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
.toast--success,
|
||||
.toast--progress {
|
||||
align-items: center;
|
||||
padding: var(--space-8) var(--space-6) var(--space-8) var(--space-14);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -143,6 +153,28 @@ function runAction(toast: Toast) {
|
||||
color: var(--color-on-brand-success);
|
||||
}
|
||||
|
||||
/* 진행 — 돌아가는 아이콘 · 숫자 폭을 고정해 "600 / 1,234통"이 바뀔 때 글자가 흔들리지 않게 */
|
||||
.toast--progress {
|
||||
min-height: var(--size-touch);
|
||||
padding-right: var(--space-14);
|
||||
}
|
||||
|
||||
.toast--progress .toast__icon {
|
||||
margin-top: 0;
|
||||
color: var(--color-on-brand-link);
|
||||
animation: toast-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.toast--progress .toast__message {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@keyframes toast-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.toast__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -13,8 +13,8 @@ interface Props {
|
||||
/** 툴팁 — 주지 않으면 이름 */
|
||||
title?: string
|
||||
labelMode?: 'always' | 'wide' | 'never'
|
||||
/** danger: 삭제 · 영구 삭제 (빨강) */
|
||||
tone?: 'default' | 'danger'
|
||||
/** danger: 삭제 · 영구 삭제 (빨강) · accent: 스팸 아님 (강조 글자 굵게) */
|
||||
tone?: 'default' | 'danger' | 'accent'
|
||||
/** 메뉴를 여는 단추 — 이름 뒤 ▾ (아이콘만일 때는 빼고) */
|
||||
menu?: boolean
|
||||
disabled?: boolean
|
||||
@@ -108,6 +108,16 @@ defineExpose({
|
||||
color: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
/* 스팸 아님 — 읽기 화면과 같은 강조 글자 (디자인 원본 MailActions4) */
|
||||
.bulk-button--accent {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bulk-button--accent:hover:not(:disabled) {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 아이콘만 40×44 (분할 보기의 목록 칸) */
|
||||
.bulk-button--label-never {
|
||||
width: var(--size-icon-button);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · 보기 전환 · 새로고침
|
||||
// 목록 머리 — 메일함 이름 · 개수("전체 N · 안 읽음 M") · [휴지통 비우기 · 스팸메일함 비우기] · 보기 전환 · 새로고침
|
||||
// 분할 보기(MessageList)와 목록 보기(MessageTable)가 함께 쓴다. 안쪽 여백만 보기마다 다르다 (디자인 원본).
|
||||
// 비우기는 휴지통 · 스팸메일함에 메일이 있을 때만 (디자인 원본 MailboxManage — MailboxEmptyButton). 좁으면 이름 · 개수가 먼저 줄어든다.
|
||||
// 스크롤해도 위에 남는 것은 목록 틀(MailListFrame)의 머리 묶음이 맡는다 — 메일을 고르면 이 자리가 도구 줄로 바뀐다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxEmptyButton from '@/components/mail/MailboxEmptyButton.vue'
|
||||
import ViewModeSwitch from '@/components/mail/ViewModeSwitch.vue'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { isEmptiableMailbox } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +32,11 @@ const countText = computed(() => {
|
||||
return box.unreadCount > 0 ? `${total} · 안 읽음 ${formatCount(box.unreadCount)}` : total
|
||||
})
|
||||
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
|
||||
/** 비울 수 있는 메일함 — 휴지통 · 스팸메일함에 메일이 있을 때 */
|
||||
const emptiable = computed(() => {
|
||||
const box = mailStore.currentMailbox
|
||||
return box && isEmptiableMailbox(box) && box.totalCount > 0 ? box : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,16 +48,23 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
|
||||
{{ title }}
|
||||
</h1>
|
||||
<span class="list-header__count">{{ countText }}</span>
|
||||
<ViewModeSwitch class="list-header__switch" />
|
||||
<button
|
||||
type="button"
|
||||
class="list-header__refresh"
|
||||
aria-label="새로고침"
|
||||
:disabled="busy"
|
||||
@click="mailStore.refresh()"
|
||||
>
|
||||
<AppIcon name="refresh" />
|
||||
</button>
|
||||
<div class="list-header__end">
|
||||
<MailboxEmptyButton
|
||||
v-if="emptiable"
|
||||
:mailbox="emptiable"
|
||||
:variant="variant"
|
||||
/>
|
||||
<ViewModeSwitch />
|
||||
<button
|
||||
type="button"
|
||||
class="list-header__refresh"
|
||||
aria-label="새로고침"
|
||||
:disabled="busy"
|
||||
@click="mailStore.refresh()"
|
||||
>
|
||||
<AppIcon name="refresh" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -70,19 +85,33 @@ const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRe
|
||||
}
|
||||
|
||||
.list-header__title {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 좁으면 이름보다 먼저 줄어든다 */
|
||||
.list-header__count {
|
||||
flex: 0 100 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list-header__switch {
|
||||
.list-header__end {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
const bulkPatches = () =>
|
||||
requests.filter((request) => request.method === 'PATCH' && request.url === '/messages').map((request) => request.data)
|
||||
|
||||
it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => {
|
||||
it('도구 줄 — "N통 선택됨" · 읽음 · 안 읽음 · 중요 표시 | 이동 · 스팸 신고 · 삭제 … 선택 해제 (목록 보기는 이름을 보인다)', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
@@ -117,7 +117,7 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
|
||||
expect(wrapper.get('.bulk-bar__count').text()).toBe('2통 선택됨')
|
||||
const labels = wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))
|
||||
expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '삭제', '선택 해제'])
|
||||
expect(labels).toEqual(['읽음 표시', '안 읽음 표시', '중요 표시', '이동', '스팸 신고', '삭제', '선택 해제'])
|
||||
expect(wrapper.get('[data-tool="clear"]').attributes('title')).toBe('선택 해제 (Esc)')
|
||||
expect(wrapper.get('.bulk-bar').classes()).toContain('bulk-bar--list')
|
||||
expect(wrapper.get('[data-tool="markRead"]').text()).toBe('읽음 표시')
|
||||
@@ -376,4 +376,60 @@ describe('여러 통 처리 도구 줄', () => {
|
||||
release(ok({ updatedIds: ['m1'], failedIds: [] }))
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
describe('스팸 신고 · 스팸 아님 (4단계 — 디자인 원본 MailActions4)', () => {
|
||||
it('스팸 신고는 이동 ▾ 과 삭제 사이 — PATCH { ids, mailboxId: 스팸메일함, isJunk: true }, 알림 + [되돌리기](원래 메일함 · isJunk: false)', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.toggleCheck('m1')
|
||||
store.toggleCheck('m2')
|
||||
await flushPromises()
|
||||
const tools = wrapper.findAll('[role="toolbar"] [data-tool]').map((tool) => tool.attributes('data-tool'))
|
||||
expect(tools.slice(3, 6)).toEqual(['move', 'spam', 'delete'])
|
||||
|
||||
await wrapper.get('[data-tool="spam"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['m1', 'm2'], mailboxId: 'junk', isJunk: true }])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m3'])
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 2통을 스팸메일함으로 옮겼습니다.')
|
||||
expect(toast?.action?.label).toBe('되돌리기')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['m1', 'm2'], mailboxId: 'inbox', isJunk: false })
|
||||
expect(toastTexts()).toContain('메일 2통을 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('스팸메일함에서는 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림 · 되돌리면 스팸메일함으로 · isJunk: true', async () => {
|
||||
const junked = [summary('j1', { mailboxIds: ['junk'] }), summary('j2', { mailboxIds: ['junk'] })]
|
||||
const { wrapper, store } = await mountView({ boxId: 'junk', messages: junked })
|
||||
store.toggleCheck('j1')
|
||||
await flushPromises()
|
||||
const notSpam = wrapper.get('[data-tool="spam"]')
|
||||
expect(notSpam.attributes('aria-label')).toBe('스팸 아님')
|
||||
expect(notSpam.classes()).toContain('bulk-button--accent')
|
||||
|
||||
await notSpam.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(bulkPatches()).toEqual([{ ids: ['j1'], mailboxId: 'inbox', isJunk: false }])
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(bulkPatches()[1]).toEqual({ ids: ['j1'], mailboxId: 'junk', isJunk: true })
|
||||
})
|
||||
|
||||
it('스팸메일함을 모르면 스팸 단추를 그리지 않는다 — 로빙 tabindex 도 건너뛴다', async () => {
|
||||
const { wrapper, store } = await mountView()
|
||||
store.mailboxes = MAILBOXES.filter((mailbox) => mailbox.role !== 'junk')
|
||||
store.toggleCheck('m1')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-tool="spam"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-tool="move"]').trigger('keydown', { key: 'ArrowRight' })
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-tool="delete"]').element)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 여러 통 처리 도구 줄 — 하나라도 고르면 목록 머리(52) 자리에 (디자인 원본 BulkSelect · ListStates, 바탕 #F3F8F8)
|
||||
// "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · 중요 표시(모두 중요면 "중요 해제") | 이동 ▾ · 삭제(휴지통에서는 "영구 삭제", 빨강)
|
||||
// … 오른쪽 끝 선택 해제(✕). 하는 일은 useBulkMailActions 가 맡는다.
|
||||
// "N통 선택됨"(14px 굵게) · 읽음 표시 · 안 읽음 표시 · 중요 표시(모두 중요면 "중요 해제") | 이동 ▾ · 스팸 신고(스팸메일함에서는 "스팸 아님") ·
|
||||
// 삭제(휴지통에서는 "영구 삭제", 빨강) … 오른쪽 끝 선택 해제(✕). 하는 일은 useBulkMailActions 가 맡는다.
|
||||
// 스팸 단추는 옮길 메일함(스팸메일함 · 받은편지함)을 알 때만 그린다 (디자인 README "4단계 › 스팸 · 원문")
|
||||
// - 단추(MailBulkButton)는 높이 44 · 아이콘 + 이름. 폭 1100 미만이거나 분할 보기의 목록 칸에서는 아이콘만(40×44 — 이름은 툴팁 · aria-label).
|
||||
// "선택 해제"는 목록 보기에서 이름을 늘 보인다 (캔버스 BulkSelect)
|
||||
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). 이동 메뉴는 MailBulkMoveButton.
|
||||
@@ -14,7 +15,7 @@ import MailBulkMoveButton from '@/components/mail/MailBulkMoveButton.vue'
|
||||
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import { useBulkMailActions } from '@/composables/useBulkMailActions'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_BULK_TEXT, MAIL_SPAM_TEXT } from '@/shared/constants/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
@@ -29,8 +30,8 @@ const props = defineProps<Props>()
|
||||
const mailStore = useMailStore()
|
||||
const bulk = useBulkMailActions()
|
||||
|
||||
type ToolId = 'markRead' | 'markUnread' | 'flag' | 'move' | 'delete' | 'clear'
|
||||
const TOOL_ORDER: readonly ToolId[] = ['markRead', 'markUnread', 'flag', 'move', 'delete', 'clear']
|
||||
type ToolId = 'markRead' | 'markUnread' | 'flag' | 'move' | 'spam' | 'delete' | 'clear'
|
||||
const ALL_TOOLS: readonly ToolId[] = ['markRead', 'markUnread', 'flag', 'move', 'spam', 'delete', 'clear']
|
||||
|
||||
interface LeadingTool {
|
||||
id: Extract<ToolId, 'markRead' | 'markUnread' | 'flag'>
|
||||
@@ -46,17 +47,23 @@ const purgeOpen = ref<boolean>(false)
|
||||
/** 초점이 도구 줄 안의 어느 단추에 있었는지 — 그 단추가 눌리지 않게 되어 초점을 잃었을 때 되돌려 줄 곳을 찾는다 */
|
||||
let lastFocusedTool: ToolId | null = null
|
||||
|
||||
/** 그리는 단추 — 스팸 단추는 옮길 메일함을 알 때만 */
|
||||
const toolOrder = computed<ToolId[]>(() => ALL_TOOLS.filter((id) => id !== 'spam' || bulk.spamTarget.value !== null))
|
||||
/** 지금 누를 수 없는 단추 — 옮기기 · 지우기를 하는 동안 */
|
||||
const disabledTools = computed<ReadonlySet<ToolId>>(() => new Set<ToolId>(bulk.busy.value ? ['move', 'delete'] : []))
|
||||
const disabledTools = computed<ReadonlySet<ToolId>>(
|
||||
() => new Set<ToolId>(bulk.busy.value ? ['move', 'spam', 'delete'] : []),
|
||||
)
|
||||
/** 화살표 · Home · End 로 옮겨 다닐 단추 — 누를 수 없는 단추는 건너뛴다 */
|
||||
const enabledTools = computed<ToolId[]>(() => TOOL_ORDER.filter((id) => !disabledTools.value.has(id)))
|
||||
/** Tab 으로 들어올 단추 — 마지막 단추를 누를 수 없게 되면 그다음(끝이면 처음부터) 누를 수 있는 단추 */
|
||||
const enabledTools = computed<ToolId[]>(() => toolOrder.value.filter((id) => !disabledTools.value.has(id)))
|
||||
/** Tab 으로 들어올 단추 — 마지막 단추를 누를 수 없게 되면(또는 사라지면) 그다음(끝이면 처음부터) 누를 수 있는 단추 */
|
||||
const rovingTool = computed<ToolId>(() => {
|
||||
if (!disabledTools.value.has(activeTool.value)) return activeTool.value
|
||||
const start = TOOL_ORDER.indexOf(activeTool.value)
|
||||
for (let step = 1; step < TOOL_ORDER.length; step += 1) {
|
||||
const candidate = TOOL_ORDER[(start + step) % TOOL_ORDER.length]
|
||||
if (candidate && !disabledTools.value.has(candidate)) return candidate
|
||||
const order = toolOrder.value
|
||||
const usable = (id: ToolId | undefined): id is ToolId => id !== undefined && order.includes(id) && !disabledTools.value.has(id)
|
||||
if (usable(activeTool.value)) return activeTool.value
|
||||
const start = ALL_TOOLS.indexOf(activeTool.value)
|
||||
for (let step = 1; step < ALL_TOOLS.length; step += 1) {
|
||||
const candidate = ALL_TOOLS[(start + step) % ALL_TOOLS.length]
|
||||
if (usable(candidate)) return candidate
|
||||
}
|
||||
return 'clear'
|
||||
})
|
||||
@@ -65,6 +72,7 @@ const rovingTool = computed<ToolId>(() => {
|
||||
const labelMode = computed(() => (props.variant === 'split' ? 'never' : 'wide'))
|
||||
const countText = computed(() => MAIL_BULK_TEXT.selected(formatCount(mailStore.checkedCount)))
|
||||
const deleteLabel = computed(() => (bulk.inTrash.value ? MAIL_BULK_TEXT.purge : MAIL_BULK_TEXT.delete))
|
||||
const spamLabel = computed(() => (bulk.inJunk.value ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report))
|
||||
const leadingTools = computed<LeadingTool[]>(() => [
|
||||
{ id: 'markRead', icon: 'mail-open', label: MAIL_BULK_TEXT.markRead, run: () => void bulk.markRead(true) },
|
||||
{ id: 'markUnread', icon: 'mail', label: MAIL_BULK_TEXT.markUnread, run: () => void bulk.markRead(false) },
|
||||
@@ -120,7 +128,7 @@ function onKeydown(event: KeyboardEvent) {
|
||||
|
||||
function onFocusIn(event: FocusEvent) {
|
||||
const element = event.target instanceof HTMLElement ? event.target : null
|
||||
const tool = TOOL_ORDER.find((id) => id === element?.dataset.tool)
|
||||
const tool = ALL_TOOLS.find((id) => id === element?.dataset.tool)
|
||||
if (!tool) return
|
||||
activeTool.value = tool
|
||||
lastFocusedTool = tool
|
||||
@@ -188,6 +196,17 @@ watch(disabledTools, (disabled) => {
|
||||
:tabindex="tabindexOf('move')"
|
||||
@move="bulk.moveTo"
|
||||
/>
|
||||
<MailBulkButton
|
||||
v-if="bulk.spamTarget.value"
|
||||
:icon="bulk.inJunk.value ? 'inbox' : 'alert-octagon'"
|
||||
:label="spamLabel"
|
||||
:label-mode="labelMode"
|
||||
:tone="bulk.inJunk.value ? 'accent' : 'default'"
|
||||
:disabled="bulk.busy.value"
|
||||
data-tool="spam"
|
||||
:tabindex="tabindexOf('spam')"
|
||||
@click="bulk.moveSpam()"
|
||||
/>
|
||||
<MailBulkButton
|
||||
icon="trash"
|
||||
:label="deleteLabel"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { endOfDayIso, startOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MailVacationNotice from './MailVacationNotice.vue'
|
||||
|
||||
// 메일함 패널 아래 부재중 안내 — 시각에 따라 바뀌므로 1분마다 다시 계산한다 (손으로 돌리는 시계로 시간을 넘긴다)
|
||||
// 날짜는 브라우저 시간대 기준이라 기대값도 로컬 Date 로 만든다
|
||||
|
||||
const ONE_MINUTE_MS = 60_000
|
||||
|
||||
function vacation(partial: Partial<MailVacation>): MailVacation {
|
||||
return { isEnabled: true, fromDate: null, toDate: null, subject: '부재중입니다', textBody: '자리를 비웁니다.', ...partial }
|
||||
}
|
||||
|
||||
describe('MailVacationNotice — 1분마다 다시 계산', () => {
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
})
|
||||
|
||||
async function mountNotice(value: MailVacation, clock: ManualClock) {
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail')
|
||||
await router.isReady()
|
||||
wrapper = mount(MailVacationNotice, {
|
||||
props: { vacation: value },
|
||||
global: { plugins: [router], provide: { [CLOCK as symbol]: clock } },
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const detail = (view: VueWrapper) => view.find('[data-vacation-notice] .vacation-notice__detail')
|
||||
|
||||
it('끝나는 시각이 지나면 1분 안에 안내가 사라진다 — 새로고침을 기다리지 않는다', async () => {
|
||||
const clock = createManualClock(new Date(2026, 10, 3, 23, 59, 30).getTime())
|
||||
const view = await mountNotice(vacation({ toDate: endOfDayIso('2026-11-03') }), clock)
|
||||
expect(detail(view).text()).toBe('11월 3일까지 · 설정')
|
||||
|
||||
clock.advance(ONE_MINUTE_MS)
|
||||
await nextTick()
|
||||
expect(view.find('[data-vacation-notice]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('시작 전에는 기간을 보이다가, 시작하면 끝나는 날 문구로 바뀐다', async () => {
|
||||
const clock = createManualClock(new Date(2026, 9, 29, 23, 59, 30).getTime())
|
||||
const view = await mountNotice(
|
||||
vacation({ fromDate: startOfDayIso('2026-10-30'), toDate: endOfDayIso('2026-11-03') }),
|
||||
clock,
|
||||
)
|
||||
expect(detail(view).text()).toBe('10월 30일 ~ 11월 3일 · 설정')
|
||||
|
||||
clock.advance(ONE_MINUTE_MS)
|
||||
await nextTick()
|
||||
expect(detail(view).text()).toBe('11월 3일까지 · 설정')
|
||||
})
|
||||
|
||||
it('타이머는 하나만 걸어 두고 이어 걸며, 화면을 떠나면 거둔다', async () => {
|
||||
const clock = createManualClock(new Date(2026, 9, 29, 9, 0).getTime())
|
||||
const view = await mountNotice(vacation({}), clock)
|
||||
expect(detail(view).text()).toBe('끌 때까지 · 설정')
|
||||
expect(clock.pendingCount()).toBe(1)
|
||||
|
||||
clock.advance(ONE_MINUTE_MS * 3)
|
||||
expect(clock.pendingCount()).toBe(1)
|
||||
|
||||
view.unmount()
|
||||
wrapper = null
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답 안내 — 메일함 패널 아래(사용량 위) (디자인 원본 MailboxManage · README "4단계 › 부재중 자동 응답")
|
||||
// 바탕 #FFF7E6 · #7A5200, 높이 44 이상: "부재중 자동 응답 켜짐" / "11월 3일까지 · 설정" — 누르면 설정 › 메일 › 부재중 자동 응답으로.
|
||||
// 켜져 있는 동안만 보인다 — 꺼져 있거나 끝나는 날이 지났으면 그리지 않는다 (아직 시작하지 않았으면 시작일을 보인다, mail-vacation.ts).
|
||||
// 시각에 따라 바뀌므로 1분마다 다시 계산한다 (useClock — 테스트는 손으로 돌리는 시계)
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useClock, type ClockTimer } from '@/composables/useClock'
|
||||
import { VACATION_NOTICE_REFRESH_MS, VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { vacationNoticeText } from '@/shared/utils/mail-vacation'
|
||||
|
||||
interface Props {
|
||||
vacation: MailVacation | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const clock = useClock()
|
||||
|
||||
/** 안내를 계산한 시각 — 1분마다 바뀐다 */
|
||||
const now = ref<number>(clock.now())
|
||||
let timer: ClockTimer | null = null
|
||||
|
||||
const detail = computed(() => vacationNoticeText(props.vacation, new Date(now.value)))
|
||||
|
||||
function tick() {
|
||||
now.value = clock.now()
|
||||
timer = clock.setTimeout(tick, VACATION_NOTICE_REFRESH_MS)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
timer = clock.setTimeout(tick, VACATION_NOTICE_REFRESH_MS)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== null) clock.clearTimeout(timer)
|
||||
timer = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
v-if="detail"
|
||||
:to="{ name: SETTINGS_ROUTES.vacation }"
|
||||
class="vacation-notice"
|
||||
data-vacation-notice
|
||||
>
|
||||
<AppIcon
|
||||
name="calendar"
|
||||
:size="15"
|
||||
class="vacation-notice__icon"
|
||||
/>
|
||||
<span class="vacation-notice__text">
|
||||
<span class="vacation-notice__title">{{ VACATION_TEXT.noticeTitle }}</span>
|
||||
<span class="vacation-notice__detail">{{ detail }}</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: var(--space-8) var(--space-10);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-surface);
|
||||
color: var(--color-warning-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.vacation-notice:hover {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.vacation-notice:hover .vacation-notice__detail {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.vacation-notice__icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.vacation-notice__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: var(--line-height-heading);
|
||||
}
|
||||
|
||||
.vacation-notice__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vacation-notice__detail {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 지우기 확인 (디자인 원본 MailboxManage, 공용 모양 ModalDialog — role=alertdialog)
|
||||
// - 하위 메일함이 있으면 지울 수 없다: "‘프로젝트’ 메일함을 지울 수 없습니다" · "하위 메일함이 있습니다. 하위 메일함을 먼저 지워 주세요." · [확인]
|
||||
// - 아니면: "‘거래처’ 메일함을 지울까요?" · "안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다."
|
||||
// (빈 메일함은 "빈 메일함입니다. …") · [지우기](빨강 채움) [취소] — 열리면 「취소」에 초점 (실수로 Enter 를 눌러도 지워지지 않게)
|
||||
// - 휴지통을 비우는 동안은 [지우기]를 누를 수 없다 (blocked — 비우기가 옮겨 간 메일까지 영구 삭제하지 않게)
|
||||
// 고정 id — #mailbox-delete-dialog · #mailbox-delete-title · #mailbox-delete-message · #mailbox-delete-confirm · #mailbox-delete-cancel
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { MAILBOX_DIALOG_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
mailboxName: string
|
||||
/** 안의 메일 수 */
|
||||
messageCount: number
|
||||
/** 하위 메일함이 있다 — 지울 수 없다고 알린다 */
|
||||
hasChildren: boolean
|
||||
/** 지금은 시작할 수 없다 (휴지통을 비우는 중) — [지우기]를 끈다 */
|
||||
blocked?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
blocked: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const title = computed(() =>
|
||||
props.hasChildren ? MAILBOX_DIALOG_TEXT.blockedTitle(props.mailboxName) : MAILBOX_DIALOG_TEXT.deleteTitle(props.mailboxName),
|
||||
)
|
||||
const message = computed(() => {
|
||||
if (props.hasChildren) return MAILBOX_DIALOG_TEXT.blockedMessage
|
||||
return props.messageCount > 0
|
||||
? MAILBOX_DIALOG_TEXT.deleteMessage(formatCount(props.messageCount))
|
||||
: MAILBOX_DIALOG_TEXT.deleteEmptyMessage
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="mailbox-delete-dialog"
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="title"
|
||||
:message="message"
|
||||
title-id="mailbox-delete-title"
|
||||
message-id="mailbox-delete-message"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
v-if="hasChildren"
|
||||
id="mailbox-delete-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.blockedConfirm }}
|
||||
</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>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널의 대화상자 — ⋯ 메뉴 · [+ 새 메일함]이 연다 (디자인 원본 MailboxManage · useMailboxActions)
|
||||
// - 새 메일함 · 하위 메일함 · 이름 바꾸기: 이름 대화상자. 같은 이름(MAIL_009) · 형식(VAL_001)은 칸 아래에 보이고 대화상자를 둔다.
|
||||
// 메일함이 그사이 사라졌으면(RES_001 — 알림은 인터셉터) 닫고 메일함 목록을 다시 맞춘다. 그 밖의 실패는 알림을 보고 다시 누를 수 있게 둔다.
|
||||
// 이름을 바꾸지 않았으면 보내지 않고 닫는다. 기다리는 사이 화면이 사라졌으면 칸 아래에 보일 실패를 알림으로 알린다 (notifyApiError)
|
||||
// - 지우기: 하위 메일함이 있으면 지울 수 없다고 알린다. 확인하면 닫고 지우기를 시작한다 (진행은 알림으로)
|
||||
// - 비우기: 확인하면 닫고 비우기를 시작한다
|
||||
// - 휴지통 비우기 · 메일함 지우기는 서로 도는 동안 시작할 수 없다 — 확인 단추를 끄고, 누르는 순간에도 다시 본다 (useMailboxActions 의 isBlocked)
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import MailboxDeleteDialog from '@/components/mail/MailboxDeleteDialog.vue'
|
||||
import MailboxEmptyDialog from '@/components/mail/MailboxEmptyDialog.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 type { Mailbox } from '@/shared/types/mail'
|
||||
import { hasChildMailbox } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
request: MailboxDialogRequest | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
const actions = useMailboxActions()
|
||||
|
||||
const pending = ref<boolean>(false)
|
||||
const nameError = ref<string | null>(null)
|
||||
/** 화면(메일 모듈)이 사라졌다 — 칸 아래에 보일 곳이 없다 */
|
||||
let unmounted = false
|
||||
|
||||
const nameMode = computed<'create' | 'rename' | null>(() => {
|
||||
const kind = props.request?.kind
|
||||
return kind === 'create' || kind === 'rename' ? kind : null
|
||||
})
|
||||
/** 대상 메일함 — 연 뒤에 바뀐 개수 · 이름을 쓰도록 지금 목록에서 다시 찾는다 */
|
||||
const target = computed<Mailbox | null>(() => {
|
||||
const request = props.request
|
||||
if (!request || request.kind === 'create') return null
|
||||
return mailStore.mailboxes.find((mailbox) => mailbox.id === request.mailbox.id) ?? request.mailbox
|
||||
})
|
||||
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))
|
||||
/** 지금은 시작할 수 없다 — 휴지통 비우기 ↔ 메일함 지우기 */
|
||||
const deleteBlocked = computed(() => (target.value ? actions.isBlocked('delete', target.value) : false))
|
||||
const emptyBlocked = computed(() => (target.value ? actions.isBlocked('empty', target.value) : false))
|
||||
|
||||
function close() {
|
||||
pending.value = false
|
||||
nameError.value = null
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function onSubmitName(name: string) {
|
||||
const request = props.request
|
||||
if (!request || pending.value) return
|
||||
if (request.kind === 'rename' && name === target.value?.name) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
const result =
|
||||
request.kind === 'rename'
|
||||
? await actions.rename(target.value ?? request.mailbox, name)
|
||||
: 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()
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirmDelete() {
|
||||
const mailbox = target.value
|
||||
// 누르는 순간 다시 본다 — 그사이 휴지통 비우기가 시작됐으면 대화상자를 그대로 둔다
|
||||
if (!mailbox || deleteBlocked.value) return
|
||||
close()
|
||||
void actions.remove(mailbox)
|
||||
}
|
||||
|
||||
function onConfirmEmpty() {
|
||||
const mailbox = target.value
|
||||
if (!mailbox || emptyBlocked.value) return
|
||||
close()
|
||||
void actions.empty(mailbox)
|
||||
}
|
||||
|
||||
// 다른 대화상자로 바뀌면 지난 오류를 지운다
|
||||
watch(
|
||||
() => props.request,
|
||||
() => {
|
||||
nameError.value = null
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MailboxNameDialog
|
||||
:open="nameMode !== null"
|
||||
:mode="nameMode ?? 'create'"
|
||||
:parent-name="parentName"
|
||||
:initial-name="nameMode === 'rename' ? (target?.name ?? '') : ''"
|
||||
:pending="pending"
|
||||
:error="nameError"
|
||||
@submit="onSubmitName"
|
||||
@cancel="close"
|
||||
@clear-error="nameError = null"
|
||||
/>
|
||||
<MailboxDeleteDialog
|
||||
:open="request?.kind === 'delete'"
|
||||
:mailbox-name="target?.name ?? ''"
|
||||
:message-count="target?.totalCount ?? 0"
|
||||
:has-children="hasChildren"
|
||||
:blocked="deleteBlocked"
|
||||
@confirm="onConfirmDelete"
|
||||
@cancel="close"
|
||||
/>
|
||||
<MailboxEmptyDialog
|
||||
:open="request?.kind === 'empty'"
|
||||
:mailbox-name="target?.name ?? ''"
|
||||
:message-count="target?.totalCount ?? 0"
|
||||
:blocked="emptyBlocked"
|
||||
id-prefix="mailbox-empty"
|
||||
@confirm="onConfirmEmpty"
|
||||
@cancel="close"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import MessageList from './MessageList.vue'
|
||||
import MessageTable from './MessageTable.vue'
|
||||
|
||||
// 휴지통 · 스팸메일함 목록 머리의 「비우기」 (디자인 원본 MailboxManage · 계약 2절 "메일함 관리")
|
||||
|
||||
function box(id: string, role: Mailbox['role'], name: string, totalCount: number): Mailbox {
|
||||
return { id, name, role, parentId: null, sortOrder: 0, totalCount, unreadCount: 0 }
|
||||
}
|
||||
|
||||
function summary(id: string, mailboxId: string): MessageSummary {
|
||||
return {
|
||||
id,
|
||||
threadId: `t-${id}`,
|
||||
mailboxIds: [mailboxId],
|
||||
from: { name: '호스팅 안내', email: 'ad@example.test' },
|
||||
to: [],
|
||||
subject: `제목 ${id}`,
|
||||
preview: '',
|
||||
receivedAt: '2026-10-21T01:00:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
}
|
||||
}
|
||||
|
||||
describe('목록 머리의 휴지통 · 스팸메일함 비우기', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
let listed: MessageSummary[]
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountList(
|
||||
mailbox: Mailbox,
|
||||
view: 'split' | 'list' = 'list',
|
||||
handler?: (request: MockRequest) => MockResult | Promise<MockResult> | undefined,
|
||||
) {
|
||||
listed = [summary('t1', mailbox.id), summary('t2', mailbox.id)]
|
||||
requests = installMockApi((request) => {
|
||||
const custom = handler?.(request)
|
||||
if (custom) return custom
|
||||
if (request.method === 'DELETE') {
|
||||
listed = []
|
||||
return ok({ processed: 2, remaining: 0 })
|
||||
}
|
||||
if (request.url === '/messages') return ok({ items: listed, pageInfo: { hasNext: false, nextCursor: null, total: listed.length } })
|
||||
if (request.url === '/mailboxes') return ok({ items: useMailStore().mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
|
||||
return ok(null)
|
||||
})
|
||||
const store = useMailStore()
|
||||
store.mailboxes = [box('inbox', 'inbox', '받은편지함', 3), mailbox]
|
||||
store.currentMailboxId = mailbox.id
|
||||
store.messages = listed
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: listed.length }
|
||||
store.listStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${mailbox.id}`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(view === 'split' ? MessageList : MessageTable, {
|
||||
global: { plugins: [pinia, router] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, store }
|
||||
}
|
||||
|
||||
it('휴지통 — 빨간 글자 「휴지통 비우기」, 확인(처음 초점 「취소」) 뒤 비우고 목록을 다시 받는다 · 되돌리기 없는 알림', async () => {
|
||||
const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2))
|
||||
const empty = wrapper.get('[data-mailbox-empty]')
|
||||
expect(empty.text()).toBe('휴지통 비우기')
|
||||
|
||||
await empty.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.getElementById('list-empty-dialog')?.hasAttribute('open')).toBe(true)
|
||||
expect(wrapper.get('#list-empty-title').text()).toBe('휴지통의 메일 2통을 모두 영구 삭제할까요?')
|
||||
expect(document.activeElement?.id).toBe('list-empty-cancel')
|
||||
|
||||
await wrapper.get('#list-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.filter((request) => request.method === 'DELETE').map((request) => request.url)).toEqual([
|
||||
'/mailboxes/trash/messages',
|
||||
])
|
||||
expect(store.messages).toEqual([])
|
||||
expect(useToastStore().toasts.map((toast) => [toast.message, toast.action])).toEqual([
|
||||
['휴지통을 비웠습니다 — 메일 2통.', null],
|
||||
])
|
||||
// 비었으니 단추도 사라진다
|
||||
expect(wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('스팸메일함 · 분할 보기 — 「스팸메일함 비우기」는 아이콘만(이름은 aria-label · 툴팁)', async () => {
|
||||
const { wrapper } = await mountList(box('junk', 'junk', '스팸메일함', 2), 'split')
|
||||
const empty = wrapper.get('[data-mailbox-empty]')
|
||||
|
||||
expect(empty.classes()).toContain('empty-button--split')
|
||||
expect(empty.attributes('aria-label')).toBe('스팸메일함 비우기')
|
||||
expect(empty.attributes('title')).toBe('스팸메일함 비우기')
|
||||
})
|
||||
|
||||
it('빈 휴지통 · 다른 메일함에는 없다', async () => {
|
||||
const emptyTrash = await mountList(box('trash', 'trash', '휴지통', 0))
|
||||
expect(emptyTrash.wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
|
||||
emptyTrash.wrapper.unmount()
|
||||
|
||||
const inbox = await mountList(box('archive', 'archive', '보관함', 2))
|
||||
expect(inbox.wrapper.find('[data-mailbox-empty]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
const dialogOpen = () => document.getElementById('list-empty-dialog')?.hasAttribute('open') ?? false
|
||||
const deletes = () => requests.filter((request) => request.method === 'DELETE').map((request) => request.url)
|
||||
|
||||
it('확인창은 연 때의 메일함을 붙잡는다 — 연 채 보는 메일함이 바뀌면(뒤로 가기 등) 닫고, 연 적 없는 메일함을 비우지 않는다', async () => {
|
||||
const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2))
|
||||
store.mailboxes = [...store.mailboxes, box('junk', 'junk', '스팸메일함', 5)]
|
||||
await wrapper.get('[data-mailbox-empty]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(true)
|
||||
expect(wrapper.get('#list-empty-title').text()).toBe('휴지통의 메일 2통을 모두 영구 삭제할까요?')
|
||||
|
||||
// 확인창을 연 채 뒤로 가기로 스팸메일함을 보게 되었다
|
||||
store.currentMailboxId = 'junk'
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(false)
|
||||
expect(deletes()).toEqual([])
|
||||
|
||||
// 다시 열면 지금 메일함의 창 — 확인하면 그 메일함만 비운다
|
||||
await wrapper.get('[data-mailbox-empty]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#list-empty-title').text()).toBe('스팸메일함의 메일 5통을 모두 영구 삭제할까요?')
|
||||
await wrapper.get('#list-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(deletes()).toEqual(['/mailboxes/junk/messages'])
|
||||
})
|
||||
|
||||
it('메일함을 지우는 동안 「휴지통 비우기」는 누를 수 없다 — 꺼도 초점을 잃지 않게 aria-disabled', async () => {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const deleting = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const { wrapper, store } = await mountList(box('trash', 'trash', '휴지통', 2), 'list', (request) =>
|
||||
request.url === '/mailboxes/c' ? deleting : undefined,
|
||||
)
|
||||
const custom = box('c', null, '거래처', 12)
|
||||
store.mailboxes = [...store.mailboxes, custom]
|
||||
const running = store.runMailboxOperation('delete', custom, () => undefined)
|
||||
await flushPromises()
|
||||
|
||||
const empty = wrapper.get('[data-mailbox-empty]')
|
||||
expect(empty.attributes('aria-disabled')).toBe('true')
|
||||
expect(empty.attributes('disabled')).toBeUndefined()
|
||||
await empty.trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen()).toBe(false)
|
||||
|
||||
finish(ok({ deleted: true, processed: 12, movedToTrash: 12, remaining: 0 }))
|
||||
await running
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-mailbox-empty]').attributes('aria-disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
// 휴지통 · 스팸메일함 목록 머리의 「휴지통 비우기」 · 「스팸메일함 비우기」 (디자인 원본 MailboxManage — 빨강 글자 · 44)
|
||||
// 확인 대화상자(MailboxEmptyDialog — 처음 초점 「취소」)를 거쳐 비운다 (useMailboxActions — 진행은 알림으로).
|
||||
// - 확인창은 연 때의 메일함을 붙잡는다: 열어 둔 채 뒤로 가기 등으로 보는 메일함이 바뀌면 창을 닫는다 — 연 적 없는 메일함을 비우지 않게
|
||||
// - 비우는 중 · 메일함을 지우는 중(휴지통 — 함께 돌 수 없다)에는 누를 수 없다. 꺼도 초점은 잃지 않게 aria-disabled 로 둔다
|
||||
// (끝나서 단추가 사라지면 useMailboxActions 가 메일함 패널의 지금 메일함으로 초점을 옮긴다)
|
||||
// 분할 보기의 목록 칸(384)은 머리가 좁아 아이콘만(40×44 — 이름은 툴팁 · aria-label), 목록 보기는 아이콘 + 이름
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxEmptyDialog from '@/components/mail/MailboxEmptyDialog.vue'
|
||||
import { useMailboxActions } from '@/composables/useMailboxActions'
|
||||
import { MAILBOX_MENU_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
mailbox: Mailbox
|
||||
variant: 'split' | 'list'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const actions = useMailboxActions()
|
||||
const authStore = useAuthStore()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/** 확인창을 연 메일함 — 열려 있는 동안만 */
|
||||
const target = shallowRef<Mailbox | null>(null)
|
||||
|
||||
const label = computed(() => (props.mailbox.role === 'junk' ? MAILBOX_MENU_TEXT.emptyJunk : MAILBOX_MENU_TEXT.emptyTrash))
|
||||
/** 지금은 누를 수 없다 — 비우는 중 · 함께 돌 수 없는 처리가 도는 중 */
|
||||
const unavailable = computed(
|
||||
() => actions.isRunning('empty', props.mailbox.id) || actions.isBlocked('empty', props.mailbox),
|
||||
)
|
||||
/** 확인창에 보일 메일함 — 연 뒤에 바뀐 개수를 쓰도록 지금 목록에서 다시 찾는다 */
|
||||
const shown = computed<Mailbox | null>(() => {
|
||||
const opened = target.value
|
||||
if (!opened) return null
|
||||
return mailStore.mailboxes.find((mailbox) => mailbox.id === opened.id) ?? opened
|
||||
})
|
||||
const blocked = computed(() => (shown.value ? actions.isBlocked('empty', shown.value) : false))
|
||||
|
||||
function open() {
|
||||
if (unavailable.value) return
|
||||
target.value = props.mailbox
|
||||
}
|
||||
|
||||
function close() {
|
||||
target.value = null
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
const mailbox = shown.value
|
||||
// 누르는 순간 다시 본다 — 그사이 메일함 지우기가 시작됐으면 창을 그대로 둔다
|
||||
if (!mailbox || blocked.value) return
|
||||
close()
|
||||
void actions.empty(mailbox)
|
||||
}
|
||||
|
||||
// 보는 메일함이 바뀌면(뒤로 가기 등) 확인창을 닫는다 — 잡아 둔 메일함과 다른 메일함의 단추가 되었다
|
||||
watch(
|
||||
() => props.mailbox.id,
|
||||
(id) => {
|
||||
if (target.value && target.value.id !== id) close()
|
||||
},
|
||||
)
|
||||
|
||||
// 세션이 바뀌면(로그아웃 · 세션 만료) 확인을 닫는다 — 세션 만료 대화상자와 겹치지 않게
|
||||
watch(() => authStore.generation, close)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="['empty-button', `empty-button--${variant}`]"
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
:aria-disabled="unavailable ? 'true' : undefined"
|
||||
data-mailbox-empty
|
||||
@click="open"
|
||||
>
|
||||
<AppIcon
|
||||
name="trash"
|
||||
:size="15"
|
||||
/>
|
||||
<span class="empty-button__label">{{ label }}</span>
|
||||
</button>
|
||||
<MailboxEmptyDialog
|
||||
:open="shown !== null"
|
||||
:mailbox-name="shown?.name ?? ''"
|
||||
:message-count="shown?.totalCount ?? 0"
|
||||
:blocked="blocked"
|
||||
id-prefix="list-empty"
|
||||
@confirm="onConfirm"
|
||||
@cancel="close"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-button {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
min-width: var(--size-icon-button);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-button:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
.empty-button[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 분할 보기의 목록 칸 — 아이콘만 40×44 */
|
||||
.empty-button--split {
|
||||
width: var(--size-icon-button);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.empty-button--split .empty-button__label {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
// 휴지통 · 스팸메일함 비우기 확인 (디자인 원본 MailboxManage, 공용 모양 ModalDialog — role=alertdialog)
|
||||
// "휴지통의 메일 21통을 모두 영구 삭제할까요?" · "지운 메일은 되돌릴 수 없습니다." · [영구 삭제](빨강 채움) [취소]
|
||||
// 열리면 「취소」에 초점이 간다 — 실수로 Enter · Space 를 눌러도 지워지지 않게. Esc 는 취소와 같다.
|
||||
// 쓰는 곳: 메일함 패널의 ⋯ 메뉴 · 휴지통 / 스팸메일함 목록 머리의 「비우기」 — 둘이 함께 보일 수 있어 id 앞부분(idPrefix)을 따로 받는다
|
||||
// 메일함을 지우는 동안의 휴지통 비우기는 [영구 삭제]를 누를 수 없다 (blocked — 지우는 메일함의 메일이 휴지통에 들어가자마자 지워지지 않게)
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { MAILBOX_DIALOG_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
mailboxName: string
|
||||
/** 안의 메일 수 */
|
||||
messageCount: number
|
||||
/** 제목 · 글 · 단추의 id 앞부분 — 화면에 하나뿐인 값 */
|
||||
idPrefix: string
|
||||
/** 지금은 시작할 수 없다 (메일함을 지우는 중의 휴지통) — [영구 삭제]를 끈다 */
|
||||
blocked?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
blocked: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
:id="`${idPrefix}-dialog`"
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="MAILBOX_DIALOG_TEXT.emptyTitle(mailboxName, formatCount(messageCount))"
|
||||
:message="MAILBOX_DIALOG_TEXT.emptyMessage"
|
||||
:title-id="`${idPrefix}-title`"
|
||||
:message-id="`${idPrefix}-message`"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
:id="`${idPrefix}-confirm`"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--danger"
|
||||
:disabled="blocked"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.emptyConfirm }}
|
||||
</button>
|
||||
<button
|
||||
:id="`${idPrefix}-cancel`"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
autofocus
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 행의 ⋯ 단추와 메뉴 (디자인 원본 MailboxManage · README "4단계 › 메일함 관리")
|
||||
// - 단추 32×40 — 목록 표의 별 칸과 같은 밀도 예외(--size-mailbox-more). 행에 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다
|
||||
// (보임 · 숨김은 행 MailboxPanelRow 가 정한다 — 열림을 open-change 로 알린다)
|
||||
// - 메뉴는 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점.
|
||||
// Esc 는 메뉴만 닫고 이 단추로 돌아간다. 항목을 고르면 메뉴를 닫고 이 단추에 초점을 둔 뒤 알린다 —
|
||||
// 대화상자가 열리면 닫힐 때 초점이 이 단추로 돌아온다
|
||||
// - 메뉴는 아래로 열되, 메일함 패널(스크롤되는 칸)의 보이는 영역 아래가 모자라면 위로 연다 — 아래쪽 줄의 메뉴가 잘리지 않게 (popup-placement.ts)
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { MAILBOX_PANEL_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import { opensUpward } from '@/shared/utils/popup-placement'
|
||||
|
||||
interface Props {
|
||||
/** 메일함 이름 — 단추 · 메뉴의 이름 */
|
||||
mailboxName: string
|
||||
items: readonly EditorMenuItem[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', id: string): void
|
||||
(e: 'open-change', open: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 잘라 내는 overflow — 이 조상 밖은 보이지 않는다 */
|
||||
const CLIPPING_OVERFLOW = new Set(['auto', 'scroll', 'hidden', 'clip', 'overlay'])
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const menuRef = ref<InstanceType<typeof EditorMenu> | null>(null)
|
||||
const open = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
/** 위로 연다 — 아래가 모자랄 때 */
|
||||
const upward = ref<boolean>(false)
|
||||
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
|
||||
|
||||
function setOpen(next: boolean) {
|
||||
if (open.value === next) return
|
||||
open.value = next
|
||||
emit('open-change', next)
|
||||
}
|
||||
|
||||
/** 단추가 보일 수 있는 세로 영역 — 화면과 잘라 내는 조상(메일함 패널 등)들이 겹치는 부분 */
|
||||
function visibleArea(element: Element): { top: number; bottom: number } {
|
||||
let top = 0
|
||||
let bottom = window.innerHeight
|
||||
for (let node = element.parentElement; node; node = node.parentElement) {
|
||||
if (!CLIPPING_OVERFLOW.has(getComputedStyle(node).overflowY)) continue
|
||||
const rect = node.getBoundingClientRect()
|
||||
top = Math.max(top, rect.top)
|
||||
bottom = Math.min(bottom, rect.bottom)
|
||||
}
|
||||
return { top, bottom }
|
||||
}
|
||||
|
||||
/** 아래로 그린 메뉴를 재어 위로 열지 정한다 */
|
||||
function place() {
|
||||
const button = buttonRef.value
|
||||
const menu = menuRef.value?.$el
|
||||
if (!button || !(menu instanceof HTMLElement)) return
|
||||
const buttonRect = button.getBoundingClientRect()
|
||||
const menuRect = menu.getBoundingClientRect()
|
||||
const area = visibleArea(button)
|
||||
upward.value = opensUpward({
|
||||
triggerTop: buttonRect.top,
|
||||
triggerBottom: buttonRect.bottom,
|
||||
popupHeight: menuRect.height,
|
||||
areaTop: area.top,
|
||||
areaBottom: area.bottom,
|
||||
gap: Math.max(0, menuRect.top - buttonRect.bottom),
|
||||
})
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (open.value) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
// detail 0 — Enter · Space 로 눌렀다
|
||||
byKeyboard.value = event.detail === 0
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' || open.value) return
|
||||
event.preventDefault()
|
||||
byKeyboard.value = true
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function onClose(restoreFocus: boolean) {
|
||||
setOpen(false)
|
||||
if (restoreFocus) buttonRef.value?.focus()
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
setOpen(false)
|
||||
buttonRef.value?.focus()
|
||||
emit('select', id)
|
||||
}
|
||||
|
||||
// 메뉴를 그린 직후(초점을 주기 전) 잰다 — 닫으면 다음에 열 때 다시 아래로 재기 시작한다
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
upward.value = false
|
||||
if (isOpen) place()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mailbox-more">
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
class="mailbox-more__button"
|
||||
:aria-label="MAILBOX_PANEL_TEXT.more(props.mailboxName)"
|
||||
:title="MAILBOX_PANEL_TEXT.more(props.mailboxName)"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
data-mailbox-more
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<AppIcon
|
||||
name="more-horizontal"
|
||||
:size="15"
|
||||
/>
|
||||
</button>
|
||||
<EditorMenu
|
||||
v-if="open"
|
||||
ref="menuRef"
|
||||
:class="['mailbox-more__menu', { 'mailbox-more__menu--up': upward }]"
|
||||
:label="MAILBOX_PANEL_TEXT.menu(props.mailboxName)"
|
||||
:items="items"
|
||||
:trigger="trigger"
|
||||
align="end"
|
||||
:focus-content="!byKeyboard"
|
||||
@select="onSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-more {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 32×40 — 밀도 예외 (tokens.css --size-mailbox-more · 행 높이 --size-row-min) */
|
||||
.mailbox-more__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-mailbox-more);
|
||||
height: var(--size-row-min);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mailbox-more__button:hover,
|
||||
.mailbox-more__button[aria-expanded='true'] {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* 메뉴 폭 196 — 행 오른쪽 끝에 맞춘다 (디자인 원본 MailboxManage) */
|
||||
.mailbox-more__menu {
|
||||
min-width: 12.25rem;
|
||||
}
|
||||
|
||||
/* 아래가 모자라면 위로 — 단추 위쪽에 같은 간격을 두고 연다 (편집기 메뉴의 아래 열림보다 앞선다) */
|
||||
.mailbox-more .mailbox-more__menu--up {
|
||||
top: auto;
|
||||
bottom: calc(100% + var(--space-4));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 이름 대화상자 — 새 메일함 · "‘프로젝트’ 안에 새 메일함" · 메일함 이름 바꾸기 (디자인 원본 MailboxManage, 공용 모양 ModalDialog)
|
||||
// 이름 칸(44 · 64자) + 안내 "64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다." — 오류는 그 자리에 빨강으로
|
||||
// (같은 이름 MAIL_009 · 형식 VAL_001 — 문구는 에러 코드 사전). 열리면 이름 칸에 초점(이름 바꾸기는 이름 전체를 고른다).
|
||||
// Enter 는 [만들기 · 바꾸기]와 같다 (한글 조합 중의 Enter 는 글자 확정이라 무시한다). Esc 는 취소와 같다.
|
||||
// 고정 id — #mailbox-name-dialog · #mailbox-name-title · #mailbox-name-input · #mailbox-name-help · #mailbox-name-confirm · #mailbox-name-cancel
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import { MAILBOX_DIALOG_TEXT, MAILBOX_NAME_MAX_LENGTH } from '@/shared/constants/mailbox-manage'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { mailboxNameProblem } from '@/shared/utils/mailbox'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
mode: 'create' | 'rename'
|
||||
/** 하위 메일함을 만들 곳의 이름 — 맨 위면 null */
|
||||
parentName?: string | null
|
||||
/** 처음 이름 — 이름 바꾸기는 지금 이름 */
|
||||
initialName?: string
|
||||
/** 보내는 중 — 단추를 다시 누를 수 없다 */
|
||||
pending?: boolean
|
||||
/** 서버가 거절한 까닭(사전 문구) — 이름을 고치면 부모가 지운다(clear-error) */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', name: string): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'clear-error'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
parentName: null,
|
||||
initialName: '',
|
||||
pending: false,
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const name = ref<string>(props.initialName)
|
||||
/** 보내기 전에 막은 형식 오류 ('/' · 길이) */
|
||||
const formatError = ref<string | null>(null)
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.mode === 'rename') return MAILBOX_DIALOG_TEXT.renameTitle
|
||||
return props.parentName ? MAILBOX_DIALOG_TEXT.createChildTitle(props.parentName) : MAILBOX_DIALOG_TEXT.createTitle
|
||||
})
|
||||
const confirmLabel = computed(() => (props.mode === 'rename' ? MAILBOX_DIALOG_TEXT.rename : MAILBOX_DIALOG_TEXT.create))
|
||||
const shownError = computed(() => formatError.value ?? props.error)
|
||||
/** 이름을 비웠으면 누를 수 없다 */
|
||||
const canSubmit = computed(() => mailboxNameProblem(name.value) !== 'empty' && !props.pending)
|
||||
|
||||
function onInput(event: Event) {
|
||||
name.value = (event.target as HTMLInputElement).value
|
||||
formatError.value = null
|
||||
if (props.error) emit('clear-error')
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value) return
|
||||
if (mailboxNameProblem(name.value) === 'invalid') {
|
||||
formatError.value = getErrorMessage('VAL_001')
|
||||
inputRef.value?.focus()
|
||||
return
|
||||
}
|
||||
emit('submit', name.value.trim())
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
|
||||
// 열 때마다 처음 이름으로 — 이름 바꾸기는 이름 전체를 골라 바로 고쳐 쓸 수 있게
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
name.value = props.initialName
|
||||
formatError.value = null
|
||||
void nextTick(() => {
|
||||
if (props.mode === 'rename') inputRef.value?.select()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// 서버가 거절하면 칸에 초점을 돌려준다 — 고쳐 쓰기 쉽게
|
||||
watch(
|
||||
() => props.error,
|
||||
(error) => {
|
||||
if (error) void nextTick(() => inputRef.value?.focus())
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="mailbox-name-dialog"
|
||||
:open="open"
|
||||
:title="title"
|
||||
title-id="mailbox-name-title"
|
||||
described-by="mailbox-name-help"
|
||||
@cancel="emit('cancel')"
|
||||
>
|
||||
<div class="mailbox-name">
|
||||
<label
|
||||
for="mailbox-name-input"
|
||||
class="mailbox-name__label"
|
||||
>{{ MAILBOX_DIALOG_TEXT.nameLabel }}</label>
|
||||
<input
|
||||
id="mailbox-name-input"
|
||||
ref="inputRef"
|
||||
class="form-input mailbox-name__input"
|
||||
type="text"
|
||||
:value="name"
|
||||
:maxlength="MAILBOX_NAME_MAX_LENGTH"
|
||||
autocomplete="off"
|
||||
:aria-invalid="shownError ? 'true' : 'false'"
|
||||
aria-describedby="mailbox-name-help"
|
||||
autofocus
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<p
|
||||
id="mailbox-name-help"
|
||||
:class="['form-message', { 'form-message--error': shownError }]"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="shownError"
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ shownError ?? MAILBOX_DIALOG_TEXT.nameHelp }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<template #actions>
|
||||
<button
|
||||
id="mailbox-name-confirm"
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
:disabled="!canSubmit"
|
||||
:aria-busy="pending"
|
||||
@click="submit"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</button>
|
||||
<button
|
||||
id="mailbox-name-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ MAILBOX_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.mailbox-name__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mailbox-name__input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,777 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MailVacation, Mailbox } from '@/shared/types/mail'
|
||||
import { endOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.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'
|
||||
|
||||
// 메일함 패널의 메일함 관리 (디자인 원본 MailboxManage · MailActions4 · 계약 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 }
|
||||
}
|
||||
|
||||
const MAILBOXES: Mailbox[] = [
|
||||
box('inbox', 'inbox', '받은편지함', { unreadCount: 3, totalCount: 148 }),
|
||||
box('sent', 'sent', '보낸편지함', { totalCount: 64 }),
|
||||
box('drafts', 'drafts', '임시보관함', { totalCount: 2 }),
|
||||
box('junk', 'junk', '스팸메일함', { unreadCount: 1, totalCount: 5 }),
|
||||
box('trash', 'trash', '휴지통', { totalCount: 21 }),
|
||||
box('p', null, '프로젝트', { unreadCount: 2, totalCount: 18 }),
|
||||
box('pq', null, '견적', { parentId: 'p', totalCount: 6 }),
|
||||
box('c', null, '거래처', { unreadCount: 1, totalCount: 12 }),
|
||||
]
|
||||
|
||||
/** 응답을 직접 풀어 주는 요청 — 진행 알림을 그 사이에 본다 */
|
||||
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 — 메일함 관리', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
/** "METHOD url" 마다 차례로 줄 응답 — 다 쓰면 마지막 것을 되풀이한다 */
|
||||
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(path = '/mail/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: 8 } })
|
||||
}
|
||||
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(path)
|
||||
await router.isReady()
|
||||
const match = /^\/mail\/([^/]+)/.exec(path)
|
||||
if (match?.[1]) store.currentMailboxId = match[1]
|
||||
const wrapper = mount(MailboxPanel, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const row = (wrapper: VueWrapper, name: string) => {
|
||||
const found = wrapper.findAll('.mailbox-row').find((item) => item.get('.mailbox-row__name').text() === name)
|
||||
if (!found) throw new Error(`${name} 줄이 없다`)
|
||||
return found
|
||||
}
|
||||
|
||||
async function openMenu(wrapper: VueWrapper, name: string) {
|
||||
await row(wrapper, name).get('[data-mailbox-more]').trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper.findAll('[role="menu"] [role^="menuitem"]')
|
||||
}
|
||||
|
||||
async function choose(wrapper: VueWrapper, name: string, label: string) {
|
||||
const items = await openMenu(wrapper, name)
|
||||
const item = items.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)
|
||||
|
||||
it('기본 메일함 아래 "내 메일함" 묶음 — 하위 메일함은 들여 쓰고, 끝에 [+ 새 메일함]', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
const lists = wrapper.findAll('.mailbox-tree__list')
|
||||
expect(lists[0]?.findAll('.mailbox-row__name').map((name) => name.text())).toEqual([
|
||||
'받은편지함',
|
||||
'보낸편지함',
|
||||
'임시보관함',
|
||||
'스팸메일함',
|
||||
'휴지통',
|
||||
])
|
||||
expect(wrapper.get('.mailbox-tree__group').text()).toBe('내 메일함')
|
||||
expect(lists[1]?.attributes('aria-labelledby')).toBe(wrapper.get('.mailbox-tree__group').attributes('id'))
|
||||
expect(lists[1]?.findAll('.mailbox-row__name').map((name) => name.text())).toEqual(['거래처', '프로젝트', '견적'])
|
||||
expect(row(wrapper, '견적').get('a').classes()).toContain('mailbox-row__link--depth-1')
|
||||
expect(wrapper.get('[data-mailbox-new]').text()).toBe('새 메일함')
|
||||
// 안 읽은 수는 평범한 숫자 — ⋯ 앞에 그대로
|
||||
expect(row(wrapper, '받은편지함').get('.mailbox-row__count').text()).toContain('3')
|
||||
})
|
||||
|
||||
it('⋯ 메뉴 — 메일함마다 할 수 있는 일만 · 할 일이 없는 메일함에는 ⋯ 가 없다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
const labels = async (name: string) => {
|
||||
const items = await openMenu(wrapper, name)
|
||||
const result = items.map((item) => [item.text(), item.classes().includes('menu__item--danger')])
|
||||
await row(wrapper, name).get('[data-mailbox-more]').trigger('click')
|
||||
await flushPromises()
|
||||
return result
|
||||
}
|
||||
|
||||
expect(await labels('받은편지함')).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['하위 메일함 만들기', false],
|
||||
])
|
||||
expect(await labels('스팸메일함')).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['스팸메일함 비우기', true],
|
||||
])
|
||||
expect(await labels('휴지통')).toEqual([['휴지통 비우기', true]])
|
||||
expect(await labels('프로젝트')).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['하위 메일함 만들기', false],
|
||||
['이름 바꾸기', false],
|
||||
['지우기', true],
|
||||
])
|
||||
expect(await labels('견적')).toEqual([
|
||||
['하위 메일함 만들기', false],
|
||||
['이름 바꾸기', false],
|
||||
['지우기', true],
|
||||
])
|
||||
expect(row(wrapper, '보낸편지함').find('[data-mailbox-more]').exists()).toBe(false)
|
||||
expect(row(wrapper, '임시보관함').find('[data-mailbox-more]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('⋯ 단추 — 이름 · aria-expanded, 메뉴가 열린 동안 줄이 is-open(⋯ 가 보인다), Esc 는 메뉴만 닫고 단추로', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
const more = row(wrapper, '거래처').get('[data-mailbox-more]')
|
||||
expect(more.attributes('aria-label')).toBe('거래처 메일함 메뉴')
|
||||
expect(more.attributes('aria-haspopup')).toBe('menu')
|
||||
|
||||
await more.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const menu = wrapper.get('[role="menu"]')
|
||||
expect(menu.attributes('aria-label')).toBe('거래처 메일함')
|
||||
expect(more.attributes('aria-expanded')).toBe('true')
|
||||
expect(row(wrapper, '거래처').classes()).toContain('is-open')
|
||||
expect(document.activeElement?.textContent?.trim()).toBe('모두 읽음으로 표시')
|
||||
|
||||
await menu.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
||||
expect(row(wrapper, '거래처').classes()).not.toContain('is-open')
|
||||
expect(document.activeElement).toBe(more.element)
|
||||
})
|
||||
|
||||
describe('새 메일함 · 하위 메일함 · 이름 바꾸기', () => {
|
||||
it('[+ 새 메일함] — 이름 칸(64자)에 초점 · 안내, 만들면 POST /mailboxes { name } → 패널에 바로 나타나고 짧은 알림', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
reply('POST /mailboxes', ok(box('n1', null, '협력사'), 201))
|
||||
|
||||
await wrapper.get('[data-mailbox-new]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-name-dialog')).toBe(true)
|
||||
expect(wrapper.get('#mailbox-name-title').text()).toBe('새 메일함')
|
||||
const input = wrapper.get('#mailbox-name-input')
|
||||
expect(input.attributes('maxlength')).toBe('64')
|
||||
expect(document.activeElement).toBe(input.element)
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.')
|
||||
// 이름을 비웠으면 누를 수 없다
|
||||
expect(wrapper.get('#mailbox-name-confirm').attributes('disabled')).toBeDefined()
|
||||
|
||||
await input.setValue(' 협력사 ')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('POST', '/mailboxes').map((request) => request.data)).toEqual([{ name: '협력사' }])
|
||||
expect(dialogOpen('mailbox-name-dialog')).toBe(false)
|
||||
expect(wrapper.findAll('.mailbox-row__name').map((name) => name.text())).toContain('협력사')
|
||||
expect(toastTexts()).toEqual(['‘협력사’ 메일함을 만들었습니다.'])
|
||||
})
|
||||
|
||||
it('하위 메일함 만들기 — "‘프로젝트’ 안에 새 메일함", parentId 를 함께 보낸다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
reply('POST /mailboxes', ok(box('n2', null, '2027', { parentId: 'p' }), 201))
|
||||
|
||||
await choose(wrapper, '프로젝트', '하위 메일함 만들기')
|
||||
expect(wrapper.get('#mailbox-name-title').text()).toBe('‘프로젝트’ 안에 새 메일함')
|
||||
await wrapper.get('#mailbox-name-input').setValue('2027')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('POST', '/mailboxes').map((request) => request.data)).toEqual([{ name: '2027', parentId: 'p' }])
|
||||
})
|
||||
|
||||
it('같은 이름(409 MAIL_009) — 알림 없이 칸 아래에 빨간 사전 문구 · 대화상자는 그대로, 고치면 안내로 돌아간다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
reply('POST /mailboxes', fail(409, 'MAIL_009'))
|
||||
|
||||
await wrapper.get('[data-mailbox-new]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#mailbox-name-input').setValue('거래처')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const help = wrapper.get('#mailbox-name-help')
|
||||
expect(help.text()).toBe(ERROR_CODE_MESSAGES.MAIL_009)
|
||||
expect(ERROR_CODE_MESSAGES.MAIL_009).toBe('같은 이름의 메일함이 이미 있습니다.')
|
||||
expect(help.classes()).toContain('form-message--error')
|
||||
expect(wrapper.get('#mailbox-name-input').attributes('aria-invalid')).toBe('true')
|
||||
expect(dialogOpen('mailbox-name-dialog')).toBe(true)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
await wrapper.get('#mailbox-name-input').setValue('거래처 2')
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe('64자까지. 같은 자리에 같은 이름은 쓸 수 없습니다.')
|
||||
})
|
||||
|
||||
it('/ 가 든 이름은 보내지 않고 칸 아래에 사전 문구(VAL_001)', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
await wrapper.get('[data-mailbox-new]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#mailbox-name-input').setValue('2026/견적')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('POST', '/mailboxes')).toEqual([])
|
||||
expect(wrapper.get('#mailbox-name-help').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
})
|
||||
|
||||
it('이름 바꾸기 — 지금 이름으로 채운다. 그대로면 보내지 않고 닫고, 바꾸면 PATCH { name } + "메일함 이름을 바꿨습니다."', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
reply('PATCH /mailboxes/c', ok(box('c', null, '거래처 (2026)', { totalCount: 12 })))
|
||||
|
||||
await choose(wrapper, '거래처', '이름 바꾸기')
|
||||
expect(wrapper.get('#mailbox-name-title').text()).toBe('메일함 이름 바꾸기')
|
||||
const input = wrapper.get<HTMLInputElement>('#mailbox-name-input')
|
||||
expect(input.element.value).toBe('거래처')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-name-dialog')).toBe(false)
|
||||
expect(sent('PATCH', '/mailboxes/c')).toEqual([])
|
||||
|
||||
await choose(wrapper, '거래처', '이름 바꾸기')
|
||||
await wrapper.get('#mailbox-name-input').setValue('거래처 (2026)')
|
||||
await wrapper.get('#mailbox-name-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('PATCH', '/mailboxes/c').map((request) => request.data)).toEqual([{ name: '거래처 (2026)' }])
|
||||
expect(row(wrapper, '거래처 (2026)').exists()).toBe(true)
|
||||
expect(toastTexts()).toEqual(['메일함 이름을 바꿨습니다.'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('지우기 — 안의 메일은 휴지통으로', () => {
|
||||
it('하위 메일함이 있으면 지울 수 없다 — [확인]만, 보내지 않는다', 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('확인')
|
||||
|
||||
await wrapper.get('#mailbox-delete-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-delete-dialog')).toBe(false)
|
||||
expect(requests.some((request) => request.method === 'DELETE')).toBe(false)
|
||||
})
|
||||
|
||||
it('확인(처음 초점 「취소」) 뒤 DELETE 를 deleted 가 될 때까지 다시 보낸다 — 진행 알림이 결과로 바뀌고, 보던 메일함이면 받은편지함으로', async () => {
|
||||
const second = deferred()
|
||||
reply(
|
||||
'DELETE /mailboxes/c',
|
||||
ok({ deleted: false, processed: 500, movedToTrash: 500, remaining: 734 }),
|
||||
second,
|
||||
ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }),
|
||||
)
|
||||
const { wrapper, router, store } = await mountPanel('/mail/c')
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
expect(wrapper.get('#mailbox-delete-title').text()).toBe('‘거래처’ 메일함을 지울까요?')
|
||||
expect(wrapper.get('#mailbox-delete-message').text()).toBe(
|
||||
'안의 메일 12통은 휴지통으로 옮깁니다. 지운 메일함은 되돌릴 수 없습니다.',
|
||||
)
|
||||
expect(document.activeElement?.id).toBe('mailbox-delete-cancel')
|
||||
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen('mailbox-delete-dialog')).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['progress', '‘거래처’ 메일함을 지우는 중 · 500 / 1,234통'],
|
||||
])
|
||||
// 지우는 동안 그 메일함의 메뉴 항목은 누를 수 없다
|
||||
const items = await openMenu(wrapper, '거래처')
|
||||
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
|
||||
|
||||
second.resolve(ok({ deleted: false, processed: 734, movedToTrash: 734, remaining: 0 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('DELETE', '/mailboxes/c')).toHaveLength(3)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['success', '‘거래처’ 메일함을 지웠습니다. 안의 메일 1,234통은 휴지통에 있습니다.'],
|
||||
])
|
||||
expect(store.mailboxes.some((mailbox) => mailbox.id === 'c')).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('빈 메일함은 "빈 메일함입니다." — 지운 뒤 "‘…’ 메일함을 지웠습니다." · 다른 메일함을 보고 있었으면 그대로', async () => {
|
||||
const { wrapper, router, store } = await mountPanel('/mail/inbox')
|
||||
store.mailboxes = store.mailboxes.map((mailbox) => (mailbox.id === 'c' ? { ...mailbox, totalCount: 0 } : mailbox))
|
||||
reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }))
|
||||
await flushPromises()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
expect(wrapper.get('#mailbox-delete-message').text()).toBe('빈 메일함입니다. 지운 메일함은 되돌릴 수 없습니다.')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다.'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
})
|
||||
|
||||
describe('오래 걸리는 처리 — 모두 읽음 · 비우기', () => {
|
||||
it('모두 읽음 — remaining 이 0 이 될 때까지 PATCH /mailboxes/{id}/messages { isRead: true } · 안 읽은 수가 바로 줄고 결과 알림', async () => {
|
||||
const second = deferred()
|
||||
reply(
|
||||
'PATCH /mailboxes/inbox/messages',
|
||||
ok({ processed: 500, remaining: 734 }),
|
||||
second,
|
||||
)
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')[0]?.data).toEqual({ isRead: true })
|
||||
expect(toastTexts()).toEqual(['받은편지함의 메일을 읽음으로 표시하는 중 · 500 / 1,234통'])
|
||||
expect(store.mailboxes.find((mailbox) => mailbox.id === 'inbox')?.unreadCount).toBe(734)
|
||||
// 처리하는 동안 다른 일을 해도 된다 — 같은 처리만 다시 누를 수 없다
|
||||
const items = await openMenu(wrapper, '받은편지함')
|
||||
expect(items.map((item) => [item.text(), item.attributes('disabled') !== undefined])).toEqual([
|
||||
['모두 읽음으로 표시', true],
|
||||
['하위 메일함 만들기', false],
|
||||
])
|
||||
await row(wrapper, '받은편지함').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
second.resolve(ok({ processed: 734, remaining: 0 }))
|
||||
await flushPromises()
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(2)
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['success', '받은편지함의 메일 1,234통을 읽음으로 표시했습니다.'],
|
||||
])
|
||||
expect(row(wrapper, '받은편지함').find('.mailbox-row__count').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('휴지통 비우기 — 확인(처음 초점 「취소」) 뒤 DELETE /mailboxes/{id}/messages 를 되풀이 · "휴지통을 비웠습니다 — 메일 21통."', async () => {
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), ok({ processed: 1, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
expect(dialogOpen('mailbox-empty-dialog')).toBe(true)
|
||||
expect(wrapper.get('#mailbox-empty-title').text()).toBe('휴지통의 메일 21통을 모두 영구 삭제할까요?')
|
||||
expect(wrapper.get('#mailbox-empty-message').text()).toBe('지운 메일은 되돌릴 수 없습니다.')
|
||||
expect(document.activeElement?.id).toBe('mailbox-empty-cancel')
|
||||
|
||||
await wrapper.get('#mailbox-empty-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/trash/messages')).toEqual([])
|
||||
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/trash/messages')).toHaveLength(2)
|
||||
expect(toastTexts()).toEqual(['휴지통을 비웠습니다 — 메일 21통.'])
|
||||
// 되돌릴 수 없어 [되돌리기]가 없다
|
||||
expect(useToastStore().toasts[0]?.action).toBeNull()
|
||||
})
|
||||
|
||||
it('도중에 실패하면 거기까지 처리한 수를 알린다 (까닭은 인터셉터 알림) · 처음부터 실패하면 진행 알림만 닫는다', async () => {
|
||||
reply('PATCH /mailboxes/inbox/messages', ok({ processed: 500, remaining: 734 }), fail(503, 'MAIL_001'))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
// 진행 알림은 그 자리에서 결과로 바뀐다 — 까닭(사전 문구 · 문의 코드)은 인터셉터의 알림
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', '1,234통 가운데 500통까지 처리했습니다 — 다시 시도해 주세요.'],
|
||||
['error', ERROR_CODE_MESSAGES.MAIL_001],
|
||||
])
|
||||
|
||||
useToastStore().clear()
|
||||
reply('DELETE /mailboxes/junk/messages', fail(503, 'SYS_001'))
|
||||
await choose(wrapper, '스팸메일함', '스팸메일함 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['error'])
|
||||
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
|
||||
})
|
||||
|
||||
it('서버가 나아가지 못하면(남은 수만 돌려줌) 되풀이를 멈추고 사전 문구로 알린다', async () => {
|
||||
reply('PATCH /mailboxes/c/messages', ok({ processed: 0, remaining: 1 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '모두 읽음으로 표시')
|
||||
|
||||
expect(sent('PATCH', '/mailboxes/c/messages')).toHaveLength(3)
|
||||
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.BIZ_001])
|
||||
})
|
||||
})
|
||||
|
||||
it('부재중 자동 응답이 켜져 있으면 사용량 위에 안내 — 누르면 설정으로, 꺼지면 사라진다', async () => {
|
||||
const vacation: MailVacation = {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: endOfDayIso('2099-11-03'),
|
||||
subject: '부재중 안내',
|
||||
textBody: '휴가입니다.',
|
||||
}
|
||||
const { wrapper } = await mountPanel()
|
||||
useMailVacationStore().vacation = vacation
|
||||
useMailStore().quota = { usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 }
|
||||
await flushPromises()
|
||||
|
||||
const notice = wrapper.get('[data-vacation-notice]')
|
||||
expect(notice.text()).toContain('부재중 자동 응답 켜짐')
|
||||
expect(notice.text()).toContain('2099년 11월 3일까지 · 설정')
|
||||
expect(notice.attributes('href')).toBe('/settings/mail/vacation')
|
||||
const footer = wrapper.get('.mailbox-panel__footer').element
|
||||
expect(footer.firstElementChild).toBe(notice.element)
|
||||
expect(footer.lastElementChild?.classList.contains('quota')).toBe(true)
|
||||
|
||||
useMailVacationStore().vacation = { ...vacation, isEnabled: false }
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-vacation-notice]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
describe('지우기 · 비우기 — 리뷰 보완', () => {
|
||||
it('다른 메일함에도 든 메일은 이 메일함에서만 빠진다 — 진행 · 정체는 processed 로, 끝 알림의 휴지통 수는 movedToTrash 합계로', async () => {
|
||||
reply(
|
||||
'DELETE /mailboxes/c',
|
||||
ok({ deleted: false, processed: 500, movedToTrash: 300, remaining: 300 }),
|
||||
ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 200 }),
|
||||
ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 100 }),
|
||||
ok({ deleted: false, processed: 100, movedToTrash: 0, remaining: 0 }),
|
||||
ok({ deleted: true, processed: 0, movedToTrash: 0, remaining: 0 }),
|
||||
)
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// 휴지통으로 옮긴 것이 없는 차례가 이어져도 나아가고 있다 — 멈추지 않는다
|
||||
expect(sent('DELETE', '/mailboxes/c')).toHaveLength(5)
|
||||
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다. 안의 메일 300통은 휴지통에 있습니다.'])
|
||||
})
|
||||
|
||||
it('이미 지워진 메일함(404 RES_001)은 끝난 것으로 — 오류 알림 없이 "‘거래처’ 메일함을 지웠습니다." · 메일함 목록을 다시 맞춘다', async () => {
|
||||
reply('DELETE /mailboxes/c', fail(404, 'RES_001'))
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['success', '‘거래처’ 메일함을 지웠습니다.'],
|
||||
])
|
||||
expect(store.mailboxes.some((mailbox) => mailbox.id === 'c')).toBe(false)
|
||||
expect(sent('GET', '/mailboxes').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('응답이 끊긴 뒤 다시 보내 404 를 받아도 지운 것으로 — 처음 끊김은 인터셉터 알림만', async () => {
|
||||
reply('DELETE /mailboxes/c', 'network', fail(404, 'RES_001'))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toEqual([ERROR_CODE_MESSAGES.SYS_001])
|
||||
|
||||
useToastStore().clear()
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toEqual(['‘거래처’ 메일함을 지웠습니다.'])
|
||||
})
|
||||
|
||||
it('다른 탭이 먼저 했으면 — "거래처에 안 읽은 메일이 없습니다." · "휴지통이 이미 비어 있습니다."', async () => {
|
||||
reply('PATCH /mailboxes/c/messages', ok({ processed: 0, remaining: 0 }))
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 0, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '모두 읽음으로 표시')
|
||||
expect(toastTexts()).toEqual(['거래처에 안 읽은 메일이 없습니다.'])
|
||||
|
||||
useToastStore().clear()
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toEqual(['휴지통이 이미 비어 있습니다.'])
|
||||
})
|
||||
|
||||
it('다른 탭이 같은 메일함을 처리하는 중이면(429 REQ_001) 인터셉터 알림만 — 진행 알림은 닫는다', async () => {
|
||||
reply('PATCH /mailboxes/inbox/messages', fail(429, 'REQ_001'))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
|
||||
expect(useToastStore().toasts.map((toast) => [toast.kind, toast.message])).toEqual([
|
||||
['error', ERROR_CODE_MESSAGES.REQ_001],
|
||||
])
|
||||
})
|
||||
|
||||
it('비운 뒤 누른 ⋯ 가 사라지면 초점을 메일함 패널의 지금 메일함으로', async () => {
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 21, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel('/mail/inbox')
|
||||
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(row(wrapper, '휴지통').find('[data-mailbox-more]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(row(wrapper, '받은편지함').get('a').element)
|
||||
})
|
||||
|
||||
it('지운 메일함의 ⋯ 가 사라져도 초점을 메일함 패널의 지금 메일함으로', async () => {
|
||||
reply('DELETE /mailboxes/c', ok({ deleted: true, processed: 12, movedToTrash: 12, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel('/mail/inbox')
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(row(wrapper, '받은편지함').get('a').element)
|
||||
})
|
||||
})
|
||||
|
||||
describe('휴지통 비우기 ↔ 메일함 지우기 — 함께 돌지 않는다', () => {
|
||||
const itemState = (items: DOMWrapper<Element>[]) =>
|
||||
items.map((item) => [item.text(), item.attributes('disabled') !== undefined])
|
||||
|
||||
it('휴지통을 비우는 동안은 「지우기」가 꺼진다 — 끝나면 다시 켜진다', async () => {
|
||||
const second = deferred()
|
||||
reply('DELETE /mailboxes/trash/messages', ok({ processed: 20, remaining: 1 }), second)
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '휴지통', '휴지통 비우기')
|
||||
await wrapper.get('#mailbox-empty-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(itemState(await openMenu(wrapper, '거래처'))).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['하위 메일함 만들기', false],
|
||||
['이름 바꾸기', false],
|
||||
['지우기', true],
|
||||
])
|
||||
await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
second.resolve(ok({ processed: 1, remaining: 0 }))
|
||||
await flushPromises()
|
||||
expect(itemState(await openMenu(wrapper, '거래처')).at(-1)).toEqual(['지우기', false])
|
||||
})
|
||||
|
||||
it('지우기 대화상자를 연 뒤 휴지통 비우기가 시작되면 [지우기]를 누를 수 없다 — 누르는 순간에도 다시 본다', async () => {
|
||||
const emptying = deferred()
|
||||
reply('DELETE /mailboxes/trash/messages', emptying)
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
const trash = store.mailboxes.find((mailbox) => mailbox.id === 'trash')
|
||||
const target = store.mailboxes.find((mailbox) => mailbox.id === 'c')
|
||||
if (!trash || !target) throw new Error('메일함이 없다')
|
||||
const running = store.runMailboxOperation('empty', trash, () => undefined)
|
||||
await flushPromises()
|
||||
|
||||
const confirm = wrapper.get('#mailbox-delete-confirm')
|
||||
expect(confirm.attributes('disabled')).toBeDefined()
|
||||
await confirm.trigger('click')
|
||||
await flushPromises()
|
||||
expect(sent('DELETE', '/mailboxes/c')).toEqual([])
|
||||
expect(dialogOpen('mailbox-delete-dialog')).toBe(true)
|
||||
// 스토어도 시작하지 않는다
|
||||
expect((await store.runMailboxOperation('delete', target, () => undefined)).status).toBe('canceled')
|
||||
|
||||
emptying.resolve(ok({ processed: 21, remaining: 0 }))
|
||||
await running
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#mailbox-delete-confirm').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('메일함을 지우는 동안은 「휴지통 비우기」가 꺼진다 — 「스팸메일함 비우기」는 그대로', async () => {
|
||||
const second = deferred()
|
||||
reply('DELETE /mailboxes/c', ok({ deleted: false, processed: 6, movedToTrash: 6, remaining: 6 }), second)
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '거래처', '지우기')
|
||||
await wrapper.get('#mailbox-delete-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(itemState(await openMenu(wrapper, '휴지통'))).toEqual([['휴지통 비우기', true]])
|
||||
await row(wrapper, '휴지통').get('[data-mailbox-more]').trigger('click')
|
||||
expect(itemState(await openMenu(wrapper, '스팸메일함'))).toEqual([
|
||||
['모두 읽음으로 표시', false],
|
||||
['스팸메일함 비우기', false],
|
||||
])
|
||||
await row(wrapper, '스팸메일함').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
second.resolve(ok({ deleted: true, processed: 6, movedToTrash: 6, remaining: 0 }))
|
||||
await flushPromises()
|
||||
expect(itemState(await openMenu(wrapper, '휴지통'))).toEqual([['휴지통 비우기', false]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('세션이 바뀌면 (stores/mail/mailbox-admin.ts)', () => {
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const inbox = () => MAILBOXES[0] as Mailbox
|
||||
|
||||
it('처리 중 로그아웃하면 다음 요청을 보내지 않고 진행 알림이 닫힌다', async () => {
|
||||
const first = deferred()
|
||||
reply('PATCH /mailboxes/inbox/messages', first, ok({ processed: 734, remaining: 0 }))
|
||||
const { wrapper } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
expect(useToastStore().toasts.map((toast) => toast.kind)).toEqual(['progress'])
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
first.resolve(ok({ processed: 500, remaining: 734 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(1)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('「모두 읽음」을 두 번 눌러도 요청은 한 줄 — 도는 동안 항목이 꺼지고 스토어도 다시 시작하지 않는다', async () => {
|
||||
const first = deferred()
|
||||
reply('PATCH /mailboxes/inbox/messages', first)
|
||||
const { wrapper, store } = await mountPanel()
|
||||
|
||||
await choose(wrapper, '받은편지함', '모두 읽음으로 표시')
|
||||
const items = await openMenu(wrapper, '받은편지함')
|
||||
await items[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
expect((await store.runMailboxOperation('read', inbox(), () => undefined)).status).toBe('canceled')
|
||||
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(1)
|
||||
first.resolve(ok({ processed: 3, remaining: 0 }))
|
||||
await flushPromises()
|
||||
expect(toastTexts()).toEqual(['받은편지함의 메일 3통을 읽음으로 표시했습니다.'])
|
||||
})
|
||||
|
||||
it('새 세션에서 같은 처리를 다시 시작할 수 있다 — 옛 처리가 끝나도 새 처리의 표시를 지우지 않는다', async () => {
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
reply('PATCH /mailboxes/inbox/messages', first, second)
|
||||
const { store } = await mountPanel()
|
||||
|
||||
const old = store.runMailboxOperation('read', inbox(), () => undefined)
|
||||
await flushPromises()
|
||||
// 로그아웃 → 다시 로그인 (사이에 로그인 화면이 있다)
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
useAuthStore().startSession(USER)
|
||||
await nextTick()
|
||||
store.mailboxes = MAILBOXES.map((mailbox) => ({ ...mailbox }))
|
||||
|
||||
const fresh = store.runMailboxOperation('read', inbox(), () => undefined)
|
||||
await flushPromises()
|
||||
expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(true)
|
||||
|
||||
first.resolve(ok({ processed: 1, remaining: 2 }))
|
||||
expect((await old).status).toBe('canceled')
|
||||
expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(true)
|
||||
expect(sent('PATCH', '/mailboxes/inbox/messages')).toHaveLength(2)
|
||||
|
||||
second.resolve(ok({ processed: 3, remaining: 0 }))
|
||||
expect((await fresh).status).toBe('done')
|
||||
expect(store.isMailboxOperationRunning('read', 'inbox')).toBe(false)
|
||||
})
|
||||
|
||||
it('만들기 · 이름 바꾸기의 늦은 응답은 쓰지 않는다 — 다음 사용자의 메일함을 덮지 않고 알리지도 않는다', async () => {
|
||||
const created = deferred()
|
||||
const renamed = deferred()
|
||||
reply('POST /mailboxes', created)
|
||||
reply('PATCH /mailboxes/c', renamed)
|
||||
const { store } = await mountPanel()
|
||||
|
||||
const creating = store.createMailbox('협력사', null)
|
||||
const renaming = store.renameMailbox('c', '거래처 (옛 사용자)')
|
||||
await flushPromises()
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' })
|
||||
await nextTick()
|
||||
// 다음 사용자의 메일함 — id 가 겹칠 수 있다
|
||||
store.mailboxes = [box('c', null, '도윤의 거래처')]
|
||||
|
||||
created.resolve(ok(box('n1', null, '협력사'), 201))
|
||||
renamed.resolve(ok(box('c', null, '거래처 (옛 사용자)')))
|
||||
expect(await creating).toBeNull()
|
||||
expect(await renaming).toBeNull()
|
||||
expect(store.mailboxes.map((mailbox) => mailbox.name)).toEqual(['도윤의 거래처'])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('⋯ 메뉴는 메일함 패널의 보이는 영역 아래가 모자라면 위로 연다', async () => {
|
||||
const { wrapper } = await mountPanel()
|
||||
const panel = wrapper.get('[data-mailbox-panel]').element as HTMLElement
|
||||
panel.style.overflowY = 'auto'
|
||||
let buttonTop = 540
|
||||
const rectOf = (top: number, bottom: number) =>
|
||||
({ top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) }) as DOMRect
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
if (this === panel) return rectOf(0, 600)
|
||||
if (this.matches('[data-mailbox-more]')) return rectOf(buttonTop, buttonTop + 40)
|
||||
if (this.matches('[role="menu"]')) return rectOf(buttonTop + 44, buttonTop + 44 + 254)
|
||||
return rectOf(0, 0)
|
||||
})
|
||||
|
||||
await openMenu(wrapper, '거래처')
|
||||
expect(wrapper.get('[role="menu"]').classes()).toContain('mailbox-more__menu--up')
|
||||
await row(wrapper, '거래처').get('[data-mailbox-more]').trigger('click')
|
||||
|
||||
buttonTop = 100
|
||||
await openMenu(wrapper, '거래처')
|
||||
expect(wrapper.get('[role="menu"]').classes()).not.toContain('mailbox-more__menu--up')
|
||||
})
|
||||
})
|
||||
@@ -1,33 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 하단 사용량 (디자인 원본 Main · Compose 왼쪽)
|
||||
// 메일함 패널 — 메일 쓰기 + 메일함 목록 + 아래 안내 · 사용량 (디자인 원본 Main · Compose 왼쪽 · MailboxManage)
|
||||
// - 메일함 목록은 두 묶음(기본 메일함 · "내 메일함")과 [+ 새 메일함] — MailboxTree
|
||||
// - 메일함마다 ⋯ 메뉴(MailboxPanelRow) — 모두 읽음 · 하위 메일함 · 이름 바꾸기 · 지우기 · 비우기. 대화상자는 MailboxDialogs, 하는 일은 useMailboxActions
|
||||
// - 아래: 부재중 자동 응답이 켜져 있으면 안내(MailVacationNotice) → 사용량
|
||||
// 안 읽은 개수는 뱃지가 아니라 오른쪽의 평범한 숫자로 보인다.
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailQuotaMeter from '@/components/mail/MailQuotaMeter.vue'
|
||||
import { mailboxIcon } from '@/shared/utils/mailbox'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import MailVacationNotice from '@/components/mail/MailVacationNotice.vue'
|
||||
import MailboxDialogs from '@/components/mail/MailboxDialogs.vue'
|
||||
import MailboxTree from '@/components/mail/MailboxTree.vue'
|
||||
import type { MailboxDialogRequest } from '@/components/mail/mailbox-dialog'
|
||||
import { useMailboxActions } from '@/composables/useMailboxActions'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import type { MailboxAction } from '@/shared/utils/mailbox'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/** 하위 메일함 들여쓰기는 3단계까지만 (그 아래는 같은 깊이로) */
|
||||
const MAX_INDENT_DEPTH = 3
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const mailStore = useMailStore()
|
||||
const vacationStore = useMailVacationStore()
|
||||
const actions = useMailboxActions()
|
||||
|
||||
const dialog = ref<MailboxDialogRequest | null>(null)
|
||||
|
||||
const isComposing = computed(() => route.name === 'mail-compose')
|
||||
const activeMailboxId = computed(() => (isComposing.value ? null : mailStore.currentMailboxId))
|
||||
|
||||
function depthClass(depth: number): string {
|
||||
return `mailbox-link--depth-${Math.min(depth, MAX_INDENT_DEPTH)}`
|
||||
/** ⋯ 메뉴에서 고른 일 — 모두 읽음은 바로, 그 밖은 대화상자를 거친다 */
|
||||
function onAction(action: MailboxAction, mailbox: Mailbox) {
|
||||
if (action === 'markRead') {
|
||||
void actions.markAllRead(mailbox)
|
||||
return
|
||||
}
|
||||
if (action === 'createChild') dialog.value = { kind: 'create', parent: mailbox }
|
||||
else if (action === 'rename') dialog.value = { kind: 'rename', mailbox }
|
||||
else if (action === 'delete') dialog.value = { kind: 'delete', mailbox }
|
||||
else dialog.value = { kind: 'empty', mailbox }
|
||||
}
|
||||
|
||||
// 세션이 바뀌면(로그아웃 · 세션 만료) 대화상자를 닫는다 — 세션 만료 대화상자와 겹치지 않게
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => {
|
||||
dialog.value = null
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- data-mailbox-panel — 비우기 · 지우기 뒤 초점을 잃으면 여기의 지금 메일함으로 (useMailboxActions) -->
|
||||
<nav
|
||||
class="mailbox-panel"
|
||||
aria-label="메일함"
|
||||
data-mailbox-panel
|
||||
>
|
||||
<span
|
||||
v-if="isComposing"
|
||||
@@ -54,39 +84,17 @@ function depthClass(depth: number): string {
|
||||
<span>메일 쓰기</span>
|
||||
</RouterLink>
|
||||
|
||||
<ul
|
||||
<MailboxTree
|
||||
v-if="mailStore.orderedMailboxes.length > 0"
|
||||
class="mailbox-panel__list"
|
||||
>
|
||||
<li
|
||||
v-for="node in mailStore.orderedMailboxes"
|
||||
:key="node.mailbox.id"
|
||||
>
|
||||
<RouterLink
|
||||
:to="{ name: 'mail-mailbox', params: { mailboxId: node.mailbox.id } }"
|
||||
:class="[
|
||||
'mailbox-link',
|
||||
depthClass(node.depth),
|
||||
{ 'is-active': node.mailbox.id === activeMailboxId },
|
||||
]"
|
||||
:aria-current="node.mailbox.id === activeMailboxId ? 'page' : undefined"
|
||||
>
|
||||
<AppIcon :name="mailboxIcon(node.mailbox)" />
|
||||
<span class="mailbox-link__name">{{ node.mailbox.name }}</span>
|
||||
<span
|
||||
v-if="node.mailbox.unreadCount > 0"
|
||||
class="mailbox-link__count"
|
||||
>
|
||||
<span class="visually-hidden">안 읽음</span>
|
||||
{{ formatCount(node.mailbox.unreadCount) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
:nodes="mailStore.orderedMailboxes"
|
||||
:active-id="activeMailboxId"
|
||||
@action="onAction"
|
||||
@create="dialog = { kind: 'create', parent: null }"
|
||||
/>
|
||||
|
||||
<ul
|
||||
v-else-if="mailStore.mailboxesStatus === 'loading' || mailStore.mailboxesStatus === 'idle'"
|
||||
class="mailbox-panel__list"
|
||||
class="mailbox-panel__placeholders"
|
||||
aria-label="메일함 불러오는 중"
|
||||
aria-busy="true"
|
||||
>
|
||||
@@ -114,12 +122,20 @@ function depthClass(depth: number): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 사용량은 보조 정보 — 받지 못했으면 영역을 그리지 않는다 -->
|
||||
<MailQuotaMeter
|
||||
v-if="mailStore.quota"
|
||||
:quota="mailStore.quota"
|
||||
/>
|
||||
<!-- 아래 묶음 — 부재중 안내(켜져 있을 때) → 사용량(받지 못했으면 그리지 않는다 — 보조 정보) -->
|
||||
<div class="mailbox-panel__footer">
|
||||
<MailVacationNotice :vacation="vacationStore.vacation" />
|
||||
<MailQuotaMeter
|
||||
v-if="mailStore.quota"
|
||||
:quota="mailStore.quota"
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- 대화상자는 메일함 목록(nav) 밖에 둔다 — 열리면 맨 위 층에 뜬다 -->
|
||||
<MailboxDialogs
|
||||
:request="dialog"
|
||||
@close="dialog = null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -156,7 +172,7 @@ a.mailbox-panel__primary:hover {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.mailbox-panel__list {
|
||||
.mailbox-panel__placeholders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
@@ -165,60 +181,6 @@ a.mailbox-panel__primary:hover {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mailbox-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-10);
|
||||
border-radius: var(--radius-item);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mailbox-link:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mailbox-link.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-link--depth-1 {
|
||||
padding-left: var(--space-28);
|
||||
}
|
||||
|
||||
.mailbox-link--depth-2 {
|
||||
padding-left: var(--space-44);
|
||||
}
|
||||
|
||||
.mailbox-link--depth-3 {
|
||||
padding-left: var(--space-56);
|
||||
}
|
||||
|
||||
.mailbox-link__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mailbox-link__count {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mailbox-link.is-active .mailbox-link__count {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.mailbox-panel__placeholder {
|
||||
height: var(--size-row-min);
|
||||
margin: var(--space-2) 0;
|
||||
@@ -249,6 +211,14 @@ a.mailbox-panel__primary:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 아래에 붙는다 — 안내와 사용량 사이 10 */
|
||||
.mailbox-panel__footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 패널만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.mailbox-panel {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 패널의 한 줄 — 아이콘 · 이름 · 안 읽은 수(평범한 숫자, 뱃지 아님) · ⋯ 메뉴 (디자인 원본 Main · MailboxManage)
|
||||
// - 하위 메일함은 16px 씩 들여 쓴다 (3단계까지 — 그 아래는 같은 깊이로)
|
||||
// - ⋯ 는 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다 (터치 화면은 늘 보인다). 할 일이 없는 메일함은 ⋯ 를 그리지 않는다
|
||||
// - 메뉴 항목은 메일함마다 다르다 (shared/utils/mailbox.ts 의 mailboxActions). 같은 처리가 도는 동안은 그 항목을 끈다 —
|
||||
// 휴지통을 비우는 동안의 「지우기」, 메일함을 지우는 동안의 「휴지통 비우기」도 끈다 (함께 돌 수 없다 — useMailboxActions 의 isBlocked)
|
||||
import { computed, ref } 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 { MAILBOX_MENU_TEXT } from '@/shared/constants/mailbox-manage'
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { mailboxActions, mailboxIcon, type MailboxAction } from '@/shared/utils/mailbox'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
mailbox: Mailbox
|
||||
depth: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'action', action: MailboxAction, mailbox: Mailbox): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/** 하위 메일함 들여쓰기는 3단계까지만 (그 아래는 같은 깊이로) */
|
||||
const MAX_INDENT_DEPTH = 3
|
||||
|
||||
const menuOpen = ref<boolean>(false)
|
||||
|
||||
/** 그 메일함에서 돌고 있는 처리 — 지우는 중이면 모든 항목을 끈다. 함께 돌 수 없는 처리가 도는 동안의 지우기 · 비우기도 끈다 */
|
||||
function isDisabled(action: MailboxAction): boolean {
|
||||
const mailbox = props.mailbox
|
||||
if (mailStore.isMailboxOperationRunning('delete', mailbox.id)) return true
|
||||
if (action === 'markRead') return mailStore.isMailboxOperationRunning('read', mailbox.id)
|
||||
if (action === 'empty') {
|
||||
return mailStore.isMailboxOperationRunning('empty', mailbox.id) || mailStore.isMailboxOperationBlocked('empty', mailbox)
|
||||
}
|
||||
if (action === 'delete') return mailStore.isMailboxOperationBlocked('delete', mailbox)
|
||||
return false
|
||||
}
|
||||
|
||||
function labelOf(action: MailboxAction): string {
|
||||
if (action === 'empty') return props.mailbox.role === 'junk' ? MAILBOX_MENU_TEXT.emptyJunk : MAILBOX_MENU_TEXT.emptyTrash
|
||||
return MAILBOX_MENU_TEXT[action]
|
||||
}
|
||||
|
||||
const items = computed<EditorMenuItem[]>(() =>
|
||||
mailboxActions(props.mailbox).map((action) => ({
|
||||
id: action,
|
||||
label: labelOf(action),
|
||||
disabled: isDisabled(action),
|
||||
tone: action === 'delete' || action === 'empty' ? 'danger' : undefined,
|
||||
})),
|
||||
)
|
||||
|
||||
const depthClass = computed(() => `mailbox-row__link--depth-${Math.min(props.depth, MAX_INDENT_DEPTH)}`)
|
||||
|
||||
function onSelect(id: string) {
|
||||
const action = mailboxActions(props.mailbox).find((candidate) => candidate === id)
|
||||
if (action) emit('action', action, props.mailbox)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['mailbox-row', { 'is-active': active, 'is-open': menuOpen }]">
|
||||
<RouterLink
|
||||
:to="{ name: 'mail-mailbox', params: { mailboxId: mailbox.id } }"
|
||||
:class="['mailbox-row__link', depthClass]"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
>
|
||||
<AppIcon :name="mailboxIcon(mailbox)" />
|
||||
<span class="mailbox-row__name">{{ mailbox.name }}</span>
|
||||
<span
|
||||
v-if="mailbox.unreadCount > 0"
|
||||
class="mailbox-row__count"
|
||||
>
|
||||
<span class="visually-hidden">안 읽음</span>
|
||||
{{ formatCount(mailbox.unreadCount) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<MailboxMoreButton
|
||||
v-if="items.length > 0"
|
||||
class="mailbox-row__more"
|
||||
:mailbox-name="mailbox.name"
|
||||
:items="items"
|
||||
@select="onSelect"
|
||||
@open-change="menuOpen = $event"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-item);
|
||||
}
|
||||
|
||||
.mailbox-row:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.mailbox-row.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.mailbox-row__link {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-4) 0 var(--space-10);
|
||||
border-radius: var(--radius-item);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mailbox-row:hover .mailbox-row__link {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mailbox-row.is-active .mailbox-row__link {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-row__link--depth-1 {
|
||||
padding-left: var(--space-28);
|
||||
}
|
||||
|
||||
.mailbox-row__link--depth-2 {
|
||||
padding-left: var(--space-44);
|
||||
}
|
||||
|
||||
.mailbox-row__link--depth-3 {
|
||||
padding-left: var(--space-56);
|
||||
}
|
||||
|
||||
.mailbox-row__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mailbox-row__count {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mailbox-row.is-active .mailbox-row__count {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* ⋯ — 마우스를 올리거나 초점이 가면, 메뉴가 열린 동안은 계속 보인다 */
|
||||
.mailbox-row__more {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mailbox-row:hover .mailbox-row__more,
|
||||
.mailbox-row:focus-within .mailbox-row__more,
|
||||
.mailbox-row.is-open .mailbox-row__more {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 마우스를 올릴 수 없는 화면(터치)에서는 늘 보인다 */
|
||||
@media (hover: none) {
|
||||
.mailbox-row__more {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
// 메일함 목록 두 묶음 — 기본 메일함 · "내 메일함"(12px 굵게) + 끝의 [+ 새 메일함](44) (디자인 원본 MailboxManage)
|
||||
// 하위 메일함은 뿌리의 묶음을 따라 16px 씩 들여 쓴다 (mailbox.ts 의 splitMailboxGroups). 줄 하나는 MailboxPanelRow.
|
||||
// "내 메일함" 이 비어도 묶음 이름과 [+ 새 메일함]은 둔다 — 첫 메일함을 만들 수 있게
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import MailboxPanelRow from '@/components/mail/MailboxPanelRow.vue'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
nodes: readonly MailboxNode[]
|
||||
/** 지금 보는 메일함 — 쓰기 화면에서는 null */
|
||||
activeId: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'action', action: MailboxAction, mailbox: Mailbox): void
|
||||
(e: 'create'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const MY_GROUP_ID = 'mailbox-panel-mine'
|
||||
|
||||
const groups = computed(() => splitMailboxGroups(props.nodes))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mailbox-tree">
|
||||
<ul class="mailbox-tree__list">
|
||||
<MailboxPanelRow
|
||||
v-for="node in groups.system"
|
||||
:key="node.mailbox.id"
|
||||
:mailbox="node.mailbox"
|
||||
:depth="node.depth"
|
||||
:active="node.mailbox.id === activeId"
|
||||
@action="(action, mailbox) => emit('action', action, mailbox)"
|
||||
/>
|
||||
</ul>
|
||||
<p
|
||||
:id="MY_GROUP_ID"
|
||||
class="mailbox-tree__group"
|
||||
>
|
||||
{{ MAILBOX_PANEL_TEXT.myMailboxes }}
|
||||
</p>
|
||||
<ul
|
||||
v-if="groups.mine.length > 0"
|
||||
class="mailbox-tree__list"
|
||||
:aria-labelledby="MY_GROUP_ID"
|
||||
>
|
||||
<MailboxPanelRow
|
||||
v-for="node in groups.mine"
|
||||
:key="node.mailbox.id"
|
||||
:mailbox="node.mailbox"
|
||||
:depth="node.depth"
|
||||
:active="node.mailbox.id === activeId"
|
||||
@action="(action, mailbox) => emit('action', action, mailbox)"
|
||||
/>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
class="mailbox-tree__new"
|
||||
data-mailbox-new
|
||||
@click="emit('create')"
|
||||
>
|
||||
<AppIcon name="plus" />
|
||||
<span>{{ MAILBOX_PANEL_TEXT.newMailbox }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mailbox-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.mailbox-tree__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* "내 메일함" — 12px 굵게 (디자인 원본 MailboxManage) */
|
||||
.mailbox-tree__group {
|
||||
margin: var(--space-12) var(--space-10) var(--space-4);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* [+ 새 메일함] — 44 */
|
||||
.mailbox-tree__new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
margin-top: var(--space-2);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mailbox-tree__new:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -3,19 +3,16 @@
|
||||
// - 「영구 삭제」인지는 그 메일의 메일함으로 정한다 — 휴지통 목록에서는 휴지통에 든 메일, 그 밖에서는 휴지통에만 든 메일 (useMessageRemoval)
|
||||
// · 「삭제」: 휴지통으로 옮긴다 — "메일 1통을 휴지통으로 옮겼습니다." + [되돌리기]
|
||||
// · 「영구 삭제」: 확인 대화상자(처음 초점은 「취소」)를 거쳐 영구 삭제 — "메일 1통을 영구 삭제했습니다."
|
||||
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (기록을 쌓지 않는다).
|
||||
// - 지운 뒤: 분할 보기는 다음 메일(없으면 이전 메일, 그것도 없으면 목록), 목록 보기는 목록으로 (useMessageRemoval 의 leaveRemoved).
|
||||
// 그사이 다른 목록으로 옮겨 갔거나 서버가 그대로 두었으면 움직이지 않는다
|
||||
// - DELETE /api/messages/{id} 는 쓰지 않는다 (그사이 다른 창이 휴지통으로 옮긴 메일을 확인 없이 영구 삭제하게 된다)
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import MailPurgeDialog from '@/components/mail/MailPurgeDialog.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { useMessageRemoval } from '@/composables/useMessageRemoval'
|
||||
import { MAIL_BULK_TEXT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
import { listLocationOf, messageLocationOf } from '@/shared/utils/mail-list-source'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -26,10 +23,8 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
const removal = useMessageRemoval()
|
||||
const { backToSourceList } = useMailNavigation()
|
||||
|
||||
const working = ref<boolean>(false)
|
||||
const confirmOpen = ref<boolean>(false)
|
||||
@@ -43,13 +38,7 @@ async function run(kind: 'trash' | 'purge') {
|
||||
working.value = true
|
||||
try {
|
||||
const outcome = kind === 'trash' ? await removal.moveToTrash(target) : await removal.purge(target)
|
||||
if (!outcome.removed || !source) return
|
||||
if (props.layout === 'list') {
|
||||
// 목록 보기에서는 지우면 목록으로 돌아간다 (디자인 원본 MainListRead)
|
||||
await backToSourceList(source)
|
||||
return
|
||||
}
|
||||
await router.replace(outcome.nextId ? messageLocationOf(source, outcome.nextId) : listLocationOf(source))
|
||||
await removal.leaveRemoved(outcome, source, props.layout)
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴에 "원문 내려받기 (.eml)" (디자인 원본 MailActions4 · 계약 2절 "스팸 · 원문")
|
||||
// 메뉴는 이동 ▾ 과 같은 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에,
|
||||
// 마우스로 열면 메뉴 자체에 초점. Esc 는 메뉴만 닫고 이 단추로 돌아간다.
|
||||
// 원문 내려받기는 첨부 내려받기처럼 이동하기 전에 세션을 확인한다 (useMail 의 downloadMessageSource — confirmSession)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
|
||||
|
||||
interface Props {
|
||||
messageId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { downloadMessageSource } = useMail()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const open = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
|
||||
const ITEMS: readonly EditorMenuItem[] = [
|
||||
{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' },
|
||||
]
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
// detail 0 — Enter · Space 로 눌렀다
|
||||
byKeyboard.value = event.detail === 0
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' || open.value) return
|
||||
event.preventDefault()
|
||||
byKeyboard.value = true
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function onClose(restoreFocus: boolean) {
|
||||
open.value = false
|
||||
if (restoreFocus) buttonRef.value?.focus()
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
open.value = false
|
||||
buttonRef.value?.focus()
|
||||
if (id === 'source') void downloadMessageSource(props.messageId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reader-more">
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
class="reader-more__button"
|
||||
:aria-label="MAIL_READER_MORE_TEXT.button"
|
||||
:title="MAIL_READER_MORE_TEXT.button"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
data-reader-more
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<AppIcon name="more-horizontal" />
|
||||
</button>
|
||||
<EditorMenu
|
||||
v-if="open"
|
||||
class="reader-more__menu"
|
||||
:label="MAIL_READER_MORE_TEXT.button"
|
||||
:items="ITEMS"
|
||||
:trigger="trigger"
|
||||
align="end"
|
||||
:focus-content="!byKeyboard"
|
||||
@select="onSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메뉴는 이 단추 바로 아래 — 도구 줄 아래 선 밑에서 연다 (EditorPopup) */
|
||||
.reader-more {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 40×44 — 새로고침 · 이전/다음과 같은 크기 */
|
||||
.reader-more__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-icon-button);
|
||||
height: var(--size-touch);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.reader-more__button:hover,
|
||||
.reader-more__button[aria-expanded='true'] {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 메뉴 폭 200 (디자인 원본 MailActions4) */
|
||||
.reader-more__menu {
|
||||
min-width: 12.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
@@ -457,3 +458,149 @@ describe('MessageReader 삭제 · 영구 삭제 — DELETE /messages/{id} 를
|
||||
expect(deleteButton(inTrashList.wrapper).text()).toBe('영구 삭제')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자인 원본 MailActions4)', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
/** 내려받기를 시작하는 임시 <a download> 의 클릭 — jsdom 은 이동하지 못하므로 가로채 기록만 한다 */
|
||||
let downloads: (string | null)[]
|
||||
|
||||
const INBOX: Mailbox = { id: 'inbox', name: '받은편지함', role: 'inbox', parentId: null, sortOrder: 0, totalCount: 2, unreadCount: 0 }
|
||||
const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk' }
|
||||
const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash' }
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().startSession({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
downloads = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
downloads.push(this.getAttribute('href'))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountWith(
|
||||
options: { box?: string; mailboxes?: Mailbox[]; me?: MockResult; refresh?: MockResult } = {},
|
||||
) {
|
||||
const box = options.box ?? 'inbox'
|
||||
const mailboxes = options.mailboxes ?? [INBOX, JUNK, TRASH]
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/auth/me') return options.me ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
if (request.url === '/auth/refresh') return options.refresh ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
if (request.url === '/mailboxes') return ok({ items: mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
|
||||
if (request.url === '/messages' && request.method === 'GET') {
|
||||
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: box }
|
||||
const open = { ...DETAIL, mailboxIds: [box] }
|
||||
store.messages = [open, { ...DETAIL, id: 'm2', mailboxIds: [box], subject: '다음 메일' }]
|
||||
store.pageInfo = { hasNext: false, nextCursor: null, total: 2 }
|
||||
store.listStatus = 'ready'
|
||||
store.selected = open
|
||||
store.selectedId = open.id
|
||||
store.selectedStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${box}/m1`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, { global: { plugins: [pinia, router] }, attachTo: document.body })
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
const patches = () => requests.filter((request) => request.method === 'PATCH').map((request) => request.data)
|
||||
|
||||
it('도구 줄 — 답장 · 전체 답장 · 전달 | 스팸 신고 · 삭제 · ⋯(더 보기)', async () => {
|
||||
const { wrapper } = await mountWith()
|
||||
const toolbar = wrapper.get('.reader__toolbar')
|
||||
|
||||
expect(toolbar.findAll('.tool-button').map((button) => button.text())).toEqual([
|
||||
'답장',
|
||||
'전체 답장',
|
||||
'전달',
|
||||
'스팸 신고',
|
||||
'삭제',
|
||||
])
|
||||
const more = toolbar.get('[data-reader-more]')
|
||||
expect(more.attributes('aria-label')).toBe('더 보기')
|
||||
expect(more.attributes('aria-haspopup')).toBe('menu')
|
||||
expect(toolbar.element.lastElementChild?.contains(more.element)).toBe(true)
|
||||
})
|
||||
|
||||
it('스팸 신고 — 스팸메일함으로 옮기며 isJunk: true, 다음 메일을 연다 · [되돌리기]는 원래 메일함 · isJunk: false', async () => {
|
||||
const { wrapper, router, store } = await mountWith()
|
||||
|
||||
await wrapper.get('[data-reader-spam]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'junk', isJunk: true }])
|
||||
expect(store.messages.map((message) => message.id)).toEqual(['m2'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m2')
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('메일 1통을 스팸메일함으로 옮겼습니다.')
|
||||
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
expect(patches().at(-1)).toEqual({ ids: ['m1'], mailboxId: 'inbox', isJunk: false })
|
||||
})
|
||||
|
||||
it('스팸메일함의 메일은 「스팸 아님」(강조) — 받은편지함으로 · isJunk: false, "(스팸 아님)" 알림', async () => {
|
||||
const { wrapper } = await mountWith({ box: 'junk' })
|
||||
const notSpam = wrapper.get('[data-reader-spam]')
|
||||
expect(notSpam.text()).toBe('스팸 아님')
|
||||
expect(notSpam.classes()).toContain('tool-button--accent')
|
||||
|
||||
await notSpam.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toEqual([{ ids: ['m1'], mailboxId: 'inbox', isJunk: false }])
|
||||
expect(useToastStore().toasts[0]?.message).toBe('메일 1통을 받은편지함으로 옮겼습니다 (스팸 아님).')
|
||||
})
|
||||
|
||||
it('스팸메일함을 모르면 「스팸 신고」를 그리지 않는다', async () => {
|
||||
const { wrapper } = await mountWith({ mailboxes: [INBOX, TRASH] })
|
||||
|
||||
expect(wrapper.find('[data-reader-spam]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('⋯ → "원문 내려받기 (.eml)" — 이동하기 전에 세션을 확인하고(confirmSession) 원문 주소로 내려받는다', async () => {
|
||||
const { wrapper } = await mountWith()
|
||||
const more = wrapper.get('[data-reader-more]')
|
||||
|
||||
await more.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
|
||||
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
|
||||
expect(document.activeElement).toBe(items[0]?.element)
|
||||
|
||||
await items[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /auth/me'])
|
||||
expect(downloads).toEqual(['/api/messages/m1/source'])
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(more.element)
|
||||
})
|
||||
|
||||
it('세션을 확인하지 못하면(갱신도 401) 내려받지 않고 세션 만료로 다룬다', async () => {
|
||||
const { wrapper } = await mountWith({ me: fail(401, 'AUTH_001'), refresh: fail(401, 'AUTH_001') })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[role="menuitem"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(downloads).toEqual([])
|
||||
expect(useAuthStore().sessionExpired).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
|
||||
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
|
||||
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다.
|
||||
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기) (디자인 README "4단계 › 스팸 · 원문").
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
|
||||
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -15,6 +16,8 @@ import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import MailToolbarDivider from '@/components/mail/MailToolbarDivider.vue'
|
||||
import MessageAttachments from '@/components/mail/MessageAttachments.vue'
|
||||
import MessageDeleteButton from '@/components/mail/MessageDeleteButton.vue'
|
||||
import MessageMoreMenu from '@/components/mail/MessageMoreMenu.vue'
|
||||
import MessageSpamButton from '@/components/mail/MessageSpamButton.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { MAIL_SEARCH_TEXT } from '@/shared/constants/mail'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
@@ -99,10 +102,15 @@ function leaveToList() {
|
||||
:to="{ name: 'mail-compose', query: { forward: message.id } }"
|
||||
/>
|
||||
<MailToolbarDivider v-if="layout === 'split'" />
|
||||
<MessageSpamButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
<MessageDeleteButton
|
||||
:message="message"
|
||||
:layout="layout"
|
||||
/>
|
||||
<MessageMoreMenu :message-id="message.id" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면의 「스팸 신고」 · 「스팸 아님」 — 열린 메일 한 통 (디자인 README "4단계 › 스팸 · 원문 · 오래 걸리는 처리" · useMessageRemoval)
|
||||
// - 스팸메일함에 든 메일이면 「스팸 아님」(받은편지함 아이콘 · 강조 글자 #0A5F67 굵게) — 받은편지함으로 옮기고 "스팸 아님" 표시
|
||||
// - 그 밖은 「스팸 신고」 — 스팸메일함으로 옮기고 스팸 표시. 알림에 [되돌리기](원래 메일함 · 원래 표시로)
|
||||
// - 옮길 메일함(스팸메일함 · 받은편지함)을 모르면(메일함 목록을 아직 못 받음) 그리지 않는다
|
||||
// - 옮긴 뒤: 삭제와 같다 — 분할 보기는 다음 메일, 목록 보기는 목록으로 (leaveRemoved)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import { useMessageRemoval } from '@/composables/useMessageRemoval'
|
||||
import { MAIL_SPAM_TEXT } from '@/shared/constants/mail'
|
||||
import type { MessageSummary } from '@/shared/types/mail'
|
||||
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 working = ref<boolean>(false)
|
||||
|
||||
const inJunk = computed(() => removal.isInJunk(props.message))
|
||||
const available = computed(() => mailStore.mailboxByRole(inJunk.value ? 'inbox' : 'junk') !== null)
|
||||
|
||||
async function onClick() {
|
||||
if (working.value) return
|
||||
const source = mailStore.listSource
|
||||
working.value = true
|
||||
try {
|
||||
const outcome = await removal.moveSpam(props.message)
|
||||
await removal.leaveRemoved(outcome, source, props.layout)
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MailToolButton
|
||||
v-if="available"
|
||||
:icon="inJunk ? 'inbox' : 'alert-octagon'"
|
||||
:label="inJunk ? MAIL_SPAM_TEXT.notSpam : MAIL_SPAM_TEXT.report"
|
||||
:tone="inJunk ? 'accent' : 'default'"
|
||||
:disabled="working"
|
||||
data-reader-spam
|
||||
@click="onClick"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 툴바 메뉴 — 글꼴 · 글자 크기 · 더보기 (디자인 원본 ComposeEditor "더보기 메뉴" · "글꼴 · 글자 크기")
|
||||
// 항목 높이 40 · 아이콘 16 · 이름 · 단축키(흐린 글자) · 켜진 것은 오른쪽 ✓ · 한 단계 더 여는 것은 ›
|
||||
// 같은 모양을 쓰는 곳: 여러 통 처리의 이동 ▾ · 메일함 패널의 ⋯ (되돌릴 수 없는 항목은 tone 'danger' — 빨간 글자) · 읽기 화면의 ⋯
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorColorGlyph from '@/components/mail/editor/EditorColorGlyph.vue'
|
||||
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
|
||||
@@ -52,7 +53,7 @@ function isToggle(role: string | undefined): boolean {
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="menu__item"
|
||||
:class="['menu__item', { 'menu__item--danger': item.tone === 'danger' }]"
|
||||
:role="item.role ?? 'menuitem'"
|
||||
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
|
||||
:aria-haspopup="item.opens"
|
||||
@@ -125,6 +126,19 @@ function isToggle(role: string | undefined): boolean {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 되돌릴 수 없는 일 (디자인 원본 MailboxManage "지우기 · 비우기") */
|
||||
.menu__item--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.menu__item--danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-surface);
|
||||
}
|
||||
|
||||
.menu__item--danger .menu__icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.menu__icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface EditorMenuEntry {
|
||||
hint?: string
|
||||
checked?: boolean
|
||||
disabled?: boolean
|
||||
/** danger: 되돌릴 수 없는 일 — 빨간 글자 (메일함 ⋯ 메뉴의 지우기 · 비우기) */
|
||||
tone?: 'danger'
|
||||
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
|
||||
opens?: 'menu' | 'dialog'
|
||||
/** 미리보기 모양 (글꼴 · 크기) — EditorMenu 의 class 이름 */
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// 메일함 패널의 대화상자 요청 — 패널(MailboxPanel)이 열고 대화상자 묶음(MailboxDialogs)이 그린다
|
||||
import type { Mailbox } from '@/shared/types/mail'
|
||||
|
||||
export type MailboxDialogRequest =
|
||||
/** 새 메일함 — parent 가 없으면 맨 위 ("내 메일함"), 있으면 그 안 */
|
||||
| { kind: 'create'; parent: Mailbox | null }
|
||||
| { kind: 'rename'; mailbox: Mailbox }
|
||||
| { kind: 'delete'; mailbox: Mailbox }
|
||||
| { kind: 'empty'; mailbox: Mailbox }
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
// 비밀번호 바꾸기 (디자인 원본 SettingsAccount · 계약 1절 "PUT /api/auth/password")
|
||||
// 칸 셋(최대 폭 420): 지금 비밀번호 · 새 비밀번호 · 새 비밀번호 확인 — 앞의 두 칸 끝에 보기/숨기기 (확인 칸은 새 비밀번호를 따른다)
|
||||
// - 보내기 전에 서버와 같은 규칙을 본다: 8자 이상 · 지금과 다름(어기면 새 비밀번호 안내 글이 빨간 오류가 된다) · 확인 칸 일치("새 비밀번호가 서로 다릅니다.")
|
||||
// · 지금 비밀번호를 비움 · 128자 초과(사전 문구 VAL_001). 처음 걸린 칸으로 초점을 옮긴다
|
||||
// - 서버: 지금 비밀번호가 틀림(AUTH_005)은 지금 비밀번호 칸 아래, 새 비밀번호가 엔진 규칙에 어긋남(VAL_001)은 새 비밀번호 칸 아래에
|
||||
// 사전 문구로 (알림은 끈다). 그 밖(요청 과다 · 메일 서버 장애)은 인터셉터의 알림. 기다리는 사이 화면을 떠났으면 끈 거절도 알림으로 (notifyApiError).
|
||||
// 요청 한도는 30초다 (useAuth — 서버가 엔진을 두 번 부르고 다른 세션을 끊는다)
|
||||
// - 바꾸면 "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다." — 세 칸을 비우고 보기를 끈다. 무엇이든 다시 치면 내린다
|
||||
// 고정 id — #password-current · #password-next · #password-confirm · #password-submit · #password-status
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import PasswordField from '@/components/settings/PasswordField.vue'
|
||||
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { PASSWORD_TEXT } from '@/shared/constants/account'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import {
|
||||
hasPasswordChangeProblem,
|
||||
passwordChangeProblems,
|
||||
type PasswordChangeProblems,
|
||||
} from '@/shared/utils/password-change'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
/** 칸 아래에 보이는 서버 거절 — 알림은 끈다 */
|
||||
const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'VAL_001']
|
||||
const NO_PROBLEMS: PasswordChangeProblems = { current: null, next: null, confirm: null }
|
||||
|
||||
const { changePassword } = useAuth()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const currentRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
const nextRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
const confirmRef = ref<InstanceType<typeof PasswordField> | null>(null)
|
||||
|
||||
const current = ref<string>('')
|
||||
const next = ref<string>('')
|
||||
const confirm = ref<string>('')
|
||||
const showCurrent = ref<boolean>(false)
|
||||
const showNext = ref<boolean>(false)
|
||||
const problems = ref<PasswordChangeProblems>(NO_PROBLEMS)
|
||||
/** 서버가 거절한 까닭 — 그 칸 아래에 (사전 문구) */
|
||||
const serverError = ref<{ field: 'current' | 'next'; message: string } | null>(null)
|
||||
const submitting = ref<boolean>(false)
|
||||
const done = ref<boolean>(false)
|
||||
/** 화면이 사라졌다 — 칸 아래 글을 볼 곳이 없다 */
|
||||
let unmounted = false
|
||||
|
||||
const currentError = computed(() => {
|
||||
if (problems.value.current === 'missing') return getErrorMessage('VAL_001')
|
||||
return serverError.value?.field === 'current' ? serverError.value.message : null
|
||||
})
|
||||
const nextError = computed(() => {
|
||||
if (problems.value.next === 'tooLong') return getErrorMessage('VAL_001')
|
||||
return serverError.value?.field === 'next' ? serverError.value.message : null
|
||||
})
|
||||
const confirmError = computed(() => (problems.value.confirm === 'mismatch' ? PASSWORD_TEXT.mismatch : null))
|
||||
|
||||
// 칸을 고치면 그 칸에 걸린 오류와 "바꿨습니다"를 내린다 (지금 비밀번호를 고치면 "지금과 같음"도 다시 본다)
|
||||
function setCurrent(value: string) {
|
||||
current.value = value
|
||||
problems.value = { ...problems.value, current: null, next: problems.value.next === 'rule' ? null : problems.value.next }
|
||||
if (serverError.value?.field === 'current') serverError.value = null
|
||||
done.value = false
|
||||
}
|
||||
|
||||
function setNext(value: string) {
|
||||
next.value = value
|
||||
problems.value = { ...problems.value, next: null, confirm: null }
|
||||
if (serverError.value?.field === 'next') serverError.value = null
|
||||
done.value = false
|
||||
}
|
||||
|
||||
function setConfirm(value: string) {
|
||||
confirm.value = value
|
||||
problems.value = { ...problems.value, confirm: null }
|
||||
done.value = false
|
||||
}
|
||||
|
||||
function focusFirstProblem(found: PasswordChangeProblems) {
|
||||
if (found.current) currentRef.value?.focus()
|
||||
else if (found.next) nextRef.value?.focus()
|
||||
else if (found.confirm) confirmRef.value?.focus()
|
||||
}
|
||||
|
||||
function clearFields() {
|
||||
current.value = ''
|
||||
next.value = ''
|
||||
confirm.value = ''
|
||||
showCurrent.value = false
|
||||
showNext.value = false
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
done.value = false
|
||||
serverError.value = null
|
||||
const found = passwordChangeProblems({ current: current.value, next: next.value, confirm: confirm.value })
|
||||
problems.value = found
|
||||
if (hasPasswordChangeProblem(found)) {
|
||||
focusFirstProblem(found)
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await changePassword({ currentPassword: current.value, newPassword: next.value }, { silent: INLINE_CODES })
|
||||
clearFields()
|
||||
problems.value = NO_PROBLEMS
|
||||
done.value = true
|
||||
} catch (error) {
|
||||
const apiError = toApiError(error)
|
||||
if (unmounted) {
|
||||
if (INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
|
||||
return
|
||||
}
|
||||
if (apiError.code === 'AUTH_005') {
|
||||
serverError.value = { field: 'current', message: apiError.message }
|
||||
currentRef.value?.focus()
|
||||
} else if (apiError.code === 'VAL_001') {
|
||||
serverError.value = { field: 'next', message: apiError.message }
|
||||
nextRef.value?.focus()
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="password-form"
|
||||
novalidate
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<!-- 비밀번호 관리자가 어느 계정의 비밀번호인지 알게 한다 (보이지 않는다) -->
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
:value="authStore.user?.email ?? ''"
|
||||
hidden
|
||||
readonly
|
||||
>
|
||||
<PasswordField
|
||||
id="password-current"
|
||||
ref="currentRef"
|
||||
:label="PASSWORD_TEXT.current"
|
||||
:model-value="current"
|
||||
autocomplete="current-password"
|
||||
:shown="showCurrent"
|
||||
:toggle-label="PASSWORD_TEXT.showCurrent"
|
||||
:error="currentError"
|
||||
@update:model-value="setCurrent"
|
||||
@toggle="showCurrent = !showCurrent"
|
||||
/>
|
||||
<PasswordField
|
||||
id="password-next"
|
||||
ref="nextRef"
|
||||
:label="PASSWORD_TEXT.next"
|
||||
:model-value="next"
|
||||
autocomplete="new-password"
|
||||
:shown="showNext"
|
||||
:toggle-label="PASSWORD_TEXT.showNext"
|
||||
:hint="PASSWORD_TEXT.hint"
|
||||
:hint-is-error="problems.next === 'rule'"
|
||||
:error="nextError"
|
||||
@update:model-value="setNext"
|
||||
@toggle="showNext = !showNext"
|
||||
/>
|
||||
<PasswordField
|
||||
id="password-confirm"
|
||||
ref="confirmRef"
|
||||
:label="PASSWORD_TEXT.confirm"
|
||||
:model-value="confirm"
|
||||
autocomplete="new-password"
|
||||
:shown="showNext"
|
||||
:error="confirmError"
|
||||
@update:model-value="setConfirm"
|
||||
/>
|
||||
<div class="password-form__actions">
|
||||
<button
|
||||
id="password-submit"
|
||||
type="submit"
|
||||
class="password-form__submit"
|
||||
:disabled="submitting"
|
||||
:aria-busy="submitting"
|
||||
>
|
||||
{{ PASSWORD_TEXT.submit }}
|
||||
</button>
|
||||
<span
|
||||
id="password-status"
|
||||
class="password-form__status"
|
||||
role="status"
|
||||
>
|
||||
<template v-if="done">
|
||||
<AppIcon
|
||||
name="check"
|
||||
:size="15"
|
||||
:stroke-width="2.2"
|
||||
/>
|
||||
<span>{{ PASSWORD_TEXT.done }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-18);
|
||||
max-width: var(--form-max-width);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.password-form__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.password-form__submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-22);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.password-form__submit:hover:not(:disabled) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.password-form__submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.password-form__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
color: var(--color-success-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
// 비밀번호 칸 하나 — 이름 · 칸(44) · [보기/숨기기](44 · 눈 아이콘 · aria-pressed) · 안내 · 오류 (디자인 원본 SettingsAccount)
|
||||
// - 오류는 칸 아래 빨간 글(alert-circle) · 칸 테두리도 빨강. hintIsError 면 안내 글 자체가 오류가 된다 (새 비밀번호의 규칙)
|
||||
// - 보기 단추의 이름은 바뀌지 않는다 — 켜짐은 aria-pressed 로 알린다 (아이콘만 눈 ↔ 빗금 눈)
|
||||
// - 칸의 설명(aria-describedby)은 안내 → 오류 순서
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
label: string
|
||||
modelValue: string
|
||||
autocomplete: 'current-password' | 'new-password'
|
||||
/** 글자를 보이는 중 */
|
||||
shown: boolean
|
||||
/** 보기 단추의 이름 — 없으면 단추를 두지 않는다 (확인 칸은 새 비밀번호의 단추를 따른다) */
|
||||
toggleLabel?: string
|
||||
hint?: string
|
||||
/** 안내 글이 그대로 오류다 */
|
||||
hintIsError?: boolean
|
||||
/** 오류 문구 — 사전 문구 또는 디자인 문구 */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'toggle'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
toggleLabel: '',
|
||||
hint: '',
|
||||
hintIsError: false,
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const hintId = computed(() => `${props.id}-hint`)
|
||||
const errorId = computed(() => `${props.id}-error`)
|
||||
const invalid = computed(() => Boolean(props.error) || props.hintIsError)
|
||||
const describedBy = computed(() => {
|
||||
const ids = [props.hint ? hintId.value : '', props.error ? errorId.value : ''].filter(Boolean)
|
||||
return ids.length > 0 ? ids.join(' ') : undefined
|
||||
})
|
||||
|
||||
function onInput(event: Event) {
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="password-field">
|
||||
<label
|
||||
:for="id"
|
||||
class="password-field__label"
|
||||
>{{ label }}</label>
|
||||
<div :class="['form-input', 'password-field__box', { 'is-invalid': invalid }]">
|
||||
<input
|
||||
:id="id"
|
||||
ref="inputRef"
|
||||
class="form-input__control"
|
||||
:type="shown ? 'text' : 'password'"
|
||||
:autocomplete="autocomplete"
|
||||
:value="modelValue"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
:aria-invalid="invalid ? 'true' : 'false'"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onInput"
|
||||
>
|
||||
<button
|
||||
v-if="toggleLabel"
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="toggleLabel"
|
||||
:title="toggleLabel"
|
||||
:aria-pressed="shown"
|
||||
:aria-controls="id"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<AppIcon :name="shown ? 'eye-off' : 'eye'" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="hint"
|
||||
:id="hintId"
|
||||
:class="['form-message', { 'form-message--error': hintIsError }]"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="hintIsError"
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ hint }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="error"
|
||||
:id="errorId"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.password-field__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 상자 안에 칸과 단추 — 상자가 테두리 · 초점을 그린다 (base.css .form-input). 칸 · 단추가 상자 높이(44)를 채운다 */
|
||||
.password-field__box {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 44 */
|
||||
.password-field__toggle {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-touch);
|
||||
border: 0;
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.password-field__toggle:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.password-field__toggle[aria-pressed='true'] {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 화면의 틀 — 제목(18px) · 설명 + 내용 (디자인 원본 SettingsAccount · SettingsVacation)
|
||||
// 콘텐츠 열은 읽기 · 쓰기 화면과 같은 값이다 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (tokens.css --content-*), 묶음 사이 22.
|
||||
// 사용 예: <SettingsPageFrame title-id="password-page-title" :title="…" :description="…">…</SettingsPageFrame>
|
||||
interface Props {
|
||||
/** 제목(h1)의 id — main 이 aria-labelledby 로 가리킨다 */
|
||||
titleId: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="settings-page"
|
||||
:aria-labelledby="titleId"
|
||||
>
|
||||
<div class="settings-page__column">
|
||||
<div class="settings-page__intro">
|
||||
<h1
|
||||
:id="titleId"
|
||||
class="settings-page__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="settings-page__description">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
flex: 999 1 35rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-page__column {
|
||||
max-width: var(--content-max-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
|
||||
}
|
||||
|
||||
.settings-page__intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.settings-page__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.settings-page__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 이 칸만 따로 스크롤한다 */
|
||||
@media (min-width: 70rem) {
|
||||
.settings-page {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답 설정 (디자인 원본 SettingsVacation · 계약 2절 "부재중 자동 응답")
|
||||
// 위에서부터 [x] 사용(44) → 기간(VacationPeriodFields) → 제목 · 내용(VacationMessageFields) → [저장] + 상태 글 → 미리 보기(VacationPreview).
|
||||
// - 끄면 아래 칸을 흐리게(쓴 글은 남긴다). 켤 때만 제목 · 내용이 필요하다 — 꺼 두면 빈 칸도 저장된다
|
||||
// - 저장하기 전에 막는 것(켜 둘 때): 기간(날짜가 없음 · 시작이 끝보다 늦음) · 빈 제목 · 빈 내용 — 그 칸 아래에 사전 문구(VAL_001), 첫 칸으로 초점.
|
||||
// 서버의 거절(VAL_001)은 저장 단추 옆에 사전 문구로 (알림은 끈다) — 저장하는 사이 화면을 떠났으면 알림으로 (notifyApiError)
|
||||
// - 저장하면 "저장했습니다. 10월 30일(금)부터 11월 3일(화)까지 자동으로 답합니다." 등 — 무엇이든 다시 바꾸면 내린다
|
||||
// - 나가기 확인: 불러온(저장한 뒤에는 저장한) 값과 다르면 앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창 (서명 설정과 같다).
|
||||
// 저장하는 중 · 세션이 끝났을 때는 앱 안 이동을 묻지 않는다
|
||||
// 고정 id — #vacation-enabled · #vacation-save · #vacation-save-status ·
|
||||
// 나가기 확인 #vacation-leave-dialog · #vacation-leave-stay · #vacation-leave-confirm (칸의 id 는 각 칸 컴포넌트에)
|
||||
import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
|
||||
import VacationMessageFields from '@/components/settings/VacationMessageFields.vue'
|
||||
import VacationPeriodFields from '@/components/settings/VacationPeriodFields.vue'
|
||||
import VacationPreview from '@/components/settings/VacationPreview.vue'
|
||||
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
|
||||
import { VACATION_LEAVE_DIALOG, VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import type { MailVacation } from '@/shared/types/mail'
|
||||
import { formatDate } from '@/shared/utils/format'
|
||||
import {
|
||||
sameVacationForm,
|
||||
vacationFormOf,
|
||||
vacationPayloadOf,
|
||||
vacationProblems,
|
||||
vacationSavedText,
|
||||
type VacationForm,
|
||||
type VacationPeriod,
|
||||
type VacationProblem,
|
||||
} from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailVacationStore } from '@/stores/mail-vacation.store'
|
||||
|
||||
interface Props {
|
||||
/** 서버의 설정 — 이 값으로 칸을 채운다 */
|
||||
vacation: MailVacation
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const vacationStore = useMailVacationStore()
|
||||
|
||||
const periodRef = ref<InstanceType<typeof VacationPeriodFields> | null>(null)
|
||||
const messageRef = ref<InstanceType<typeof VacationMessageFields> | null>(null)
|
||||
|
||||
const form = reactive<VacationForm>(vacationFormOf(props.vacation))
|
||||
/** 불러온(저장한 뒤에는 저장한) 값 — 이것과 다르면 바꾼 것이다 */
|
||||
const saved = shallowRef<VacationForm>(vacationFormOf(props.vacation))
|
||||
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
|
||||
const savedText = ref<string>('')
|
||||
const problems = ref<VacationProblem[]>([])
|
||||
/** 서버가 거절한 까닭 (사전 문구) */
|
||||
const saveError = ref<string | null>(null)
|
||||
/** 화면이 사라졌다 — 저장 단추 옆 글을 볼 곳이 없다 */
|
||||
let unmounted = false
|
||||
|
||||
const changed = computed(() => !sameVacationForm(form, saved.value))
|
||||
const problemText = getErrorMessage('VAL_001')
|
||||
|
||||
/** 그 칸에 걸린 문제의 문구 — 없으면 null */
|
||||
function problemOf(problem: VacationProblem): string | null {
|
||||
return problems.value.includes(problem) ? problemText : null
|
||||
}
|
||||
|
||||
const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({
|
||||
active: () => changed.value && saveState.value !== 'saving' && authStore.isLoggedIn,
|
||||
unloadActive: () => changed.value && authStore.isLoggedIn,
|
||||
})
|
||||
|
||||
/** 칸을 고친다 — 그 칸의 문제와 "저장했습니다" · 서버 거절을 내린다 */
|
||||
function edit(patch: Partial<VacationForm>, clears: readonly VacationProblem[]) {
|
||||
Object.assign(form, patch)
|
||||
problems.value = problems.value.filter((problem) => !clears.includes(problem))
|
||||
saveError.value = null
|
||||
if (saveState.value === 'saved') saveState.value = 'idle'
|
||||
}
|
||||
|
||||
/** 켜고 끄기 — 끄면 칸이 흐려지므로 걸려 있던 문제도 내린다 */
|
||||
function setEnabled(event: Event) {
|
||||
edit({ isEnabled: (event.target as HTMLInputElement).checked }, ['period', 'subject', 'textBody'])
|
||||
}
|
||||
|
||||
/** 기간 정하기로 바꾸는데 날짜가 비었으면 시작을 오늘로 채운다 (끝은 사용자가 고른다) */
|
||||
function setPeriod(period: VacationPeriod) {
|
||||
const prefill = period === 'range' && !form.fromDay && !form.toDay ? { fromDay: formatDate(new Date()) } : {}
|
||||
edit({ period, ...prefill }, ['period'])
|
||||
}
|
||||
|
||||
function focusFirst(found: readonly VacationProblem[]) {
|
||||
if (found.includes('period')) periodRef.value?.focus()
|
||||
else if (found.includes('subject')) messageRef.value?.focusSubject()
|
||||
else if (found.includes('textBody')) messageRef.value?.focusBody()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saveState.value === 'saving') return
|
||||
saveError.value = null
|
||||
const found = vacationProblems(form)
|
||||
problems.value = found
|
||||
if (found.length > 0) {
|
||||
focusFirst(found)
|
||||
return
|
||||
}
|
||||
const sent: VacationForm = { ...form }
|
||||
saveState.value = 'saving'
|
||||
try {
|
||||
const result = await vacationStore.save(vacationPayloadOf(sent), { silent: ['VAL_001'] })
|
||||
const savedForm = vacationFormOf(result)
|
||||
// 저장하는 동안 더 고치지 않았으면 서버가 저장한 값으로 맞춘다 (흐린 칸의 맞지 않는 기간은 비워 저장했다)
|
||||
if (sameVacationForm(form, sent)) Object.assign(form, savedForm)
|
||||
saved.value = savedForm
|
||||
savedText.value = vacationSavedText(result)
|
||||
saveState.value = 'saved'
|
||||
} catch (error) {
|
||||
saveState.value = 'idle'
|
||||
const apiError = toApiError(error)
|
||||
if (apiError.code !== 'VAL_001') return
|
||||
// 알림을 끈 거절 — 화면을 떠났으면 알림으로 알린다 (보내는 사이 떠나도 묻지 않는다)
|
||||
if (unmounted) notifyApiError(apiError)
|
||||
else saveError.value = apiError.message
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vacation-form">
|
||||
<label class="vacation-form__toggle">
|
||||
<input
|
||||
id="vacation-enabled"
|
||||
type="checkbox"
|
||||
class="vacation-form__checkbox"
|
||||
:checked="form.isEnabled"
|
||||
@change="setEnabled"
|
||||
>
|
||||
<span>{{ VACATION_TEXT.enable }}</span>
|
||||
</label>
|
||||
|
||||
<VacationPeriodFields
|
||||
ref="periodRef"
|
||||
:period="form.period"
|
||||
:from-day="form.fromDay"
|
||||
:to-day="form.toDay"
|
||||
:disabled="!form.isEnabled"
|
||||
:error="problemOf('period')"
|
||||
@update:period="setPeriod"
|
||||
@update:from-day="edit({ fromDay: $event }, ['period'])"
|
||||
@update:to-day="edit({ toDay: $event }, ['period'])"
|
||||
/>
|
||||
|
||||
<VacationMessageFields
|
||||
ref="messageRef"
|
||||
:subject="form.subject"
|
||||
:text-body="form.textBody"
|
||||
:disabled="!form.isEnabled"
|
||||
:subject-error="problemOf('subject')"
|
||||
:body-error="problemOf('textBody')"
|
||||
@update:subject="edit({ subject: $event }, ['subject'])"
|
||||
@update:text-body="edit({ textBody: $event }, ['textBody'])"
|
||||
/>
|
||||
|
||||
<div class="vacation-form__actions">
|
||||
<button
|
||||
id="vacation-save"
|
||||
type="button"
|
||||
class="vacation-form__save"
|
||||
:disabled="saveState === 'saving'"
|
||||
:aria-busy="saveState === 'saving'"
|
||||
@click="save"
|
||||
>
|
||||
{{ saveState === 'saving' ? VACATION_TEXT.saving : VACATION_TEXT.save }}
|
||||
</button>
|
||||
<span
|
||||
id="vacation-save-status"
|
||||
:class="['vacation-form__status', { 'is-error': saveError }]"
|
||||
role="status"
|
||||
>
|
||||
<template v-if="saveError">
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ saveError }}</span>
|
||||
</template>
|
||||
<template v-else-if="saveState === 'saved'">
|
||||
<AppIcon
|
||||
name="check"
|
||||
:size="15"
|
||||
:stroke-width="2.2"
|
||||
/>
|
||||
<span>{{ savedText }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr class="vacation-form__rule">
|
||||
|
||||
<VacationPreview
|
||||
:enabled="form.isEnabled"
|
||||
:sender-name="authStore.user?.name ?? ''"
|
||||
:sender-email="authStore.user?.email ?? ''"
|
||||
:subject="form.subject"
|
||||
:text-body="form.textBody"
|
||||
/>
|
||||
|
||||
<!-- 저장하지 않은 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
|
||||
<LeaveConfirmDialog
|
||||
:open="leaveConfirming"
|
||||
id-prefix="vacation-leave"
|
||||
:copy="VACATION_LEAVE_DIALOG"
|
||||
@stay="stayOnForm"
|
||||
@leave="leaveForm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
}
|
||||
|
||||
.vacation-form__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vacation-form__checkbox {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vacation-form__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.vacation-form__save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-22);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vacation-form__save:hover:not(:disabled) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.vacation-form__save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vacation-form__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
color: var(--color-success-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vacation-form__status.is-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.vacation-form__rule {
|
||||
width: 100%;
|
||||
height: var(--border-width);
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답의 제목(44 · 최대 폭 520 · 200자) · 내용(여러 줄 · 4,000자 · 글자만) (디자인 원본 SettingsVacation)
|
||||
// 끈 동안은 흐리게(쓴 글은 남긴다). 문제가 있으면 그 칸 아래에 사전 문구 — 검사는 부모(VacationForm)가 한다.
|
||||
// 고정 id — #vacation-subject · #vacation-subject-error · #vacation-body · #vacation-body-hint · #vacation-body-error
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { VACATION_BODY_MAX_LENGTH, VACATION_SUBJECT_MAX_LENGTH, VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
|
||||
interface Props {
|
||||
subject: string
|
||||
textBody: string
|
||||
disabled: boolean
|
||||
/** 칸마다의 문제 문구 — 없으면 null */
|
||||
subjectError?: string | null
|
||||
bodyError?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:subject', value: string): void
|
||||
(e: 'update:textBody', value: string): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
subjectError: null,
|
||||
bodyError: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const subjectRef = ref<HTMLInputElement | null>(null)
|
||||
const bodyRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function valueOf(event: Event): string {
|
||||
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusSubject: () => subjectRef.value?.focus(),
|
||||
focusBody: () => bodyRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vacation-message">
|
||||
<div class="vacation-message__field">
|
||||
<label
|
||||
for="vacation-subject"
|
||||
class="vacation-message__label"
|
||||
>{{ VACATION_TEXT.subject }}</label>
|
||||
<input
|
||||
id="vacation-subject"
|
||||
ref="subjectRef"
|
||||
class="form-input vacation-message__subject"
|
||||
type="text"
|
||||
:value="subject"
|
||||
:maxlength="VACATION_SUBJECT_MAX_LENGTH"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="subjectError ? 'true' : 'false'"
|
||||
:aria-describedby="subjectError ? 'vacation-subject-error' : undefined"
|
||||
@input="emit('update:subject', valueOf($event))"
|
||||
>
|
||||
<p
|
||||
v-if="subjectError"
|
||||
id="vacation-subject-error"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ subjectError }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="vacation-message__field">
|
||||
<label
|
||||
for="vacation-body"
|
||||
class="vacation-message__label"
|
||||
>{{ VACATION_TEXT.body }}</label>
|
||||
<textarea
|
||||
id="vacation-body"
|
||||
ref="bodyRef"
|
||||
class="form-input vacation-message__body"
|
||||
rows="6"
|
||||
:value="textBody"
|
||||
:maxlength="VACATION_BODY_MAX_LENGTH"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="bodyError ? 'true' : 'false'"
|
||||
:aria-describedby="bodyError ? 'vacation-body-hint vacation-body-error' : 'vacation-body-hint'"
|
||||
@input="emit('update:textBody', valueOf($event))"
|
||||
/>
|
||||
<p
|
||||
v-if="bodyError"
|
||||
id="vacation-body-error"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ bodyError }}</span>
|
||||
</p>
|
||||
<p
|
||||
id="vacation-body-hint"
|
||||
class="form-message"
|
||||
>
|
||||
{{ VACATION_TEXT.bodyHint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
}
|
||||
|
||||
.vacation-message__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.vacation-message__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vacation-message__subject {
|
||||
width: 100%;
|
||||
max-width: var(--form-wide-max-width);
|
||||
}
|
||||
|
||||
.vacation-message__body {
|
||||
width: 100%;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
line-height: var(--line-height-body);
|
||||
resize: vertical;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답의 기간 (디자인 원본 SettingsVacation) — (•) 끌 때까지 계속 / ( ) 기간 정하기 → 시작 ~ 끝 날짜 칸(44)
|
||||
// "시작일 0시부터 끝나는 날 자정까지 답합니다 (이 브라우저의 시간대 기준)." 끈 동안은 묶음째 흐리게(쓴 값은 남긴다).
|
||||
// 날짜는 <input type="date"> 값(YYYY-MM-DD) 그대로 주고받는다 — 시각으로 바꾸는 일은 mail-vacation.ts
|
||||
// 고정 id — #vacation-period-until-off · #vacation-period-range · #vacation-from · #vacation-to · #vacation-period-error
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
import type { VacationPeriod } from '@/shared/utils/mail-vacation'
|
||||
|
||||
interface Props {
|
||||
period: VacationPeriod
|
||||
fromDay: string
|
||||
toDay: string
|
||||
disabled: boolean
|
||||
/** 기간이 맞지 않는다 — 사전 문구 */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:period', period: VacationPeriod): void
|
||||
(e: 'update:fromDay', day: string): void
|
||||
(e: 'update:toDay', day: string): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
error: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const fromRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function valueOf(event: Event): string {
|
||||
return (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/** 기간이 맞지 않을 때 — 시작 날짜 칸으로 */
|
||||
focus: () => fromRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset
|
||||
class="vacation-period"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<legend class="vacation-period__legend">
|
||||
{{ VACATION_TEXT.periodLegend }}
|
||||
</legend>
|
||||
<label class="vacation-period__option">
|
||||
<input
|
||||
id="vacation-period-until-off"
|
||||
type="radio"
|
||||
name="vacation-period"
|
||||
class="vacation-period__radio"
|
||||
:checked="period === 'untilOff'"
|
||||
@change="emit('update:period', 'untilOff')"
|
||||
>
|
||||
<span>{{ VACATION_TEXT.untilOff }}</span>
|
||||
</label>
|
||||
<label class="vacation-period__option">
|
||||
<input
|
||||
id="vacation-period-range"
|
||||
type="radio"
|
||||
name="vacation-period"
|
||||
class="vacation-period__radio"
|
||||
:checked="period === 'range'"
|
||||
@change="emit('update:period', 'range')"
|
||||
>
|
||||
<span>{{ VACATION_TEXT.range }}</span>
|
||||
</label>
|
||||
<template v-if="period === 'range'">
|
||||
<div class="vacation-period__dates">
|
||||
<label
|
||||
for="vacation-from"
|
||||
class="vacation-period__date-label"
|
||||
>{{ VACATION_TEXT.from }}</label>
|
||||
<input
|
||||
id="vacation-from"
|
||||
ref="fromRef"
|
||||
class="form-input vacation-period__date"
|
||||
type="date"
|
||||
:value="fromDay"
|
||||
:max="toDay || undefined"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="error ? 'vacation-period-hint vacation-period-error' : 'vacation-period-hint'"
|
||||
@input="emit('update:fromDay', valueOf($event))"
|
||||
>
|
||||
<span
|
||||
class="vacation-period__tilde"
|
||||
aria-hidden="true"
|
||||
>~</span>
|
||||
<label
|
||||
for="vacation-to"
|
||||
class="vacation-period__date-label"
|
||||
>{{ VACATION_TEXT.to }}</label>
|
||||
<input
|
||||
id="vacation-to"
|
||||
class="form-input vacation-period__date"
|
||||
type="date"
|
||||
:value="toDay"
|
||||
:min="fromDay || undefined"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="error ? 'vacation-period-hint vacation-period-error' : 'vacation-period-hint'"
|
||||
@input="emit('update:toDay', valueOf($event))"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
id="vacation-period-hint"
|
||||
class="form-message vacation-period__note"
|
||||
>
|
||||
{{ VACATION_TEXT.rangeHint }}
|
||||
</p>
|
||||
<p
|
||||
v-if="error"
|
||||
id="vacation-period-error"
|
||||
class="form-message form-message--error vacation-period__note"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
</template>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-period {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.vacation-period:disabled {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
.vacation-period__legend {
|
||||
margin: 0 0 var(--space-4);
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.vacation-period__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vacation-period:disabled .vacation-period__option {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.vacation-period__radio {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
}
|
||||
|
||||
/* 날짜 줄 — 라디오 글자 선(28)에 맞춰 들여 쓴다 */
|
||||
.vacation-period__dates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-8) var(--space-10);
|
||||
padding: var(--space-4) 0 0 var(--space-28);
|
||||
}
|
||||
|
||||
.vacation-period__date-label {
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.vacation-period__date {
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.vacation-period__tilde {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.vacation-period__note {
|
||||
margin: var(--space-6) 0 0 var(--space-28);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
// 부재중 자동 응답의 미리 보기 — "보낸 사람이 받는 답장" (디자인 원본 SettingsVacation)
|
||||
// 보낸 사람(나) · 제목 · 내용(글자 그대로 — 메일 본문 글꼴 14px · 줄 간격 1.75) + 아래 한 줄
|
||||
// (켜짐: "같은 사람에게 매번 보내지는 않습니다(메일 엔진 규칙)." · 꺼짐: "꺼져 있습니다 — 자동으로 답하지 않습니다.")
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { MAIL_NO_SUBJECT } from '@/shared/constants/mail'
|
||||
import { VACATION_TEXT } from '@/shared/constants/mail-vacation'
|
||||
|
||||
interface Props {
|
||||
enabled: boolean
|
||||
senderName: string
|
||||
senderEmail: string
|
||||
subject: string
|
||||
textBody: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const sender = computed(() =>
|
||||
props.senderName ? `${props.senderName} <${props.senderEmail}>` : props.senderEmail,
|
||||
)
|
||||
const shownSubject = computed(() => props.subject.trim() || MAIL_NO_SUBJECT)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="vacation-preview"
|
||||
aria-labelledby="vacation-preview-title"
|
||||
data-vacation-preview
|
||||
>
|
||||
<h2
|
||||
id="vacation-preview-title"
|
||||
class="vacation-preview__title"
|
||||
>
|
||||
{{ VACATION_TEXT.previewTitle }}
|
||||
</h2>
|
||||
<div class="vacation-preview__card">
|
||||
<dl class="vacation-preview__meta">
|
||||
<dt class="vacation-preview__label">
|
||||
{{ VACATION_TEXT.previewFrom }}
|
||||
</dt>
|
||||
<dd class="vacation-preview__value">
|
||||
{{ sender }}
|
||||
</dd>
|
||||
<dt class="vacation-preview__label">
|
||||
{{ VACATION_TEXT.previewSubject }}
|
||||
</dt>
|
||||
<dd
|
||||
class="vacation-preview__value"
|
||||
data-vacation-preview-subject
|
||||
>
|
||||
{{ shownSubject }}
|
||||
</dd>
|
||||
</dl>
|
||||
<!-- 글자 그대로 — 템플릿의 들여쓰기 공백이 끼지 않게 v-text 로 넣는다 -->
|
||||
<div
|
||||
class="vacation-preview__body"
|
||||
data-vacation-preview-body
|
||||
v-text="textBody"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="vacation-preview__note"
|
||||
data-vacation-preview-note
|
||||
>
|
||||
{{ enabled ? VACATION_TEXT.previewNoteOn : VACATION_TEXT.previewNoteOff }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vacation-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.vacation-preview__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vacation-preview__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
padding: var(--space-16) var(--space-20) var(--space-20);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 이름 칸 72 · 값 (12.5px) */
|
||||
.vacation-preview__meta {
|
||||
display: grid;
|
||||
grid-template-columns: var(--space-72) minmax(0, 1fr);
|
||||
row-gap: var(--space-2);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.vacation-preview__label {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.vacation-preview__value {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 받는 쪽이 보는 모양 — 글자 그대로(줄바꿈 · 공백), 메일 본문 글꼴 */
|
||||
.vacation-preview__body {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--font-family-mail-body);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-mail-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.vacation-preview__note {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -27,7 +27,7 @@ describe('AppRail', () => {
|
||||
|
||||
expect(current.text()).toBe('메일')
|
||||
expect(current.attributes('href')).toBe('/mail')
|
||||
// 설정은 첫 메뉴(메일 › 서명)로 바꿔 끼운다
|
||||
// 설정은 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다
|
||||
expect(links).toEqual([
|
||||
['메일', '/mail'],
|
||||
['설정', '/settings'],
|
||||
|
||||
Reference in New Issue
Block a user