generated from ttipo/project-base
feat(frontend): 쓰기 화면 나가기 확인·읽기 화면 본문 안내·내 메일 답장 받는 사람
- 쓰기 화면을 연 뒤 바꾼 것이 있으면 떠나기 전에 묻는다 — 앱 안 이동은 확인 대화상자, 탭 닫기·새로고침은 브라우저 확인창. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않고, 보내는 중에 떠나면 결과는 알림으로만 알린다(떠난 양식은 건드리지 않는다). 대화상자는 공용 ModalDialog 로 모았다 — 세션 만료 대화상자도 같은 것을 쓴다 - 본문 길이 초과는 MAIL_007 문구로 본문 아래에 알린다 (서버가 같은 코드를 돌려줘도 같다, 발송 키는 그대로) - 읽기 화면에 본문 안내(서식 없이 표시·표시할 수 없음·앞부분만 표시). 잘린 원문을 인용하면 생략 표시를 둔다. 인용할 원문은 읽기 화면과 같은 규칙으로 고른다 — 공백뿐인 HTML·그림만 남는 HTML 은 글자 본문으로 - 붙여 넣은 글·원문 인용에서 8px 미만 글자 크기와 알파 0.3 미만 글자 색을 버린다 (서버와 같은 규칙, 같은 표본 표 105행) - 내가 보낸 메일에 답장하면 받는 사람이 원래 받는 사람이다 (전에는 받는 사람 칸이 비었다) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
|
||||
import { flushPromises } from '@/test-utils/mock-api'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
|
||||
// jsdom 에는 showModal 이 없다 — 대화상자는 open 속성만 붙였다 뗀다 (ModalDialog 의 대체 경로)
|
||||
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) wrapper.unmount()
|
||||
})
|
||||
|
||||
function mountDialog(open: boolean) {
|
||||
const wrapper = mount(ModalDialog, {
|
||||
attachTo: document.body,
|
||||
props: { open, title: '보내지 않은 메일이 있습니다', message: '글', titleId: 't', messageId: 'm', role: 'alertdialog' },
|
||||
slots: {
|
||||
actions: () => [
|
||||
h('button', { id: 'stay', type: 'button', autofocus: true }, '계속 쓰기'),
|
||||
h('button', { id: 'leave', type: 'button' }, '나가기'),
|
||||
],
|
||||
},
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('ModalDialog', () => {
|
||||
it('제목 · 글을 aria 로 잇고, 열면 autofocus 단추로 · 닫으면 연 때의 자리로 초점을 옮긴다', async () => {
|
||||
const before = document.createElement('button')
|
||||
document.body.append(before)
|
||||
before.focus()
|
||||
const wrapper = mountDialog(false)
|
||||
const dialog = wrapper.get('dialog')
|
||||
|
||||
expect(dialog.attributes()).toMatchObject({ role: 'alertdialog', 'aria-labelledby': 't', 'aria-describedby': 'm' })
|
||||
expect(dialog.attributes('open')).toBeUndefined()
|
||||
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
expect(dialog.attributes('open')).toBeDefined()
|
||||
expect(document.activeElement?.id).toBe('stay')
|
||||
|
||||
await wrapper.setProps({ open: false })
|
||||
expect(dialog.attributes('open')).toBeUndefined()
|
||||
expect(document.activeElement).toBe(before)
|
||||
before.remove()
|
||||
})
|
||||
|
||||
it('Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 닫을지는 쓰는 쪽이 정한다', async () => {
|
||||
const wrapper = mountDialog(true)
|
||||
await flushPromises()
|
||||
|
||||
const cancel = new Event('cancel', { cancelable: true })
|
||||
wrapper.get('dialog').element.dispatchEvent(cancel)
|
||||
|
||||
expect(cancel.defaultPrevented).toBe(true)
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
expect(wrapper.get('dialog').attributes('open')).toBeDefined()
|
||||
})
|
||||
|
||||
it('열려 있어야 하는데 브라우저가 스스로 닫으면(close) cancel 로 알린다 — 쓰는 쪽이 닫았을 때는 알리지 않는다', async () => {
|
||||
const wrapper = mountDialog(true)
|
||||
await flushPromises()
|
||||
const dialog = wrapper.get('dialog').element
|
||||
|
||||
dialog.dispatchEvent(new Event('close'))
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
|
||||
await wrapper.setProps({ open: false })
|
||||
dialog.dispatchEvent(new Event('close'))
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('한 대화상자가 닫히며 다른 대화상자가 열리면 닫기가 먼저다 — 여는 쪽을 먼저 고쳐도 둘이 겹쳐 열리지 않는다', async () => {
|
||||
const first = ref<boolean>(true)
|
||||
// 여는 쪽(B)을 앞에 둔다 — 화면을 고치는 순서가 B 먼저여도 A 가 먼저 닫혀야 한다 (세션 만료 대화상자가 앞에 있는 앱과 같다)
|
||||
const Pair = defineComponent({
|
||||
setup() {
|
||||
return () => [
|
||||
h(ModalDialog, { open: !first.value, title: 'B', message: 'b', titleId: 'b-t', messageId: 'b-m', id: 'b' }),
|
||||
h(ModalDialog, { open: first.value, title: 'A', message: 'a', titleId: 'a-t', messageId: 'a-m', id: 'a' }),
|
||||
]
|
||||
},
|
||||
})
|
||||
const wrapper = mount(Pair, { attachTo: document.body })
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
|
||||
const changes: string[] = []
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
const target = record.target as HTMLDialogElement
|
||||
changes.push(`${target.id}:${target.hasAttribute('open') ? 'open' : 'closed'}`)
|
||||
}
|
||||
})
|
||||
for (const dialog of wrapper.findAll('dialog')) {
|
||||
observer.observe(dialog.element, { attributes: true, attributeFilter: ['open'] })
|
||||
}
|
||||
|
||||
first.value = false
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
observer.disconnect()
|
||||
|
||||
expect(changes).toEqual(['a:closed', 'b:open'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · ComposeLeaveDialog(쓰기 화면 나가기 전 확인)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary 를 더한다)
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
/** 제목 · 글 문단의 id — aria-labelledby · aria-describedby 가 가리킨다 (화면에 하나뿐인 값) */
|
||||
titleId: string
|
||||
messageId: string
|
||||
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
|
||||
role?: 'alertdialog'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** Esc 로 닫으려 했다 — 브라우저가 막을 수 없게 스스로 닫았을 때도 같다 */
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
/** 열기 전에 초점이 있던 곳 — 닫으면 돌려준다 (브라우저가 돌려주지 않는 환경에서도) */
|
||||
let returnFocus: HTMLElement | null = null
|
||||
|
||||
function show() {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog || dialog.open) return
|
||||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal()
|
||||
else dialog.setAttribute('open', '')
|
||||
// showModal 은 autofocus 단추로 초점을 옮긴다 — 없는 환경에서도 같게
|
||||
dialog.querySelector<HTMLElement>('[autofocus]')?.focus()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog?.open) return
|
||||
if (typeof dialog.close === 'function') dialog.close()
|
||||
else dialog.removeAttribute('open')
|
||||
// 초점이 대화상자 안에 남았거나 갈 곳을 잃었을 때만 돌려준다 (브라우저가 이미 돌려줬으면 그대로)
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body || dialog.contains(active)) returnFocus?.focus()
|
||||
returnFocus = null
|
||||
}
|
||||
|
||||
function onCancel(event: Event) {
|
||||
event.preventDefault()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
/** 열려 있어야 하는데 닫혔다 — 브라우저가 스스로 닫았다 (Esc 를 거듭 누르는 등). 쓰는 쪽의 상태와 맞춘다 */
|
||||
function onClose() {
|
||||
if (props.open) emit('cancel')
|
||||
}
|
||||
|
||||
// 닫기는 쓰는 쪽이 다시 그릴 때 바로, 열기는 화면을 다 고친 뒤에 (위 "닫기는 열기보다 먼저")
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) hide()
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) show()
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.open) show()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="modal-dialog"
|
||||
:role="role"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="messageId"
|
||||
@cancel="onCancel"
|
||||
@close="onClose"
|
||||
>
|
||||
<h2
|
||||
:id="titleId"
|
||||
class="modal-dialog__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
:id="messageId"
|
||||
class="modal-dialog__message"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<div class="modal-dialog__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-dialog {
|
||||
width: min(22.5rem, calc(100vw - var(--space-48)));
|
||||
padding: var(--space-18) var(--space-18) var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.modal-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.modal-dialog::backdrop {
|
||||
background: var(--color-backdrop);
|
||||
}
|
||||
|
||||
.modal-dialog__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-dialog__message {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 단추 줄 — 오른쪽 정렬 */
|
||||
.modal-dialog__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-8);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* 주 단추 — 브랜드색 (슬롯으로 받은 단추라 :slotted 로 꾸민다) */
|
||||
:slotted(.modal-dialog__action) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action:hover) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 보조 단추 — 바탕 없이 (쓰기 화면의 "취소"와 같은 모양) */
|
||||
:slotted(.modal-dialog__action--secondary) {
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:slotted(.modal-dialog__action--secondary:hover) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -2,9 +2,10 @@
|
||||
// 세션 만료(AUTH_002) 안내 — 디자인 원본 States "세션 만료"
|
||||
// useApi 가 로그인 상태의 401(갱신 실패)을 받으면 authStore.expireSession() 을 부르고, 이 대화상자가 뜬다.
|
||||
// 모달이라 뒤의 화면(이미 정리된 세션)을 더 조작할 수 없다. Esc 로 닫아도 로그인 화면으로 보낸다.
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
// 대화상자 틀(열기 · 닫기 · Esc · 모양)은 ModalDialog 가 맡는다.
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
@@ -12,125 +13,34 @@ const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
/** 문구는 에러 코드 사전에서만 온다 */
|
||||
const message = getErrorMessage('AUTH_002')
|
||||
|
||||
function open() {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog || dialog.open) return
|
||||
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal()
|
||||
else dialog.setAttribute('open', '')
|
||||
}
|
||||
|
||||
function close() {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog?.open) return
|
||||
if (typeof dialog.close === 'function') dialog.close()
|
||||
else dialog.removeAttribute('open')
|
||||
}
|
||||
|
||||
async function goToLogin() {
|
||||
// 로그인 후 보던 화면으로 돌아오게 한다 (로그인이 필요한 화면일 때만)
|
||||
const redirect = route.meta.requiresAuth ? route.fullPath : undefined
|
||||
authStore.acknowledgeSessionExpired()
|
||||
await router.push({ name: 'login', query: redirect ? { redirect } : {} })
|
||||
}
|
||||
|
||||
function onCancel(event: Event) {
|
||||
event.preventDefault()
|
||||
void goToLogin()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.sessionExpired,
|
||||
(expired) => (expired ? open() : close()),
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (authStore.sessionExpired) open()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="session-dialog"
|
||||
aria-labelledby="session-expired-title"
|
||||
aria-describedby="session-expired-message"
|
||||
@cancel="onCancel"
|
||||
<ModalDialog
|
||||
:open="authStore.sessionExpired"
|
||||
title="다시 로그인해 주세요"
|
||||
:message="message"
|
||||
title-id="session-expired-title"
|
||||
message-id="session-expired-message"
|
||||
@cancel="goToLogin"
|
||||
>
|
||||
<h2
|
||||
id="session-expired-title"
|
||||
class="session-dialog__title"
|
||||
>
|
||||
다시 로그인해 주세요
|
||||
</h2>
|
||||
<p
|
||||
id="session-expired-message"
|
||||
class="session-dialog__message"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="session-dialog__action"
|
||||
@click="goToLogin"
|
||||
>
|
||||
로그인 화면으로
|
||||
</button>
|
||||
</dialog>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
@click="goToLogin"
|
||||
>
|
||||
로그인 화면으로
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.session-dialog {
|
||||
width: min(22.5rem, calc(100vw - var(--space-48)));
|
||||
padding: var(--space-18) var(--space-18) var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.session-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.session-dialog::backdrop {
|
||||
background: var(--color-backdrop);
|
||||
}
|
||||
|
||||
.session-dialog__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.session-dialog__message {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.session-dialog__action {
|
||||
align-self: flex-end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--size-touch);
|
||||
margin-top: var(--space-4);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-dialog__action:hover {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
// 쓰기 화면 나가기 전 확인 — 디자인 README "나가기 전 확인" (세션 만료 대화상자와 같은 모양 · ModalDialog)
|
||||
// [계속 쓰기]가 처음 초점이고 Esc 도 같다. 쓰기 화면이 답을 받아 이동을 취소하거나(계속 쓰기) 그대로 보낸다(나가기).
|
||||
// 고정 id — 화면 검증 · 테스트가 쓴다: 대화상자 #compose-leave-dialog · 제목 #compose-leave-title · 글 #compose-leave-message ·
|
||||
// 단추 #compose-leave-stay(계속 쓰기) · #compose-leave-confirm(나가기)
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { COMPOSE_LEAVE_DIALOG } from '@/shared/constants/mail'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 계속 쓰기 (Esc 포함) — 이동을 취소한다 */
|
||||
(e: 'stay'): void
|
||||
/** 나가기 — 쓰던 내용을 버리고 이동한다 */
|
||||
(e: 'leave'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="compose-leave-dialog"
|
||||
:open="open"
|
||||
role="alertdialog"
|
||||
:title="COMPOSE_LEAVE_DIALOG.title"
|
||||
:message="COMPOSE_LEAVE_DIALOG.message"
|
||||
title-id="compose-leave-title"
|
||||
message-id="compose-leave-message"
|
||||
@cancel="emit('stay')"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- 처음 초점 — 실수로 Enter · Space 를 눌러도 쓰던 내용이 남는다 -->
|
||||
<button
|
||||
id="compose-leave-stay"
|
||||
type="button"
|
||||
class="modal-dialog__action"
|
||||
autofocus
|
||||
@click="emit('stay')"
|
||||
>
|
||||
{{ COMPOSE_LEAVE_DIALOG.stay }}
|
||||
</button>
|
||||
<button
|
||||
id="compose-leave-confirm"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('leave')"
|
||||
>
|
||||
{{ COMPOSE_LEAVE_DIALOG.leave }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
@@ -98,6 +99,68 @@ describe('MessageReader 도구 모음', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
async function mountWith(detail: MessageDetail) {
|
||||
const store = useMailStore()
|
||||
store.selected = detail
|
||||
store.selectedId = detail.id
|
||||
store.selectedStatus = 'ready'
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/inbox/m1')
|
||||
await router.isReady()
|
||||
return mount(MessageReader, { global: { plugins: [pinia, router] } })
|
||||
}
|
||||
|
||||
function notices(wrapper: Awaited<ReturnType<typeof mountWith>>) {
|
||||
return wrapper.findAll('.reader__notice').map((notice) => ({
|
||||
kind: notice.attributes('data-notice'),
|
||||
text: notice.text(),
|
||||
}))
|
||||
}
|
||||
|
||||
// 화면들이 같은 스토어(선택한 메일)를 보므로 한 번 띄울 때마다 바로 확인한다
|
||||
it('다 받은 본문이면 안내가 없다 — 표시 값이 없는(이전 서버) 응답도 같다', async () => {
|
||||
expect(notices(await mountWith(DETAIL))).toEqual([])
|
||||
expect(notices(await mountWith({ ...DETAIL, isHtmlBodyOmitted: false, isBodyTruncated: false }))).toEqual([])
|
||||
})
|
||||
|
||||
it('서식 본문을 비웠으면(isHtmlBodyOmitted) 글자 본문이 있을 때 · 없을 때 다른 문구', async () => {
|
||||
const withText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: '글자 본문' })
|
||||
expect(notices(withText)).toEqual([{ kind: 'htmlOmitted', text: MAIL_BODY_NOTICES.htmlOmitted }])
|
||||
|
||||
const withoutText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: null })
|
||||
expect(notices(withoutText)).toEqual([{ kind: 'unavailable', text: MAIL_BODY_NOTICES.unavailable }])
|
||||
})
|
||||
|
||||
it('생략 → 잘림 순서로, 외부 이미지 차단 안내 앞에 같은 모양으로 둔다', async () => {
|
||||
const wrapper = await mountWith({
|
||||
...DETAIL,
|
||||
htmlBody: '<p>앞부분</p>',
|
||||
isHtmlBodyOmitted: false,
|
||||
isBodyTruncated: true,
|
||||
hasBlockedImages: true,
|
||||
})
|
||||
|
||||
expect(notices(wrapper).map((notice) => notice.kind)).toEqual(['truncated', 'blockedImages'])
|
||||
expect(notices(wrapper)[0]?.text).toBe(MAIL_BODY_NOTICES.truncated)
|
||||
|
||||
const both = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '긴 글' })
|
||||
expect(notices(both).map((notice) => notice.kind)).toEqual(['htmlOmitted', 'truncated'])
|
||||
// 안내 줄 모양은 하나 — 아이콘 + 글
|
||||
for (const notice of both.findAll('.reader__notice')) {
|
||||
expect(notice.element.tagName).toBe('P')
|
||||
expect(notice.find('svg').exists()).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageReader 불러오기 실패', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import MessageAttachments from '@/components/mail/MessageAttachments.vue'
|
||||
import { useMailNavigation } from '@/composables/useMailNavigation'
|
||||
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
|
||||
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
@@ -42,6 +43,8 @@ const fromEmail = computed(() => message.value?.from?.email ?? '')
|
||||
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
|
||||
const subject = computed(() => message.value?.subject || '(제목 없음)')
|
||||
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
|
||||
/** 서버가 본문을 다 주지 못했을 때의 안내 — 서식을 비웠으면 먼저, 잘렸으면 다음 (계약 2절 "읽기 한도") */
|
||||
const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.value) : []))
|
||||
|
||||
async function onDelete() {
|
||||
const current = message.value
|
||||
@@ -203,9 +206,25 @@ function leaveToList() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 본문 안내 — 본문이 다 보이는지가 먼저다 (생략 → 잘림), 그다음 외부 이미지 차단 -->
|
||||
<p
|
||||
v-for="notice in bodyNotices"
|
||||
:key="notice.kind"
|
||||
class="reader__notice"
|
||||
:data-notice="notice.kind"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
class="reader__notice-icon"
|
||||
/>
|
||||
<span>{{ notice.text }}</span>
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="message.hasBlockedImages"
|
||||
class="reader__notice"
|
||||
data-notice="blockedImages"
|
||||
>
|
||||
<AppIcon
|
||||
name="shield"
|
||||
|
||||
@@ -41,6 +41,8 @@ const draft = ref<string>('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
|
||||
/** 아직 토큰이 되지 않은 입력이 있다 — 쓰기 화면이 "쓰던 내용"으로 본다 (나가기 전 확인 · 칸을 벗어나지 않고 뒤로 가기 등) */
|
||||
const hasDraft = computed(() => draft.value.trim() !== '')
|
||||
|
||||
/** 입력한 글을 토큰으로 확정한다 (여러 주소를 붙여 넣었으면 나눠서) */
|
||||
function commit(text: string = draft.value) {
|
||||
@@ -90,7 +92,12 @@ function focus() {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ commit, focus })
|
||||
/** 아직 토큰이 되지 않은 입력을 버린다 — 쓰기 화면이 양식을 새로 채울 때 */
|
||||
function clearDraft() {
|
||||
draft.value = ''
|
||||
}
|
||||
|
||||
defineExpose({ commit, focus, clearDraft, hasDraft })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -45,7 +45,7 @@ interface Emits {
|
||||
(e: 'ready'): void
|
||||
/** 본문에 붙여 넣거나 끌어다 놓은 파일 — 첨부로 올린다 */
|
||||
(e: 'files', files: File[]): void
|
||||
/** 본문 내용이 바뀌었다 (커서만 옮긴 것은 빼고) */
|
||||
/** 본문 내용이 바뀌었다 (커서만 옮긴 것은 빼고) — 연 때와 다른지는 isChanged 로 묻는다 */
|
||||
(e: 'change'): void
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ const revision = ref<number>(0)
|
||||
let pastedImageCount = 0
|
||||
/** 편집 영역을 화면에 붙였다 — 그 전에는 편집기 화면(view)이 없다 */
|
||||
let mounted = false
|
||||
/**
|
||||
* 편집기를 다 연 때의 문서(답장 · 전달 초안은 맞춘 뒤) — 쓰기 화면이 "본문을 바꿨는가"를 이것과 비교한다 (나가기 전 확인).
|
||||
* settled: 같은 문서에 편집기가 스스로 끝 빈 줄을 붙인 모양 — 끝이 인용처럼 문단이 아니면 첫 거래(초점 옮기기 등) 때
|
||||
* 붙인다(StarterKit 의 TrailingNode). 사용자가 쓴 것이 아니므로 "바꿈"이 아니다.
|
||||
*/
|
||||
let opened: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
|
||||
|
||||
function initialContent(extensions: AnyExtension[]): Content {
|
||||
if (!props.initialHtml) return ''
|
||||
@@ -176,6 +182,13 @@ function getContentForSend(): MailEditorContent | null {
|
||||
return content
|
||||
}
|
||||
|
||||
/** 연 뒤에 본문을 바꿨는가 — 지웠다 다시 써 처음과 같아졌으면 바꾸지 않은 것이다 */
|
||||
function isChanged(): boolean {
|
||||
if (!opened) return false
|
||||
const { doc } = editor.state
|
||||
return !doc.eq(opened.doc) && !doc.eq(opened.settled)
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문에 초점을 준다 — 'start' · 'end' 면 커서를 맨 위(답장) · 맨 끝으로, 주지 않으면 커서 자리 그대로.
|
||||
* TipTap 의 focus 명령은 다음 화면 그리기 때 초점을 옮긴다 — 곧바로 쓰는 첫 글자가 사라지지 않게 바로 옮긴다.
|
||||
@@ -194,8 +207,11 @@ onMounted(() => {
|
||||
editor.mount(bodyRef.value)
|
||||
mounted = true
|
||||
}
|
||||
// 붙이면서 플러그인(실행 취소 기록 등)이 켜진다 — 툴바가 다시 읽게 한다
|
||||
// 붙이면서 플러그인(실행 취소 기록 · 끝 빈 줄 등)이 켜진다 — 툴바가 다시 읽게 하고, 연 때의 문서를 찍어 둔다.
|
||||
// settled 는 빈 거래 하나를 적용해 본 모양이다 (화면에 보내지 않는다 — 플러그인이 붙일 것만 미리 본다)
|
||||
revision.value += 1
|
||||
const { state } = editor
|
||||
opened = { doc: state.doc, settled: state.apply(state.tr).doc }
|
||||
emit('ready')
|
||||
})
|
||||
|
||||
@@ -211,7 +227,7 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({ getContent, getContentForSend, focus })
|
||||
defineExpose({ getContent, getContentForSend, focus, isChanged })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('fitDraftDoc — 답장·전달 초안 한도 (계약 2절)', () => {
|
||||
expect(fitted.child(0).textContent).toBe('내가 쓴 첫 줄')
|
||||
})
|
||||
|
||||
it('원문 인용이 없는 문서는 그대로 둔다 (넘어도 — 보낼 때 서버가 VAL_001 로 막는다)', () => {
|
||||
it('원문 인용이 없는 문서는 그대로 둔다 (넘어도 — 쓰기 화면이 보내지 않고, 보내도 서버가 MAIL_007 로 막는다)', () => {
|
||||
const doc = createDocument(lines(200), schema)
|
||||
|
||||
expect(fitDraftDoc(doc, schema, { text: 100, html: 1_000 })).toBe(doc)
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface DraftLimits {
|
||||
html: number
|
||||
}
|
||||
|
||||
/** 발송 본문 한도 — 계약 2절 "발송 본문" (넘기면 400 VAL_001) */
|
||||
/** 발송 본문 한도 — 계약 2절 "발송 본문" (넘기면 400 MAIL_007) */
|
||||
export const DRAFT_LIMITS: DraftLimits = { text: MAIL_BODY_MAX_LENGTH, html: MAIL_BODY_HTML_MAX_LENGTH }
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,11 @@ export interface MailEditorHandle {
|
||||
getContentForSend: () => MailEditorContent | null
|
||||
/** 본문으로 초점을 옮긴다 — 'start' 는 맨 위 첫 줄(답장), 'end' 는 맨 끝, 주지 않으면 커서 자리 그대로 */
|
||||
focus: (position?: 'start' | 'end') => void
|
||||
/**
|
||||
* 편집기를 연 뒤(답장 · 전달 초안을 맞춘 뒤) 사용자가 본문을 바꿨는가 — 쓰기 화면의 나가기 전 확인이 쓴다.
|
||||
* 지웠다 다시 써 처음과 같아졌거나, 편집기가 스스로 붙인 끝 빈 줄뿐이면 false
|
||||
*/
|
||||
isChanged: () => boolean
|
||||
}
|
||||
|
||||
/** 편집기 청크를 내려받는다 — 쓰기 화면의 defineAsyncComponent 가 부른다 (TipTap 은 이 청크에만 있다) */
|
||||
|
||||
@@ -79,6 +79,22 @@ describe('글자 서식 값 — 서버가 남기는 형식만 읽는다 (보이
|
||||
expect(outgoingHtml(doc)).not.toMatch(/font-size: 0|transparent/)
|
||||
})
|
||||
|
||||
it('보이지 않을 만큼 작은 글자(8px 미만) · 거의 투명한 글자 색(알파 0.3 미만)도 서식째 버린다 — 배경 색의 알파는 그대로', () => {
|
||||
const tiny = parse('<p><span style="font-size: 1px">작은 글</span></p>')
|
||||
const pale = parse('<p><span style="color: rgba(0, 0, 0, 0.1)">흐린 글</span></p>')
|
||||
const word = parse('<p><span style="font-size: 5.25pt">워드 7px</span></p>')
|
||||
const highlight = parse('<p><span style="background-color: rgba(0, 0, 0, 0)">배경</span></p>')
|
||||
|
||||
expect(firstTextStyle(tiny)?.fontSize ?? null).toBeNull()
|
||||
expect(firstTextStyle(pale)?.color ?? null).toBeNull()
|
||||
expect(firstTextStyle(word)?.fontSize ?? null).toBeNull()
|
||||
expect(outgoingHtml(tiny)).not.toContain('font-size: 1px')
|
||||
expect(outgoingHtml(pale)).not.toContain('rgba')
|
||||
expect(docToText(tiny)).toBe('작은 글')
|
||||
expect(firstTextStyle(highlight)?.backgroundColor).toBe('rgba(0, 0, 0, 0)')
|
||||
expect(firstTextStyle(parse('<p><span style="font-size: 6pt">a</span></p>'))?.fontSize).toBe('8px')
|
||||
})
|
||||
|
||||
it('pt · 긴 소수의 글자 크기는 px 로 맞춰 읽는다 (워드 10pt · 아웃룩 13.3333px), !important 는 뗀다', () => {
|
||||
expect(firstTextStyle(parse('<p><span style="font-size: 10pt">a</span></p>'))?.fontSize).toBe('13.33px')
|
||||
expect(firstTextStyle(parse('<p><span style="font-size: 13.3333px">a</span></p>'))?.fontSize).toBe('13.33px')
|
||||
|
||||
Reference in New Issue
Block a user