diff --git a/frontend/src/components/feedback/ModalDialog.spec.ts b/frontend/src/components/feedback/ModalDialog.spec.ts new file mode 100644 index 0000000..288b0bb --- /dev/null +++ b/frontend/src/components/feedback/ModalDialog.spec.ts @@ -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(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']) + }) +}) diff --git a/frontend/src/components/feedback/ModalDialog.vue b/frontend/src/components/feedback/ModalDialog.vue new file mode 100644 index 0000000..b7be5d4 --- /dev/null +++ b/frontend/src/components/feedback/ModalDialog.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/frontend/src/components/feedback/SessionExpiredDialog.vue b/frontend/src/components/feedback/SessionExpiredDialog.vue index 6dab928..baec51f 100644 --- a/frontend/src/components/feedback/SessionExpiredDialog.vue +++ b/frontend/src/components/feedback/SessionExpiredDialog.vue @@ -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(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() -}) - - diff --git a/frontend/src/components/mail/ComposeLeaveDialog.vue b/frontend/src/components/mail/ComposeLeaveDialog.vue new file mode 100644 index 0000000..aa125b3 --- /dev/null +++ b/frontend/src/components/mail/ComposeLeaveDialog.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/components/mail/MessageReader.spec.ts b/frontend/src/components/mail/MessageReader.spec.ts index 3a10575..4e3bc02 100644 --- a/frontend/src/components/mail/MessageReader.spec.ts +++ b/frontend/src/components/mail/MessageReader.spec.ts @@ -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>) { + 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: '

앞부분

', + 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 diff --git a/frontend/src/components/mail/MessageReader.vue b/frontend/src/components/mail/MessageReader.vue index 38837d5..21f44f3 100644 --- a/frontend/src/components/mail/MessageReader.vue +++ b/frontend/src/components/mail/MessageReader.vue @@ -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() { + +

+ + {{ notice.text }} +

+

('') const inputRef = ref(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 })