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:
@@ -8,7 +8,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockHandler } from '@/test-utils/mock-api'
|
||||
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import ComposePage from './ComposePage.vue'
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,11 @@ vi.mock('@/components/mail/editor/mail-editor-handle', async (importOriginal) =>
|
||||
})
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 10_000
|
||||
/**
|
||||
* 편집기 청크 · 편집기 자리를 기다리는 시간 — ComposePage.spec.ts 와 같은 값.
|
||||
* 두 겹 부하에서 이 파일의 테스트가 12.3초까지 걸렸다 (2026-10-27) — 테스트 한도(vitest.config.ts, 20초)보다는 짧게 둔다.
|
||||
*/
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 15_000
|
||||
|
||||
const ORIGINAL: MessageDetail = {
|
||||
id: 'm1',
|
||||
@@ -109,6 +113,7 @@ describe('ComposePage — 서식 편집기 청크', () => {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
provide: routeViewProvide(router),
|
||||
// 내려받기 오류는 Vue 가 앱 오류로도 알린다 — 테스트에서는 모아 둔다
|
||||
config: { errorHandler: (error) => loadErrors.push(error) },
|
||||
},
|
||||
@@ -117,11 +122,17 @@ describe('ComposePage — 서식 편집기 청크', () => {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/** 붙잡아 둔 청크를 놓아 준다 — 편집기 자리가 그려져 내려받기가 시작될 때까지 기다린 뒤 */
|
||||
/**
|
||||
* 붙잡아 둔 청크를 놓아 준다 — 편집기 자리가 그려져 내려받기가 시작될 때까지 기다린 뒤.
|
||||
* (답장은 원문을 받아 초안을 만들고 편집기 자리를 다시 그린 뒤에야 내려받기를 시작한다 — 편집기 기다림과 같은 시간을 준다)
|
||||
*/
|
||||
async function releaseEditor() {
|
||||
await vi.waitFor(() => {
|
||||
if (!loader.release) throw new Error('아직 내려받기를 시작하지 않았다')
|
||||
})
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!loader.release) throw new Error('아직 내려받기를 시작하지 않았다')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
loader.release?.()
|
||||
}
|
||||
|
||||
@@ -139,9 +150,13 @@ describe('ComposePage — 서식 편집기 청크', () => {
|
||||
loader.mode = 'fail-once'
|
||||
const view = await mountCompose('/mail/compose')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
if (!view.find('.compose__editor [role="alert"]').exists()) throw new Error('아직 실패를 보이지 않는다')
|
||||
})
|
||||
// 청크 내려받기가 실패로 끝나고 오류 화면이 그려질 때까지 — 편집기 기다림과 같은 시간을 준다
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!view.find('.compose__editor [role="alert"]').exists()) throw new Error('아직 실패를 보이지 않는다')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
const alert = view.get('.compose__editor [role="alert"]')
|
||||
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
|
||||
expect(view.get('.compose__send').attributes('disabled')).toBeDefined()
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { defineComponent, h, type FunctionalComponent } from 'vue'
|
||||
|
||||
import SessionExpiredDialog from '@/components/feedback/SessionExpiredDialog.vue'
|
||||
import {
|
||||
AUTH_CHANNEL_NAME,
|
||||
closeLogoutChannel,
|
||||
useLogoutSync,
|
||||
} from '@/composables/useLogoutBroadcast'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import {
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
@@ -13,6 +20,7 @@ import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
@@ -24,12 +32,17 @@ import {
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import { LONG_DRAFT_TIMEOUT_MS } from '@/test-utils/timeouts'
|
||||
import ComposePage from './ComposePage.vue'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 지연 로딩한 편집기를 기다리는 시간 — 테스트를 한꺼번에 돌리면 첫 변환이 느리다 */
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 10_000
|
||||
/**
|
||||
* 지연 로딩한 편집기를 기다리는 시간 — 테스트를 한꺼번에 돌리면 첫 변환이 느리다.
|
||||
* 전체 실행 두 개를 함께 돌린 부하에서 편집기를 띄우는 테스트가 12.8초까지 걸렸다 (2026-10-27) — 15초로 두되,
|
||||
* 테스트 한도(vitest.config.ts, 20초)보다 짧게 둬 편집기가 끝내 뜨지 않으면 이 기다림이 먼저 알린다.
|
||||
*/
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 15_000
|
||||
let clock = Date.UTC(2026, 9, 26, 6, 0, 0)
|
||||
|
||||
const ORIGINAL: MessageDetail = {
|
||||
@@ -87,15 +100,20 @@ describe('ComposePage', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다 */
|
||||
async function mountCompose(path: string, handler: MockHandler, attach = false) {
|
||||
/**
|
||||
* attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다.
|
||||
* 화면은 RouterView 없이 띄운다 — 다른 경로로 가도 화면이 남아 결과를 볼 수 있다. 이동 가드는 지금 경로에 건다 (routeViewProvide)
|
||||
*/
|
||||
async function mountCompose(path: string, handler: MockHandler, attach = false, visited: readonly string[] = []) {
|
||||
const requests = installMockApi(handler)
|
||||
const router = createTestRouter()
|
||||
// 방문 기록 — 뒤로 가기를 확인할 때 쓰기 화면 앞에 둘 화면들
|
||||
for (const earlier of visited) await router.push(earlier)
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, {
|
||||
attachTo: attach ? document.body : undefined,
|
||||
global: { plugins: [pinia, router] },
|
||||
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
@@ -625,7 +643,7 @@ describe('ComposePage', () => {
|
||||
await input.trigger('blur')
|
||||
}
|
||||
|
||||
it('내 글만으로 본문 한도를 넘으면 보내지 않고 본문 아래에 사전 문구(VAL_001) — 본문으로 초점, 고치면 내린다', async () => {
|
||||
it('내 글만으로 본문 한도를 넘으면 보내지 않고 본문 아래에 사전 문구(MAIL_007) — 본문으로 초점, 고치면 내린다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
|
||||
await addRecipient(wrapper)
|
||||
const editor = editorOf(wrapper.element)
|
||||
@@ -635,8 +653,9 @@ describe('ComposePage', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(sendRequest(requests)).toBeUndefined()
|
||||
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_007)
|
||||
expect(wrapper.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
|
||||
expect(wrapper.get('#compose-body').attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement?.id).toBe('compose-body')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
@@ -646,6 +665,45 @@ describe('ComposePage', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('서버가 본문을 MAIL_007 로 거절하면(정화 한도 등) 토스트 없이 같은 띠 · 잘못된 입력 · 본문으로 초점 — 줄여 다시 보내면 같은 키', async () => {
|
||||
const outcomes: MockResult[] = [fail(400, 'MAIL_007'), ok({ id: 'sent-1' }, 201)]
|
||||
const { wrapper, requests } = await mountCompose(
|
||||
'/mail/compose',
|
||||
(request) =>
|
||||
request.method === 'POST' && request.url === '/messages'
|
||||
? (outcomes.shift() ?? ok({ id: 'sent-2' }, 201))
|
||||
: replyApi(request),
|
||||
true,
|
||||
)
|
||||
await addRecipient(wrapper)
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.setContent('<p>서식이 아주 복잡한 본문</p>')
|
||||
;(wrapper.get('#compose-subject').element as HTMLInputElement).focus()
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.MAIL_007)
|
||||
expect(wrapper.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
|
||||
expect(wrapper.get('#compose-body').attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement?.id).toBe('compose-body')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
// 줄이면 띠를 내리고, 다시 보내면 같은 키로 간다 (서버가 엔진에 넘기기 전에 거절했다)
|
||||
editor.commands.setContent('<p>짧게 줄인 본문</p>')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#compose-body-error').exists()).toBe(false)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const keys = requests
|
||||
.filter((request) => request.method === 'POST' && request.url === '/messages')
|
||||
.map((request) => request.config.headers.get('Idempotency-Key'))
|
||||
expect(keys).toHaveLength(2)
|
||||
expect(keys[1]).toBe(keys[0])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('답장 원문이 길어도 맞춘 초안에 내 글을 쓰면 보낸다 — 남겨 둔 자리보다 길면 인용을 더 줄여(생략 표시) 보낸다', async () => {
|
||||
const longOriginal = { ...ORIGINAL, textBody: `${'긴 원문 줄입니다. '.repeat(10)}\n`.repeat(1_000) }
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', (request) =>
|
||||
@@ -662,7 +720,7 @@ describe('ComposePage', () => {
|
||||
expect(payload?.bodyHtml.length).toBeLessThanOrEqual(MAIL_BODY_HTML_MAX_LENGTH)
|
||||
expect(payload?.body.startsWith(mine.trimEnd())).toBe(true)
|
||||
expect(payload?.body.endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
|
||||
})
|
||||
}, LONG_DRAFT_TIMEOUT_MS)
|
||||
|
||||
it('제목이 255자를 넘으면(붙여 넣기 등) 보내지 않고 제목 아래에 사전 문구 — 제목으로 초점', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
|
||||
@@ -847,4 +905,505 @@ describe('ComposePage', () => {
|
||||
expect(new Set([replyKey, newMailKey, reopenedKey]).size).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('나가기 전 확인 (디자인 README "나가기 전 확인")', () => {
|
||||
const DIALOG = '#compose-leave-dialog'
|
||||
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
|
||||
const forwardApi: MockHandler = (request) =>
|
||||
request.method === 'GET' && request.url === '/messages/m1'
|
||||
? ok({ ...ORIGINAL, attachments: [CHECKLIST] })
|
||||
: replyApi(request)
|
||||
|
||||
/** 이 탭에서 useLogoutSync 를 부르는 최소 화면 (App.vue 대신) */
|
||||
const LogoutSyncHost = defineComponent({
|
||||
setup() {
|
||||
useLogoutSync()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeLogoutChannel()
|
||||
FakeBroadcastChannel.closeAll()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function dialogOpen(wrapper: VueWrapper): boolean {
|
||||
return wrapper.get(DIALOG).attributes('open') !== undefined
|
||||
}
|
||||
|
||||
/** 창 닫기 · 새로고침 때 브라우저 확인창을 띄우려 하는가 */
|
||||
function blocksUnload(): boolean {
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
window.dispatchEvent(event)
|
||||
return event.defaultPrevented
|
||||
}
|
||||
|
||||
async function writeSubject(wrapper: VueWrapper, text: string) {
|
||||
await wrapper.get('#compose-subject').setValue(text)
|
||||
}
|
||||
|
||||
function choose(file: File, wrapper: VueWrapper) {
|
||||
const picker = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(picker.element, 'files', { value: [file], configurable: true })
|
||||
return picker.trigger('change')
|
||||
}
|
||||
|
||||
it('손대지 않은 답장 초안은 바꾼 것이 아니다 — 편집기가 인용 뒤에 끝 빈 줄을 붙여도 묻지 않고 떠난다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', replyApi, true)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
await flushPromises()
|
||||
// 초점을 옮기는 첫 거래에 편집기가 인용(맨 끝 블록) 뒤에 빈 문단을 붙였다
|
||||
expect(editorOf(wrapper.element).state.doc.lastChild?.type.name).toBe('paragraph')
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('손대지 않은 전달 초안(원본 첨부 포함)도 묻지 않는다 — 원본 첨부를 빼면 바꾼 것이다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?forward=m1', forwardApi)
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
await wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]').trigger('click')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
void router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
})
|
||||
|
||||
it('바꾸면 앱 안 이동 전에 묻는다 — alertdialog · 처음 초점은 계속 쓰기, 계속 쓰기는 이동을 취소하고 초점을 돌려준다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, true)
|
||||
await writeSubject(wrapper, '일정 공유')
|
||||
const subjectInput = wrapper.get('#compose-subject').element as HTMLInputElement
|
||||
subjectInput.focus()
|
||||
|
||||
const navigation = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
|
||||
const dialog = wrapper.get(DIALOG)
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
expect(dialog.attributes('role')).toBe('alertdialog')
|
||||
expect(dialog.attributes('aria-labelledby')).toBe('compose-leave-title')
|
||||
expect(dialog.attributes('aria-describedby')).toBe('compose-leave-message')
|
||||
expect(wrapper.get('#compose-leave-title').text()).toBe('보내지 않은 메일이 있습니다')
|
||||
expect(wrapper.get('#compose-leave-message').text()).toBe('이 화면을 나가면 쓰던 내용이 사라집니다.')
|
||||
expect(wrapper.get('#compose-leave-stay').text()).toBe('계속 쓰기')
|
||||
expect(wrapper.get('#compose-leave-confirm').text()).toBe('나가기')
|
||||
expect(document.activeElement?.id).toBe('compose-leave-stay')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
|
||||
await wrapper.get('#compose-leave-stay').trigger('click')
|
||||
await navigation
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
expect(subjectInput.value).toBe('일정 공유')
|
||||
expect(document.activeElement).toBe(subjectInput)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('쓰기 화면의 닫기 ✕ · 취소도 묻는다 — 나가기를 고르면 쓰던 내용을 버리고 보던 화면으로 간다', async () => {
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('.compose__cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
await wrapper.get('#compose-leave-stay').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
|
||||
await wrapper.get('.compose__close').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
await wrapper.get('#compose-leave-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
})
|
||||
|
||||
it('Esc 는 계속 쓰기와 같다 — 브라우저가 닫지 않게 막고 이동을 취소한다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
await writeSubject(wrapper, '일정 공유')
|
||||
const navigation = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
|
||||
const cancel = new Event('cancel', { cancelable: true })
|
||||
wrapper.get(DIALOG).element.dispatchEvent(cancel)
|
||||
await navigation
|
||||
await flushPromises()
|
||||
|
||||
expect(cancel.defaultPrevented).toBe(true)
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
})
|
||||
|
||||
it('뒤로 가기를 취소하면 주소와 방문 기록이 그대로다 — 다시 뒤로 가서 나가기를 고르면 앞 화면으로', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, false, ['/mail/inbox'])
|
||||
await writeSubject(wrapper, '초안')
|
||||
|
||||
router.back()
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
await wrapper.get('#compose-leave-stay').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
expect(router.options.history.location).toBe('/mail/compose')
|
||||
|
||||
router.back()
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
await wrapper.get('#compose-leave-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(router.options.history.location).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('묻는 중에 또 이동하면 대화상자는 하나다 — 나가기를 고르면 마지막 이동으로 간다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
await writeSubject(wrapper, '일정 공유')
|
||||
|
||||
const first = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
const second = router.push('/mail/sent')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('dialog[open]')).toHaveLength(1)
|
||||
await wrapper.get('#compose-leave-confirm').trigger('click')
|
||||
await Promise.all([first, second])
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
})
|
||||
|
||||
it('같은 쓰기 화면 안에서도 쓰기 종류가 바뀌면(답장 → 새 메일) 묻는다 — 계속 쓰면 답장 그대로', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
|
||||
await writeSubject(wrapper, 'Re: 고친 제목')
|
||||
|
||||
const navigation = router.push('/mail/compose')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
await wrapper.get('#compose-leave-stay').trigger('click')
|
||||
await navigation
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?replyTo=m1')
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('Re: 고친 제목')
|
||||
})
|
||||
|
||||
it('받는 사람 칸에 쓰던 글 · 본문 · 첨부(올리는 중 포함)도 바꿈이다 — 지우거나 되돌리면 묻지 않는다', async () => {
|
||||
// 올리기는 끝내지 않고 붙잡아 둔다 — "올리는 중"인 첨부도 바꿈이다
|
||||
const upload: { finish: ((result: MockResult) => void) | null } = { finish: null }
|
||||
const { wrapper } = await mountCompose('/mail/compose', (request) =>
|
||||
request.method === 'POST' && request.url === '/mail-attachments'
|
||||
? new Promise<MockResult>((resolve) => {
|
||||
upload.finish = resolve
|
||||
})
|
||||
: replyApi(request),
|
||||
)
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
// 받는 사람 칸에 아직 토큰이 되지 않은 글
|
||||
await wrapper.get('#compose-to').setValue('kim@ara')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
await wrapper.get('#compose-to').setValue('')
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
// 본문 — 쓰고 실행 취소하면 처음과 같다
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.insertContent('안녕하세요')
|
||||
await flushPromises()
|
||||
expect(blocksUnload()).toBe(true)
|
||||
editor.commands.undo()
|
||||
await flushPromises()
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
// 첨부 — 올리는 중이어도 바꿈, 빼면 처음과 같다
|
||||
await choose(new File(['x'], '회의록.pdf'), wrapper)
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.upload-item--uploading')).toHaveLength(1)
|
||||
expect(blocksUnload()).toBe(true)
|
||||
await wrapper.get('button[aria-label="회의록.pdf 첨부 취소"]').trigger('click')
|
||||
expect(blocksUnload()).toBe(false)
|
||||
upload.finish?.(ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 1 }, 201))
|
||||
})
|
||||
|
||||
it('창 닫기 · 새로고침 확인(beforeunload)은 바꾼 동안만 건다 — 처음으로 돌아오거나 화면을 내리면 뗀다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', replyApi)
|
||||
|
||||
expect(blocksUnload()).toBe(false)
|
||||
await writeSubject(wrapper, '제목')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
await writeSubject(wrapper, '')
|
||||
expect(blocksUnload()).toBe(false)
|
||||
await writeSubject(wrapper, '다시')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
expect(blocksUnload()).toBe(false)
|
||||
})
|
||||
|
||||
it('보낸 뒤에는 묻지 않는다 — 보던 화면으로 바로 돌아가고 창 닫기도 막지 않는다', async () => {
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox')
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('doyun.lee@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
await writeSubject(wrapper, '일정 공유')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(blocksUnload()).toBe(false)
|
||||
})
|
||||
|
||||
describe('보내는 중에 떠나기 — 묻지 않고, 결과는 토스트로만 알린다 (떠난 양식은 건드리지 않는다)', () => {
|
||||
/** 라우터만 쓰는 빈 화면 — 쓰기 화면을 내린 뒤에도 앱(App.vue)처럼 라우터를 붙잡아 둔다 */
|
||||
const RouterHost: FunctionalComponent = () => h('div')
|
||||
|
||||
/** 첫 발송의 응답을 붙잡아 두는 서버 — 그다음 발송은 outcomes 를 차례로 돌려준다 */
|
||||
function heldSendApi(outcomes: MockResult[] = []) {
|
||||
const held: { release: ((result: MockResult) => void) | null } = { release: null }
|
||||
let first = true
|
||||
const handler: MockHandler = (request) => {
|
||||
if (request.method !== 'POST' || request.url !== '/messages') return replyApi(request)
|
||||
if (!first) return outcomes.shift() ?? ok({ id: 'sent-2' }, 201)
|
||||
first = false
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
held.release = resolve
|
||||
})
|
||||
}
|
||||
return { handler, held }
|
||||
}
|
||||
|
||||
function sentKeys(requests: MockRequest[]): unknown[] {
|
||||
return requests
|
||||
.filter((request) => request.method === 'POST' && request.url === '/messages')
|
||||
.map((request) => request.config.headers.get('Idempotency-Key'))
|
||||
}
|
||||
|
||||
function toastMessages(): string[] {
|
||||
return useToastStore().toasts.map((toast) => toast.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰기 화면만 내린다 — 앱(App.vue)처럼 라우터를 쓰는 화면 하나를 남겨 둔다.
|
||||
* (라우터를 쓰는 앱이 모두 내려가면 vue-router 가 지금 경로를 처음('/')으로 되돌려 "끌고 오지 않았다"를 볼 수 없다)
|
||||
*/
|
||||
function leaveScreen(wrapper: VueWrapper, router: ReturnType<typeof createTestRouter>) {
|
||||
mountedWrappers.push(mount(RouterHost, { global: { plugins: [pinia, router] } }))
|
||||
wrapper.unmount()
|
||||
}
|
||||
|
||||
/** 고친 답장을 보내 놓는다 — 응답은 붙잡혀 있다 */
|
||||
async function startSending(wrapper: VueWrapper) {
|
||||
await writeSubject(wrapper, 'Re: 고친 제목')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.compose__send').text()).toBe('보내는 중…')
|
||||
}
|
||||
|
||||
it('보내는 중에는 앱 안 이동을 묻지 않는다 — 떠난 뒤 보내지면 성공 토스트만, 간 곳에서 끌고 오지 않는다 (창 닫기는 묻는다)', async () => {
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
|
||||
const { handler, held } = heldSendApi()
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
|
||||
await startSending(wrapper)
|
||||
// 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳이 없다 — 창 닫기 · 새로고침은 보내는 중에도 묻는다
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
await router.push('/mail/sent')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
// RouterView 안이라면 화면이 내려간다
|
||||
leaveScreen(wrapper, router)
|
||||
|
||||
held.release?.(ok({ id: 'sent-1' }, 201))
|
||||
await flushPromises()
|
||||
|
||||
expect(toastMessages()).toEqual(['메일을 보냈습니다.'])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/sent')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['MAIL_002 (평소에는 받는 사람 칸 아래에 알린다)', fail(400, 'MAIL_002'), 'MAIL_002'],
|
||||
['MAIL_004 (평소에는 첨부 목록에 알린다)', fail(400, 'MAIL_004'), 'MAIL_004'],
|
||||
['MAIL_007 (평소에는 본문 아래 띠로 알린다)', fail(400, 'MAIL_007'), 'MAIL_007'],
|
||||
['MAIL_001 (평소에도 토스트)', fail(503, 'MAIL_001'), 'MAIL_001'],
|
||||
] as const)('떠난 뒤 보내기가 %s 로 끝나면 사전 문구 토스트 하나로 알린다', async (_label, failure, code) => {
|
||||
const { handler, held } = heldSendApi()
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
|
||||
await startSending(wrapper)
|
||||
await router.push('/mail/inbox')
|
||||
leaveScreen(wrapper, router)
|
||||
|
||||
held.release?.(failure)
|
||||
await flushPromises()
|
||||
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES[code]])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('보내는 중에 쓰기 종류가 바뀌면(답장 → 새 메일) 묻지 않고 새 양식을 연다 — 앞 보내기가 끝나도 새 양식과 그 키는 그대로', async () => {
|
||||
const { handler, held } = heldSendApi(['network', ok({ id: 'sent-3' }, 201)])
|
||||
const { wrapper, router, requests } = await mountCompose('/mail/compose?replyTo=m1', handler)
|
||||
await startSending(wrapper)
|
||||
|
||||
await router.push('/mail/compose')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(wrapper.get('h1').text()).toBe('새 메일')
|
||||
// 앞 요청에 묶이지 않는다 — 새 양식은 바로 보낼 수 있다
|
||||
expect(wrapper.get('.compose__send').text()).toBe('보내기')
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('junho.park@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
await writeSubject(wrapper, '새 메일 제목')
|
||||
// 새 양식의 첫 보내기 — 응답 없음(키를 그대로 둔다)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
// 앞 양식의 보내기가 이제 끝난다 — 알리기만 한다
|
||||
held.release?.(ok({ id: 'sent-1' }, 201))
|
||||
await flushPromises()
|
||||
|
||||
expect(toastMessages()).toContain('메일을 보냈습니다.')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
expect(chips(wrapper, 'compose-to')).toEqual(['junho.park@arasystem.it.kr'])
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe('새 메일 제목')
|
||||
|
||||
// 앞 양식의 성공이 새 양식의 키를 바꾸지 않았다 — 다시 보내면 같은 키
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
const [oldKey, newKey, retryKey] = sentKeys(requests)
|
||||
expect(newKey).not.toBe(oldKey)
|
||||
expect(retryKey).toBe(newKey)
|
||||
})
|
||||
|
||||
it('쓰기 종류가 바뀐 뒤 앞 보내기가 MAIL_002 로 끝나면 — 새 양식에 받는 사람 오류를 띄우지 않고 토스트로 알린다', async () => {
|
||||
const { handler, held } = heldSendApi()
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', handler)
|
||||
await startSending(wrapper)
|
||||
await router.push('/mail/compose')
|
||||
await flushPromises()
|
||||
|
||||
held.release?.(fail(400, 'MAIL_002'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('#compose-recipient-error').exists()).toBe(false)
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.MAIL_002])
|
||||
expect(wrapper.get('.compose__send').text()).toBe('보내기')
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['이 탭의 로그아웃', (store: ReturnType<typeof useAuthStore>) => store.clearAuth()],
|
||||
['세션 만료', (store: ReturnType<typeof useAuthStore>) => store.expireSession()],
|
||||
])('세션이 끝나면(%s) 묻지 않는다 — 로그인 화면으로 가는 이동이 이긴다', async (_label, end) => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
await writeSubject(wrapper, '쓰던 메일')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
end(useAuthStore())
|
||||
await flushPromises()
|
||||
expect(blocksUnload()).toBe(false)
|
||||
await router.push({ name: 'login' })
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
})
|
||||
|
||||
it('다른 탭이 로그아웃하면 묻지 않고 로그인 화면으로 간다', async () => {
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel)
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
mountedWrappers.push(mount(LogoutSyncHost, { global: { plugins: [pinia, router] } }))
|
||||
await writeSubject(wrapper, '쓰던 메일')
|
||||
|
||||
new FakeBroadcastChannel(AUTH_CHANNEL_NAME).postMessage({ type: 'logout' })
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(useAuthStore().isLoggedIn).toBe(false)
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
})
|
||||
|
||||
it('묻는 중에 세션이 만료되면 이 대화상자를 닫고 그 이동은 취소한다 — 세션 만료 대화상자와 겹치지 않는다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
const sessionDialog = mount(SessionExpiredDialog, { global: { plugins: [pinia, router] } })
|
||||
mountedWrappers.push(sessionDialog)
|
||||
await writeSubject(wrapper, '쓰던 메일')
|
||||
const navigation = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await navigation
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(sessionDialog.get('dialog').attributes('open')).toBeDefined()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
|
||||
// 세션 만료 대화상자의 "로그인 화면으로"는 묻지 않고 간다 — 다시 로그인하면 쓰기 화면으로 돌아온다
|
||||
await sessionDialog.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/mail/compose')
|
||||
})
|
||||
|
||||
it('원문을 불러오는 동안 붙인 파일은 처음 채운 첨부가 아니다 — 바꿈으로 남는다', async () => {
|
||||
// 원문 응답을 붙잡아 둔다 — 그동안 양식은 막혀 있지만 첨부는 붙일 수 있다
|
||||
const original: { release: (() => void) | null } = { release: null }
|
||||
const requests = installMockApi((request) => {
|
||||
if (request.method === 'GET' && request.url === '/messages/m1') {
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
original.release = () => resolve(ok({ ...ORIGINAL, attachments: [CHECKLIST] }))
|
||||
})
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/mail-attachments') {
|
||||
return ok({ id: 'up1', name: '회의록.pdf', type: 'application/pdf', size: 1 }, 201)
|
||||
}
|
||||
return replyApi(request)
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/compose?forward=m1')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, { global: { plugins: [pinia, router], provide: routeViewProvide(router) } })
|
||||
mountedWrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
|
||||
await choose(new File(['x'], '회의록.pdf'), wrapper)
|
||||
await flushPromises()
|
||||
original.release?.()
|
||||
await editorLoaded(wrapper)
|
||||
|
||||
expect(requests.some((request) => request.url === '/mail-attachments')).toBe(true)
|
||||
expect(wrapper.findAll('.upload-item__name').map((item) => item.text())).toEqual(['회의록.pdf', '체크리스트.xlsx'])
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
// 올린 파일을 빼면 처음 채운 모양(원본 첨부만)과 같다
|
||||
await wrapper.get('button[aria-label="회의록.pdf 첨부 취소"]').trigger('click')
|
||||
expect(blocksUnload()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,21 +7,27 @@
|
||||
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문에는 이미지를 넣지 않는다 (사용자 확정).
|
||||
// 화면 어디에 파일을 놓아도 첨부로 받는다 — 브라우저가 파일을 열어 쓰던 메일을 잃지 않게 (useWindowFileDrop).
|
||||
// 받는 사람 오류 문구는 에러 코드 사전(MAIL_002)에서만 온다 — 형식 오류(즉시)와 서버 거부 모두 같은 문구.
|
||||
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구(VAL_001).
|
||||
import { computed, defineAsyncComponent, nextTick, provide, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구
|
||||
// (제목 VAL_001 · 본문 MAIL_007). 서버가 본문을 MAIL_007 로 거절해도 본문 아래에 같은 띠를 보인다.
|
||||
// 연 뒤에 받는 사람 · 제목 · 본문 · 첨부를 바꿨으면 떠나기 전에 묻는다 (디자인 README "나가기 전 확인", useLeaveConfirm) —
|
||||
// 보내는 중 · 보낸 뒤 · 세션이 끝났을 때(로그아웃 · 만료 · 다른 탭의 로그아웃)는 앱 안 이동을 묻지 않는다.
|
||||
// 보내는 중에 떠나면 요청은 이어지고, 결과는 토스트로만 알린다 — 떠난 양식은 건드리지 않고 끌고 오지도 않는다 (formGeneration).
|
||||
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, provide, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
|
||||
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
|
||||
import ComposeLeaveDialog from '@/components/mail/ComposeLeaveDialog.vue'
|
||||
import RecipientField from '@/components/mail/RecipientField.vue'
|
||||
import {
|
||||
MAIL_EDITOR_RETRY,
|
||||
loadMailEditor,
|
||||
type MailEditorHandle,
|
||||
} from '@/components/mail/editor/mail-editor-handle'
|
||||
import { getErrorMessage, toApiError } from '@/composables/useApi'
|
||||
import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { useAttachmentUploads } from '@/composables/useAttachmentUploads'
|
||||
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
|
||||
@@ -36,7 +42,13 @@ import {
|
||||
type ComposeMode,
|
||||
} from '@/shared/constants/mail'
|
||||
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { SendMessagePayload } from '@/shared/types/mail'
|
||||
import {
|
||||
isSameComposeForm,
|
||||
snapshotComposeForm,
|
||||
type ComposeFormSnapshot,
|
||||
} from '@/shared/utils/compose-changes'
|
||||
import { isImageFile } from '@/shared/utils/editor-files'
|
||||
import {
|
||||
createIdempotencyKey,
|
||||
@@ -110,8 +122,30 @@ const sending = ref<boolean>(false)
|
||||
const attempted = ref<boolean>(false)
|
||||
/** 서버가 받는 사람을 거부했다 (MAIL_002) */
|
||||
const rejectedByServer = ref<boolean>(false)
|
||||
/** 보내기 직전에 잰 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도) */
|
||||
/**
|
||||
* 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도).
|
||||
* 본문은 보내기 직전에 잰 값이 넘었거나 서버가 MAIL_007 로 거절했을 때 (정화 전 비용 · 정화 뒤 크기 한도 포함)
|
||||
*/
|
||||
const lengthError = ref<'subject' | 'body' | null>(null)
|
||||
/**
|
||||
* 쓰기 화면을 다 연 때의 양식(받는 사람 · 제목 · 첨부) — 답장 · 전달로 처음 채운 내용은 "바꿈"이 아니다.
|
||||
* 이것과 달라졌거나 본문을 바꿨으면 떠나기 전에 묻는다 (디자인 README "나가기 전 확인"). 본문은 편집기가 잰다(bodyChanged)
|
||||
*/
|
||||
const openedForm = shallowRef<ComposeFormSnapshot>(
|
||||
snapshotComposeForm({ to: [], cc: [], bcc: [], subject: '', attachments: [] }),
|
||||
)
|
||||
/** 편집기를 연 뒤 본문을 바꿨다 — 편집기를 새로 만들면 처음부터 다시 잰다 */
|
||||
const bodyChanged = ref<boolean>(false)
|
||||
/** 보냈다 — 쓴 내용이 메일로 나갔으므로 떠날 때 묻지 않는다 (양식을 새로 채우면 풀린다) */
|
||||
const sent = ref<boolean>(false)
|
||||
/**
|
||||
* 양식 세대 — 양식을 새로 채울 때(resetForm)와 화면을 내릴 때 하나씩 올린다.
|
||||
* 보내기는 시작할 때 세대를 잡아 두고, 응답이 왔을 때 바뀌었으면 그 양식은 이미 없다 (보내는 중에 떠났거나 쓰기 종류가 바뀌었다).
|
||||
* 그때는 양식 상태(보냄 표시 · 발송 키 · 칸 안내 · 첨부 표시)를 건드리지 않고 이동하지도 않는다 — 결과는 토스트로만 알린다.
|
||||
*/
|
||||
let formGeneration = 0
|
||||
/** 보내기 결과를 이 화면이 칸 안에서 직접 안내하는 코드 — 인터셉터 알림을 끈다 (양식이 사라졌으면 토스트로 대신 알린다) */
|
||||
const SEND_INLINE_CODES: readonly ErrorCode[] = ['MAIL_002', 'MAIL_004', 'MAIL_007']
|
||||
/**
|
||||
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
|
||||
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
|
||||
@@ -138,12 +172,23 @@ function isReply(value: ComposeMode): value is 'reply' | 'replyAll' {
|
||||
}
|
||||
|
||||
/** 답장·전달할 원본 메일 id */
|
||||
const sourceId = computed(() => queryId(route.query.forward) ?? queryId(route.query.replyTo))
|
||||
const mode = computed<ComposeMode>(() => {
|
||||
if (queryId(route.query.forward)) return 'forward'
|
||||
if (!queryId(route.query.replyTo)) return 'new'
|
||||
return route.query.all === '1' ? 'replyAll' : 'reply'
|
||||
})
|
||||
function sourceIdOf(query: LocationQuery): string | null {
|
||||
return queryId(query.forward) ?? queryId(query.replyTo)
|
||||
}
|
||||
|
||||
function modeOf(query: LocationQuery): ComposeMode {
|
||||
if (queryId(query.forward)) return 'forward'
|
||||
if (!queryId(query.replyTo)) return 'new'
|
||||
return query.all === '1' ? 'replyAll' : 'reply'
|
||||
}
|
||||
|
||||
/** 쓰기 종류(새 메일·답장·전달)와 원본 메일 — 바뀌면 양식을 새로 채운다 (같은 화면 안의 이동도 쓰던 내용을 버린다) */
|
||||
function draftKeyOf(query: LocationQuery): string {
|
||||
return `${modeOf(query)}:${sourceIdOf(query) ?? ''}`
|
||||
}
|
||||
|
||||
const sourceId = computed(() => sourceIdOf(route.query))
|
||||
const mode = computed<ComposeMode>(() => modeOf(route.query))
|
||||
const title = computed(() => COMPOSE_TITLES[mode.value])
|
||||
const attachmentHintText = computed(() =>
|
||||
attachmentHint(mailStore.attachmentMaxBytes, mailStore.attachmentTotalMaxBytes, MAIL_MAX_ATTACHMENTS),
|
||||
@@ -164,17 +209,56 @@ const showRecipientError = computed(
|
||||
)
|
||||
/** 문구는 에러 코드 사전에서만 온다 */
|
||||
const recipientErrorMessage = getErrorMessage('MAIL_002')
|
||||
/** 길이가 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
|
||||
const lengthErrorMessage = getErrorMessage('VAL_001')
|
||||
/** 제목이 255자를 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
|
||||
const subjectErrorMessage = getErrorMessage('VAL_001')
|
||||
/** 본문이 너무 길면 서버는 MAIL_007 로 거절한다 — 보내기 전에 막을 때(내 글만으로 넘음)와 서버가 거절했을 때 모두 이 문구 */
|
||||
const bodyErrorMessage = getErrorMessage('MAIL_007')
|
||||
/** 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 보내지 않는다 — 모르는 사이 첨부 없이 나가지 않게 */
|
||||
const canSend = computed(
|
||||
() =>
|
||||
editorReady.value && !preparing.value && !sending.value && !isUploading.value && !hasMissing.value,
|
||||
)
|
||||
|
||||
/** 지금 양식(받는 사람 · 제목 · 첨부) — 연 때의 양식(openedForm)과 견준다 */
|
||||
function snapshotForm(): ComposeFormSnapshot {
|
||||
return snapshotComposeForm({
|
||||
to: to.value,
|
||||
cc: cc.value,
|
||||
bcc: bcc.value,
|
||||
subject: subject.value,
|
||||
attachments: uploadItems.value,
|
||||
})
|
||||
}
|
||||
|
||||
const formChanged = computed(() => !isSameComposeForm(openedForm.value, snapshotForm()))
|
||||
/** 받는 사람 칸에 아직 토큰이 되지 않은 글이 있다 — 칸을 벗어나지 않고 떠나면(뒤로 가기 등) 확정되지 않은 채 사라진다 */
|
||||
const typingRecipient = computed(() =>
|
||||
[toField.value, ccField.value, bccField.value].some((field) => field?.hasDraft === true),
|
||||
)
|
||||
/** 떠나면 잃는 것이 있다 — 연 뒤에 바꿨고 아직 보내지 않았다 */
|
||||
const hasUnsentChanges = computed(
|
||||
() => !sent.value && (formChanged.value || bodyChanged.value || typingRecipient.value),
|
||||
)
|
||||
|
||||
/**
|
||||
* 떠나기 전 확인 — 앱 안 이동은 대화상자(ComposeLeaveDialog), 창 닫기 · 새로고침은 브라우저 확인창.
|
||||
* 세션이 끝났으면(로그아웃 · 만료 · 다른 탭의 로그아웃) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다.
|
||||
* 묻는 중에 세션이 끝나면 대화상자를 닫는다 — 세션 만료 대화상자와 겹치지 않는다.
|
||||
* 같은 쓰기 화면 안의 이동도 쓰기 종류나 원본이 바뀌면 양식을 새로 채우므로 묻는다 (답장 쓰다 "쓰기" 등).
|
||||
* 보내는 중에는 앱 안 이동을 묻지 않는다 — 이미 보내기로 했고, 요청은 이어지며 결과는 토스트로 알린다 (formGeneration).
|
||||
* 창 닫기 · 새로고침은 보내는 중에도 묻는다 — 창을 닫으면 요청이 끊길 수 있고 결과를 알릴 곳이 없다.
|
||||
*/
|
||||
const { confirming: leaveConfirming, stay: stayOnCompose, leave: leaveCompose } = useLeaveConfirm({
|
||||
active: () => hasUnsentChanges.value && authStore.isLoggedIn && !sending.value,
|
||||
unloadActive: () => hasUnsentChanges.value && authStore.isLoggedIn,
|
||||
leavesOnUpdate: (next, current) => draftKeyOf(next.query) !== draftKeyOf(current.query),
|
||||
})
|
||||
|
||||
/** 편집기를 새로 만든다 — 다음에 그릴 때 draftHtml 로 시작한다 */
|
||||
function renewEditor() {
|
||||
editorKey.value += 1
|
||||
editorReady.value = false
|
||||
bodyChanged.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,6 +276,8 @@ function resetForm() {
|
||||
to.value = []
|
||||
cc.value = []
|
||||
bcc.value = []
|
||||
// 칸에 쓰다 만 주소도 버린다 — 새로 채운 양식이 처음부터 "바꿈"이 되지 않게
|
||||
for (const field of [toField.value, ccField.value, bccField.value]) field?.clearDraft()
|
||||
showCopies.value = false
|
||||
subject.value = ''
|
||||
draftHtml.value = ''
|
||||
@@ -202,8 +288,12 @@ function resetForm() {
|
||||
rejectedByServer.value = false
|
||||
lengthError.value = null
|
||||
resetUploads()
|
||||
// 양식을 새로 채우면 다른 메일이다
|
||||
// 양식을 새로 채우면 다른 메일이다 — 앞 양식에서 보내던 요청은 끝나도 이 양식을 건드리지 않는다 (formGeneration)
|
||||
formGeneration += 1
|
||||
sending.value = false
|
||||
sendKey = createIdempotencyKey()
|
||||
sent.value = false
|
||||
openedForm.value = snapshotForm()
|
||||
}
|
||||
|
||||
let prepareSeq = 0
|
||||
@@ -216,12 +306,14 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
|
||||
try {
|
||||
const original = await getMessage(messageId)
|
||||
if (seq !== prepareSeq) return
|
||||
/** 처음 채운 원본 첨부 — 불러오는 동안 사용자가 붙인 파일(창에 끌어다 놓기 등)은 여기 들지 않는다 ("바꿈"으로 남는다) */
|
||||
let filledAttachments: readonly number[] = []
|
||||
if (sourceMode === 'forward') {
|
||||
const draft = buildForwardDraft(original)
|
||||
subject.value = draft.subject
|
||||
draftHtml.value = draft.html
|
||||
// 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 목록에서 하나씩 뺄 수 있다
|
||||
addExisting(draft.attachments)
|
||||
filledAttachments = addExisting(draft.attachments).map((item) => item.key)
|
||||
} else {
|
||||
const draft = buildReplyDraft(original, authStore.user?.email ?? '', sourceMode)
|
||||
to.value = draft.to.map(tokenFromAddress)
|
||||
@@ -232,6 +324,8 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
|
||||
// 답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다 — 편집기가 준비되면
|
||||
focusBodyWhenReady = true
|
||||
}
|
||||
// 처음 채운 내용은 "바꿈"이 아니다 — 받는 사람 · 제목은 불러오는 동안 막혀 있어 지금 값이 곧 채운 값이다
|
||||
openedForm.value = { ...snapshotForm(), attachments: filledAttachments }
|
||||
// 한도(텍스트 100,000 · 서식 500,000)를 넘는 원문은 편집기가 인용을 줄인다 (계약 2절 "답장·전달 초안의 길이")
|
||||
fitDraft.value = true
|
||||
renewEditor()
|
||||
@@ -256,6 +350,7 @@ function isFocusFree(): boolean {
|
||||
|
||||
function onEditorReady() {
|
||||
editorReady.value = true
|
||||
bodyChanged.value = false
|
||||
if (!focusBodyWhenReady) return
|
||||
focusBodyWhenReady = false
|
||||
void nextTick(() => {
|
||||
@@ -288,11 +383,11 @@ function settleSendKey(outcome: SendOutcome) {
|
||||
}
|
||||
|
||||
function notifySent() {
|
||||
const sent = mailStore.mailboxByRole('sent')
|
||||
const sentMailbox = mailStore.mailboxByRole('sent')
|
||||
toast.success(
|
||||
MAIL_SENT_MESSAGE,
|
||||
sent
|
||||
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sent.id } } }
|
||||
sentMailbox
|
||||
? { label: MAIL_SENT_ACTION_LABEL, to: { name: 'mail-mailbox', params: { mailboxId: sentMailbox.id } } }
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
@@ -308,7 +403,7 @@ async function onSubmit() {
|
||||
}
|
||||
if (!canSend.value || !editorRef.value) return
|
||||
|
||||
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 VAL_001 로 거절한다)
|
||||
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 제목은 VAL_001, 본문은 MAIL_007 로 거절한다)
|
||||
const subjectText = subject.value.trim()
|
||||
if (subjectText.length > MAIL_SUBJECT_MAX_LENGTH) {
|
||||
lengthError.value = 'subject'
|
||||
@@ -319,8 +414,7 @@ async function onSubmit() {
|
||||
// 한도를 넘으면 편집기가 원문 인용을 더 줄인다. 내 글만으로 넘으면 null — 보내지 않고 본문 아래에 알린다
|
||||
const content = editorRef.value.getContentForSend()
|
||||
if (content === null) {
|
||||
lengthError.value = 'body'
|
||||
editorRef.value.focus()
|
||||
showBodyTooLong()
|
||||
return
|
||||
}
|
||||
lengthError.value = null
|
||||
@@ -337,25 +431,41 @@ async function onSubmit() {
|
||||
...(isReply(mode.value) && sourceId.value ? { inReplyTo: sourceId.value } : {}),
|
||||
}
|
||||
|
||||
/** 이 보내기가 속한 양식 — 응답이 왔을 때 바뀌었으면 양식을 건드리지 않고 토스트로만 알린다 */
|
||||
const generation = formGeneration
|
||||
sending.value = true
|
||||
try {
|
||||
// 받는 사람 거부(MAIL_002)와 첨부 만료(MAIL_004)는 이 화면이 해당 칸에 직접 안내한다.
|
||||
// 받는 사람 거부(MAIL_002) · 첨부 만료(MAIL_004) · 본문이 너무 김(MAIL_007)은 이 화면이 해당 칸에 직접 안내한다.
|
||||
// 그 밖의 실패는 인터셉터 알림(사전 문구)으로 충분하다 — AUTH_003 권한·출처, REQ_001 발송 한도·처리 중,
|
||||
// MAIL_005 같은 키·다른 내용(문구가 보낸편지함 확인을 안내한다), FILE_002 첨부 합계, MAIL_006 첨부 개수
|
||||
// (붙일 때 미리 막으므로 드물다), MAIL_001 엔진 장애.
|
||||
// REQ_001 은 자동으로 다시 보내지 않는다 — 사용자가 잠시 뒤 다시 누르면 같은 키로 간다.
|
||||
await sendMessage(payload, { idempotencyKey: sendKey, silent: ['MAIL_002', 'MAIL_004'] })
|
||||
settleSendKey('sent')
|
||||
await sendMessage(payload, { idempotencyKey: sendKey, silent: SEND_INLINE_CODES })
|
||||
notifySent()
|
||||
// 보낸편지함 개수 · 사용량을 서버 기준으로 다시 맞춘다
|
||||
mailStore.syncCounts()
|
||||
// 보내는 중에 화면을 떠났거나 양식을 새로 채웠다 — 알리기만 하고 간 곳에 둔다
|
||||
if (generation !== formGeneration) return
|
||||
settleSendKey('sent')
|
||||
// 쓴 내용이 메일로 나갔다 — 이제 떠날 때 묻지 않는다
|
||||
sent.value = true
|
||||
await router.push(backTo.value)
|
||||
} catch (error) {
|
||||
const { code } = toApiError(error)
|
||||
const apiError = toApiError(error)
|
||||
if (generation !== formGeneration) {
|
||||
// 양식이 이미 없다 — 칸 안 안내 대신 토스트로 (그 밖의 코드는 인터셉터가 이미 알렸다)
|
||||
if (SEND_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
|
||||
return
|
||||
}
|
||||
const { code } = apiError
|
||||
// MAIL_007 도 키를 그대로 둔다 — 서버가 엔진에 넘기기 전에 거절했으므로, 줄인 뒤 같은 키로 보내면 된다 (계약 2절)
|
||||
settleSendKey(code)
|
||||
if (code === 'MAIL_002') {
|
||||
rejectedByServer.value = true
|
||||
toField.value?.focus()
|
||||
} else if (code === 'MAIL_007') {
|
||||
// 서버가 본문을 거절했다(정화 전 비용 · 정화 뒤 크기 한도 등) — 보내기 전 검사와 같은 띠 · 같은 초점
|
||||
showBodyTooLong()
|
||||
} else if (code === 'MAIL_004') {
|
||||
// 서버가 첨부를 찾지 못했다 — 어느 것인지 알 수 없으니 보낼 예정이던 첨부 전부를 "찾지 못함"으로 표시한다
|
||||
const marked = markMissing()
|
||||
@@ -370,7 +480,8 @@ async function onSubmit() {
|
||||
}
|
||||
// 그 밖의 실패는 인터셉터가 알렸다 — 쓴 내용은 그대로 둔다
|
||||
} finally {
|
||||
sending.value = false
|
||||
// 양식이 바뀌었으면 손대지 않는다 — 새 양식은 resetForm 이 이미 풀었고, 그 양식의 보내기가 진행 중일 수 있다
|
||||
if (generation === formGeneration) sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,11 +492,23 @@ function onSubjectEnter(event: KeyboardEvent) {
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 본문이 너무 길다(MAIL_007) — 본문 아래 띠를 보이고 본문으로 초점 (본문이 그 띠를 가리키고 잘못된 입력으로 읽힌다) */
|
||||
function showBodyTooLong() {
|
||||
lengthError.value = 'body'
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 길이 안내는 그 칸을 고치면 내린다 */
|
||||
function clearLengthError(field: 'subject' | 'body') {
|
||||
if (lengthError.value === field) lengthError.value = null
|
||||
}
|
||||
|
||||
/** 본문이 바뀌었다 — 길이 안내를 내리고, 연 때와 다른지 다시 잰다 (나가기 전 확인) */
|
||||
function onEditorChange() {
|
||||
clearLengthError('body')
|
||||
bodyChanged.value = editorRef.value?.isChanged() ?? false
|
||||
}
|
||||
|
||||
function pickFiles() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
@@ -397,8 +520,13 @@ function onFilesChosen(event: Event) {
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 화면을 떠났다 — 진행 중인 보내기가 끝나도 이 양식을 건드리거나 사용자를 끌고 오지 않는다
|
||||
formGeneration += 1
|
||||
})
|
||||
|
||||
// 쓰기 종류(새 메일·답장·전달)나 원본 메일이 바뀌면 양식을 새로 채운다
|
||||
const draftKey = computed(() => `${mode.value}:${sourceId.value ?? ''}`)
|
||||
const draftKey = computed(() => draftKeyOf(route.query))
|
||||
watch(
|
||||
draftKey,
|
||||
() => {
|
||||
@@ -531,7 +659,7 @@ watch(allRecipients, () => {
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ lengthErrorMessage }}</span>
|
||||
<span>{{ subjectErrorMessage }}</span>
|
||||
</p>
|
||||
|
||||
<!-- 편집기를 불러오거나 원문을 채우는 동안에도 자리를 지켜 아래 줄이 뛰지 않게 한다 -->
|
||||
@@ -546,7 +674,7 @@ watch(allRecipients, () => {
|
||||
:described-by="lengthError === 'body' ? BODY_ERROR_ID : null"
|
||||
@ready="onEditorReady"
|
||||
@files="attachFromBody"
|
||||
@change="clearLengthError('body')"
|
||||
@change="onEditorChange"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
@@ -559,7 +687,7 @@ watch(allRecipients, () => {
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ lengthErrorMessage }}</span>
|
||||
<span>{{ bodyErrorMessage }}</span>
|
||||
</p>
|
||||
|
||||
<div class="compose__attachments">
|
||||
@@ -625,6 +753,13 @@ watch(allRecipients, () => {
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 바꾼 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
|
||||
<ComposeLeaveDialog
|
||||
:open="leaveConfirming"
|
||||
@stay="stayOnCompose"
|
||||
@leave="leaveCompose"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user