generated from ttipo/project-base
feat(frontend): 필터 고치기 창 — 이미 받은 메일에도 적용 · 나가기 확인, 부재중 동작 안 함 줄
- 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」(창을 열 때마다 꺼짐): 저장한 뒤 cursor 가 끝날 때까지
보내며 진행 알림을 고치고, 끝나면 그 자리에서 결과(적용한 수 · [{메일함} 보기], 멈추면 [다시 시도]로 이어서)
- 이미 받은 메일에 할 일이 없는 필터(지워진 메일함 · 받은편지함으로만 · 전달만)와 이 탭에서 적용 중에는 칸을 끈다.
실패 알림은 응답이 온 때 세션이 그대로일 때만, 나아가지 못한 응답이 세 번이면 멈춘다
- 고치기 창의 나가기 확인([취소] · Esc · 다른 화면 · 탭 닫기 — 6단계의 한계를 고친다). 브라우저가 스스로 닫은 창은
ModalDialog 의 reshow() 로 다시 띄우고 초점을 닫히기 전 자리로 돌려준다
- 부재중 화면: 켜 둔 자동 응답이 엔진에서 꺼져 있으면(isActive: false) 머리 아래 노란 줄
- 진행 알림의 결과에도 단추를 붙인다(useToast)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 화면을 떠나기 전 확인 — 세션 만료 대화상자와 같은 모양 (ModalDialog · role=alertdialog)
|
||||
// 쓰는 곳: 쓰기 화면(ComposeLeaveDialog — 저장하지 못한 내용) · 설정 › 메일 › 서명(저장하지 않은 서명)
|
||||
// 쓰는 곳: 쓰기 화면(ComposeLeaveDialog — 저장하지 못한 내용) · 설정 › 메일 › 서명(저장하지 않은 서명) · 부재중 자동 응답 ·
|
||||
// 필터 고치기 창(MailFilterDialog — 창 위에 뜬다: [취소] · Esc 의 "창을 닫으면 …" [닫기] · 다른 화면으로 갈 때 [나가기])
|
||||
// [머무르기](계속 쓰기 · 계속 고치기)가 처음 초점이고 Esc 도 같다. 쓰는 쪽이 답을 받아 이동을 취소하거나(머무르기) 그대로 보낸다(나가기).
|
||||
// 단추는 오른쪽 정렬 — 머무르기는 채운 기본 단추 · 나가기는 보조 단추 (디자인 README "나가기 전 확인").
|
||||
// 고정 id — {idPrefix}-dialog · {idPrefix}-title · {idPrefix}-message · {idPrefix}-stay · {idPrefix}-confirm
|
||||
|
||||
@@ -13,6 +13,16 @@ afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) wrapper.unmount()
|
||||
})
|
||||
|
||||
/**
|
||||
* 브라우저가 스스로 닫는 것을 흉내 낸다 (Esc 를 거듭 눌러 막을 수 없게 닫힘) — open 을 떼고, 초점을 연 때의 자리(창 밖)로 옮긴 뒤 close 를 쏜다
|
||||
* (브라우저는 close 이벤트를 쏘기 전에 초점을 창 밖으로 옮긴다)
|
||||
*/
|
||||
function browserCloses(dialog: HTMLDialogElement) {
|
||||
dialog.removeAttribute('open')
|
||||
;(document.activeElement as HTMLElement | null)?.blur()
|
||||
dialog.dispatchEvent(new Event('close'))
|
||||
}
|
||||
|
||||
function mountDialog(open: boolean) {
|
||||
const wrapper = mount(ModalDialog, {
|
||||
attachTo: document.body,
|
||||
@@ -75,6 +85,49 @@ describe('ModalDialog', () => {
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('브라우저가 스스로 닫았는데 쓰는 쪽이 닫지 않기로 했으면 reshow() 가 다시 띄운다 — 열려 있으면 아무것도 하지 않는다', async () => {
|
||||
const wrapper = mountDialog(true)
|
||||
await flushPromises()
|
||||
const dialog = wrapper.get('dialog').element
|
||||
const reshow = () => (wrapper.vm as unknown as { reshow: () => void }).reshow()
|
||||
|
||||
// 열려 있는 동안은 그대로 (초점도 옮기지 않는다)
|
||||
;(wrapper.get('#leave').element as HTMLElement).focus()
|
||||
reshow()
|
||||
expect(dialog.hasAttribute('open')).toBe(true)
|
||||
expect(document.activeElement?.id).toBe('leave')
|
||||
|
||||
// Esc 를 거듭 눌러 브라우저가 막을 수 없게 닫았다 — 쓰는 쪽은 확인 창을 띄우고 열린 채로 둔다
|
||||
browserCloses(dialog)
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
reshow()
|
||||
expect(dialog.hasAttribute('open')).toBe(true)
|
||||
|
||||
// 쓰는 쪽이 닫았으면 다시 띄우지 않는다
|
||||
await wrapper.setProps({ open: false })
|
||||
reshow()
|
||||
expect(dialog.hasAttribute('open')).toBe(false)
|
||||
})
|
||||
|
||||
it('reshow() 의 초점 — 닫히기 전 창 안에서 마지막으로 있던 요소로, 그 요소가 없어졌으면 처음 초점(autofocus)으로', async () => {
|
||||
const wrapper = mountDialog(true)
|
||||
await flushPromises()
|
||||
const dialog = wrapper.get('dialog').element
|
||||
const reshow = () => (wrapper.vm as unknown as { reshow: () => void }).reshow()
|
||||
|
||||
;(wrapper.get('#leave').element as HTMLElement).focus()
|
||||
browserCloses(dialog)
|
||||
reshow()
|
||||
expect(document.activeElement?.id).toBe('leave')
|
||||
|
||||
// 마지막 자리가 사라졌다 — 처음 초점으로
|
||||
const leave = wrapper.get('#leave').element
|
||||
browserCloses(dialog)
|
||||
leave.remove()
|
||||
reshow()
|
||||
expect(document.activeElement?.id).toBe('stay')
|
||||
})
|
||||
|
||||
it('한 대화상자가 닫히며 다른 대화상자가 열리면 닫기가 먼저다 — 여는 쪽을 먼저 고쳐도 둘이 겹쳐 열리지 않는다', async () => {
|
||||
const first = ref<boolean>(true)
|
||||
// 여는 쪽(B)을 앞에 둔다 — 화면을 고치는 순서가 B 먼저여도 A 가 먼저 닫혀야 한다 (세션 만료 대화상자가 앞에 있는 앱과 같다)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답) ·
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답 · 필터 고치기 창 위) ·
|
||||
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog) ·
|
||||
// 메일 필터 고치기 창(MailFilterDialog — 넓은 창 620, wide)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// 브라우저가 막지 못하게 스스로 닫았는데(Esc 를 거듭 누름) 쓰는 쪽이 닫지 않기로 했으면(나가기 확인의 "계속 고치기") reshow() 로 다시 띄운다 —
|
||||
// 초점은 닫히기 전에 창 안에서 마지막으로 있던 요소로 돌려준다(브라우저는 close 이벤트 전에 초점을 창 밖으로 옮기므로 열린 동안 focusin 으로 기억해 둔다)
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary, 되돌릴 수 없는 일은 --danger 를 더한다)
|
||||
// 글(message) 대신 · 글 아래에 입력 칸을 둘 때는 기본 슬롯에 넣는다 — 글이 없으면 describedBy 로 칸의 안내를 가리킨다
|
||||
@@ -45,6 +47,8 @@ const descriptionId = computed(() => (props.message ? props.messageId : props.de
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
/** 열기 전에 초점이 있던 곳 — 닫으면 돌려준다 (브라우저가 돌려주지 않는 환경에서도) */
|
||||
let returnFocus: HTMLElement | null = null
|
||||
/** 열린 동안 창 안에서 마지막으로 초점이 있던 요소 — 브라우저가 스스로 닫은 뒤 다시 띄울 때(reshow) 그 자리로 돌려준다 */
|
||||
let lastFocused: HTMLElement | null = null
|
||||
|
||||
function show() {
|
||||
const dialog = dialogRef.value
|
||||
@@ -66,6 +70,11 @@ function hide() {
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body || dialog.contains(active)) returnFocus?.focus()
|
||||
returnFocus = null
|
||||
lastFocused = null
|
||||
}
|
||||
|
||||
function onFocusIn(event: FocusEvent) {
|
||||
if (event.target instanceof HTMLElement) lastFocused = event.target
|
||||
}
|
||||
|
||||
function onCancel(event: Event) {
|
||||
@@ -78,6 +87,21 @@ function onClose() {
|
||||
if (props.open) emit('cancel')
|
||||
}
|
||||
|
||||
/**
|
||||
* 열려 있어야 하는데 브라우저가 닫았으면 다시 띄운다 — 위 onClose 의 cancel 에 쓰는 쪽이 닫지 않기로 했을 때 부른다
|
||||
* (예: 필터 고치기 창 — 저장하지 않은 것이 있어 확인 창을 띄웠고 "계속 고치기"를 골랐다). 열려 있으면 아무것도 하지 않는다.
|
||||
* 초점은 닫히기 전에 창 안에서 마지막으로 있던 요소로 — 그 요소가 아직 창 안에 있을 때만 (없으면 처음 초점 autofocus 그대로)
|
||||
*/
|
||||
function reshow() {
|
||||
const dialog = dialogRef.value
|
||||
if (!props.open || !dialog || dialog.open) return
|
||||
const previous = lastFocused
|
||||
show()
|
||||
if (previous?.isConnected && dialog.contains(previous)) previous.focus()
|
||||
}
|
||||
|
||||
defineExpose({ reshow })
|
||||
|
||||
// 닫기는 쓰는 쪽이 다시 그릴 때 바로, 열기는 화면을 다 고친 뒤에 (위 "닫기는 열기보다 먼저")
|
||||
watch(
|
||||
() => props.open,
|
||||
@@ -107,6 +131,7 @@ onMounted(() => {
|
||||
:aria-describedby="descriptionId"
|
||||
@cancel="onCancel"
|
||||
@close="onClose"
|
||||
@focusin="onFocusIn"
|
||||
>
|
||||
<h2
|
||||
:id="titleId"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MailVacation, Mailbox } from '@/shared/types/mail'
|
||||
import type { MailVacationState, Mailbox } from '@/shared/types/mail'
|
||||
import { endOfDayIso } from '@/shared/utils/mail-vacation'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -458,12 +458,13 @@ describe('MailboxPanel — 메일함 관리', () => {
|
||||
})
|
||||
|
||||
it('부재중 자동 응답이 켜져 있으면 사용량 위에 안내 — 누르면 설정으로, 꺼지면 사라진다', async () => {
|
||||
const vacation: MailVacation = {
|
||||
const vacation: MailVacationState = {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: endOfDayIso('2099-11-03'),
|
||||
subject: '부재중 안내',
|
||||
textBody: '휴가입니다.',
|
||||
isActive: true,
|
||||
}
|
||||
const { wrapper } = await mountPanel()
|
||||
useMailVacationStore().vacation = vacation
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
// 고치기 창의 「받은편지함에 이미 있는 메일에도 적용」 (7단계 — 디자인 원본 MailFilterApply · MailFilterApplyStates) — 할 일 아래, 6단계의 안내 띠 자리
|
||||
// 회색 상자(바탕 #F8F9FB) 하나: 체크 칸(이름 굵게) + 칸 아래 설명(12.5 — 칸의 aria-describedby) → 상자 아래 한 줄 "스팸으로 판정되어 …"
|
||||
// - 켤지는 부모가 정한다(창을 열 때마다 꺼짐 — 필터의 설정이 아니라 이번 저장에 한 번 쓴다)
|
||||
// - 고를 수 없으면(block — mail-filter-run.ts 의 filterApplyBlock) 칸을 disabled · 이름을 흐리게, 설명이 까닭을 말한다:
|
||||
// 이 탭에서 다른 필터를 적용하는 중 · 옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만 하는 필터 (이미 받은 메일에 할 일이 없다).
|
||||
// 고를 수 있을 때 전달도 하는 필터면 설명에 "전달은 새로 오는 메일에만 합니다."를 더한다
|
||||
// - 줄 높이는 할 일 칸과 같은 44 (보드는 40 — 고치기 창의 밀도 예외는 칸 · 단추 40 뿐이라 새 예외를 만들지 않는다)
|
||||
// 고정 id — #mail-filter-apply · #mail-filter-apply-help
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { MAIL_FILTER_APPLY_TEXT } from '@/shared/constants/mail-filter'
|
||||
import type { MailFilterApplyBlock } from '@/shared/utils/mail-filter-run'
|
||||
|
||||
interface Props {
|
||||
/** 골랐다 — 고를 수 없는 동안은 꺼진 채로 보인다 */
|
||||
checked: boolean
|
||||
/** 고를 수 없는 까닭 — null 이면 고를 수 있다 */
|
||||
block: MailFilterApplyBlock | null
|
||||
/** 전달도 하는 필터 */
|
||||
forwards: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:checked', checked: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 고를 수 없는 까닭의 설명 */
|
||||
const BLOCK_HELP: Readonly<Record<MailFilterApplyBlock, string>> = {
|
||||
busy: MAIL_FILTER_APPLY_TEXT.busy,
|
||||
missingMailbox: MAIL_FILTER_APPLY_TEXT.missingMailbox,
|
||||
inboxOnly: MAIL_FILTER_APPLY_TEXT.inboxOnly,
|
||||
forwardOnly: MAIL_FILTER_APPLY_TEXT.forwardOnly,
|
||||
}
|
||||
|
||||
const help = computed(() => {
|
||||
if (props.block !== null) return BLOCK_HELP[props.block]
|
||||
return props.forwards ? `${MAIL_FILTER_APPLY_TEXT.help}${MAIL_FILTER_APPLY_TEXT.helpForward}` : MAIL_FILTER_APPLY_TEXT.help
|
||||
})
|
||||
|
||||
function onChange(event: Event) {
|
||||
emit('update:checked', (event.target as HTMLInputElement).checked)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="filter-apply"
|
||||
data-filter-apply
|
||||
>
|
||||
<div class="filter-apply__box">
|
||||
<label :class="['filter-apply__check', { 'is-disabled': block !== null }]">
|
||||
<input
|
||||
id="mail-filter-apply"
|
||||
type="checkbox"
|
||||
class="filter-apply__input"
|
||||
:checked="checked && block === null"
|
||||
:disabled="block !== null"
|
||||
aria-describedby="mail-filter-apply-help"
|
||||
@change="onChange"
|
||||
>
|
||||
<span class="filter-apply__label">{{ MAIL_FILTER_APPLY_TEXT.label }}</span>
|
||||
</label>
|
||||
<p
|
||||
id="mail-filter-apply-help"
|
||||
class="form-message filter-apply__help"
|
||||
>
|
||||
{{ help }}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="form-message"
|
||||
data-filter-apply-spam
|
||||
>
|
||||
{{ MAIL_FILTER_APPLY_TEXT.spam }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 상자와 아래 한 줄 사이 10 (디자인 원본 MailFilterApply — 창의 묶음 사이 16 에서 6 을 당긴다) */
|
||||
.filter-apply {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.filter-apply__box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-6) var(--space-12) var(--space-10);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.filter-apply__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-apply__check.is-disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.filter-apply__input {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.filter-apply__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-apply__check.is-disabled .filter-apply__label {
|
||||
color: var(--color-icon-muted);
|
||||
}
|
||||
|
||||
/* 고르기 상자(18) + 사이(10) 만큼 들여 쓴다 — 할 일 칸의 안내와 같은 선 */
|
||||
.filter-apply__help {
|
||||
margin-left: calc(var(--size-option-input) + var(--space-10));
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 필터 고치기 창 (디자인 원본 SettingsFilter "새 필터" · "필터 고치기" — 모달 620, 공용 모양 ModalDialog 의 넓은 창)
|
||||
// 이름(선택, 50자) → "어떤 메일에"(MailFilterConditionFields — 모두 · 하나라도 · 모든 메일 + 조건 줄 5개까지) →
|
||||
// 할 일(MailFilterActionFields) → 안내 띠 → [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
|
||||
// 할 일(MailFilterActionFields) → 「받은편지함에 이미 있는 메일에도 적용」(MailFilterApplyField — 7단계, 6단계 안내 띠 자리) →
|
||||
// [이 필터 지우기](고칠 때만, 왼쪽) · [저장] [취소].
|
||||
// - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 빈 조건 · `${` · 할 일 없음 · 전달 주소(빈 칸 · 형식 · 사내 주소가 아님 · 내 주소) ·
|
||||
// 이름 · 찾을 글의 길이 · 제어 문자(사전의 VAL_001).
|
||||
// 서버와 같은 규칙(filterDraftProblems)이고, 첫 잘못된 칸에 초점을 준다(알림 없음). 칸을 고치면 그 칸의 잘못을 내린다.
|
||||
@@ -10,20 +11,36 @@
|
||||
// - 열 때마다 칸을 새로 채운다 — 새 필터는 빈 조건 한 줄(읽기 화면에서 왔으면 "보낸 사람 · 포함 · 그 주소"), 고치기는 그 규칙.
|
||||
// 처음 초점은 이름 칸, 읽기 화면에서 만든 새 필터는 할 일의 첫 칸 (디자인 원본 MailFilterStates)
|
||||
// - 보내는 · 다시 받는 동안(pending)과 다른 저장을 기다리는 동안(busy)은 [저장] · [이 필터 지우기]를 aria-disabled 로 막는다 — 누른 단추가 초점을 잃지 않게.
|
||||
// [취소] · Esc 는 언제든 닫는다(보낸 저장의 결과는 부모가 알림으로 알린다)
|
||||
// - 「받은편지함에 이미 있는 메일에도 적용」은 열 때마다 꺼져 있다(필터에 저장하지 않는다). 이 탭에서 다른 적용이 진행 중(applyBusy)이거나
|
||||
// 이미 받은 메일에 할 일이 없으면(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만 — filterApplyBlock) 고를 수 없다.
|
||||
// 고른 채 [저장]하면 save 의 apply 가 true — 저장한 뒤의 적용 · 알림은 부모가 한다(useMailFilterActions)
|
||||
// - 나가기 확인(7단계 — 6단계 한계를 고친다, useDialogLeaveConfirm): 창을 연 때의 값(새 필터는 빈 값 · 읽기 화면에서 채운 값)과 다르면
|
||||
// [취소] · Esc 는 확인 창 "저장하지 않은 필터가 있습니다"([계속 고치기] — 처음 초점 · Esc, [닫기]), 창을 연 채 다른 화면으로 가면 같은 창에
|
||||
// "이 화면을 나가면 …" [나가기], 탭 닫기 · 새로고침은 브라우저 확인창. 보내는 중(저장 · 지우기 — 결과는 부모가 알림으로 알린다) ·
|
||||
// 세션이 끝났을 때는 묻지 않는다. 확인 창은 고치기 창 위에 뜨고, 닫으면 초점이 제자리로 간다
|
||||
// 고정 id — #mail-filter-dialog · #mail-filter-dialog-title · #mail-filter-name · #mail-filter-name-error · #mail-filter-error · #mail-filter-reload ·
|
||||
// #mail-filter-remove · #mail-filter-save · #mail-filter-cancel (조건 · 할 일 칸의 id 는 각 칸 컴포넌트에)
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
// #mail-filter-remove · #mail-filter-save · #mail-filter-cancel · 나가기 확인 #mail-filter-leave-dialog · #mail-filter-leave-stay · #mail-filter-leave-confirm
|
||||
// (조건 · 할 일 · 적용 칸의 id 는 각 칸 컴포넌트에)
|
||||
import { computed, nextTick, reactive, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import MailFilterActionFields from '@/components/settings/MailFilterActionFields.vue'
|
||||
import MailFilterApplyField from '@/components/settings/MailFilterApplyField.vue'
|
||||
import MailFilterConditionFields from '@/components/settings/MailFilterConditionFields.vue'
|
||||
import { getErrorMessage } from '@/composables/useApi'
|
||||
import { useDialogLeaveConfirm } from '@/composables/useDialogLeaveConfirm'
|
||||
import type { MailFilterDialogPending } from '@/composables/useMailFilterDialog'
|
||||
import { MAIL_FILTER_DIALOG_TEXT, MAIL_FILTER_NAME_MAX_LENGTH, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import {
|
||||
MAIL_FILTER_DIALOG_TEXT,
|
||||
MAIL_FILTER_LEAVE_DIALOG,
|
||||
MAIL_FILTER_NAME_MAX_LENGTH,
|
||||
MAIL_FILTER_TEXT,
|
||||
} from '@/shared/constants/mail-filter'
|
||||
import type { Mailbox, MailFilterCondition, MailFilterMatch, MailFilterRule } from '@/shared/types/mail'
|
||||
import {
|
||||
copyFilterDraft,
|
||||
defaultMoveTarget,
|
||||
emptyCondition,
|
||||
filterDraftOf,
|
||||
@@ -36,12 +53,14 @@ import {
|
||||
newFilterDraft,
|
||||
newFilterRuleBase,
|
||||
noFilterProblems,
|
||||
sameFilterDraft,
|
||||
type MailFilterActionDraft,
|
||||
type MailFilterConditionProblem,
|
||||
type MailFilterDraft,
|
||||
type MailFilterForwardProblem,
|
||||
type MailFilterProblems,
|
||||
} from '@/shared/utils/mail-filter'
|
||||
import { filterApplyBlock } from '@/shared/utils/mail-filter-run'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -60,10 +79,13 @@ interface Props {
|
||||
error?: string | null
|
||||
/** 그 거절이 그사이 바뀜(MAIL_012)이다 — 옆에 [다시 불러오기]를 둔다 */
|
||||
conflict?: boolean
|
||||
/** 이 탭에서 다른 필터를 받은편지함에 적용하는 중 — 「…에도 적용」을 고를 수 없다 */
|
||||
applyBusy?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'save', rule: MailFilterRule): void
|
||||
/** apply — 「받은편지함에 이미 있는 메일에도 적용」을 골랐다 (저장한 뒤 이번에 한 번) */
|
||||
(e: 'save', rule: MailFilterRule, apply: boolean): void
|
||||
(e: 'remove'): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'clear-error'): void
|
||||
@@ -77,9 +99,11 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
busy: false,
|
||||
error: null,
|
||||
conflict: false,
|
||||
applyBusy: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const modalRef = ref<InstanceType<typeof ModalDialog> | null>(null)
|
||||
const nameRef = ref<HTMLInputElement | null>(null)
|
||||
const conditionsRef = ref<InstanceType<typeof MailFilterConditionFields> | null>(null)
|
||||
const actionsRef = ref<InstanceType<typeof MailFilterActionFields> | null>(null)
|
||||
@@ -98,6 +122,18 @@ const draft = reactive<MailFilterDraft>(initialDraft())
|
||||
const problems = ref<MailFilterProblems>(noFilterProblems(draft.conditions.length))
|
||||
/** 새 규칙의 id · 켜짐 — 창을 열 때 한 번 만든다 (서버가 거절한 뒤 다시 눌러도 같은 규칙이다) */
|
||||
let base = baseOf()
|
||||
/** 창을 연 때의 칸 값 — 이것과 다르면 바꾼 것이다 (나가기 확인) */
|
||||
const opened = shallowRef<MailFilterDraft>(copyFilterDraft(draft))
|
||||
/** 「받은편지함에 이미 있는 메일에도 적용」을 골랐다 — 열 때마다 꺼진다 */
|
||||
const applyChosen = ref<boolean>(false)
|
||||
|
||||
const applyBlock = computed(() => filterApplyBlock(draft, props.mailboxes, props.applyBusy))
|
||||
/** 저장할 때 적용할지 — 고를 수 없는 동안은 골라 둔 것도 쓰지 않는다 (다시 고를 수 있게 되면 돌아온다) */
|
||||
const applyChecked = computed(() => applyChosen.value && applyBlock.value === null)
|
||||
/** 창을 연 뒤 바꾼 것이 있다 — 적용 칸을 고른 것도 바꾼 것이다 */
|
||||
const changed = computed(() => props.open && (applyChecked.value || !sameFilterDraft(draft, opened.value)))
|
||||
/** 저장 · 지우기를 보내는 중 — 닫거나 떠나도 묻지 않는다 (결과는 부모가 알림으로 알린다) */
|
||||
const sending = computed(() => props.pending === 'save' || props.pending === 'remove')
|
||||
|
||||
const actionsValue = computed<MailFilterActionDraft>(() => ({
|
||||
move: draft.move,
|
||||
@@ -123,11 +159,13 @@ function baseOf(): Pick<MailFilterRule, 'id' | 'isEnabled'> {
|
||||
return props.rule ? { id: props.rule.id, isEnabled: props.rule.isEnabled } : newFilterRuleBase()
|
||||
}
|
||||
|
||||
/** 열 때마다 — 그 규칙(또는 새 필터)으로 칸을 다시 채운다 */
|
||||
/** 열 때마다 — 그 규칙(또는 새 필터)으로 칸을 다시 채운다. 적용 칸은 꺼진다 */
|
||||
function resetDraft() {
|
||||
Object.assign(draft, initialDraft())
|
||||
problems.value = noFilterProblems(draft.conditions.length)
|
||||
base = baseOf()
|
||||
opened.value = copyFilterDraft(draft)
|
||||
applyChosen.value = false
|
||||
}
|
||||
|
||||
function conditionMessage(problem: MailFilterConditionProblem | null): string | null {
|
||||
@@ -187,6 +225,11 @@ function updateActions(patch: Partial<MailFilterActionDraft>) {
|
||||
})
|
||||
}
|
||||
|
||||
function setApply(checked: boolean) {
|
||||
applyChosen.value = checked
|
||||
edited({})
|
||||
}
|
||||
|
||||
/** 첫 잘못된 칸 — 이름 → 조건(차례대로) → 할 일 → 전달 주소 */
|
||||
function focusFirstProblem(found: MailFilterProblems) {
|
||||
const condition = found.conditions.findIndex((problem) => problem !== null)
|
||||
@@ -204,7 +247,7 @@ function save() {
|
||||
focusFirstProblem(found)
|
||||
return
|
||||
}
|
||||
emit('save', filterRuleOf(draft, base))
|
||||
emit('save', filterRuleOf(draft, base), applyChecked.value)
|
||||
}
|
||||
|
||||
function remove() {
|
||||
@@ -215,6 +258,17 @@ function reload() {
|
||||
if (props.pending === null) emit('reload')
|
||||
}
|
||||
|
||||
const { asking: leaveAsking, requestClose, stay: stayEditing, leave: leaveEditing } = useDialogLeaveConfirm({
|
||||
changed: () => changed.value,
|
||||
sending: () => sending.value,
|
||||
close: () => emit('cancel'),
|
||||
// 확인 창이 닫힌 뒤 — 브라우저가 고치기 창을 스스로 닫았으면(Esc 를 거듭 누름) 다시 띄운다
|
||||
stayed: () => void nextTick(() => modalRef.value?.reshow()),
|
||||
})
|
||||
const leaveCopy = computed(() =>
|
||||
leaveAsking.value === 'route' ? MAIL_FILTER_LEAVE_DIALOG.route : MAIL_FILTER_LEAVE_DIALOG.close,
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
/** 다시 받은 뒤 — [저장]으로 (다시 받은 목록 위에 저장한다) */
|
||||
focusSave: () => saveRef.value?.focus(),
|
||||
@@ -232,11 +286,12 @@ watch(
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="mail-filter-dialog"
|
||||
ref="modalRef"
|
||||
:open="open"
|
||||
:title="title"
|
||||
title-id="mail-filter-dialog-title"
|
||||
wide
|
||||
@cancel="emit('cancel')"
|
||||
@cancel="requestClose"
|
||||
>
|
||||
<div class="filter-dialog">
|
||||
<div class="filter-dialog__field">
|
||||
@@ -299,9 +354,12 @@ watch(
|
||||
@update="updateActions"
|
||||
/>
|
||||
|
||||
<p class="filter-dialog__notice">
|
||||
{{ MAIL_FILTER_DIALOG_TEXT.notice }}
|
||||
</p>
|
||||
<MailFilterApplyField
|
||||
:checked="applyChecked"
|
||||
:block="applyBlock"
|
||||
:forwards="draft.forward"
|
||||
@update:checked="setApply"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
@@ -365,12 +423,21 @@ watch(
|
||||
id="mail-filter-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('cancel')"
|
||||
@click="requestClose"
|
||||
>
|
||||
{{ MAIL_FILTER_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
|
||||
<!-- 저장하지 않고 닫거나 떠나려 할 때 — 고치기 창 위에 뜬다 (닫기는 답을 받아 창을 닫고, 떠나기는 이동이 답을 기다린다) -->
|
||||
<LeaveConfirmDialog
|
||||
:open="leaveAsking !== null"
|
||||
id-prefix="mail-filter-leave"
|
||||
:copy="leaveCopy"
|
||||
@stay="stayEditing"
|
||||
@leave="leaveEditing"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -431,15 +498,6 @@ watch(
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.filter-dialog__notice {
|
||||
margin: 0;
|
||||
padding: var(--space-10) var(--space-12);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-panel);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* [이 필터 지우기] — 왼쪽 끝, 바탕 없는 빨간 글 (디자인 원본 SettingsFilter) */
|
||||
.modal-dialog__action.filter-dialog__remove {
|
||||
gap: var(--space-6);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 필터 아래 안내 상자 (디자인 원본 SettingsFilter — 바탕 #F3F8F8, 최대 700):
|
||||
// 차례로 적용 · 옮기면 멈춤 / 스팸 제외 / 사내 전달만 · 한 곳 · 사본 남김 / 부재중과 함께 동작. 첫 줄의 "위에서부터 차례로"는 굵게
|
||||
// 설정 › 필터 아래 안내 상자 (디자인 원본 SettingsFilter · 7단계 MailFilterApply 의 글 — 바탕 #F3F8F8, 최대 700):
|
||||
// 차례로 적용 · 옮기면 멈춤 / 이미 받은 메일에도 적용하려면 저장할 때 고른다(7단계) / 스팸 제외 / 사내 전달만 · 새로 오는 메일만 · 한 곳 · 사본 남김 /
|
||||
// 부재중과 함께 동작. 첫 줄의 "위에서부터 차례로" · 둘째 줄의 칸 이름은 굵게
|
||||
import { MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
|
||||
const NOTES = [MAIL_FILTER_TEXT.noteSpam, MAIL_FILTER_TEXT.noteForward, MAIL_FILTER_TEXT.noteVacation] as const
|
||||
@@ -17,6 +18,15 @@ const NOTES = [MAIL_FILTER_TEXT.noteSpam, MAIL_FILTER_TEXT.noteForward, MAIL_FIL
|
||||
MAIL_FILTER_TEXT.noteOrderAfter
|
||||
}}</span>
|
||||
</p>
|
||||
<p
|
||||
class="filter-notes__note"
|
||||
data-filter-note-apply
|
||||
>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ MAIL_FILTER_TEXT.noteApplyBefore }}<strong class="filter-notes__strong">{{
|
||||
MAIL_FILTER_TEXT.noteApplyStrong
|
||||
}}</strong>{{ MAIL_FILTER_TEXT.noteApplyAfter }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-for="note in NOTES"
|
||||
:key="note"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 필터 목록 위 노란 줄 (디자인 원본 MailFilterStates) — 바탕 #FFF7E6 · 글 #7A5200, 아이콘 16 · 글 · 단추(36 — 밀도 예외 --size-notice-action)
|
||||
// 다른 곳에서 먼저 바꿈(409 MAIL_012 — [다시 불러오기]) · 동작하지 않음(isActive: false — [다시 켜기]) 두 곳에 쓴다.
|
||||
// 설정 화면 머리 아래 노란 줄 (디자인 원본 MailFilterStates · MailFilterApplyStates) — 바탕 #FFF7E6 · 글 #7A5200, 아이콘 16 · 글 · 단추(36 — 밀도 예외 --size-notice-action)
|
||||
// 필터 목록 위의 다른 곳에서 먼저 바꿈(409 MAIL_012 — [다시 불러오기]) · 동작하지 않음(isActive: false — [다시 켜기]),
|
||||
// 그리고 부재중 자동 응답 화면의 "켜 둔 자동 응답이 지금은 동작하지 않습니다"(7단계 — 단추 없음: actionLabel 을 주지 않는다)에 쓴다.
|
||||
// 단추를 누르고 기다리는 동안은 disabled 가 아니라 aria-disabled · aria-busy — 초점이 그대로 남게 (다시 누르는 것은 부모가 막는다)
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
@@ -8,7 +9,8 @@ import type { IconName } from '@/shared/constants/icons'
|
||||
interface Props {
|
||||
icon: IconName
|
||||
message: string
|
||||
actionLabel: string
|
||||
/** 줄 안의 단추 — 없으면 글만 */
|
||||
actionLabel?: string
|
||||
busy?: boolean
|
||||
/** 저장이 막혀 바로 알려야 하면 alert(그사이 바뀜), 들어올 때부터 있던 상태면 status(동작하지 않음) */
|
||||
live?: 'alert' | 'status'
|
||||
@@ -19,6 +21,7 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
actionLabel: '',
|
||||
busy: false,
|
||||
live: 'status',
|
||||
})
|
||||
@@ -31,7 +34,7 @@ function onAction() {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="filter-notice"
|
||||
:class="['filter-notice', { 'filter-notice--plain': !actionLabel }]"
|
||||
:role="live"
|
||||
>
|
||||
<AppIcon
|
||||
@@ -42,6 +45,7 @@ function onAction() {
|
||||
/>
|
||||
<span class="filter-notice__text">{{ message }}</span>
|
||||
<button
|
||||
v-if="actionLabel"
|
||||
type="button"
|
||||
class="filter-notice__action"
|
||||
:aria-disabled="busy ? 'true' : undefined"
|
||||
@@ -64,6 +68,11 @@ function onAction() {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
/* 단추 없는 줄 — 좌우 여백이 같다 (디자인 원본 MailFilterApplyStates 의 부재중 안내) */
|
||||
.filter-notice--plain {
|
||||
padding-right: var(--space-12);
|
||||
}
|
||||
|
||||
.filter-notice__icon {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user