generated from ttipo/project-base
fix(frontend): 14단계 화면을 실사용 시험 · 사용자 관점 점검대로 고친다
- 분할 보기에서 대화 줄을 고르면 도구 줄이 두 줄로 넘치던 것, 목록 머리의
안 읽은 수가 1440 에서도 늘 잘리던 것
- 묶은 줄에 한 일의 알림은 "{메일함}에 있는 메일 N통을 …"(줄의 통 수는
대화 전체라서), 되돌리기 알림은 되돌린 메일함을 말한다
- 같은 대화 칸: 펼친 줄의 첨부 수 안내, 우리 원문 인용 머리부터 뺀 미리
보기, 보내기를 기다리는 메일은 줄 끝에
- 검색 준비 중 · 결과 없음 · 하루짜리 기간 · 내 이름 조건의 글, 예약 시각의
띄어쓰기, 이름 맞추기(대화로 묶기 · 내용에 든 말 · 표 머리 참여자)
- 다른 탭 · PC 에서 바꾼 설정을 탭으로 돌아올 때 다시 받는다(60초 지났을 때)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,8 @@ import { useMailStore, type MailBulkResult, type MoveOptions } from '@/stores/ma
|
||||
// - 스팸 신고(스팸메일함으로 + 스팸 표시) · 스팸 아님(스팸메일함에서 — 받은편지함으로 + "스팸 아님" 표시): 옮기기와 같은 길에 isJunk 를 함께 보낸다
|
||||
// - 열어 둔 메일을 옮기거나 지워 목록에서 빠졌으면 목록 주소로 바꿔 끼운다 (본문 칸이 없는 메일을 붙들지 않게).
|
||||
// 끝났을 때 다른 목록으로 옮겨 가 있으면(stale) 그 목록에 기대어 움직이지 않는다
|
||||
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때
|
||||
// - 대화로 묶은 메일함(14단계)은 고른 줄의 메일 모두에 한다(붙잡힌 메일은 빼고 — 스토어). "중요 해제"는 고른 줄이 모두 중요 표시가 있을 때.
|
||||
// 옮기기 · 삭제 · 스팸 알림은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 "4통"은 대화 전체라서 (2026-11-06 실사용 시험)
|
||||
|
||||
export function useBulkMailActions() {
|
||||
const mailStore = useMailStore()
|
||||
@@ -64,11 +65,13 @@ export function useBulkMailActions() {
|
||||
|
||||
async function moveTo(mailbox: Mailbox, options: MoveOptions = {}): Promise<void> {
|
||||
if (busy.value || mailStore.checkedCount === 0) return
|
||||
// 대화로 묶은 목록은 줄의 통 수가 대화 전체라 알림이 지금 메일함의 메일임을 밝힌다 — 시작할 때의 메일함 (기다리는 사이 목록이 바뀔 수 있다)
|
||||
const from = mailStore.isConversationList ? (mailStore.currentMailbox?.name ?? null) : null
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await mailStore.moveChecked(mailbox.id, options)
|
||||
await leaveRemovedMessage(result)
|
||||
feedback.announceMove(result, mailbox.name)
|
||||
feedback.announceMove(result, mailbox.name, { from })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
|
||||
@@ -6,14 +6,17 @@ import {
|
||||
purgedMessageText,
|
||||
restoredMessageText,
|
||||
type BulkOrigin,
|
||||
type MovedTextOptions,
|
||||
} from '@/shared/utils/mail-bulk'
|
||||
import { useMailStore, type BulkMoveResult, type MailBulkResult, type MoveOptions } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 옮기기 · 영구 삭제의 결과 알림 (디자인 README "3단계 › 여러 통 처리" · "4단계 › 스팸 · 원문") — 도구 줄과 읽기 화면의 「삭제」 · 「스팸 신고」가 함께 쓴다.
|
||||
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고 "메일 N통을 되돌렸습니다.")
|
||||
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 받은편지함으로 옮겼습니다 (스팸 아님)."
|
||||
* - "메일 N통을 X으로 옮겼습니다." + [되돌리기](10초 — 원래 메일함별로 다시 옮기고, 한 메일함으로 되돌렸으면 "메일 N통을 X으로 되돌렸습니다.",
|
||||
* 여러 곳이면 "메일 N통을 되돌렸습니다.")
|
||||
* 스팸 신고는 같은 문구("…스팸메일함으로 옮겼습니다."), 스팸 아님은 "메일 N통을 스팸이 아닌 메일로 표시해 받은편지함으로 옮겼습니다."
|
||||
* — 스팸 신고 · 스팸 아님의 되돌리기는 원래 메일함으로 옮기며 표시도 되돌린다(보낸 값의 반대)
|
||||
* - 대화로 묶은 목록에서 줄에 한 일(14단계)은 "받은편지함에 있는 메일 2통을 휴지통으로 옮겼습니다." — 줄의 통 수는 대화 전체라서 (options.from)
|
||||
* - "메일 N통을 영구 삭제했습니다."
|
||||
* - 일부만 처리했으면 뒤에 "N통은 그사이 옮겨졌거나 지워져 그대로 두었습니다." (하나도 못 했으면 그 문장만)
|
||||
* - 하나도 보내지 못했으면(요청 실패 — 인터셉터가 알렸다) 알리지 않는다
|
||||
@@ -23,21 +26,31 @@ export function useMailMoveFeedback() {
|
||||
const mailStore = useMailStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** 되돌린 메일함의 이름 — 한 메일함으로 되돌릴 때만(여러 곳이면 null), 메일함 목록에 없으면 null */
|
||||
function restoredPlaceOf(origins: readonly BulkOrigin[]): string | null {
|
||||
if (origins.length !== 1) return null
|
||||
const mailboxId = origins[0]?.mailboxId
|
||||
return mailStore.mailboxes.find((mailbox) => mailbox.id === mailboxId)?.name ?? null
|
||||
}
|
||||
|
||||
async function undo(origins: readonly BulkOrigin[], options: MoveOptions): Promise<void> {
|
||||
const result = await mailStore.restoreMoved(origins, options)
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped))
|
||||
if (done > 0 || skipped > 0) toast.success(restoredMessageText(done, skipped, restoredPlaceOf(origins)))
|
||||
}
|
||||
|
||||
function announceMove(result: BulkMoveResult, mailboxName: string) {
|
||||
/** textOptions.from — 대화로 묶은 목록에서 줄에 한 일이면 그 목록의 메일함 이름 ("받은편지함에 있는 메일 2통을 …") */
|
||||
function announceMove(result: BulkMoveResult, mailboxName: string, textOptions: MovedTextOptions = {}) {
|
||||
const done = result.doneIds.length
|
||||
const skipped = result.skippedIds.length
|
||||
if (done === 0 && skipped === 0) return
|
||||
const origins = result.origins
|
||||
const undoOptions: MoveOptions = result.isJunk === undefined ? {} : { isJunk: !result.isJunk }
|
||||
const text =
|
||||
result.isJunk === false ? notSpamMovedText(done, mailboxName, skipped) : movedMessageText(done, mailboxName, skipped)
|
||||
result.isJunk === false
|
||||
? notSpamMovedText(done, mailboxName, skipped, textOptions)
|
||||
: movedMessageText(done, mailboxName, skipped, textOptions)
|
||||
toast.success(
|
||||
text,
|
||||
origins.length > 0 ? { label: DRAFT_UNDO_LABEL, run: () => void undo(origins, undoOptions) } : undefined,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, type EffectScope } from 'vue'
|
||||
|
||||
import { useMailSettingsRefresh } from '@/composables/useMailSettingsRefresh'
|
||||
import { MAIL_SETTINGS_REFRESH_AFTER_MS } from '@/shared/constants/mail-settings'
|
||||
import type { MailSettings } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import { fail, flushPromises, installMockApi, ok, restoreApiAdapter, type MockRequest, type MockResult } from '@/test-utils/mock-api'
|
||||
|
||||
// 메일 화면이 탭으로 돌아올 때 보내기 · 읽기 설정을 다시 받는다 (14단계 — 2026-11-06 실사용 시험: 다른 탭에서 끈 묶기가 열어 둔 탭에 남았다)
|
||||
// 손으로 돌리는 시계와 화면 보임 상태로 확인한다 (고정 대기 없이)
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
const SETTINGS: MailSettings = { undoSendSeconds: 10, externalImages: 'ask', groupByConversation: true }
|
||||
|
||||
describe('useMailSettingsRefresh (탭으로 돌아오면 설정 다시 받기)', () => {
|
||||
let scope: EffectScope
|
||||
let clock: ManualClock
|
||||
let visibility: DocumentVisibilityState
|
||||
let server: MailSettings
|
||||
let load: () => MockResult
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(async () => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
scope = effectScope()
|
||||
clock = createManualClock()
|
||||
visibility = 'visible'
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
|
||||
server = SETTINGS
|
||||
load = () => ok(server)
|
||||
requests = installMockApi((request) => (request.method === 'GET' ? load() : ok(server)))
|
||||
// 메일 화면에 들어올 때 받아 둔다 (MailLayout 의 ensureLoaded)
|
||||
await useMailSettingsStore().load()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
Reflect.deleteProperty(document, 'visibilityState')
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
function start() {
|
||||
scope.run(() => useMailSettingsRefresh({ clock }))
|
||||
}
|
||||
|
||||
async function comeBack(afterMs: number) {
|
||||
visibility = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
clock.advance(afterMs)
|
||||
visibility = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const fetched = () => requests.filter((request) => request.method === 'GET' && request.url === '/mail-settings').length
|
||||
|
||||
it('마지막으로 받은 지 60초가 지난 뒤 탭으로 돌아오면 조용히 다시 받아 그 값을 쓴다 — 묶기 · 보내기 취소 초 · 외부 이미지', async () => {
|
||||
start()
|
||||
server = { undoSendSeconds: 30, externalImages: 'always', groupByConversation: false }
|
||||
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
|
||||
expect(fetched()).toBe(2)
|
||||
expect(requests.at(-1)?.config.silent).toBe(true)
|
||||
const store = useMailSettingsStore()
|
||||
expect(store.groupByConversation).toBe(false)
|
||||
expect(store.knownUndoSendSeconds).toBe(30)
|
||||
expect(store.settings?.externalImages).toBe('always')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('60초가 안 됐으면 다시 받지 않는다 — 받은 뒤에는 또 60초를 센다', async () => {
|
||||
start()
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS - 1)
|
||||
expect(fetched()).toBe(1)
|
||||
|
||||
await comeBack(1)
|
||||
expect(fetched()).toBe(2)
|
||||
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS - 1)
|
||||
expect(fetched()).toBe(2)
|
||||
})
|
||||
|
||||
it('숨는 때에는 받지 않는다 — 다시 보일 때만', async () => {
|
||||
start()
|
||||
clock.advance(MAIL_SETTINGS_REFRESH_AFTER_MS * 2)
|
||||
visibility = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await flushPromises()
|
||||
|
||||
expect(fetched()).toBe(1)
|
||||
})
|
||||
|
||||
it('세션을 쓸 수 없으면(제한된 세션 · 로그아웃) 받지 않는다', async () => {
|
||||
start()
|
||||
// 같은 직원의 세션에 제한이 붙었다 — 받아 둔 설정은 그대로지만 묻지 않는다
|
||||
useAuthStore().markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
expect(useMailSettingsStore().status).toBe('ready')
|
||||
expect(fetched()).toBe(1)
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
expect(fetched()).toBe(1)
|
||||
})
|
||||
|
||||
it('받지 못하면 알리지 않고 지금 값을 둔다 — 60초 뒤 다시 보일 때 또 해 본다', async () => {
|
||||
start()
|
||||
load = () => fail(503, 'SYS_001')
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
|
||||
expect(fetched()).toBe(2)
|
||||
expect(useMailSettingsStore().settings).toEqual(SETTINGS)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
load = () => ok({ ...SETTINGS, groupByConversation: false })
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
expect(fetched()).toBe(3)
|
||||
expect(useMailSettingsStore().groupByConversation).toBe(false)
|
||||
})
|
||||
|
||||
it('화면이 사라지면 그만 본다', async () => {
|
||||
start()
|
||||
scope.stop()
|
||||
await comeBack(MAIL_SETTINGS_REFRESH_AFTER_MS)
|
||||
expect(fetched()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { onScopeDispose, watch } from 'vue'
|
||||
|
||||
import { systemClock, type Clock } from './useClock'
|
||||
import { MAIL_SETTINGS_REFRESH_AFTER_MS } from '@/shared/constants/mail-settings'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailSettingsStore } from '@/stores/mail-settings.store'
|
||||
|
||||
// 보내기 · 읽기 설정을 메일 화면에서 다시 받는 때 (14단계 — 2026-11-06 실사용 시험: 다른 탭에서 끈 [대화로 묶기]가 열어 둔 탭에 남았다)
|
||||
// - 메일 화면(MailLayout)이 있는 동안, 탭이 다시 보이게 될 때 설정을 마지막으로 받은 지 60초가 지났으면 조용히 다시 받는다(알리지 않는다)
|
||||
// — 받은 묶기 값이 다르면 메일함 화면이 그 값으로 목록을 다시 열고(MailboxPage), 보내기 취소 초 · 외부 이미지도 받은 값을 쓴다
|
||||
// - 마지막으로 받은 때는 화면에 들어온 때(그때 받아 두었거나 지금 받는다)와 받을 때마다(스토어의 fetchSeq)다. 다시 받기를 시작하면 그때로 —
|
||||
// 받지 못하면(알리지 않는다) 60초 뒤 다시 보일 때 또 해 본다
|
||||
// - 세션이 끝났으면(로그아웃 · 세션 만료 · 제한된 세션) 받지 않는다. 저장하는 중 · 받는 사이 고른 값은 스토어가 지킨다(refresh)
|
||||
|
||||
export interface MailSettingsRefreshOptions {
|
||||
/** 테스트는 손으로 돌리는 시계를 넘긴다 (test-utils/manual-clock.ts) */
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
function isPageVisible(): boolean {
|
||||
return typeof document === 'undefined' || document.visibilityState === 'visible'
|
||||
}
|
||||
|
||||
export function useMailSettingsRefresh(options: MailSettingsRefreshOptions = {}): void {
|
||||
const clock = options.clock ?? systemClock
|
||||
const authStore = useAuthStore()
|
||||
const settingsStore = useMailSettingsStore()
|
||||
|
||||
/** 설정을 마지막으로 받은 때 (ms) */
|
||||
let fetchedAt = clock.now()
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (!isPageVisible() || !authStore.canUseApp) return
|
||||
const now = clock.now()
|
||||
if (now - fetchedAt < MAIL_SETTINGS_REFRESH_AFTER_MS) return
|
||||
fetchedAt = now
|
||||
void settingsStore.refresh()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => settingsStore.fetchSeq,
|
||||
() => {
|
||||
fetchedAt = clock.now()
|
||||
},
|
||||
)
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
onScopeDispose(() => document.removeEventListener('visibilitychange', onVisibilityChange))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user