generated from ttipo/project-base
feat(frontend): 주소록 화면 · 메일 연동
레일의 주소록 모듈 — 내 연락처 · 그룹 · 사내 목록(직원 · 공용 주소), 초성 찾기, 연락처 추가 · 고치기 창, 그룹 · 구성원 넣기 — 과 메일 연동(받는 사람 추천의 주소록 · 그룹, 연락처 · 그룹 · 사내의 [메일 쓰기], 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」)을 더한다. 디자인 README "8단계", 계약 3절. 리뷰로 고친 것: - 쓰기 화면을 채우는 라우터 상태에 연 직원을 싣는다 — 같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가면 앞 직원의 받는 사람이 채워졌다. - [고치기]는 창을 열 때 새로 받고 바꾼 칸만 보낸다 — 받아 둔 값으로 다른 탭 · 휴대폰의 변경을 덮었다. 검사도 바꾼 칸만. - [되돌리기]는 서버의 되돌리기 표로 — 다시 만들기로는 다른 프로그램의 칸 · uid 를 잃었다. - 읽기 화면에서 연 연락처 추가 창을 메일 레이아웃으로 옮겼다 — 메일을 바꾸면 쓴 것이 확인 없이 사라졌다. - 그룹을 이어 고르면 앞 그룹이 빠지던 것, 저장 중 창을 닫으면 거절이 묻히던 것, 구성원 넣기의 첫 쪽이 모두 구성원이면 막히던 것(excludeGroupId). - 받는 사람 추천은 초성 · 조합 중 글자도 서버에 묻는다(서버가 같은 맞추기 — 같은 표본 표를 두 쪽 테스트에 둔다). - 바꿀 때마다 불러온 쪽을 모두 다시 받지 않고 응답을 서버 차례로 그 자리에 넣는다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
// 연락처 아바타 — 이름 첫 글자(디자인 원본 Contacts: 목록 32 · 상세 48). 공용 주소는 회색 (neutral)
|
||||
// 장식이다 — 이름은 옆 글이 읽는다 (aria-hidden)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { initialOf } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
name: string | null
|
||||
/** 이름이 없을 때 첫 글자를 가져올 글 (주소) */
|
||||
fallback?: string
|
||||
size?: 'md' | 'lg'
|
||||
tone?: 'brand' | 'neutral'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fallback: '',
|
||||
size: 'md',
|
||||
tone: 'brand',
|
||||
})
|
||||
|
||||
const initial = computed(() => initialOf(props.name, props.fallback))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="['contact-avatar', `contact-avatar--${size}`, `contact-avatar--${tone}`]"
|
||||
aria-hidden="true"
|
||||
>{{ initial }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contact-avatar {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-round);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 목록 줄 — 32 · 13px */
|
||||
.contact-avatar--md {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
/* 상세 머리 — 48 · 18px */
|
||||
.contact-avatar--lg {
|
||||
width: var(--space-48);
|
||||
height: var(--space-48);
|
||||
font-size: var(--font-size-title-lg);
|
||||
}
|
||||
|
||||
.contact-avatar--brand {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 공용 주소 — 회색 (디자인 원본 Contacts: #EEF0F3 · #344054) */
|
||||
.contact-avatar--neutral {
|
||||
background: var(--color-border-subtle);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,576 @@
|
||||
<script setup lang="ts">
|
||||
// 주소록 상세 칸 (디자인 원본 Contacts 오른쪽 · README "8단계 › 주소록 화면") — 콘텐츠 열(최대 800)
|
||||
// - 머리 줄: 내 연락처 [메일 쓰기] [고치기] … [⋯](주소 복사 · 연락처 지우기) / 사내 [메일 쓰기] [내 주소록에 추가]
|
||||
// - 아래로 아바타 48 · 이름(20) · "회사 · 부서 · 직책"(없으면 "소속 없음", 사내는 "사내 직원" · "공용 주소")
|
||||
// - 칸(왼쪽 이름 88): 이메일(주소마다 링크 — 누르면 그 주소로 새 메일, 옆에 종류) · 전화(번호 · 종류) · 소속 · 직책 · 그룹(누르면 그 그룹의 목록) ·
|
||||
// 메모(줄바꿈 그대로). 빈 칸은 줄째 뺀다. 사내 상세 아래에는 "사내 목록은 메일 서버의 계정에서 옵니다 — …"
|
||||
// - [메일 쓰기] · 주소 링크는 받는 사람을 채운 새 쓰기 화면 — 주소는 URL 이 아니라 라우터 상태로 (compose-prefill.ts).
|
||||
// 주소가 없는 연락처의 [메일 쓰기] · 3,000개일 때의 [내 주소록에 추가]는 aria-disabled + 까닭(가린 글 · title — 메뉴의 note 처럼)
|
||||
// - [고치기]는 그 연락처를 새로 받아 창을 연다(받는 동안 aria-busy — useContactActions 의 loadForEdit). 못 받으면 창을 열지 않는다
|
||||
// - 고른 것이 없으면 "연락처를 고르세요", 없는 연락처 · 사내 항목(지워짐 · 옛 주소)이면 사전 문구 + [목록으로], 그 밖에 받지 못했으면 사전 문구 + [다시 시도]
|
||||
// 고정 id — #contact-detail-name · 까닭 #contact-compose-note · #contact-add-mine-note
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import ContactAvatar from '@/components/contacts/ContactAvatar.vue'
|
||||
import ContactMoreMenu from '@/components/contacts/ContactMoreMenu.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import { type ApiError } from '@/composables/useApi'
|
||||
import { useContactActions } from '@/composables/useContactActions'
|
||||
import { useContactsDialogs } from '@/composables/useContactsDialogs'
|
||||
import {
|
||||
CONTACT_DETAIL_TEXT,
|
||||
CONTACT_EMAIL_LABEL_TEXT,
|
||||
CONTACT_PHONE_LABEL_TEXT,
|
||||
CONTACTS_LIST_TEXT,
|
||||
CONTACTS_PANEL_TEXT,
|
||||
} from '@/shared/constants/contacts'
|
||||
import type { Address } from '@/shared/types/mail'
|
||||
import { composePrefillState } from '@/shared/utils/compose-prefill'
|
||||
import {
|
||||
contactDisplayName,
|
||||
contactOrgLine,
|
||||
contactSubtitle,
|
||||
primaryEmail,
|
||||
} from '@/shared/utils/contacts'
|
||||
import {
|
||||
contactsItemLocation,
|
||||
contactsListLocation,
|
||||
type ContactsRouteState,
|
||||
} from '@/shared/utils/contacts-route'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useContactsStore } from '@/stores/contacts.store'
|
||||
|
||||
interface Props {
|
||||
state: ContactsRouteState
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const store = useContactsStore()
|
||||
const authStore = useAuthStore()
|
||||
const dialogs = useContactsDialogs()
|
||||
const actions = useContactActions()
|
||||
const router = useRouter()
|
||||
|
||||
const directoryKind = computed(() =>
|
||||
props.state.source.kind === 'directory' ? props.state.source.directoryKind : null,
|
||||
)
|
||||
const contact = computed(() => (directoryKind.value ? null : store.contactById(props.state.selectedId)))
|
||||
const entry = computed(() => (directoryKind.value ? store.entryById(props.state.selectedId) : null))
|
||||
/** 목록에 없는 고른 것을 따로 받은 상태 — 연락처(GET /contacts/{id}) · 사내 항목(GET /directory-entries/{id}) */
|
||||
const pickedStatus = computed(() => (directoryKind.value ? store.entryDetailStatus : store.detailStatus))
|
||||
/** 고른 것을 아직 찾는 중 — 목록 · 그것을 받는 중 */
|
||||
const loading = computed(() => {
|
||||
if (!props.state.selectedId || contact.value || entry.value) return false
|
||||
if (store.listStatus === 'loading' || store.listStatus === 'idle') return true
|
||||
return pickedStatus.value === 'loading' || pickedStatus.value === 'idle'
|
||||
})
|
||||
/** 고른 것을 보일 수 없다 — 따로 받기가 실패했을 때(없음 · 장애) */
|
||||
const failure = computed<ApiError | null>(() => {
|
||||
if (!props.state.selectedId || contact.value || entry.value || loading.value) return null
|
||||
if (pickedStatus.value !== 'error') return null
|
||||
return directoryKind.value ? store.entryDetailError : store.detailError
|
||||
})
|
||||
/** 없는 것(RES_001) — 다시 해도 같으니 [목록으로], 그 밖은 [다시 시도] */
|
||||
const notFound = computed(() => failure.value?.code === 'RES_001')
|
||||
|
||||
function composeLocation(addresses: readonly Address[]): RouteLocationRaw {
|
||||
return { name: 'mail-compose', state: composePrefillState(addresses, authStore.user?.email ?? '') }
|
||||
}
|
||||
|
||||
const contactName = computed(() => (contact.value ? contactDisplayName(contact.value) : ''))
|
||||
const contactPrimary = computed(() => (contact.value ? primaryEmail(contact.value) : null))
|
||||
const contactCompose = computed(() =>
|
||||
contact.value && contactPrimary.value
|
||||
? composeLocation([{ name: contact.value.name, email: contactPrimary.value }])
|
||||
: null,
|
||||
)
|
||||
const orgLine = computed(() => (contact.value ? contactOrgLine(contact.value) : ''))
|
||||
const contactGroups = computed(() =>
|
||||
(contact.value?.groupIds ?? []).flatMap((id) => {
|
||||
const group = store.groupById(id)
|
||||
return group ? [group] : []
|
||||
}),
|
||||
)
|
||||
const entryCompose = computed(() =>
|
||||
entry.value ? composeLocation([{ name: entry.value.name, email: entry.value.email }]) : null,
|
||||
)
|
||||
|
||||
/** 이 연락처를 새로 받는 중 — [고치기]의 바쁜 표시 */
|
||||
const editLoading = computed(() => Boolean(contact.value) && store.editLoadingId === contact.value?.id)
|
||||
|
||||
/** [고치기] — 새로 받은 값으로 창을 연다. 받는 사이 다른 연락처를 골랐으면 열지 않는다 */
|
||||
async function editContact() {
|
||||
const current = contact.value
|
||||
if (!current || editLoading.value) return
|
||||
const fresh = await actions.loadForEdit(current)
|
||||
if (fresh && props.state.selectedId === fresh.id) dialogs.openContact(fresh)
|
||||
}
|
||||
|
||||
function addEntryToMine() {
|
||||
const current = entry.value
|
||||
if (!current || store.isFull) return
|
||||
dialogs.openContact(null, { name: current.name, email: current.email, label: 'work' })
|
||||
}
|
||||
|
||||
function leaveToList() {
|
||||
void router.replace(contactsListLocation(props.state.source, props.state.q))
|
||||
}
|
||||
|
||||
function onFailureAction() {
|
||||
const id = props.state.selectedId
|
||||
if (notFound.value) leaveToList()
|
||||
else if (id && directoryKind.value) void store.openEntry(id)
|
||||
else if (id) void store.openDetail(id)
|
||||
}
|
||||
|
||||
function groupLocation(groupId: string): RouteLocationRaw {
|
||||
return contact.value
|
||||
? contactsItemLocation({ kind: 'group', groupId }, contact.value.id)
|
||||
: contactsListLocation({ kind: 'group', groupId })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contact-detail">
|
||||
<article
|
||||
v-if="contact"
|
||||
class="contact-detail__article"
|
||||
aria-labelledby="contact-detail-name"
|
||||
:data-contact-detail="contact.id"
|
||||
>
|
||||
<div class="contact-detail__bar">
|
||||
<div
|
||||
class="contact-detail__toolbar"
|
||||
role="group"
|
||||
:aria-label="CONTACT_DETAIL_TEXT.actions"
|
||||
>
|
||||
<MailToolButton
|
||||
icon="edit"
|
||||
:label="CONTACT_DETAIL_TEXT.compose"
|
||||
:to="contactCompose"
|
||||
:unavailable="!contactCompose"
|
||||
:describedby="contactCompose ? undefined : 'contact-compose-note'"
|
||||
:title="contactCompose ? undefined : CONTACT_DETAIL_TEXT.composeNoAddress"
|
||||
data-contact-compose
|
||||
/>
|
||||
<span
|
||||
v-if="!contactCompose"
|
||||
id="contact-compose-note"
|
||||
class="visually-hidden"
|
||||
>{{ CONTACT_DETAIL_TEXT.composeNoAddress }}</span>
|
||||
<MailToolButton
|
||||
icon="edit-square"
|
||||
:label="CONTACT_DETAIL_TEXT.edit"
|
||||
:busy="editLoading"
|
||||
data-contact-edit
|
||||
@click="editContact"
|
||||
/>
|
||||
<ContactMoreMenu
|
||||
class="contact-detail__more"
|
||||
:contact="contact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-detail__content">
|
||||
<div class="contact-detail__head">
|
||||
<ContactAvatar
|
||||
:name="contact.name"
|
||||
:fallback="contactPrimary ?? ''"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="contact-detail__who">
|
||||
<h2
|
||||
id="contact-detail-name"
|
||||
class="contact-detail__name"
|
||||
>
|
||||
{{ contactName }}
|
||||
</h2>
|
||||
<span class="contact-detail__subtitle">{{ contactSubtitle(contact) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="contact-detail__fields">
|
||||
<template v-if="contact.emails.length > 0">
|
||||
<dt>{{ CONTACT_DETAIL_TEXT.email }}</dt>
|
||||
<dd class="contact-detail__list">
|
||||
<span
|
||||
v-for="(email, index) in contact.emails"
|
||||
:key="`${index}-${email.address}`"
|
||||
class="contact-detail__item"
|
||||
>
|
||||
<RouterLink
|
||||
:to="composeLocation([{ name: contact.name, email: email.address }])"
|
||||
class="contact-detail__address"
|
||||
:title="CONTACT_DETAIL_TEXT.composeTo(email.address)"
|
||||
data-contact-address
|
||||
>{{ email.address }}</RouterLink>
|
||||
<span class="contact-detail__kind">{{ CONTACT_EMAIL_LABEL_TEXT[email.label] }}</span>
|
||||
</span>
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="contact.phones.length > 0">
|
||||
<dt>{{ CONTACT_DETAIL_TEXT.phone }}</dt>
|
||||
<dd class="contact-detail__list">
|
||||
<span
|
||||
v-for="(phone, index) in contact.phones"
|
||||
:key="`${index}-${phone.number}`"
|
||||
class="contact-detail__item"
|
||||
>
|
||||
<span class="contact-detail__number">{{ phone.number }}</span>
|
||||
<span class="contact-detail__kind">{{ CONTACT_PHONE_LABEL_TEXT[phone.label] }}</span>
|
||||
</span>
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="orgLine">
|
||||
<dt>{{ CONTACT_DETAIL_TEXT.org }}</dt>
|
||||
<dd>{{ orgLine }}</dd>
|
||||
</template>
|
||||
<template v-if="contact.title?.trim()">
|
||||
<dt>{{ CONTACT_DETAIL_TEXT.title }}</dt>
|
||||
<dd>{{ contact.title }}</dd>
|
||||
</template>
|
||||
<template v-if="contactGroups.length > 0">
|
||||
<dt>{{ CONTACT_DETAIL_TEXT.groups }}</dt>
|
||||
<dd class="contact-detail__groups">
|
||||
<RouterLink
|
||||
v-for="group in contactGroups"
|
||||
:key="group.id"
|
||||
:to="groupLocation(group.id)"
|
||||
class="contact-detail__group"
|
||||
:title="CONTACT_DETAIL_TEXT.openGroup(group.name)"
|
||||
>
|
||||
{{ group.name }}
|
||||
</RouterLink>
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="contact.note?.trim()">
|
||||
<dt>{{ CONTACT_DETAIL_TEXT.note }}</dt>
|
||||
<dd class="contact-detail__note">
|
||||
{{ contact.note }}
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
v-else-if="entry"
|
||||
class="contact-detail__article"
|
||||
aria-labelledby="contact-detail-name"
|
||||
:data-directory-detail="entry.id"
|
||||
>
|
||||
<div class="contact-detail__bar">
|
||||
<div
|
||||
class="contact-detail__toolbar"
|
||||
role="group"
|
||||
:aria-label="CONTACT_DETAIL_TEXT.actions"
|
||||
>
|
||||
<MailToolButton
|
||||
icon="edit"
|
||||
:label="CONTACT_DETAIL_TEXT.compose"
|
||||
:to="entryCompose"
|
||||
data-contact-compose
|
||||
/>
|
||||
<MailToolButton
|
||||
icon="plus"
|
||||
:label="CONTACT_DETAIL_TEXT.addToMine"
|
||||
:icon-stroke-width="1.9"
|
||||
:unavailable="store.isFull"
|
||||
:describedby="store.isFull ? 'contact-add-mine-note' : undefined"
|
||||
:title="store.isFull ? CONTACTS_PANEL_TEXT.fullNote : undefined"
|
||||
data-contact-add-mine
|
||||
@click="addEntryToMine"
|
||||
/>
|
||||
<span
|
||||
v-if="store.isFull"
|
||||
id="contact-add-mine-note"
|
||||
class="visually-hidden"
|
||||
>{{ CONTACTS_PANEL_TEXT.fullNote }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-detail__content">
|
||||
<div class="contact-detail__head">
|
||||
<ContactAvatar
|
||||
:name="entry.name"
|
||||
:fallback="entry.email"
|
||||
size="lg"
|
||||
:tone="entry.kind === 'shared' ? 'neutral' : 'brand'"
|
||||
/>
|
||||
<div class="contact-detail__who">
|
||||
<h2
|
||||
id="contact-detail-name"
|
||||
class="contact-detail__name"
|
||||
>
|
||||
{{ entry.name }}
|
||||
</h2>
|
||||
<span class="contact-detail__subtitle">{{
|
||||
entry.kind === 'shared' ? CONTACT_DETAIL_TEXT.shared : CONTACT_DETAIL_TEXT.person
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="contact-detail__fields">
|
||||
<dt>{{ CONTACT_DETAIL_TEXT.email }}</dt>
|
||||
<dd class="contact-detail__list">
|
||||
<span class="contact-detail__item">
|
||||
<RouterLink
|
||||
v-if="entryCompose"
|
||||
:to="entryCompose"
|
||||
class="contact-detail__address"
|
||||
:title="CONTACT_DETAIL_TEXT.composeTo(entry.email)"
|
||||
data-contact-address
|
||||
>{{ entry.email }}</RouterLink>
|
||||
</span>
|
||||
</dd>
|
||||
</dl>
|
||||
<p class="contact-detail__hint">
|
||||
{{ CONTACT_DETAIL_TEXT.directoryHint }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div
|
||||
v-else-if="loading"
|
||||
class="contact-detail__loading"
|
||||
aria-busy="true"
|
||||
:aria-label="CONTACT_DETAIL_TEXT.loading"
|
||||
>
|
||||
<span class="contact-detail__bar-skeleton contact-detail__bar-skeleton--title" />
|
||||
<span class="contact-detail__bar-skeleton" />
|
||||
<span class="contact-detail__bar-skeleton contact-detail__bar-skeleton--short" />
|
||||
</div>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="failure"
|
||||
tone="danger"
|
||||
icon="alert-circle"
|
||||
:title="failure.message"
|
||||
:trace-id="failure.traceId"
|
||||
fill
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="contact-detail__leave"
|
||||
@click="onFailureAction"
|
||||
>
|
||||
{{ notFound ? CONTACT_DETAIL_TEXT.leaveToList : CONTACTS_LIST_TEXT.retry }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
|
||||
<StateMessage
|
||||
v-else
|
||||
icon="user"
|
||||
:title="CONTACT_DETAIL_TEXT.noneTitle"
|
||||
:hint="CONTACT_DETAIL_TEXT.noneHint"
|
||||
fill
|
||||
data-contact-none
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contact-detail {
|
||||
flex: 999 1 30rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.contact-detail__article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 머리 줄 띠 — 칸 전체 폭으로 아래 선, 스크롤해도 위에 남는다 (읽기 화면과 같다) */
|
||||
.contact-detail__bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 단추 줄 — 콘텐츠 열 안 (안쪽 22 + 단추 안쪽 10 = 첫 아이콘이 32px 선) */
|
||||
.contact-detail__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
max-width: var(--content-max-width);
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4) var(--space-24) var(--space-4) var(--content-toolbar-start);
|
||||
}
|
||||
|
||||
/* ⋯ 는 오른쪽 끝 */
|
||||
.contact-detail__more {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 콘텐츠 열 — 최대 800 · 위 22 · 좌우 32 · 아래 36 (디자인 원본 Contacts) */
|
||||
.contact-detail__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-20);
|
||||
max-width: var(--content-max-width);
|
||||
padding: var(--space-22) var(--content-inline) var(--content-block-end);
|
||||
}
|
||||
|
||||
.contact-detail__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-14);
|
||||
}
|
||||
|
||||
.contact-detail__who {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.contact-detail__name {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-heading);
|
||||
line-height: var(--line-height-heading);
|
||||
font-weight: 650;
|
||||
letter-spacing: var(--letter-spacing-tighter);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.contact-detail__subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 칸 — 왼쪽 이름 88 · 사이 12 16 */
|
||||
.contact-detail__fields {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr);
|
||||
gap: var(--space-12) var(--space-16);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.contact-detail__fields dt {
|
||||
padding-top: var(--space-1);
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.contact-detail__fields dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.contact-detail__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.contact-detail__item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2) var(--space-8);
|
||||
}
|
||||
|
||||
.contact-detail__address {
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.contact-detail__address:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.contact-detail__kind {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.contact-detail__number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.contact-detail__groups {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-12);
|
||||
}
|
||||
|
||||
.contact-detail__group {
|
||||
color: var(--color-brand);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.contact-detail__group:hover {
|
||||
color: var(--color-brand-strong);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 메모 — 줄바꿈 그대로 */
|
||||
.contact-detail__note {
|
||||
white-space: pre-line;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
/* 사내 상세 아래 안내 — 위 선 (디자인 원본 Contacts) */
|
||||
.contact-detail__hint {
|
||||
margin: 0;
|
||||
padding-top: var(--space-14);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.contact-detail__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
max-width: var(--content-max-width);
|
||||
padding: calc(var(--size-header-min) + var(--space-22)) var(--content-inline) var(--content-block-end);
|
||||
}
|
||||
|
||||
.contact-detail__bar-skeleton {
|
||||
width: 60%;
|
||||
height: var(--space-10);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.contact-detail__bar-skeleton--title {
|
||||
width: 40%;
|
||||
height: var(--space-18);
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.contact-detail__bar-skeleton--short {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.contact-detail__leave {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contact-detail__leave:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 상세 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.contact-detail {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,550 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { Contact, ContactGroup, ContactInput } from '@/shared/types/contacts'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
import type { ContactPrefill } from '@/shared/utils/contacts'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useContactsStore } from '@/stores/contacts.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import ContactDialog from './ContactDialog.vue'
|
||||
|
||||
// 연락처 추가 · 고치기 창 (디자인 원본 ContactsEdit · README "8단계 › 연락처 추가 · 고치기")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const GROUPS: ContactGroup[] = [
|
||||
{ id: 'g1', name: '거래처', memberCount: 4 },
|
||||
{ id: 'g2', name: '프로젝트 알파', memberCount: 4 },
|
||||
]
|
||||
const SEOYUN: Contact = {
|
||||
id: 'c1',
|
||||
name: '강서윤',
|
||||
emails: [{ address: 'seoyun.kang@hanbit.example', label: 'work' }],
|
||||
phones: [],
|
||||
company: '(주)한빛상사',
|
||||
department: '구매팀',
|
||||
title: '과장',
|
||||
note: null,
|
||||
groupIds: ['g1'],
|
||||
}
|
||||
const JIHOON: Contact = {
|
||||
id: 'c2',
|
||||
name: '박지훈',
|
||||
emails: [
|
||||
{ address: 'jihoon.park@hanbit.example', label: 'work' },
|
||||
{ address: 'jihoon.p@mail.example', label: 'personal' },
|
||||
],
|
||||
phones: [{ number: '010-3192-5540', label: 'mobile' }],
|
||||
company: '(주)한빛상사',
|
||||
department: '영업팀',
|
||||
title: '팀장',
|
||||
note: '견적 문의는 박지훈 팀장 → 강서윤 과장 차례로.',
|
||||
groupIds: ['g1', 'g2'],
|
||||
}
|
||||
|
||||
function pageOf<T>(items: T[]): PaginatedResult<T> {
|
||||
return { items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } }
|
||||
}
|
||||
|
||||
interface Server {
|
||||
/** GET /contacts?q= — 같은 주소 찾기 */
|
||||
search?: (q: string) => Contact[]
|
||||
save?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
describe('ContactDialog', () => {
|
||||
let pinia: Pinia
|
||||
let requests: MockRequest[]
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().startSession(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function installServer(server: Server = {}) {
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/contact-groups') return ok(pageOf(GROUPS))
|
||||
if (request.url === '/contacts' && request.method === 'GET') {
|
||||
return ok(pageOf(server.search?.(String(request.params.q ?? '')) ?? []))
|
||||
}
|
||||
if (request.url === '/contact-restorations') return ok({ kind: 'contact', contact: { ...JIHOON, id: 'c20' } }, 201)
|
||||
if (request.method === 'POST' || request.method === 'PATCH') {
|
||||
return server.save ? server.save(request) : ok({ ...JIHOON, ...(request.data as ContactInput), id: 'c9' })
|
||||
}
|
||||
if (request.method === 'DELETE') return ok({ contact: JIHOON, restoreToken: 't-c2' })
|
||||
return ok(null)
|
||||
})
|
||||
}
|
||||
|
||||
async function mountDialog(
|
||||
props: { contact?: Contact | null; prefill?: ContactPrefill | null } = {},
|
||||
server: Server = {},
|
||||
path = '/contacts',
|
||||
) {
|
||||
installServer(server)
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const closes: number[] = []
|
||||
const wrapper: VueWrapper = mount(ContactDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
open: true,
|
||||
contact: props.contact ?? null,
|
||||
prefill: props.prefill ?? null,
|
||||
onClose: () => {
|
||||
closes.push(closes.length + 1)
|
||||
return wrapper.setProps({ open: false })
|
||||
},
|
||||
},
|
||||
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
return { wrapper, router, closes }
|
||||
}
|
||||
|
||||
// 이메일 칸은 type="text" + inputmode="email" (type="email" 이면 Chrome 이 한글 도메인을 퓨니코드로 바꿀 수 있다)
|
||||
const emailInputs = (wrapper: VueWrapper) => wrapper.findAll('input[inputmode="email"]')
|
||||
const phoneInputs = (wrapper: VueWrapper) => wrapper.findAll('input[type="tel"]')
|
||||
const saves = () =>
|
||||
requests.filter(
|
||||
(request) => (request.method === 'POST' && request.url === '/contacts') || request.method === 'PATCH',
|
||||
)
|
||||
const toasts = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const isOpen = (wrapper: VueWrapper, selector: string) => wrapper.find(`${selector}[open]`).exists()
|
||||
|
||||
it('새 연락처 — 이메일 · 전화번호 빈 줄 하나씩과 안내, 그룹은 받아 둔다 · 처음 초점은 이름', async () => {
|
||||
const { wrapper } = await mountDialog()
|
||||
|
||||
expect(wrapper.get('#contact-dialog-title').text()).toBe('연락처 추가')
|
||||
expect(document.activeElement?.id).toBe('contact-name')
|
||||
expect(emailInputs(wrapper).map((input) => input.attributes('aria-label'))).toEqual(['첫째 이메일'])
|
||||
expect(wrapper.get('select[aria-label="첫째 이메일의 종류"]').findAll('option').map((option) => option.text())).toEqual([
|
||||
'회사',
|
||||
'개인',
|
||||
'기타',
|
||||
])
|
||||
expect(wrapper.get('select[aria-label="첫째 전화번호의 종류"]').findAll('option').map((option) => option.text())).toEqual([
|
||||
'휴대전화',
|
||||
'회사',
|
||||
'집',
|
||||
'기타',
|
||||
])
|
||||
expect(wrapper.text()).toContain('5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다')
|
||||
expect(wrapper.get('#contact-required').text()).toBe('이름이나 이메일 가운데 하나는 있어야 합니다.')
|
||||
expect(wrapper.get('[data-contact-note-count]').text()).toBe('0 / 1,000')
|
||||
expect(wrapper.findAll('[data-contact-group]').map((input) => input.attributes('data-contact-group'))).toEqual([
|
||||
'g1',
|
||||
'g2',
|
||||
])
|
||||
expect(wrapper.find('#contact-remove').exists()).toBe(false)
|
||||
expect(requests.map((request) => request.url)).toEqual(['/contact-groups'])
|
||||
})
|
||||
|
||||
it('잘못은 [저장]을 누를 때만 칸 아래 빨간 글 — 첫 잘못된 칸에 초점, 알림 없음 · 고치면 그 칸의 잘못을 내린다', async () => {
|
||||
const { wrapper } = await mountDialog()
|
||||
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
expect(wrapper.get('#contact-required').classes()).toContain('form-message--error')
|
||||
expect(document.activeElement?.id).toBe('contact-name')
|
||||
|
||||
await wrapper.get('#contact-name').setValue('박지훈')
|
||||
await emailInputs(wrapper)[0]?.setValue('jihoon.park@hanbit')
|
||||
await phoneInputs(wrapper)[0]?.setValue('내선 12')
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
|
||||
const email = emailInputs(wrapper)[0]
|
||||
expect(email?.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get(`#${email?.attributes('aria-describedby')}`).text()).toBe('이메일 형식이 올바르지 않습니다.')
|
||||
expect(document.activeElement).toBe(email?.element)
|
||||
expect(phoneInputs(wrapper)[0]?.attributes('aria-invalid')).toBe('true')
|
||||
expect(saves()).toEqual([])
|
||||
expect(toasts()).toEqual([])
|
||||
|
||||
await email?.setValue('jihoon.park@hanbit.example')
|
||||
expect(email?.attributes('aria-invalid')).toBe('false')
|
||||
})
|
||||
|
||||
it('저장 — 앞뒤 공백 · 빈 줄을 빼서 보내고 "연락처를 저장했습니다." · 창을 닫는다', async () => {
|
||||
const { wrapper, closes } = await mountDialog()
|
||||
|
||||
await wrapper.get('#contact-name').setValue(' 한도경 ')
|
||||
await emailInputs(wrapper)[0]?.setValue(' dokyung.han@mail.example ')
|
||||
await wrapper.get('select[aria-label="첫째 이메일의 종류"]').setValue('personal')
|
||||
await wrapper.get('#contact-company').setValue(' ')
|
||||
await wrapper.get('#contact-note').setValue('대학 동기\n')
|
||||
expect(wrapper.get('[data-contact-note-count]').text()).toBe('6 / 1,000')
|
||||
await wrapper.get('[data-contact-group="g2"]').setValue(true)
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saves().map((request) => [request.method, request.url])).toEqual([['POST', '/contacts']])
|
||||
expect(saves()[0]?.data).toEqual({
|
||||
name: '한도경',
|
||||
emails: [{ address: 'dokyung.han@mail.example', label: 'personal' }],
|
||||
phones: [],
|
||||
company: null,
|
||||
department: null,
|
||||
title: null,
|
||||
note: '대학 동기',
|
||||
groupIds: ['g2'],
|
||||
})
|
||||
expect(toasts()).toEqual(['연락처를 저장했습니다.'])
|
||||
expect(closes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('이메일 칸은 type="text" + inputmode="email" · [더하기]는 옆 안내("5개까지 …")를 까닭으로 잇는다(aria-describedby)', async () => {
|
||||
const { wrapper } = await mountDialog()
|
||||
|
||||
const email = emailInputs(wrapper)[0]
|
||||
expect(email?.attributes('type')).toBe('text')
|
||||
const addEmail = wrapper.findAll('.entry-rows__add-button')[0]
|
||||
expect(wrapper.get(`#${addEmail?.attributes('aria-describedby')}`).text()).toBe(
|
||||
'5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다',
|
||||
)
|
||||
})
|
||||
|
||||
it('고치기 — PATCH 로 바꾼 칸만 보낸다(이메일 · 전화번호 · 그룹은 바뀌면 통째로) · 바꾼 것이 없으면 보내지 않고 닫는다', async () => {
|
||||
const { wrapper, closes } = await mountDialog({ contact: JIHOON })
|
||||
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(saves()).toEqual([])
|
||||
expect(closes).toHaveLength(1)
|
||||
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
await wrapper.get('#contact-title').setValue('부장')
|
||||
await phoneInputs(wrapper)[0]?.setValue('010-3192-0000')
|
||||
await wrapper.get('[data-contact-group="g2"]').setValue(false)
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(saves().map((request) => [request.method, request.url])).toEqual([['PATCH', '/contacts/c2']])
|
||||
expect(saves()[0]?.data).toEqual({
|
||||
phones: [{ number: '010-3192-0000', label: 'mobile' }],
|
||||
title: '부장',
|
||||
groupIds: ['g1'],
|
||||
})
|
||||
expect(toasts()).toEqual(['연락처를 저장했습니다.'])
|
||||
})
|
||||
|
||||
it('고치기는 바꾼 칸만 검사한다 — 손대지 않은 남의 값(21번째 주소 · "내선" 번호 · 긴 메모 · 100자 넘는 회사)은 그대로 저장된다', async () => {
|
||||
const imported: Contact = {
|
||||
...JIHOON,
|
||||
emails: Array.from({ length: 21 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })),
|
||||
phones: [{ number: '내선 12', label: 'work' }],
|
||||
company: '한'.repeat(150),
|
||||
note: '가'.repeat(1200),
|
||||
}
|
||||
const { wrapper } = await mountDialog({ contact: imported })
|
||||
|
||||
await wrapper.get('#contact-title').setValue('부장')
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(saves()[0]?.data).toEqual({ title: '부장' })
|
||||
})
|
||||
|
||||
it('고친 칸은 그 칸 전부가 규칙에 맞아야 한다 — 전화번호를 고치면 남의 "내선" 번호도, 회사를 고치면 100자, 이메일을 고치면 20개', async () => {
|
||||
const imported: Contact = {
|
||||
...JIHOON,
|
||||
emails: Array.from({ length: 21 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })),
|
||||
phones: [
|
||||
{ number: '내선 12', label: 'work' },
|
||||
{ number: '010-1111-2222', label: 'mobile' },
|
||||
],
|
||||
company: '한'.repeat(150),
|
||||
}
|
||||
const { wrapper } = await mountDialog({ contact: imported })
|
||||
|
||||
await phoneInputs(wrapper)[1]?.setValue('010-1111-3333')
|
||||
await wrapper.get('#contact-company').setValue('한'.repeat(149))
|
||||
await wrapper.get('select[aria-label="첫째 이메일의 종류"]').setValue('personal')
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saves()).toEqual([])
|
||||
expect(wrapper.get('[data-entry-count-error]').text()).toBe('이메일은 20개까지 넣을 수 있습니다. 몇 개를 빼 주세요.')
|
||||
expect(document.activeElement).toBe(emailInputs(wrapper)[0]?.element)
|
||||
expect(phoneInputs(wrapper)[0]?.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('#contact-company').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('#contact-company-error').text()).toBe('100자까지 쓸 수 있습니다.')
|
||||
})
|
||||
|
||||
it('고치기 — 다른 프로그램이 넣은 6개 이상의 주소는 그대로 보이고 저장된다 · [이메일 더하기]는 5개에서 멈춘다', async () => {
|
||||
const many: Contact = {
|
||||
...JIHOON,
|
||||
emails: Array.from({ length: 6 }, (_, index) => ({ address: `jihoon${index}@hanbit.example`, label: 'work' as const })),
|
||||
}
|
||||
const { wrapper } = await mountDialog({ contact: many })
|
||||
|
||||
expect(wrapper.get('#contact-dialog-title').text()).toBe('연락처 고치기')
|
||||
expect(emailInputs(wrapper)).toHaveLength(6)
|
||||
const addEmail = wrapper.findAll('.entry-rows__add-button')[0]
|
||||
expect(addEmail?.attributes('aria-disabled')).toBe('true')
|
||||
await addEmail?.trigger('click')
|
||||
expect(emailInputs(wrapper)).toHaveLength(6)
|
||||
// 전화번호는 하나 — 더할 수 있다
|
||||
const addPhone = wrapper.findAll('.entry-rows__add-button')[1]
|
||||
expect(addPhone?.attributes('aria-disabled')).toBeUndefined()
|
||||
await addPhone?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(phoneInputs(wrapper)).toHaveLength(2)
|
||||
expect(document.activeElement).toBe(phoneInputs(wrapper)[1]?.element)
|
||||
|
||||
await phoneInputs(wrapper)[1]?.setValue('02-555-0147')
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
await flushPromises()
|
||||
// 이메일은 손대지 않았다 — 전화번호만 보낸다
|
||||
expect(saves().map((request) => [request.method, request.url])).toEqual([['PATCH', '/contacts/c2']])
|
||||
expect(saves()[0]?.data).toEqual({
|
||||
phones: [
|
||||
{ number: '010-3192-5540', label: 'mobile' },
|
||||
{ number: '02-555-0147', label: 'mobile' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('같은 주소 — 이메일 칸을 떠나면 알림 없이 찾아 갈색 글 + [그 연락처 보기] (자기 자신은 뺀다) · 막지 않는다', async () => {
|
||||
const { wrapper } = await mountDialog({}, { search: () => [SEOYUN] })
|
||||
await useContactsStore().loadGroups()
|
||||
|
||||
const email = emailInputs(wrapper)[0]
|
||||
await email?.setValue('Seoyun.Kang@hanbit.example')
|
||||
await email?.trigger('blur')
|
||||
await flushPromises()
|
||||
|
||||
const search = requests.find((request) => request.url === '/contacts')
|
||||
expect(search?.params).toEqual({ q: 'Seoyun.Kang@hanbit.example', limit: 100 })
|
||||
expect(search?.config.silent).toBe(true)
|
||||
const warning = wrapper.get('[data-contact-duplicate]')
|
||||
expect(warning.text()).toBe(
|
||||
'이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. 그 연락처 보기',
|
||||
)
|
||||
expect(email?.attributes('aria-describedby')).toBe(warning.attributes('id'))
|
||||
expect(email?.attributes('aria-invalid')).toBe('false')
|
||||
|
||||
// 고치기 창에서 자기 자신의 주소는 경고하지 않는다
|
||||
const own = await mountDialog({ contact: SEOYUN }, { search: () => [SEOYUN] })
|
||||
const ownEmail = emailInputs(own.wrapper)[0]
|
||||
await ownEmail?.trigger('blur')
|
||||
await flushPromises()
|
||||
expect(own.wrapper.find('[data-contact-duplicate]').exists()).toBe(false)
|
||||
|
||||
// 고치는 동안은 옛 주소의 안내를 내린다
|
||||
await email?.setValue('seoyun.k@hanbit.example')
|
||||
expect(wrapper.find('[data-contact-duplicate]').exists()).toBe(false)
|
||||
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(saves()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('채워 연 새 연락처(읽기 화면 · 사내 목록)는 열 때 채운 주소를 바로 찾아보고, 채운 그대로면 닫을 때 묻지 않는다', async () => {
|
||||
const { wrapper, closes } = await mountDialog(
|
||||
{ prefill: { name: '강서윤', email: 'seoyun.kang@hanbit.example' } },
|
||||
{ search: () => [SEOYUN] },
|
||||
)
|
||||
|
||||
expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('강서윤')
|
||||
expect(wrapper.get('[data-contact-duplicate]').text()).toContain('이미 주소록에 있는 주소입니다 — 강서윤')
|
||||
await wrapper.get('#contact-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false)
|
||||
expect(closes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('[그 연락처 보기] — 창을 닫고 그 연락처를 고른 주소록으로 (바꾼 것이 있으면 먼저 묻는다)', async () => {
|
||||
const { wrapper, router, closes } = await mountDialog({}, { search: () => [SEOYUN] }, '/mail/inbox')
|
||||
await wrapper.get('#contact-name').setValue('강 과장')
|
||||
const email = emailInputs(wrapper)[0]
|
||||
await email?.setValue('seoyun.kang@hanbit.example')
|
||||
await email?.trigger('blur')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('.entry-rows__link').trigger('click')
|
||||
await flushPromises()
|
||||
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true)
|
||||
expect(wrapper.get('#contact-leave-title').text()).toBe('저장하지 않은 연락처가 있습니다')
|
||||
expect(wrapper.get('#contact-leave-message').text()).toBe('창을 닫으면 고친 내용이 사라집니다.')
|
||||
await wrapper.get('#contact-leave-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(closes).toHaveLength(1)
|
||||
expect(router.currentRoute.value.name).toBe('contacts-contact')
|
||||
expect(router.currentRoute.value.params).toEqual({ contactId: 'c1' })
|
||||
})
|
||||
|
||||
it('다른 프로그램이 넣은 제어 문자(탭 · CR LF)는 열 때 고친다 — 고친 것만으로는 [취소]에 묻지 않고, 저장하면 고친 값을 보낸다', async () => {
|
||||
const imported: Contact = { ...JIHOON, name: '박지훈\t', title: '팀장\u0000', note: '첫 줄\r\n둘째\t줄' }
|
||||
const { wrapper, closes } = await mountDialog({ contact: imported })
|
||||
|
||||
expect((wrapper.get('#contact-note').element as HTMLTextAreaElement).value).toBe('첫 줄\n둘째 줄')
|
||||
await wrapper.get('#contact-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false)
|
||||
expect(closes).toHaveLength(1)
|
||||
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
await wrapper.get('#contact-department').setValue('영업\t2팀')
|
||||
await wrapper.get('#contact-note').setValue('첫 줄\n셋째\t줄')
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
await flushPromises()
|
||||
// 고친 칸만 고친 값으로 — 손대지 않은 이름 · 직책(남의 제어 문자)은 보내지 않는다
|
||||
expect(saves()[0]?.data).toEqual({ department: '영업 2팀', note: '첫 줄\n셋째 줄' })
|
||||
})
|
||||
|
||||
it('다른 프로그램이 넣은 1,000자 넘는 메모를 고쳤으면 [저장] 때 메모 아래에 알린다 — 보내지 않는다', async () => {
|
||||
const { wrapper } = await mountDialog({ contact: { ...JIHOON, note: '가'.repeat(1200) } })
|
||||
|
||||
expect(wrapper.get('[data-contact-note-count]').text()).toBe('1,200 / 1,000')
|
||||
await wrapper.get('#contact-note').setValue('가'.repeat(1100))
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#contact-note').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.get('#contact-note-error').text()).toBe('메모는 1,000자까지 쓸 수 있습니다.')
|
||||
expect(document.activeElement?.id).toBe('contact-note')
|
||||
expect(saves()).toEqual([])
|
||||
})
|
||||
|
||||
it('나가기 확인 — 바꾼 것이 있으면 [취소] · Esc 에 묻는다: [계속 고치기]면 그대로, [닫기]면 닫는다 · 다른 화면으로 가면 [나가기]', async () => {
|
||||
const { wrapper, router, closes } = await mountDialog({ contact: JIHOON }, {}, '/contacts/all/c2')
|
||||
|
||||
await wrapper.get('#contact-title').setValue('부장')
|
||||
await wrapper.get('#contact-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true)
|
||||
expect(document.activeElement?.id).toBe('contact-leave-stay')
|
||||
expect(wrapper.get('#contact-leave-stay').text()).toBe('계속 고치기')
|
||||
expect(wrapper.get('#contact-leave-confirm').text()).toBe('닫기')
|
||||
|
||||
await wrapper.get('#contact-leave-stay').trigger('click')
|
||||
await flushPromises()
|
||||
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(false)
|
||||
expect(isOpen(wrapper, '#contact-dialog')).toBe(true)
|
||||
expect(closes).toEqual([])
|
||||
|
||||
const leaving = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(isOpen(wrapper, '#contact-leave-dialog')).toBe(true)
|
||||
expect(wrapper.get('#contact-leave-message').text()).toBe('이 화면을 나가면 고친 내용이 사라집니다.')
|
||||
expect(wrapper.get('#contact-leave-confirm').text()).toBe('나가기')
|
||||
await wrapper.get('#contact-leave-confirm').trigger('click')
|
||||
await leaving
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('서버의 형식 거절(VAL_001)은 단추 줄 위에 사전 문구 — 알림 없이 · 한도(CONTACT_002)는 사전 문구 알림, 창은 그대로', async () => {
|
||||
let reply: MockResult = fail(400, 'VAL_001')
|
||||
const { wrapper, closes } = await mountDialog(
|
||||
{ prefill: { name: null, email: 'dokyung.han@mail.example' } },
|
||||
{ save: () => reply },
|
||||
)
|
||||
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#contact-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(toasts()).toEqual([])
|
||||
|
||||
reply = fail(409, 'CONTACT_002')
|
||||
await wrapper.get('#contact-name').setValue('한도경')
|
||||
expect(wrapper.find('#contact-error').exists()).toBe(false)
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.CONTACT_002])
|
||||
expect(wrapper.find('#contact-error').exists()).toBe(false)
|
||||
expect(closes).toEqual([])
|
||||
})
|
||||
|
||||
it('[이 연락처 지우기] — 확인 없이 지우고 닫는다 · 알림의 [되돌리기]가 지운 연락처를 다시 만든다', async () => {
|
||||
const { wrapper, closes } = await mountDialog({ contact: JIHOON })
|
||||
await useContactsStore().loadGroups()
|
||||
|
||||
await wrapper.get('#contact-remove').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.some((request) => request.method === 'DELETE' && request.url === '/contacts/c2')).toBe(true)
|
||||
expect(closes).toHaveLength(1)
|
||||
const [toast] = useToastStore().toasts
|
||||
expect(toast?.message).toBe('‘박지훈’ 연락처를 지웠습니다.')
|
||||
expect(toast?.action?.label).toBe('되돌리기')
|
||||
if (toast?.action && 'run' in toast.action) toast.action.run()
|
||||
await flushPromises()
|
||||
|
||||
// 되돌리기는 지우기 응답의 표로 — 서버가 지운 그대로(다른 프로그램의 칸까지) 다시 만든다
|
||||
expect(requests.find((request) => request.url === '/contact-restorations')).toMatchObject({
|
||||
method: 'POST',
|
||||
data: { token: 't-c2' },
|
||||
})
|
||||
expect(saves()).toEqual([])
|
||||
expect(toasts()).toContain('연락처를 되돌렸습니다.')
|
||||
})
|
||||
|
||||
it('저장을 보내는 사이 창을 닫았으면(취소) 창 안에 보일 거절(VAL_001)을 알림으로 알린다', async () => {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const { wrapper, closes } = await mountDialog(
|
||||
{ prefill: { name: '한도경', email: 'dokyung.han@mail.example' } },
|
||||
{
|
||||
save: () =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
// 보내는 중에는 묻지 않고 닫는다 (결과는 알림으로)
|
||||
await wrapper.get('#contact-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(closes).toHaveLength(1)
|
||||
finish(fail(400, 'VAL_001'))
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts()).toEqual([ERROR_CODE_MESSAGES.VAL_001])
|
||||
expect(wrapper.find('#contact-error').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('보내는 사이 세션이 바뀌면(로그아웃 · 다른 계정) 늦게 온 응답으로 알리지도 닫지도 않는다', async () => {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const late = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const { wrapper, closes } = await mountDialog(
|
||||
{ prefill: { name: '한도경', email: 'dokyung.han@mail.example' } },
|
||||
{ save: () => late },
|
||||
)
|
||||
|
||||
await wrapper.get('#contact-save').trigger('click')
|
||||
expect(wrapper.get('#contact-save').attributes('aria-disabled')).toBe('true')
|
||||
useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' })
|
||||
finish(ok({ ...JIHOON, id: 'c9' }))
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts()).toEqual([])
|
||||
expect(closes).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,761 @@
|
||||
<script setup lang="ts">
|
||||
// 연락처 추가 · 고치기 창 (디자인 원본 ContactsEdit "연락처 추가" · README "8단계 › 연락처 추가 · 고치기" — 모달 620, 필터 고치기 창과 같은 틀)
|
||||
// 이름(100자) → 이메일(종류 · 주소 · ✕, [이메일 더하기] 5개까지) → 전화번호(종류 · 번호 · ✕, 5개까지) → 회사 · 부서 · 직책(한 줄에 셋) →
|
||||
// 메모(1,000자 — "38 / 1,000") → 그룹(체크 칸) → "이름이나 이메일 가운데 하나는 있어야 합니다." → [이 연락처 지우기](고칠 때만, 왼쪽) · [저장] [취소].
|
||||
// - 고치기는 상세의 [고치기]가 그 연락처를 새로 받아 연다(useContactActions 의 loadForEdit). 저장은 PATCH 로 연 때와 달라진 칸만 보낸다
|
||||
// (contacts.ts 의 contactPatchOf — 창을 연 사이 다른 곳에서 바꾼 다른 칸을 덮지 않게). 바꾼 것이 없으면 보내지 않고 닫는다
|
||||
// - 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만 — 첫 잘못된 칸에 초점, 알림 없음. 규칙은 서버와 같거나 더 좁다(이메일은 서버보다 좁게 —
|
||||
// contacts.ts 의 contactDraftProblems). 고치기는 바꾼 칸만 본다 — 손대지 않은 남의 값(21번째 주소 · "내선" 번호 · 긴 메모)은 그대로 둔다.
|
||||
// 칸을 고치면 그 칸의 잘못을 내린다. 서버의 형식 거절(VAL_001 — 다른 프로그램이 uid 없이 만든 카드를 그룹에 넣을 때도)은 단추 줄 위에 사전 문구로,
|
||||
// 한도(CONTACT_002) · 장애는 인터셉터의 알림으로. 보내는 사이 창을 닫았으면 창 안에 보일 거절도 알림으로 알린다
|
||||
// - 같은 주소: 이메일 칸을 떠날 때 내 주소록에 같은 주소(다른 연락처 — 대소문자 무시)가 있으면 그 칸 아래 갈색 글 + [그 연락처 보기] — 막지 않는다
|
||||
// (useContactDuplicateCheck). 채워 연 새 연락처(읽기 화면 · 사내 목록)는 열 때 채운 주소를 바로 찾아본다
|
||||
// - 다른 프로그램이 넣은 6개 이상의 주소 · 전화번호는 그대로 보이고 저장된다 — [더하기]만 5개에서 멈춘다
|
||||
// - 지우기는 확인 없이 — 창을 닫고 알림의 [되돌리기] (useContactActions)
|
||||
// - 나가기 확인(useDialogLeaveConfirm — 필터 고치기 창과 같다): 창을 연 때와 다르면 [취소] · Esc 는 "저장하지 않은 연락처가 있습니다"
|
||||
// ([계속 고치기] — 처음 초점 · Esc, [닫기]), 창을 연 채 다른 화면으로 가면 같은 창에 [나가기], 탭 닫기 · 새로고침은 브라우저 확인창.
|
||||
// 채워 연 새 연락처는 채운 그대로면 묻지 않는다. 저장 · 지우기를 보내는 중에는 묻지 않는다(결과는 알림으로)
|
||||
// - [그 연락처 보기]는 이 창을 닫는(바꾼 것이 있으면 먼저 묻는) 길로 그 연락처를 고른 주소록에 간다
|
||||
// 고정 id — #contact-dialog · #contact-dialog-title · #contact-name · #contact-email-{n} · #contact-phone-{n} · #contact-company ·
|
||||
// #contact-department · #contact-title · #contact-note · #contact-required · #contact-error · #contact-remove · #contact-save · #contact-cancel ·
|
||||
// 나가기 확인 #contact-leave-dialog · #contact-leave-stay · #contact-leave-confirm
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ContactEntryRows from '@/components/contacts/ContactEntryRows.vue'
|
||||
import type { ContactEntryPatch } from '@/components/contacts/contacts-ui'
|
||||
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { CONTACT_SAVE_INLINE_CODES, useContactActions } from '@/composables/useContactActions'
|
||||
import { notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { useContactDuplicateCheck } from '@/composables/useContactDuplicateCheck'
|
||||
import { useDialogLeaveConfirm } from '@/composables/useDialogLeaveConfirm'
|
||||
import {
|
||||
CONTACT_DIALOG_TEXT,
|
||||
CONTACT_EMAIL_LABEL_TEXT,
|
||||
CONTACT_EMAIL_LABELS,
|
||||
CONTACT_EMAIL_MAX_LENGTH,
|
||||
CONTACT_EMAILS_MAX,
|
||||
CONTACT_FIELD_ROWS_MAX,
|
||||
CONTACT_LEAVE_DIALOG,
|
||||
CONTACT_NOTE_MAX_LENGTH,
|
||||
CONTACT_PHONE_LABEL_TEXT,
|
||||
CONTACT_PHONE_LABELS,
|
||||
CONTACT_PHONE_MAX_LENGTH,
|
||||
CONTACT_PHONES_MAX,
|
||||
CONTACT_TEXT_MAX_LENGTH,
|
||||
} from '@/shared/constants/contacts'
|
||||
import type { Contact, ContactEmailLabel, ContactPhoneLabel } from '@/shared/types/contacts'
|
||||
import {
|
||||
contactDraftOf,
|
||||
contactDraftProblems,
|
||||
contactInputOf,
|
||||
contactPatchOf,
|
||||
copyContactDraft,
|
||||
emptyEmailRow,
|
||||
emptyPhoneRow,
|
||||
hasContactProblems,
|
||||
noContactProblems,
|
||||
sameContactDraft,
|
||||
type ContactDraft,
|
||||
type ContactDraftProblems,
|
||||
type ContactPrefill,
|
||||
type ContactTextField,
|
||||
} from '@/shared/utils/contacts'
|
||||
import { contactLocation } from '@/shared/utils/contacts-route'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useContactsStore } from '@/stores/contacts.store'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 고칠 연락처 — null 이면 새 연락처 */
|
||||
contact: Contact | null
|
||||
/** 새 연락처를 채울 값 (읽기 화면의 보낸 사람 · 사내 목록의 직원) */
|
||||
prefill?: ContactPrefill | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 닫았다 — 저장 · 지우기를 마쳤거나 [취소] · Esc (바꾼 것이 있었으면 확인한 뒤) */
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
prefill: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const contactsStore = useContactsStore()
|
||||
const actions = useContactActions()
|
||||
const router = useRouter()
|
||||
|
||||
const modalRef = ref<InstanceType<typeof ModalDialog> | null>(null)
|
||||
const nameRef = ref<HTMLInputElement | null>(null)
|
||||
const noteRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const emailRowsRef = ref<InstanceType<typeof ContactEntryRows> | null>(null)
|
||||
const phoneRowsRef = ref<InstanceType<typeof ContactEntryRows> | null>(null)
|
||||
|
||||
const draft = reactive<ContactDraft>(contactDraftOf(props.contact, props.prefill))
|
||||
const problems = ref<ContactDraftProblems>(noContactProblems(draft))
|
||||
/** 창을 연 때의 칸 값 — 이것과 다르면 바꾼 것이다 (나가기 확인 · 고치기의 바꾼 칸) */
|
||||
const opened = shallowRef<ContactDraft>(copyContactDraft(draft))
|
||||
/** 같은 주소 안내 */
|
||||
const duplicates = useContactDuplicateCheck({
|
||||
rows: () => draft.emails,
|
||||
exceptId: () => props.contact?.id ?? null,
|
||||
})
|
||||
const pending = ref<'save' | 'remove' | null>(null)
|
||||
/** 서버의 형식 거절(VAL_001) — 단추 줄 위 */
|
||||
const error = ref<string | null>(null)
|
||||
/** 닫는 중 — 닫기로 했으니 나가기 확인을 다시 걸지 않는다 */
|
||||
const closing = ref<boolean>(false)
|
||||
/** 연 차례 — 늦게 온 결과가 그사이 다시 연 창을 건드리지 않게 */
|
||||
let openToken = 0
|
||||
let unmounted = false
|
||||
/** 닫은 뒤 할 일 — [그 연락처 보기] */
|
||||
let afterClose: (() => void) | null = null
|
||||
|
||||
const title = computed(() => (props.contact ? CONTACT_DIALOG_TEXT.editTitle : CONTACT_DIALOG_TEXT.createTitle))
|
||||
const busy = computed(() => pending.value !== null)
|
||||
const changed = computed(() => props.open && !closing.value && !sameContactDraft(draft, opened.value))
|
||||
const groups = computed(() => contactsStore.groups)
|
||||
const noteCount = computed(() =>
|
||||
CONTACT_DIALOG_TEXT.noteCount(formatCount(draft.note.length), formatCount(CONTACT_NOTE_MAX_LENGTH)),
|
||||
)
|
||||
|
||||
const emailKinds = CONTACT_EMAIL_LABELS.map((value) => ({ value, text: CONTACT_EMAIL_LABEL_TEXT[value] }))
|
||||
const phoneKinds = CONTACT_PHONE_LABELS.map((value) => ({ value, text: CONTACT_PHONE_LABEL_TEXT[value] }))
|
||||
const emailRows = computed(() => draft.emails.map((row) => ({ key: row.key, label: row.label, value: row.address })))
|
||||
const phoneRows = computed(() => draft.phones.map((row) => ({ key: row.key, label: row.label, value: row.number })))
|
||||
const emailErrors = computed(() =>
|
||||
problems.value.emails.map((problem) => {
|
||||
if (problem === 'invalid') return CONTACT_DIALOG_TEXT.emailInvalid
|
||||
if (problem === 'repeated') return CONTACT_DIALOG_TEXT.emailRepeated
|
||||
return null
|
||||
}),
|
||||
)
|
||||
const emailWarnings = duplicates.warnings
|
||||
const phoneErrors = computed(() =>
|
||||
problems.value.phones.map((problem) => (problem ? CONTACT_DIALOG_TEXT.phoneInvalid : null)),
|
||||
)
|
||||
const noteError = computed(() =>
|
||||
problems.value.note ? CONTACT_DIALOG_TEXT.noteTooLong(formatCount(CONTACT_NOTE_MAX_LENGTH)) : null,
|
||||
)
|
||||
const textLimitError = CONTACT_DIALOG_TEXT.textTooLong(formatCount(CONTACT_TEXT_MAX_LENGTH))
|
||||
const emailCountError = computed(() =>
|
||||
problems.value.emailCount ? CONTACT_DIALOG_TEXT.emailsTooMany(formatCount(CONTACT_EMAILS_MAX)) : null,
|
||||
)
|
||||
const phoneCountError = computed(() =>
|
||||
problems.value.phoneCount ? CONTACT_DIALOG_TEXT.phonesTooMany(formatCount(CONTACT_PHONES_MAX)) : null,
|
||||
)
|
||||
|
||||
/** 회사 · 부서 · 직책 — 한 줄에 셋 (같은 모양이라 차례대로 그린다) */
|
||||
const ORG_FIELDS: readonly { field: Exclude<ContactTextField, 'name'>; label: string }[] = [
|
||||
{ field: 'company', label: CONTACT_DIALOG_TEXT.company },
|
||||
{ field: 'department', label: CONTACT_DIALOG_TEXT.department },
|
||||
{ field: 'title', label: CONTACT_DIALOG_TEXT.title },
|
||||
]
|
||||
|
||||
/** 열 때마다 — 그 연락처(또는 채운 새 연락처)로 칸을 다시 채운다. 그룹을 아직 받지 않았으면 받는다 */
|
||||
function resetDraft() {
|
||||
openToken += 1
|
||||
Object.assign(draft, contactDraftOf(props.contact, props.prefill))
|
||||
problems.value = noContactProblems(draft)
|
||||
opened.value = copyContactDraft(draft)
|
||||
duplicates.reset()
|
||||
pending.value = null
|
||||
error.value = null
|
||||
closing.value = false
|
||||
afterClose = null
|
||||
contactsStore.ensureGroups()
|
||||
// 채운 주소가 이미 주소록에 있으면 바로 보인다 (읽기 화면 ⋯ · 사내 목록의 [내 주소록에 추가])
|
||||
const filled = draft.emails[0]
|
||||
if (!props.contact && props.prefill && filled) void duplicates.check(filled.key)
|
||||
}
|
||||
|
||||
/** 칸을 고쳤다 — 그 칸의 잘못(patch)과 서버의 거절을 내린다 */
|
||||
function edited(patch: Partial<ContactDraftProblems>) {
|
||||
problems.value = { ...problems.value, ...patch }
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function setText(field: ContactTextField, event: Event) {
|
||||
draft[field] = (event.target as HTMLInputElement).value
|
||||
const texts = { ...problems.value.texts, [field]: false }
|
||||
edited(field === 'name' ? { required: false, texts } : { texts })
|
||||
}
|
||||
|
||||
function setNote(event: Event) {
|
||||
draft.note = (event.target as HTMLTextAreaElement).value
|
||||
edited({ note: false })
|
||||
}
|
||||
|
||||
function updateEmail(index: number, patch: ContactEntryPatch) {
|
||||
const row = draft.emails[index]
|
||||
if (!row) return
|
||||
if (patch.label !== undefined) row.label = patch.label as ContactEmailLabel
|
||||
if (patch.value !== undefined) {
|
||||
row.address = patch.value
|
||||
// 고치는 동안의 같은 주소 안내는 옛 주소의 것이다 — 칸을 떠나면 다시 찾아본다
|
||||
duplicates.forget(row.key)
|
||||
}
|
||||
edited({
|
||||
required: false,
|
||||
emailCount: false,
|
||||
emails: problems.value.emails.map((problem, at) => (at === index ? null : problem)),
|
||||
})
|
||||
}
|
||||
|
||||
function updatePhone(index: number, patch: ContactEntryPatch) {
|
||||
const row = draft.phones[index]
|
||||
if (!row) return
|
||||
if (patch.label !== undefined) row.label = patch.label as ContactPhoneLabel
|
||||
if (patch.value !== undefined) row.number = patch.value
|
||||
edited({ phoneCount: false, phones: problems.value.phones.map((problem, at) => (at === index ? null : problem)) })
|
||||
}
|
||||
|
||||
function addEmail() {
|
||||
if (draft.emails.length >= CONTACT_FIELD_ROWS_MAX) return
|
||||
draft.emails.push(emptyEmailRow())
|
||||
edited({ emails: [...problems.value.emails, null] })
|
||||
void nextTick(() => emailRowsRef.value?.focusValue(draft.emails.length - 1))
|
||||
}
|
||||
|
||||
function addPhone() {
|
||||
if (draft.phones.length >= CONTACT_FIELD_ROWS_MAX) return
|
||||
draft.phones.push(emptyPhoneRow())
|
||||
edited({ phones: [...problems.value.phones, null] })
|
||||
void nextTick(() => phoneRowsRef.value?.focusValue(draft.phones.length - 1))
|
||||
}
|
||||
|
||||
/** 줄을 뺀 뒤의 초점 — 같은 자리의 줄(마지막이었으면 앞 줄), 없으면 [더하기] */
|
||||
function focusAfterRemove(rows: InstanceType<typeof ContactEntryRows> | null, index: number, count: number) {
|
||||
void nextTick(() => {
|
||||
if (count === 0) rows?.focusAdd()
|
||||
else rows?.focusValue(Math.min(index, count - 1))
|
||||
})
|
||||
}
|
||||
|
||||
function removeEmail(index: number) {
|
||||
const [removed] = draft.emails.splice(index, 1)
|
||||
if (removed) duplicates.forget(removed.key)
|
||||
edited({ emailCount: false, emails: problems.value.emails.filter((_, at) => at !== index) })
|
||||
focusAfterRemove(emailRowsRef.value, index, draft.emails.length)
|
||||
}
|
||||
|
||||
function removePhone(index: number) {
|
||||
draft.phones.splice(index, 1)
|
||||
edited({ phoneCount: false, phones: problems.value.phones.filter((_, at) => at !== index) })
|
||||
focusAfterRemove(phoneRowsRef.value, index, draft.phones.length)
|
||||
}
|
||||
|
||||
function toggleGroup(groupId: string, checked: boolean) {
|
||||
const others = draft.groupIds.filter((id) => id !== groupId)
|
||||
draft.groupIds = checked ? [...others, groupId] : others
|
||||
edited({})
|
||||
}
|
||||
|
||||
/** 이메일 칸을 떠났다 — 내 주소록의 다른 연락처에 같은 주소가 있는지 알림 없이 찾아본다 (막지 않는다) */
|
||||
function onEmailLeave(index: number) {
|
||||
const row = draft.emails[index]
|
||||
if (row) void duplicates.check(row.key)
|
||||
}
|
||||
|
||||
function focusById(id: string) {
|
||||
document.getElementById(id)?.focus()
|
||||
}
|
||||
|
||||
/** 첫 잘못된 칸 — 이름(없음 · 너무 김) → 이메일(줄 · 개수) → 전화번호(줄 · 개수) → 회사 · 부서 · 직책 → 메모 */
|
||||
function focusFirstProblem(found: ContactDraftProblems) {
|
||||
const email = found.emails.findIndex((problem) => problem !== null)
|
||||
const phone = found.phones.findIndex((problem) => problem !== null)
|
||||
const org = ORG_FIELDS.find(({ field }) => found.texts[field])
|
||||
if (found.required || found.texts.name) nameRef.value?.focus()
|
||||
else if (email >= 0) emailRowsRef.value?.focusValue(email)
|
||||
else if (found.emailCount) emailRowsRef.value?.focusValue(0)
|
||||
else if (phone >= 0) phoneRowsRef.value?.focusValue(phone)
|
||||
else if (found.phoneCount) phoneRowsRef.value?.focusValue(0)
|
||||
else if (org) focusById(`contact-${org.field}`)
|
||||
else if (found.note) noteRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 보낸 창이 아직 열려 있다 — 결과(칸 안 거절)를 창 안에 보일 수 있다 */
|
||||
function isCurrent(token: number): boolean {
|
||||
return token === openToken && !unmounted && props.open && !closing.value
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
const contact = props.contact
|
||||
const found = contactDraftProblems(draft, contact ? opened.value : null)
|
||||
problems.value = found
|
||||
if (hasContactProblems(found)) {
|
||||
focusFirstProblem(found)
|
||||
return
|
||||
}
|
||||
const patch = contact ? contactPatchOf(draft, opened.value) : null
|
||||
// 고치기에서 바꾼 것이 없다 — 보낼 것이 없으니 닫는다 (빈 PATCH 는 서버가 받지 않는다)
|
||||
if (patch && Object.keys(patch).length === 0) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
pending.value = 'save'
|
||||
error.value = null
|
||||
const token = openToken
|
||||
try {
|
||||
const saved =
|
||||
contact && patch ? await actions.updateContact(contact, patch) : await actions.createContact(contactInputOf(draft))
|
||||
if (!isCurrent(token)) return
|
||||
pending.value = null
|
||||
if (saved) close()
|
||||
} catch (caught) {
|
||||
const apiError = toApiError(caught)
|
||||
if (!isCurrent(token)) {
|
||||
// 칸 안에 보일 곳이 사라졌다(창을 닫았거나 다시 열었다) — 끈 알림을 띄운다
|
||||
if (CONTACT_SAVE_INLINE_CODES.includes(apiError.code)) notifyApiError(apiError)
|
||||
if (token === openToken && !unmounted) pending.value = null
|
||||
return
|
||||
}
|
||||
pending.value = null
|
||||
if (CONTACT_SAVE_INLINE_CODES.includes(apiError.code)) error.value = apiError.message
|
||||
// 없는 그룹(그사이 다른 곳에서 지움) · 없는 연락처 — 그룹 칸을 다시 맞춘다 (알림은 인터셉터)
|
||||
if (apiError.code === 'RES_001') void contactsStore.loadGroups()
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const contact = props.contact
|
||||
if (busy.value || !contact) return
|
||||
pending.value = 'remove'
|
||||
const token = openToken
|
||||
const removed = await actions.removeContact(contact)
|
||||
if (token !== openToken || unmounted) return
|
||||
pending.value = null
|
||||
if (removed) close()
|
||||
}
|
||||
|
||||
function close() {
|
||||
closing.value = true
|
||||
emit('close')
|
||||
const next = afterClose
|
||||
afterClose = null
|
||||
next?.()
|
||||
}
|
||||
|
||||
const { asking: leaveAsking, requestClose, stay: stayEditing, leave: leaveEditing } = useDialogLeaveConfirm({
|
||||
changed: () => changed.value,
|
||||
sending: () => busy.value,
|
||||
close,
|
||||
// 확인 창이 닫힌 뒤 — 브라우저가 이 창을 스스로 닫았으면(Esc 를 거듭 누름) 다시 띄운다. [그 연락처 보기]는 하지 않는다
|
||||
stayed: () => {
|
||||
afterClose = null
|
||||
void nextTick(() => modalRef.value?.reshow())
|
||||
},
|
||||
})
|
||||
const leaveCopy = computed(() =>
|
||||
leaveAsking.value === 'route' ? CONTACT_LEAVE_DIALOG.route : CONTACT_LEAVE_DIALOG.close,
|
||||
)
|
||||
|
||||
/** [그 연락처 보기] — 이 창을 닫고(바꾼 것이 있으면 먼저 묻는다) 그 연락처를 고른 주소록으로 */
|
||||
function viewDuplicate(contactId: string) {
|
||||
if (!contactId) return
|
||||
afterClose = () => void router.push(contactLocation(contactId))
|
||||
requestClose()
|
||||
}
|
||||
|
||||
// 열 때마다 칸을 새로 채운다 — 창을 그리기 전에(처음 초점이 새 칸에 간다)
|
||||
watch(
|
||||
() => [props.open, props.contact, props.prefill] as const,
|
||||
([open]) => {
|
||||
if (open) resetDraft()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="contact-dialog"
|
||||
ref="modalRef"
|
||||
:open="open"
|
||||
:title="title"
|
||||
title-id="contact-dialog-title"
|
||||
wide
|
||||
@cancel="requestClose"
|
||||
>
|
||||
<div class="contact-dialog">
|
||||
<div class="contact-dialog__field">
|
||||
<label
|
||||
for="contact-name"
|
||||
class="contact-dialog__label"
|
||||
>{{ CONTACT_DIALOG_TEXT.name }}</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
ref="nameRef"
|
||||
class="form-input"
|
||||
type="text"
|
||||
:value="draft.name"
|
||||
:maxlength="CONTACT_TEXT_MAX_LENGTH"
|
||||
autocomplete="off"
|
||||
:aria-invalid="problems.required || problems.texts.name ? 'true' : 'false'"
|
||||
:aria-describedby="problems.texts.name ? 'contact-name-error contact-required' : 'contact-required'"
|
||||
autofocus
|
||||
@input="setText('name', $event)"
|
||||
>
|
||||
<p
|
||||
v-if="problems.texts.name"
|
||||
id="contact-name-error"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ textLimitError }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ContactEntryRows
|
||||
ref="emailRowsRef"
|
||||
id-prefix="contact-email"
|
||||
:legend="CONTACT_DIALOG_TEXT.email"
|
||||
:rows="emailRows"
|
||||
:kinds="emailKinds"
|
||||
input-type="email"
|
||||
:max-length="CONTACT_EMAIL_MAX_LENGTH"
|
||||
:kind-label="CONTACT_DIALOG_TEXT.emailKind"
|
||||
:value-label="CONTACT_DIALOG_TEXT.emailAddress"
|
||||
:remove-label="CONTACT_DIALOG_TEXT.emailRemove"
|
||||
:add-label="CONTACT_DIALOG_TEXT.addEmail"
|
||||
:hint="CONTACT_DIALOG_TEXT.emailHint"
|
||||
:errors="emailErrors"
|
||||
:warnings="emailWarnings"
|
||||
:count-error="emailCountError"
|
||||
:can-add="draft.emails.length < CONTACT_FIELD_ROWS_MAX"
|
||||
@update="updateEmail"
|
||||
@add="addEmail"
|
||||
@remove="removeEmail"
|
||||
@leave="onEmailLeave"
|
||||
@view="viewDuplicate"
|
||||
/>
|
||||
|
||||
<ContactEntryRows
|
||||
ref="phoneRowsRef"
|
||||
id-prefix="contact-phone"
|
||||
:legend="CONTACT_DIALOG_TEXT.phone"
|
||||
:rows="phoneRows"
|
||||
:kinds="phoneKinds"
|
||||
input-type="tel"
|
||||
:max-length="CONTACT_PHONE_MAX_LENGTH"
|
||||
:kind-label="CONTACT_DIALOG_TEXT.phoneKind"
|
||||
:value-label="CONTACT_DIALOG_TEXT.phoneNumber"
|
||||
:remove-label="CONTACT_DIALOG_TEXT.phoneRemove"
|
||||
:add-label="CONTACT_DIALOG_TEXT.addPhone"
|
||||
:hint="CONTACT_DIALOG_TEXT.phoneHint"
|
||||
:errors="phoneErrors"
|
||||
:count-error="phoneCountError"
|
||||
:can-add="draft.phones.length < CONTACT_FIELD_ROWS_MAX"
|
||||
@update="updatePhone"
|
||||
@add="addPhone"
|
||||
@remove="removePhone"
|
||||
/>
|
||||
|
||||
<div class="contact-dialog__org">
|
||||
<div
|
||||
v-for="{ field, label } in ORG_FIELDS"
|
||||
:key="field"
|
||||
class="contact-dialog__field"
|
||||
>
|
||||
<label
|
||||
:for="`contact-${field}`"
|
||||
class="contact-dialog__label"
|
||||
>{{ label }}</label>
|
||||
<input
|
||||
:id="`contact-${field}`"
|
||||
class="form-input contact-dialog__compact"
|
||||
type="text"
|
||||
:value="draft[field]"
|
||||
:maxlength="CONTACT_TEXT_MAX_LENGTH"
|
||||
autocomplete="off"
|
||||
:aria-invalid="problems.texts[field] ? 'true' : 'false'"
|
||||
:aria-describedby="problems.texts[field] ? `contact-${field}-error` : undefined"
|
||||
@input="setText(field, $event)"
|
||||
>
|
||||
<p
|
||||
v-if="problems.texts[field]"
|
||||
:id="`contact-${field}-error`"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ textLimitError }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact-dialog__field">
|
||||
<div class="contact-dialog__label-row">
|
||||
<label
|
||||
for="contact-note"
|
||||
class="contact-dialog__label"
|
||||
>{{ CONTACT_DIALOG_TEXT.note }}</label>
|
||||
<span
|
||||
class="contact-dialog__count"
|
||||
data-contact-note-count
|
||||
>{{ noteCount }}</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="contact-note"
|
||||
ref="noteRef"
|
||||
class="form-input contact-dialog__note"
|
||||
rows="2"
|
||||
:value="draft.note"
|
||||
:maxlength="CONTACT_NOTE_MAX_LENGTH"
|
||||
:aria-invalid="noteError ? 'true' : 'false'"
|
||||
:aria-describedby="noteError ? 'contact-note-error' : undefined"
|
||||
@input="setNote"
|
||||
/>
|
||||
<p
|
||||
v-if="noteError"
|
||||
id="contact-note-error"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ noteError }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset
|
||||
v-if="groups.length > 0"
|
||||
class="contact-dialog__groups"
|
||||
>
|
||||
<legend class="contact-dialog__label contact-dialog__legend">
|
||||
{{ CONTACT_DIALOG_TEXT.groups }}
|
||||
</legend>
|
||||
<div class="contact-dialog__group-list">
|
||||
<label
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="contact-dialog__group"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="contact-dialog__check"
|
||||
:checked="draft.groupIds.includes(group.id)"
|
||||
:data-contact-group="group.id"
|
||||
@change="toggleGroup(group.id, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<span>{{ group.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p
|
||||
id="contact-required"
|
||||
:class="['form-message', 'contact-dialog__required', { 'form-message--error': problems.required }]"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="problems.required"
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ CONTACT_DIALOG_TEXT.required }}</span>
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
id="contact-error"
|
||||
class="form-message form-message--error"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<button
|
||||
v-if="contact"
|
||||
id="contact-remove"
|
||||
type="button"
|
||||
class="modal-dialog__action contact-dialog__remove"
|
||||
:aria-disabled="busy ? 'true' : undefined"
|
||||
:aria-busy="pending === 'remove' ? 'true' : undefined"
|
||||
@click="remove"
|
||||
>
|
||||
<AppIcon
|
||||
name="trash"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ CONTACT_DIALOG_TEXT.remove }}</span>
|
||||
</button>
|
||||
<button
|
||||
id="contact-save"
|
||||
type="button"
|
||||
class="modal-dialog__action contact-dialog__save"
|
||||
:aria-disabled="busy ? 'true' : undefined"
|
||||
:aria-busy="pending === 'save' ? 'true' : undefined"
|
||||
@click="save"
|
||||
>
|
||||
{{ CONTACT_DIALOG_TEXT.save }}
|
||||
</button>
|
||||
<button
|
||||
id="contact-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="requestClose"
|
||||
>
|
||||
{{ CONTACT_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
|
||||
<!-- 저장하지 않고 닫거나 떠나려 할 때 — 이 창 위에 뜬다 (닫기는 답을 받아 창을 닫고, 떠나기는 이동이 답을 기다린다) -->
|
||||
<LeaveConfirmDialog
|
||||
:open="leaveAsking !== null"
|
||||
id-prefix="contact-leave"
|
||||
:copy="leaveCopy"
|
||||
@stay="stayEditing"
|
||||
@leave="leaveEditing"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 묶음 사이 16 (디자인 원본 ContactsEdit — 필터 고치기 창과 같다) */
|
||||
.contact-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-16);
|
||||
margin: var(--space-8) 0 var(--space-6);
|
||||
}
|
||||
|
||||
.contact-dialog__field {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.contact-dialog__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.contact-dialog__label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.contact-dialog__count {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 회사 · 부서 · 직책 — 한 줄에 셋, 사이 8 */
|
||||
.contact-dialog__org {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
/* 칸 높이 40 — 밀도 예외 (이메일 · 전화번호 줄과 같다, tokens.css --size-filter-control) */
|
||||
.contact-dialog__compact {
|
||||
min-width: 0;
|
||||
min-height: var(--size-filter-control);
|
||||
height: var(--size-filter-control);
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.contact-dialog__note {
|
||||
min-height: 4.5rem;
|
||||
padding: var(--space-8) var(--space-10);
|
||||
resize: vertical;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.contact-dialog__groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-dialog__legend {
|
||||
margin: 0 0 var(--space-4);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.contact-dialog__group-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 var(--space-20);
|
||||
}
|
||||
|
||||
/* 체크 줄 44 — 줄 전체가 눌린다 (필터 창의 할 일 칸과 같다) */
|
||||
.contact-dialog__group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-touch);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contact-dialog__check {
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contact-dialog__required {
|
||||
margin-top: calc(var(--space-4) * -1);
|
||||
}
|
||||
|
||||
/* [이 연락처 지우기] — 왼쪽 끝, 바탕 없는 빨간 글 (디자인 원본 ContactsEdit · 필터 창과 같다) */
|
||||
.modal-dialog__action.contact-dialog__remove {
|
||||
gap: var(--space-6);
|
||||
margin-right: auto;
|
||||
padding: 0 var(--space-12);
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.modal-dialog__action.contact-dialog__remove:not([aria-disabled='true']):hover {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger-strong);
|
||||
}
|
||||
|
||||
.modal-dialog__action.contact-dialog__save {
|
||||
padding: 0 var(--space-18);
|
||||
}
|
||||
|
||||
/* 보내는 동안 — 끄지 않고 흐리게만 (초점이 남는다) */
|
||||
.modal-dialog__action[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
// 연락처 창의 이메일 · 전화번호 줄 묶음 (디자인 원본 ContactsEdit "연락처 추가") — 줄마다 종류 112 · 값 · ✕ 36×40, 아래에 [더하기] + 안내
|
||||
// - 줄 높이 40 · ✕ 36×40 은 필터 고치기 창의 조건 줄과 같은 밀도 예외(--size-filter-control · --size-filter-remove)
|
||||
// - 줄의 잘못(빨간 글)은 그 줄 아래, 같은 주소 안내(갈색 글 + [그 연락처 보기])도 그 줄 아래 — 값 칸이 aria-describedby 로 가리킨다.
|
||||
// 같은 주소 안내는 칸을 떠난 뒤 늦게 뜨므로 줄마다 늘 있는 알림 영역(role=status) 안에 그린다 — 화면 읽기 프로그램이 읽는다
|
||||
// - [더하기]는 5개까지(그보다 많은 줄 — 다른 프로그램이 넣은 것 — 은 그대로 보인다). 다 찼으면 aria-disabled (누른 단추가 초점을 잃지 않게) —
|
||||
// 까닭(옆의 안내 "5개까지 …")을 aria-describedby 로 잇는다
|
||||
// - 묶음 전체의 잘못(서버 한도 20개를 넘음 — 다른 프로그램이 넣은 것을 고쳤을 때)은 [더하기] 아래 빨간 글 (countError)
|
||||
// - 이메일 칸은 type="text" + inputmode="email" — type="email" 이면 Chrome 이 한글 도메인(IDN)을 퓨니코드로 바꿀 수 있다(받는 사람 칸과 같다)
|
||||
// - 줄을 빼면 초점은 다음 줄의 값 칸(마지막이었으면 앞 줄, 없으면 [더하기])으로 — 부모가 focusValue · focusAdd 로 옮긴다
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { ContactEntryPatch, ContactEntryRowView, ContactEntryWarning } from '@/components/contacts/contacts-ui'
|
||||
import { koreanOrdinal } from '@/shared/utils/contacts'
|
||||
|
||||
interface Props {
|
||||
/** 고정 id 의 앞부분 — 값 칸 #{idPrefix}-{key}, 줄 아래 글 #{idPrefix}-{key}-message */
|
||||
idPrefix: string
|
||||
legend: string
|
||||
rows: readonly ContactEntryRowView[]
|
||||
kinds: readonly { value: string; text: string }[]
|
||||
inputType: 'email' | 'tel'
|
||||
maxLength: number
|
||||
kindLabel: (ordinal: string) => string
|
||||
valueLabel: (ordinal: string) => string
|
||||
removeLabel: (ordinal: string) => string
|
||||
addLabel: string
|
||||
hint: string
|
||||
/** 줄마다 잘못 — 없으면 null */
|
||||
errors: readonly (string | null)[]
|
||||
/** 줄마다 안내 — 없으면 null */
|
||||
warnings?: readonly (ContactEntryWarning | null)[]
|
||||
/** 묶음 전체의 잘못 — 없으면 null (서버 한도를 넘는 개수) */
|
||||
countError?: string | null
|
||||
/** [더하기]를 누를 수 있다 (5개 미만) */
|
||||
canAdd: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update', index: number, patch: ContactEntryPatch): void
|
||||
(e: 'add'): void
|
||||
(e: 'remove', index: number): void
|
||||
/** 값 칸을 떠났다 — 같은 주소를 찾아본다 */
|
||||
(e: 'leave', index: number): void
|
||||
(e: 'view', contactId: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
warnings: () => [],
|
||||
countError: null,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const rootRef = ref<HTMLFieldSetElement | null>(null)
|
||||
const addRef = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
function valueId(key: number): string {
|
||||
return `${props.idPrefix}-${key}`
|
||||
}
|
||||
|
||||
function messageId(key: number): string {
|
||||
return `${props.idPrefix}-${key}-message`
|
||||
}
|
||||
|
||||
const hintId = `${props.idPrefix}-hint`
|
||||
const countErrorId = `${props.idPrefix}-count-error`
|
||||
|
||||
function hasMessage(index: number): boolean {
|
||||
return Boolean(props.errors[index] ?? props.warnings[index])
|
||||
}
|
||||
|
||||
/** 값 칸이 가리키는 글 — 그 줄의 잘못 · 안내, 묶음 전체의 잘못 */
|
||||
function describedBy(index: number, key: number): string | undefined {
|
||||
const ids = [hasMessage(index) ? messageId(key) : '', props.countError ? countErrorId : '']
|
||||
return ids.filter((id) => id !== '').join(' ') || undefined
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
if (props.canAdd) emit('add')
|
||||
}
|
||||
|
||||
/** index 번째 줄의 값 칸에 초점 — v-for 의 ref 배열은 차례를 지키지 않아 id 로 찾는다 */
|
||||
function focusValue(index: number) {
|
||||
const row = props.rows[index]
|
||||
if (!row) return
|
||||
const input = rootRef.value?.querySelector<HTMLInputElement>(`[id="${valueId(row.key)}"]`)
|
||||
input?.focus()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusValue,
|
||||
focusAdd: () => addRef.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset
|
||||
ref="rootRef"
|
||||
class="entry-rows"
|
||||
>
|
||||
<legend class="entry-rows__legend">
|
||||
{{ legend }}
|
||||
</legend>
|
||||
<div
|
||||
v-for="(row, index) in rows"
|
||||
:key="row.key"
|
||||
class="entry-rows__row"
|
||||
>
|
||||
<div class="entry-rows__line">
|
||||
<select
|
||||
class="form-input entry-rows__kind"
|
||||
:aria-label="kindLabel(koreanOrdinal(index + 1))"
|
||||
:value="row.label"
|
||||
@change="emit('update', index, { label: ($event.target as HTMLSelectElement).value })"
|
||||
>
|
||||
<option
|
||||
v-for="kind in kinds"
|
||||
:key="kind.value"
|
||||
:value="kind.value"
|
||||
>
|
||||
{{ kind.text }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
:id="valueId(row.key)"
|
||||
class="form-input entry-rows__value"
|
||||
:type="inputType === 'email' ? 'text' : 'tel'"
|
||||
:inputmode="inputType === 'email' ? 'email' : 'tel'"
|
||||
:aria-label="valueLabel(koreanOrdinal(index + 1))"
|
||||
:value="row.value"
|
||||
:maxlength="maxLength"
|
||||
autocomplete="off"
|
||||
:aria-invalid="errors[index] ? 'true' : 'false'"
|
||||
:aria-describedby="describedBy(index, row.key)"
|
||||
@input="emit('update', index, { value: ($event.target as HTMLInputElement).value })"
|
||||
@blur="emit('leave', index)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="entry-rows__remove"
|
||||
:aria-label="removeLabel(koreanOrdinal(index + 1))"
|
||||
:title="removeLabel(koreanOrdinal(index + 1))"
|
||||
@click="emit('remove', index)"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="errors[index]"
|
||||
:id="messageId(row.key)"
|
||||
class="form-message form-message--error entry-rows__message"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ errors[index] }}</span>
|
||||
</p>
|
||||
<!-- 늦게 뜨는 같은 주소 안내 — 늘 있는 알림 영역 안에 그려야 읽힌다 (비면 높이 0) -->
|
||||
<div
|
||||
role="status"
|
||||
class="entry-rows__live"
|
||||
>
|
||||
<p
|
||||
v-if="!errors[index] && warnings[index]"
|
||||
:id="messageId(row.key)"
|
||||
class="form-message entry-rows__message entry-rows__message--warning"
|
||||
data-contact-duplicate
|
||||
>
|
||||
<span>
|
||||
{{ warnings[index]?.text }}
|
||||
<button
|
||||
type="button"
|
||||
class="entry-rows__link"
|
||||
@click="emit('view', warnings[index]?.contactId ?? '')"
|
||||
>{{ warnings[index]?.linkLabel }}</button>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entry-rows__add">
|
||||
<button
|
||||
ref="addRef"
|
||||
type="button"
|
||||
class="entry-rows__add-button"
|
||||
:aria-disabled="canAdd ? undefined : 'true'"
|
||||
:aria-describedby="hintId"
|
||||
@click="onAdd"
|
||||
>
|
||||
<AppIcon
|
||||
name="plus"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<span>{{ addLabel }}</span>
|
||||
</button>
|
||||
<span
|
||||
:id="hintId"
|
||||
class="entry-rows__hint"
|
||||
>{{ hint }}</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="countError"
|
||||
:id="countErrorId"
|
||||
class="form-message form-message--error"
|
||||
data-entry-count-error
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ countError }}</span>
|
||||
</p>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entry-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry-rows__legend {
|
||||
margin: 0 0 var(--space-6);
|
||||
padding: 0;
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 줄 아래 글은 위 여백 6 — 비어 있는 알림 영역(같은 주소 안내)이 자리를 차지하지 않게 gap 대신 margin 으로 */
|
||||
.entry-rows__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 종류 112 · 값 · ✕ 36 — 사이 8 (디자인 원본 ContactsEdit) */
|
||||
.entry-rows__line {
|
||||
display: grid;
|
||||
grid-template-columns: 7rem minmax(0, 1fr) var(--size-filter-remove);
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
/* 줄 높이 40 — 밀도 예외 (tokens.css --size-filter-control) */
|
||||
.entry-rows__kind,
|
||||
.entry-rows__value {
|
||||
min-width: 0;
|
||||
min-height: var(--size-filter-control);
|
||||
height: var(--size-filter-control);
|
||||
}
|
||||
|
||||
.entry-rows__kind {
|
||||
padding: 0 var(--space-8);
|
||||
}
|
||||
|
||||
.entry-rows__value {
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
/* ✕ 36×40 — 밀도 예외 (tokens.css --size-filter-remove) */
|
||||
.entry-rows__remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-filter-remove);
|
||||
height: var(--size-filter-control);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.entry-rows__remove:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 줄 아래 글 — 값 칸과 같은 선에서 (종류 112 + 사이 8) */
|
||||
.entry-rows__message {
|
||||
margin-top: var(--space-6);
|
||||
margin-left: calc(7rem + var(--space-8));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 같은 주소 — 막지 않는 안내라 갈색 (디자인 원본 ContactsEdit #7A5200) */
|
||||
.entry-rows__message--warning {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.entry-rows__link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: var(--space-2);
|
||||
}
|
||||
|
||||
.entry-rows__link:hover {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.entry-rows__add {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4) var(--space-10);
|
||||
}
|
||||
|
||||
/* [더하기] 40 — 밀도 예외 (필터 창의 [조건 더하기]와 같다) */
|
||||
.entry-rows__add-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
min-height: var(--size-filter-control);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.entry-rows__add-button:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.entry-rows__add-button[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.entry-rows__hint {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,650 @@
|
||||
<script setup lang="ts">
|
||||
// 구성원 넣기 창 (디자인 원본 ContactsEdit "‘거래처’에 구성원 넣기" — 모달 620)
|
||||
// 「내 연락처 | 사내 직원」 전환(36 — 밀도 예외 .segmented) + 찾기(초성도 — 서버 q, 치고 250ms 쉬면) → 체크 줄 48(이름 · 회사 · 주소) →
|
||||
// 안내 한 줄 → "N명 고름" · [넣기] [취소].
|
||||
// - 이미 구성원인 사람은 보이지 않는다 — 두 쪽 모두 서버가 거른다(excludeGroupId — 내 연락처는 그 그룹의 구성원, 사내 직원은 구성원이 가진 주소).
|
||||
// 화면에서 거르지 않으므로 첫 쪽이 모두 구성원이어도 빈 안내가 뜨지 않는다. 다음 쪽(cursor)도 같은 값과 함께 보낸다
|
||||
// - 고른 것은 전환 · 찾기를 오가도 남는다. 한 번에 100명까지(contactIds · directoryIds 합쳐 — 계약 3절) — 다 고르면 나머지 칸을 끄고 안내
|
||||
// (안내는 늘 있는 알림 영역 안에 그려 화면 읽기 프로그램이 "N명 고름"에 이어 읽는다)
|
||||
// - [넣기]: POST /contact-groups/{id}/members — 사내 직원은 서버가 내 주소록에도 만든다(같은 주소의 연락처가 있으면 그것을 넣는다).
|
||||
// 끝나면 "2명을 ‘거래처’ 그룹에 넣었습니다." (useContactActions). 한도(CONTACT_002) · 없는 것(RES_001) · 장애는 인터셉터가 알리고 창은 그대로
|
||||
// - 목록은 알림 없이 받는다 — 못 받으면 그 자리에 사전 문구(사내 목록은 "사내 목록을 불러오지 못했습니다") + [다시 시도]
|
||||
// 고정 id — #contact-members-dialog · #contact-members-dialog-title · #contact-members-search · #contact-members-count · #contact-members-add · #contact-members-cancel
|
||||
import { computed, onBeforeUnmount, onScopeDispose, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { useClock, type ClockTimer } from '@/composables/useClock'
|
||||
import { useContactActions } from '@/composables/useContactActions'
|
||||
import { useContacts } from '@/composables/useContacts'
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import {
|
||||
CONTACT_MEMBERS_PICK_MAX,
|
||||
CONTACT_MEMBERS_TEXT,
|
||||
CONTACTS_EMPTY_TEXT,
|
||||
CONTACTS_LIST_TEXT,
|
||||
CONTACTS_PAGE_SIZE,
|
||||
CONTACTS_SEARCH_DELAY_MS,
|
||||
CONTACTS_SEARCH_MAX_LENGTH,
|
||||
} from '@/shared/constants/contacts'
|
||||
import type { ContactGroup, ContactGroupMembersPayload } from '@/shared/types/contacts'
|
||||
import type { PageInfo } from '@/shared/types/pagination'
|
||||
import { contactDisplayName, contactListMeta, contactSearchQueryOf, primaryEmail } from '@/shared/utils/contacts'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import type { LoadStatus } from '@/stores/mail.store'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
group: ContactGroup
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const contactsApi = useContacts()
|
||||
const actions = useContactActions()
|
||||
const clock = useClock()
|
||||
|
||||
type Tab = 'mine' | 'staff'
|
||||
|
||||
/** 고를 줄 하나 — 내 연락처 · 사내 직원을 같은 모양으로 */
|
||||
interface CandidateRow {
|
||||
id: string
|
||||
name: string
|
||||
meta: string
|
||||
email: string | null
|
||||
}
|
||||
|
||||
const tab = ref<Tab>('mine')
|
||||
/** 찾기 칸에 보이는 글 (한글 조합 중 글자 포함) */
|
||||
const text = ref<string>('')
|
||||
/** 서버에 보낸 찾을 글 */
|
||||
const query = ref<string | null>(null)
|
||||
const rows = shallowRef<CandidateRow[]>([])
|
||||
const pageInfo = ref<PageInfo | null>(null)
|
||||
const status = ref<LoadStatus>('idle')
|
||||
const listError = ref<ApiError | null>(null)
|
||||
const loadingMore = ref<boolean>(false)
|
||||
const pickedContacts = ref<ReadonlySet<string>>(new Set())
|
||||
const pickedEntries = ref<ReadonlySet<string>>(new Set())
|
||||
const pending = ref<boolean>(false)
|
||||
|
||||
let listSeq = 0
|
||||
let openToken = 0
|
||||
let searchTimer: ClockTimer | null = null
|
||||
let unmounted = false
|
||||
|
||||
const title = computed(() => CONTACT_MEMBERS_TEXT.title(props.group.name))
|
||||
const pickedCount = computed(() => pickedContacts.value.size + pickedEntries.value.size)
|
||||
const atMax = computed(() => pickedCount.value >= CONTACT_MEMBERS_PICK_MAX)
|
||||
const hasMore = computed(() => pageInfo.value?.hasNext === true && Boolean(pageInfo.value.nextCursor))
|
||||
const emptyText = computed(() => {
|
||||
if (query.value) return CONTACT_MEMBERS_TEXT.searchEmpty(query.value)
|
||||
return tab.value === 'mine' ? CONTACT_MEMBERS_TEXT.emptyMine : CONTACT_MEMBERS_TEXT.emptyStaff
|
||||
})
|
||||
const errorTitle = computed(() =>
|
||||
tab.value === 'staff' ? CONTACTS_EMPTY_TEXT.directoryErrorTitle : (listError.value?.message ?? ''),
|
||||
)
|
||||
|
||||
function picked(id: string): boolean {
|
||||
return (tab.value === 'mine' ? pickedContacts.value : pickedEntries.value).has(id)
|
||||
}
|
||||
|
||||
function togglePick(id: string, checked: boolean) {
|
||||
const target = tab.value === 'mine' ? pickedContacts : pickedEntries
|
||||
const next = new Set(target.value)
|
||||
if (checked && !atMax.value) next.add(id)
|
||||
else next.delete(id)
|
||||
target.value = next
|
||||
}
|
||||
|
||||
async function fetchRows(cursor: string | null): Promise<{ rows: CandidateRow[]; pageInfo: PageInfo }> {
|
||||
// 이미 구성원인 사람은 서버가 뺀다 — 다음 쪽(cursor)도 같은 q · excludeGroupId 와 함께 (계약 3절)
|
||||
const base = {
|
||||
limit: CONTACTS_PAGE_SIZE,
|
||||
excludeGroupId: props.group.id,
|
||||
...(query.value ? { q: query.value } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
}
|
||||
if (tab.value === 'mine') {
|
||||
const page = await contactsApi.listContacts(base, { silent: true })
|
||||
return {
|
||||
rows: page.items.map((contact) => ({
|
||||
id: contact.id,
|
||||
name: contactDisplayName(contact),
|
||||
meta: contactListMeta(contact),
|
||||
email: contact.name?.trim() ? primaryEmail(contact) : null,
|
||||
})),
|
||||
pageInfo: page.pageInfo,
|
||||
}
|
||||
}
|
||||
const page = await contactsApi.listDirectoryEntries({ ...base, kind: 'person' }, { silent: true })
|
||||
return {
|
||||
rows: page.items.map((entry) => ({ id: entry.id, name: entry.name, meta: '', email: entry.email })),
|
||||
pageInfo: page.pageInfo,
|
||||
}
|
||||
}
|
||||
|
||||
/** 지금 쪽(전환 · 찾을 글)의 첫 쪽을 받는다 — 그 전 요청의 응답은 버린다 */
|
||||
async function load(): Promise<void> {
|
||||
listSeq += 1
|
||||
const seq = listSeq
|
||||
status.value = 'loading'
|
||||
listError.value = null
|
||||
rows.value = []
|
||||
pageInfo.value = null
|
||||
loadingMore.value = false
|
||||
try {
|
||||
const result = await fetchRows(null)
|
||||
if (seq !== listSeq) return
|
||||
rows.value = result.rows
|
||||
pageInfo.value = result.pageInfo
|
||||
status.value = 'ready'
|
||||
} catch (caught) {
|
||||
if (seq !== listSeq) return
|
||||
status.value = 'error'
|
||||
listError.value = toApiError(caught)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
const cursor = pageInfo.value?.nextCursor ?? null
|
||||
if (!cursor || loadingMore.value || status.value !== 'ready') return
|
||||
const seq = listSeq
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const result = await fetchRows(cursor)
|
||||
if (seq !== listSeq) return
|
||||
const known = new Set(rows.value.map((row) => row.id))
|
||||
rows.value = [...rows.value, ...result.rows.filter((row) => !known.has(row.id))]
|
||||
pageInfo.value = result.pageInfo
|
||||
} catch {
|
||||
// 알리지 않는다 — [더 보기]를 다시 누르면 된다
|
||||
} finally {
|
||||
if (seq === listSeq) loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setTab(next: Tab) {
|
||||
if (tab.value === next) return
|
||||
tab.value = next
|
||||
void load()
|
||||
}
|
||||
|
||||
function cancelSearch() {
|
||||
if (searchTimer !== null) clock.clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
}
|
||||
|
||||
/** 찾을 글을 보낸다 — 조합 중(글자와 자모가 섞임)이면 보내지 않고 지난 결과를 둔다 */
|
||||
function applySearch() {
|
||||
searchTimer = null
|
||||
const next = contactSearchQueryOf(text.value)
|
||||
if (next === undefined || next === query.value) return
|
||||
query.value = next
|
||||
void load()
|
||||
}
|
||||
|
||||
function onSearchInput(event: Event) {
|
||||
text.value = (event.target as HTMLInputElement).value
|
||||
cancelSearch()
|
||||
searchTimer = clock.setTimeout(applySearch, CONTACTS_SEARCH_DELAY_MS)
|
||||
}
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (isImeComposing(event) || event.key !== 'Enter') return
|
||||
// Enter 는 바로 찾는다 — 창의 [넣기]를 누르지 않는다
|
||||
event.preventDefault()
|
||||
cancelSearch()
|
||||
applySearch()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (pending.value || pickedCount.value === 0) return
|
||||
const payload: ContactGroupMembersPayload = {
|
||||
...(pickedContacts.value.size > 0 ? { contactIds: [...pickedContacts.value] } : {}),
|
||||
...(pickedEntries.value.size > 0 ? { directoryIds: [...pickedEntries.value] } : {}),
|
||||
}
|
||||
pending.value = true
|
||||
const token = openToken
|
||||
try {
|
||||
const added = await actions.addMembers(props.group, payload)
|
||||
if (token !== openToken || unmounted) return
|
||||
pending.value = false
|
||||
if (added) emit('close')
|
||||
} catch {
|
||||
// 인터셉터가 알렸다 — 고른 것은 그대로 두고 목록을 다시 받는다 (그사이 지워진 연락처가 있을 수 있다)
|
||||
if (token !== openToken || unmounted) return
|
||||
pending.value = false
|
||||
void load()
|
||||
}
|
||||
}
|
||||
|
||||
/** 열 때마다 처음부터 — 내 연락처 쪽 · 빈 찾기 · 고른 것 없음 */
|
||||
function reset() {
|
||||
openToken += 1
|
||||
cancelSearch()
|
||||
tab.value = 'mine'
|
||||
text.value = ''
|
||||
query.value = null
|
||||
pickedContacts.value = new Set()
|
||||
pickedEntries.value = new Set()
|
||||
pending.value = false
|
||||
void load()
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.open, () => props.group.id],
|
||||
([open]) => {
|
||||
if (open) reset()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onScopeDispose(cancelSearch)
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="contact-members-dialog"
|
||||
:open="open"
|
||||
:title="title"
|
||||
title-id="contact-members-dialog-title"
|
||||
described-by="contact-members-note"
|
||||
wide
|
||||
@cancel="emit('close')"
|
||||
>
|
||||
<div class="members">
|
||||
<div class="members__bar">
|
||||
<div
|
||||
class="segmented segmented--outline"
|
||||
role="group"
|
||||
:aria-label="CONTACT_MEMBERS_TEXT.sourceLabel"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="segmented__item"
|
||||
:aria-pressed="tab === 'mine'"
|
||||
data-members-tab="mine"
|
||||
@click="setTab('mine')"
|
||||
>
|
||||
{{ CONTACT_MEMBERS_TEXT.mine }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="segmented__item"
|
||||
:aria-pressed="tab === 'staff'"
|
||||
data-members-tab="staff"
|
||||
@click="setTab('staff')"
|
||||
>
|
||||
{{ CONTACT_MEMBERS_TEXT.staff }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="members__search">
|
||||
<AppIcon
|
||||
name="search"
|
||||
:size="15"
|
||||
class="members__search-icon"
|
||||
/>
|
||||
<input
|
||||
id="contact-members-search"
|
||||
type="search"
|
||||
class="members__search-input"
|
||||
:aria-label="CONTACT_MEMBERS_TEXT.search"
|
||||
:placeholder="CONTACT_MEMBERS_TEXT.searchPlaceholder"
|
||||
:value="text"
|
||||
:maxlength="CONTACTS_SEARCH_MAX_LENGTH"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
@input="onSearchInput"
|
||||
@compositionupdate="onSearchInput"
|
||||
@keydown="onSearchKeydown"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="members__list-box"
|
||||
:aria-busy="status === 'loading' ? 'true' : undefined"
|
||||
>
|
||||
<p
|
||||
v-if="status === 'loading'"
|
||||
class="members__state"
|
||||
>
|
||||
{{ CONTACT_MEMBERS_TEXT.loading }}
|
||||
</p>
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
class="members__state members__state--error"
|
||||
role="alert"
|
||||
>
|
||||
<p class="members__state-title">
|
||||
{{ errorTitle }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="members__retry"
|
||||
@click="load"
|
||||
>
|
||||
{{ CONTACTS_LIST_TEXT.retry }}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-else-if="rows.length === 0"
|
||||
class="members__state"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</p>
|
||||
<ul
|
||||
v-else
|
||||
class="members__list"
|
||||
:aria-label="tab === 'mine' ? CONTACT_MEMBERS_TEXT.mine : CONTACT_MEMBERS_TEXT.staff"
|
||||
>
|
||||
<li
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
class="members__item"
|
||||
>
|
||||
<label class="members__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="members__check"
|
||||
:checked="picked(row.id)"
|
||||
:disabled="atMax && !picked(row.id)"
|
||||
:data-members-pick="row.id"
|
||||
@change="togglePick(row.id, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<span class="members__text">
|
||||
<span class="members__name">
|
||||
{{ row.name }}
|
||||
<span
|
||||
v-if="row.meta"
|
||||
class="members__meta"
|
||||
>{{ row.meta }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="row.email"
|
||||
class="members__email"
|
||||
>{{ row.email }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="hasMore"
|
||||
class="members__more-item"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="members__more"
|
||||
:disabled="loadingMore"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ loadingMore ? CONTACTS_LIST_TEXT.loadingMore : CONTACT_MEMBERS_TEXT.more }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="members__notes">
|
||||
<p
|
||||
id="contact-members-note"
|
||||
class="members__note"
|
||||
>
|
||||
{{ CONTACT_MEMBERS_TEXT.note }}
|
||||
</p>
|
||||
<!-- 100명을 다 골랐다 — 늘 있는 알림 영역 안에 그려야 읽힌다 (비면 높이 0 — 위 여백은 글이 갖는다) -->
|
||||
<div role="status">
|
||||
<p
|
||||
v-if="atMax"
|
||||
class="form-message members__max"
|
||||
data-members-max
|
||||
>
|
||||
{{ CONTACT_MEMBERS_TEXT.pickMax }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<span
|
||||
id="contact-members-count"
|
||||
class="members__count"
|
||||
role="status"
|
||||
>{{ CONTACT_MEMBERS_TEXT.picked(formatCount(pickedCount)) }}</span>
|
||||
<button
|
||||
id="contact-members-add"
|
||||
type="button"
|
||||
class="modal-dialog__action members__add"
|
||||
:disabled="pickedCount === 0"
|
||||
:aria-disabled="pending ? 'true' : undefined"
|
||||
:aria-busy="pending ? 'true' : undefined"
|
||||
@click="submit"
|
||||
>
|
||||
{{ CONTACT_MEMBERS_TEXT.add }}
|
||||
</button>
|
||||
<button
|
||||
id="contact-members-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ CONTACT_MEMBERS_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* 안내와 사이 12 — 전에 묶음 사이(gap)였던 값 그대로 */
|
||||
.members__max {
|
||||
margin-top: var(--space-12);
|
||||
}
|
||||
|
||||
.members__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
/* 찾기 칸 40 — 밀도 예외 (필터 창의 조건 칸과 같다, tokens.css --size-filter-control) */
|
||||
.members__search {
|
||||
flex: 1 1 14rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
height: var(--size-filter-control);
|
||||
padding: 0 var(--space-10);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.members__search:focus-within {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
|
||||
}
|
||||
|
||||
.members__search-icon {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.members__search-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.members__search-input:focus,
|
||||
.members__search-input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 목록 상자 — 줄이 많으면 안에서 스크롤한다 (6줄 남짓) */
|
||||
.members__list-box {
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
border: var(--border-width) solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.members__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.members__item + .members__item {
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* 체크 줄 48 — 줄 전체가 눌린다 (디자인 원본 ContactsEdit) */
|
||||
.members__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
min-height: var(--space-48);
|
||||
padding: var(--space-4) var(--space-12);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.members__row:hover {
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.members__check {
|
||||
flex: 0 0 auto;
|
||||
width: var(--size-option-input);
|
||||
height: var(--size-option-input);
|
||||
margin: 0;
|
||||
accent-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.members__text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.members__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.members__meta {
|
||||
margin-left: var(--space-4);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.members__email {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.members__more-item {
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.members__more {
|
||||
width: 100%;
|
||||
min-height: var(--size-touch);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.members__more:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.members__more:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.members__state {
|
||||
margin: 0;
|
||||
padding: var(--space-24) var(--space-16);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.members__state--error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.members__state-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.members__retry {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.members__retry:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.members__note {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* "N명 고름" — 단추 줄 왼쪽 */
|
||||
.members__count {
|
||||
margin-right: auto;
|
||||
align-self: center;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.modal-dialog__action.members__add {
|
||||
padding: 0 var(--space-18);
|
||||
}
|
||||
|
||||
.modal-dialog__action.members__add[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
// 새 그룹 · 그룹 이름 바꾸기 창 (디자인 원본 ContactsEdit "새 그룹" — 모달 400, 공용 모양 ModalDialog 의 medium)
|
||||
// 칸 하나(그룹 이름 44 · 50자) + [만들기] / [저장] [취소]. 열리면 이름 칸에 초점(이름 바꾸기는 이름 전체를 고른다).
|
||||
// - 같은 이름이면 칸 아래 사전 문구(CONTACT_001 — 알림 없이), 형식(VAL_001)도 칸 아래. 그 밖의 실패(그룹 100개 CONTACT_002 · 장애)는 인터셉터의 알림
|
||||
// - 이름을 비웠으면 누를 수 없다(보내는 동안은 aria-disabled — 누른 단추가 초점을 잃지 않게). 이름 바꾸기에서 그대로면 보내지 않고 닫는다. Enter 는 [만들기 · 저장]과 같다(한글 조합 중의 Enter 는 글자 확정)
|
||||
// - 그룹이 그사이 사라졌으면(이름 바꾸기 404 RES_001 — 알림은 인터셉터) 닫고 그룹을 다시 맞춘다
|
||||
// - 기다리는 사이 창을 닫았거나(취소 · Esc) 창이 사라졌으면 칸 아래에 보일 거절을 알림으로 알린다 (필터 고치기 창과 같다)
|
||||
// 고정 id — #contact-group-dialog · #contact-group-dialog-title · #contact-group-name · #contact-group-name-error · #contact-group-confirm · #contact-group-cancel
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ModalDialog from '@/components/feedback/ModalDialog.vue'
|
||||
import { CONTACT_GROUP_NAME_INLINE_CODES, useContactActions } from '@/composables/useContactActions'
|
||||
import { notifyApiError, toApiError } from '@/composables/useApi'
|
||||
import { CONTACT_GROUP_DIALOG_TEXT, CONTACT_GROUP_NAME_MAX_LENGTH } from '@/shared/constants/contacts'
|
||||
import type { ContactGroup } from '@/shared/types/contacts'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import { useContactsStore } from '@/stores/contacts.store'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/** 이름을 바꿀 그룹 — null 이면 새 그룹 */
|
||||
group: ContactGroup | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const actions = useContactActions()
|
||||
const contactsStore = useContactsStore()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const name = ref<string>(props.group?.name ?? '')
|
||||
const pending = ref<boolean>(false)
|
||||
/** 칸 아래 거절 — 같은 이름 · 형식 (사전 문구) */
|
||||
const error = ref<string | null>(null)
|
||||
let openToken = 0
|
||||
let unmounted = false
|
||||
|
||||
const title = computed(() =>
|
||||
props.group ? CONTACT_GROUP_DIALOG_TEXT.renameTitle : CONTACT_GROUP_DIALOG_TEXT.createTitle,
|
||||
)
|
||||
const confirmLabel = computed(() => (props.group ? CONTACT_GROUP_DIALOG_TEXT.save : CONTACT_GROUP_DIALOG_TEXT.create))
|
||||
const canSubmit = computed(() => name.value.trim() !== '' && !pending.value)
|
||||
|
||||
function onInput(event: Event) {
|
||||
name.value = (event.target as HTMLInputElement).value
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value) return
|
||||
const trimmed = name.value.trim()
|
||||
const group = props.group
|
||||
if (group && trimmed === group.name) {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
error.value = null
|
||||
const token = openToken
|
||||
/** 보낸 창이 아직 열려 있다 — 칸 아래에 보일 수 있다 */
|
||||
const isCurrent = () => token === openToken && !unmounted && props.open
|
||||
try {
|
||||
const saved = group ? await actions.renameGroup(group, trimmed) : await actions.createGroup(trimmed)
|
||||
if (!isCurrent()) return
|
||||
pending.value = false
|
||||
if (saved) emit('close')
|
||||
} catch (caught) {
|
||||
const apiError = toApiError(caught)
|
||||
const inline = CONTACT_GROUP_NAME_INLINE_CODES.includes(apiError.code)
|
||||
if (!isCurrent()) {
|
||||
if (inline) notifyApiError(apiError)
|
||||
return
|
||||
}
|
||||
pending.value = false
|
||||
if (inline) {
|
||||
error.value = apiError.message
|
||||
void nextTick(() => inputRef.value?.focus())
|
||||
return
|
||||
}
|
||||
if (apiError.code === 'RES_001') {
|
||||
void contactsStore.loadGroups()
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || isImeComposing(event)) return
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}
|
||||
|
||||
// 열 때마다 처음 이름으로 — 이름 바꾸기는 이름 전체를 골라 바로 고쳐 쓸 수 있게
|
||||
watch(
|
||||
() => [props.open, props.group] as const,
|
||||
([open]) => {
|
||||
if (!open) return
|
||||
openToken += 1
|
||||
name.value = props.group?.name ?? ''
|
||||
error.value = null
|
||||
pending.value = false
|
||||
void nextTick(() => {
|
||||
if (props.group) inputRef.value?.select()
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog
|
||||
id="contact-group-dialog"
|
||||
:open="open"
|
||||
:title="title"
|
||||
title-id="contact-group-dialog-title"
|
||||
:described-by="error ? 'contact-group-name-error' : undefined"
|
||||
medium
|
||||
@cancel="emit('close')"
|
||||
>
|
||||
<div class="group-name">
|
||||
<label
|
||||
for="contact-group-name"
|
||||
class="group-name__label"
|
||||
>{{ CONTACT_GROUP_DIALOG_TEXT.name }}</label>
|
||||
<input
|
||||
id="contact-group-name"
|
||||
ref="inputRef"
|
||||
class="form-input group-name__input"
|
||||
type="text"
|
||||
:value="name"
|
||||
:maxlength="CONTACT_GROUP_NAME_MAX_LENGTH"
|
||||
autocomplete="off"
|
||||
:aria-invalid="error ? 'true' : 'false'"
|
||||
:aria-describedby="error ? 'contact-group-name-error' : undefined"
|
||||
autofocus
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<p
|
||||
v-if="error"
|
||||
id="contact-group-name-error"
|
||||
class="form-message form-message--error"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="14"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ error }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<template #actions>
|
||||
<button
|
||||
id="contact-group-confirm"
|
||||
type="button"
|
||||
class="modal-dialog__action group-name__confirm"
|
||||
:disabled="name.trim() === ''"
|
||||
:aria-disabled="pending ? 'true' : undefined"
|
||||
:aria-busy="pending ? 'true' : undefined"
|
||||
@click="submit"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</button>
|
||||
<button
|
||||
id="contact-group-cancel"
|
||||
type="button"
|
||||
class="modal-dialog__action modal-dialog__action--secondary"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ CONTACT_GROUP_DIALOG_TEXT.cancel }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.group-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.group-name__label {
|
||||
font-size: var(--font-size-label);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.group-name__input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-dialog__action.group-name__confirm {
|
||||
padding: 0 var(--space-18);
|
||||
}
|
||||
|
||||
/* 보내는 동안 — 끄지 않고 흐리게만 (초점이 남는다) */
|
||||
.modal-dialog__action.group-name__confirm[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
// 연락처 상세 머리 줄 끝의 ⋯ (44×44 — 디자인 원본 Contacts · ContactsEdit "⋯ 메뉴") — 주소 복사 · 연락처 지우기(빨간 글)
|
||||
// 메뉴는 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점.
|
||||
// Esc 는 메뉴만 닫고 이 단추로 돌아간다. 메일 주소가 없는 연락처는 「주소 복사」를 끄고 까닭을 아래에 (aria-disabled — 화살표로 가서 듣는다)
|
||||
// 지우기는 확인 없이 — 알림의 [되돌리기] (useContactActions)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { useContactActions } from '@/composables/useContactActions'
|
||||
import { CONTACT_DETAIL_TEXT, CONTACT_MENU_TEXT } from '@/shared/constants/contacts'
|
||||
import type { Contact } from '@/shared/types/contacts'
|
||||
import { primaryEmail } from '@/shared/utils/contacts'
|
||||
|
||||
interface Props {
|
||||
contact: Contact
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const actions = useContactActions()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const open = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
|
||||
const address = computed(() => primaryEmail(props.contact))
|
||||
const items = computed<EditorMenuItem[]>(() => [
|
||||
{
|
||||
id: 'copy',
|
||||
label: CONTACT_MENU_TEXT.copy,
|
||||
icon: 'copy',
|
||||
disabled: !address.value,
|
||||
note: address.value ? undefined : CONTACT_MENU_TEXT.copyEmptyNote,
|
||||
},
|
||||
{ id: 'delete', label: CONTACT_MENU_TEXT.delete, icon: 'trash', tone: 'danger' },
|
||||
])
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
// detail 0 — Enter · Space 로 눌렀다
|
||||
byKeyboard.value = event.detail === 0
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowDown' || open.value) return
|
||||
event.preventDefault()
|
||||
byKeyboard.value = true
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function onClose(restoreFocus: boolean) {
|
||||
open.value = false
|
||||
if (restoreFocus) buttonRef.value?.focus()
|
||||
}
|
||||
|
||||
function onSelect(id: string) {
|
||||
open.value = false
|
||||
buttonRef.value?.focus()
|
||||
if (id === 'copy' && address.value) void actions.copyAddress(address.value)
|
||||
if (id === 'delete') void actions.removeContact(props.contact)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contact-more">
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
class="contact-more__button"
|
||||
:aria-label="CONTACT_DETAIL_TEXT.more"
|
||||
:title="CONTACT_DETAIL_TEXT.more"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
data-contact-more
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<AppIcon name="more-horizontal" />
|
||||
</button>
|
||||
<EditorMenu
|
||||
v-if="open"
|
||||
class="contact-more__menu"
|
||||
:label="CONTACT_DETAIL_TEXT.menu"
|
||||
:items="items"
|
||||
:trigger="trigger"
|
||||
align="end"
|
||||
:focus-content="!byKeyboard"
|
||||
@select="onSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contact-more {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 44×44 (디자인 원본 Contacts) */
|
||||
.contact-more__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-touch);
|
||||
height: var(--size-touch);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.contact-more__button:hover,
|
||||
.contact-more__button[aria-expanded='true'] {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 메뉴 폭 220 (디자인 원본 ContactsEdit) */
|
||||
.contact-more__menu {
|
||||
min-width: 13.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
// 주소록 화면의 창 묶음 — 패널 · 목록 · 상세가 연 창을 그린다 (useContactsDialogs · 메일함 패널의 MailboxDialogs 와 같은 결)
|
||||
// - 연락처 추가 · 고치기(ContactDialog) · 새 그룹 · 이름 바꾸기(ContactGroupNameDialog) · 구성원 넣기(ContactGroupMembersDialog)
|
||||
// - 창은 한 번 그리면 두고 열고 닫기만 한다 — 닫을 때 초점이 연 단추로 돌아가게(ModalDialog). 닫히는 동안 글이 바뀌지 않게 마지막 요청을 쥐고 있는다
|
||||
// - 닫은 뒤 연 단추가 사라져(지운 연락처의 [고치기] 등) 초점을 잃었으면 목록 칸으로 옮긴다
|
||||
import { computed, nextTick, shallowRef, watch } from 'vue'
|
||||
|
||||
import ContactDialog from '@/components/contacts/ContactDialog.vue'
|
||||
import ContactGroupMembersDialog from '@/components/contacts/ContactGroupMembersDialog.vue'
|
||||
import ContactGroupNameDialog from '@/components/contacts/ContactGroupNameDialog.vue'
|
||||
import type { ContactsDialogRequest } from '@/composables/useContactsDialogs'
|
||||
import type { Contact, ContactGroup } from '@/shared/types/contacts'
|
||||
import type { ContactPrefill } from '@/shared/utils/contacts'
|
||||
|
||||
interface Props {
|
||||
request: ContactsDialogRequest | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 목록 칸 (ContactsList 의 data-contacts-list) */
|
||||
const CONTACTS_LIST_SELECTOR = '[data-contacts-list]'
|
||||
|
||||
/** 창마다 마지막으로 받은 요청 — 닫혀도 남겨 둔다 (닫히는 동안 칸 값이 바뀌지 않게) */
|
||||
const contactTarget = shallowRef<{ contact: Contact | null; prefill: ContactPrefill | null }>({
|
||||
contact: null,
|
||||
prefill: null,
|
||||
})
|
||||
const nameTarget = shallowRef<ContactGroup | null>(null)
|
||||
const membersTarget = shallowRef<ContactGroup | null>(null)
|
||||
|
||||
const kind = computed(() => props.request?.kind ?? null)
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
void nextTick(() => {
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body || !active.isConnected) {
|
||||
document.querySelector<HTMLElement>(CONTACTS_LIST_SELECTOR)?.focus({ preventScroll: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.request,
|
||||
(request) => {
|
||||
if (request?.kind === 'contact') contactTarget.value = { contact: request.contact, prefill: request.prefill }
|
||||
else if (request?.kind === 'group-name') nameTarget.value = request.group
|
||||
else if (request?.kind === 'members') membersTarget.value = request.group
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContactDialog
|
||||
:open="kind === 'contact'"
|
||||
:contact="contactTarget.contact"
|
||||
:prefill="contactTarget.prefill"
|
||||
@close="close"
|
||||
/>
|
||||
<ContactGroupNameDialog
|
||||
:open="kind === 'group-name'"
|
||||
:group="nameTarget"
|
||||
@close="close"
|
||||
/>
|
||||
<ContactGroupMembersDialog
|
||||
v-if="membersTarget"
|
||||
:open="kind === 'members'"
|
||||
:group="membersTarget"
|
||||
@close="close"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
// 주소록 패널의 그룹 한 줄 — 아이콘 · 이름 · 구성원 수(평범한 숫자, 뱃지 아님) · ⋯ (디자인 원본 Contacts 왼쪽 · ContactsEdit "⋯ 메뉴")
|
||||
// - ⋯ 는 메일함 줄과 같은 단추(MailboxMoreButton — 32×40 밀도 예외): 마우스를 올리거나 초점이 가면 보이고, 메뉴가 열린 동안은 계속 보인다 (터치 화면은 늘)
|
||||
// - 메뉴: 이름 바꾸기 · 그룹에 메일 쓰기(구성원이 없으면 끄고 까닭 — aria-disabled) · 그룹 지우기(빨간 글)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import type { ContactGroupAction } from '@/components/contacts/contacts-ui'
|
||||
import MailboxMoreButton from '@/components/mail/MailboxMoreButton.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { CONTACT_GROUP_MENU_TEXT, CONTACTS_PANEL_TEXT } from '@/shared/constants/contacts'
|
||||
import type { ContactGroup } from '@/shared/types/contacts'
|
||||
import { contactsListLocation } from '@/shared/utils/contacts-route'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
|
||||
interface Props {
|
||||
group: ContactGroup
|
||||
/** 지금 보는 그룹 */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'action', action: ContactGroupAction, group: ContactGroup): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const ACTIONS: readonly ContactGroupAction[] = ['rename', 'compose', 'delete']
|
||||
|
||||
const menuOpen = ref<boolean>(false)
|
||||
|
||||
const empty = computed(() => props.group.memberCount <= 0)
|
||||
const items = computed<EditorMenuItem[]>(() => [
|
||||
{ id: 'rename', label: CONTACT_GROUP_MENU_TEXT.rename, icon: 'edit-square' },
|
||||
{
|
||||
id: 'compose',
|
||||
label: CONTACT_GROUP_MENU_TEXT.compose,
|
||||
icon: 'edit',
|
||||
disabled: empty.value,
|
||||
note: empty.value ? CONTACT_GROUP_MENU_TEXT.composeEmptyNote : undefined,
|
||||
},
|
||||
{ id: 'delete', label: CONTACT_GROUP_MENU_TEXT.delete, icon: 'trash', tone: 'danger' },
|
||||
])
|
||||
const location = computed(() => contactsListLocation({ kind: 'group', groupId: props.group.id }))
|
||||
|
||||
function onSelect(id: string) {
|
||||
const action = ACTIONS.find((candidate) => candidate === id)
|
||||
if (!action || (action === 'compose' && empty.value)) return
|
||||
emit('action', action, props.group)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="['group-row', { 'is-active': active, 'is-open': menuOpen }]"
|
||||
:data-group-row="group.id"
|
||||
>
|
||||
<RouterLink
|
||||
:to="location"
|
||||
class="group-row__link"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
>
|
||||
<AppIcon name="users" />
|
||||
<span class="group-row__name">{{ group.name }}</span>
|
||||
<span class="group-row__count">{{ formatCount(group.memberCount) }}</span>
|
||||
</RouterLink>
|
||||
<MailboxMoreButton
|
||||
class="group-row__more"
|
||||
:mailbox-name="group.name"
|
||||
:label="CONTACTS_PANEL_TEXT.groupMore(group.name)"
|
||||
:menu-label="CONTACTS_PANEL_TEXT.groupMenu(group.name)"
|
||||
:items="items"
|
||||
@select="onSelect"
|
||||
@open-change="menuOpen = $event"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.group-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-item);
|
||||
}
|
||||
|
||||
.group-row:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.group-row.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 줄 40 — 메일함 패널의 줄과 같다 (tokens.css --size-row-min) */
|
||||
.group-row__link {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-4) 0 var(--space-10);
|
||||
border-radius: var(--radius-item);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.group-row:hover .group-row__link {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.group-row.is-active .group-row__link {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-row__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 구성원 수 — 흐린 평범한 숫자 (디자인 원본 Contacts) */
|
||||
.group-row__count {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ⋯ — 마우스를 올리거나 초점이 가면, 메뉴가 열린 동안은 계속 보인다 (메일함 줄과 같다) */
|
||||
.group-row__more {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.group-row:hover .group-row__more,
|
||||
.group-row:focus-within .group-row__more,
|
||||
.group-row.is-open .group-row__more {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.group-row__more {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,523 @@
|
||||
<script setup lang="ts">
|
||||
// 주소록 목록 칸 (384 — 디자인 원본 Contacts 가운데 · README "8단계 › 주소록 화면")
|
||||
// - 머리(스크롤해도 위에 남는다): 제목 · "N명"(공용 주소는 "N개", 찾으면 "찾은 결과 N명"). 그룹이면 오른쪽에 [구성원 넣기] [그룹에 메일 쓰기]
|
||||
// (구성원이 없는 그룹이면 [그룹에 메일 쓰기]를 aria-disabled + 까닭 — 그룹 ⋯ 메뉴와 같다. 구성원을 받는 동안 aria-busy).
|
||||
// 사내면 머리 아래 회색 줄 — 직원 "메일 서버에 계정이 있는 직원입니다 — …" · 공용 주소 "여러 사람이 함께 받는 사내 주소입니다."
|
||||
// - 줄(ContactsListItem) — 이름 차례(서버), 100명씩 받고 끝에 [더 보기]
|
||||
// - 상태: 불러오는 중(뼈대) · 못 받음(사내 목록은 "사내 목록을 불러오지 못했습니다" + [다시 시도], 없는 그룹은 [전체 연락처로]) ·
|
||||
// 찾은 것 없음 · 처음(연락처 없음 — [연락처 추가]) · 빈 그룹([구성원 넣기]) · 빈 사내 목록 (디자인 원본 ContactsEdit "상태")
|
||||
// data-contacts-list — 지운 뒤 · 창을 닫은 뒤 초점을 잃으면 여기로 (useContactActions · ContactsDialogs)
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ContactsListItem from '@/components/contacts/ContactsListItem.vue'
|
||||
import StateMessage from '@/components/feedback/StateMessage.vue'
|
||||
import MailToolButton from '@/components/mail/MailToolButton.vue'
|
||||
import { useContactActions } from '@/composables/useContactActions'
|
||||
import { useContactsDialogs } from '@/composables/useContactsDialogs'
|
||||
import {
|
||||
CONTACT_GROUP_MENU_TEXT,
|
||||
CONTACTS_EMPTY_TEXT,
|
||||
CONTACTS_LIST_TEXT,
|
||||
CONTACTS_PANEL_TEXT,
|
||||
DIRECTORY_KIND_TEXT,
|
||||
} from '@/shared/constants/contacts'
|
||||
import { contactDisplayName, contactListMeta, primaryEmail } from '@/shared/utils/contacts'
|
||||
import { contactsItemLocation, contactsListLocation, type ContactsRouteState } from '@/shared/utils/contacts-route'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useContactsStore } from '@/stores/contacts.store'
|
||||
|
||||
interface Props {
|
||||
state: ContactsRouteState
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const store = useContactsStore()
|
||||
const dialogs = useContactsDialogs()
|
||||
const actions = useContactActions()
|
||||
const router = useRouter()
|
||||
|
||||
/** 목록 줄 — 연락처 · 사내 항목을 같은 모양으로 */
|
||||
interface ListRow {
|
||||
id: string
|
||||
name: string
|
||||
meta: string
|
||||
email: string | null
|
||||
fallback: string
|
||||
shared: boolean
|
||||
}
|
||||
|
||||
const source = computed(() => props.state.source)
|
||||
const directoryKind = computed(() => (source.value.kind === 'directory' ? source.value.directoryKind : null))
|
||||
const group = computed(() => (source.value.kind === 'group' ? store.groupById(source.value.groupId) : null))
|
||||
const title = computed(() => {
|
||||
if (source.value.kind === 'group') return group.value?.name ?? CONTACTS_PANEL_TEXT.groups
|
||||
if (directoryKind.value) return DIRECTORY_KIND_TEXT[directoryKind.value]
|
||||
return CONTACTS_PANEL_TEXT.all
|
||||
})
|
||||
const shared = computed(() => directoryKind.value === 'shared')
|
||||
const ready = computed(() => store.listStatus === 'ready')
|
||||
const countText = computed(() => {
|
||||
const total = store.pageInfo?.total
|
||||
if (!ready.value || typeof total !== 'number') return ''
|
||||
const count = formatCount(total)
|
||||
return props.state.q ? CONTACTS_LIST_TEXT.found(count, shared.value) : CONTACTS_LIST_TEXT.count(count, shared.value)
|
||||
})
|
||||
const rows = computed<ListRow[]>(() => {
|
||||
if (directoryKind.value) {
|
||||
return store.entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
meta: entry.kind === 'shared' ? DIRECTORY_KIND_TEXT.shared : '',
|
||||
email: entry.email,
|
||||
fallback: entry.email,
|
||||
shared: entry.kind === 'shared',
|
||||
}))
|
||||
}
|
||||
return store.contacts.map((contact) => ({
|
||||
id: contact.id,
|
||||
name: contactDisplayName(contact),
|
||||
meta: contactListMeta(contact),
|
||||
// 이름이 없으면 이름 자리에 주소를 보였다 — 둘째 줄은 비운다
|
||||
email: contact.name?.trim() ? primaryEmail(contact) : null,
|
||||
fallback: primaryEmail(contact) ?? '',
|
||||
shared: false,
|
||||
}))
|
||||
})
|
||||
const directoryNote = computed(() => (directoryKind.value ? CONTACTS_LIST_TEXT.directoryNote[directoryKind.value] : null))
|
||||
/** 구성원이 없는 그룹 — [그룹에 메일 쓰기]를 끈다 */
|
||||
const groupEmpty = computed(() => group.value !== null && group.value.memberCount <= 0)
|
||||
/** 이 그룹의 구성원을 받는 중 — [그룹에 메일 쓰기]의 바쁜 표시 */
|
||||
const composing = computed(() => group.value !== null && store.composingGroupId === group.value.id)
|
||||
/** 없는 그룹 — 다른 곳에서 지웠거나 주소에 남은 옛 id */
|
||||
const missingGroup = computed(() => source.value.kind === 'group' && store.listError?.code === 'RES_001')
|
||||
|
||||
function itemLocation(id: string) {
|
||||
return contactsItemLocation(source.value, id, props.state.q)
|
||||
}
|
||||
|
||||
function addMembers() {
|
||||
if (group.value) dialogs.openMembers(group.value)
|
||||
}
|
||||
|
||||
function composeGroup() {
|
||||
if (group.value && !groupEmpty.value) void actions.composeToGroup(group.value)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
void store.openList(source.value, props.state.q)
|
||||
}
|
||||
|
||||
function leaveToAll() {
|
||||
void router.replace(contactsListLocation({ kind: 'all' }))
|
||||
}
|
||||
|
||||
function addContact() {
|
||||
if (!store.isFull) dialogs.openContact(null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="contacts-list"
|
||||
aria-labelledby="contacts-list-title"
|
||||
:aria-busy="store.listStatus === 'loading' || store.loadingMore"
|
||||
tabindex="-1"
|
||||
data-contacts-list
|
||||
>
|
||||
<div class="contacts-list__top">
|
||||
<div class="contacts-list__head">
|
||||
<h1
|
||||
id="contacts-list-title"
|
||||
class="contacts-list__title"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<span
|
||||
class="contacts-list__count"
|
||||
data-contacts-count
|
||||
>{{ countText }}</span>
|
||||
<div
|
||||
v-if="group"
|
||||
class="contacts-list__actions"
|
||||
>
|
||||
<MailToolButton
|
||||
icon="plus"
|
||||
:label="CONTACTS_LIST_TEXT.addMembers"
|
||||
:icon-stroke-width="1.9"
|
||||
data-contacts-add-members
|
||||
@click="addMembers"
|
||||
/>
|
||||
<MailToolButton
|
||||
icon="edit"
|
||||
:label="CONTACTS_LIST_TEXT.composeGroup"
|
||||
:unavailable="groupEmpty"
|
||||
:busy="composing"
|
||||
:describedby="groupEmpty ? 'contacts-compose-group-note' : undefined"
|
||||
:title="groupEmpty ? CONTACT_GROUP_MENU_TEXT.composeEmptyNote : undefined"
|
||||
data-contacts-compose-group
|
||||
@click="composeGroup"
|
||||
/>
|
||||
<span
|
||||
v-if="groupEmpty"
|
||||
id="contacts-compose-group-note"
|
||||
class="visually-hidden"
|
||||
>{{ CONTACT_GROUP_MENU_TEXT.composeEmptyNote }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="directoryNote"
|
||||
class="contacts-list__note"
|
||||
>
|
||||
{{ directoryNote }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-if="store.listStatus === 'loading' || store.listStatus === 'idle'"
|
||||
class="contacts-list__skeleton"
|
||||
:aria-label="CONTACTS_LIST_TEXT.loading"
|
||||
>
|
||||
<li
|
||||
v-for="row in 6"
|
||||
:key="row"
|
||||
class="contacts-list__skeleton-row"
|
||||
>
|
||||
<span class="contacts-list__skeleton-avatar" />
|
||||
<span class="contacts-list__skeleton-lines">
|
||||
<span class="contacts-list__skeleton-bar" />
|
||||
<span class="contacts-list__skeleton-bar contacts-list__skeleton-bar--short" />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="store.listStatus === 'error' && directoryKind"
|
||||
icon="alert-circle"
|
||||
tone="danger"
|
||||
:title="CONTACTS_EMPTY_TEXT.directoryErrorTitle"
|
||||
:hint="CONTACTS_EMPTY_TEXT.directoryErrorHint"
|
||||
:trace-id="store.listError?.traceId"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="contacts-list__outline-button"
|
||||
@click="retry"
|
||||
>
|
||||
{{ CONTACTS_LIST_TEXT.retry }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="store.listStatus === 'error' && store.listError"
|
||||
icon="alert-circle"
|
||||
tone="danger"
|
||||
:title="store.listError.message"
|
||||
:trace-id="store.listError.traceId"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="contacts-list__text-button"
|
||||
@click="missingGroup ? leaveToAll() : retry()"
|
||||
>
|
||||
{{ missingGroup ? CONTACTS_LIST_TEXT.leaveToAll : CONTACTS_LIST_TEXT.retry }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
|
||||
<template v-else-if="rows.length > 0">
|
||||
<ul class="contacts-list__items">
|
||||
<ContactsListItem
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
:to="itemLocation(row.id)"
|
||||
:name="row.name"
|
||||
:meta="row.meta"
|
||||
:email="row.email"
|
||||
:fallback="row.fallback"
|
||||
:shared="row.shared"
|
||||
:selected="row.id === state.selectedId"
|
||||
:data-contacts-row="row.id"
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
v-if="store.hasMore"
|
||||
class="contacts-list__more"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="contacts-list__more-button"
|
||||
:disabled="store.loadingMore"
|
||||
@click="store.loadMore()"
|
||||
>
|
||||
{{ store.loadingMore ? CONTACTS_LIST_TEXT.loadingMore : CONTACTS_LIST_TEXT.more }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="state.q"
|
||||
icon="search"
|
||||
:title="CONTACTS_EMPTY_TEXT.searchTitle(state.q, Boolean(directoryKind))"
|
||||
:hint="directoryKind ? CONTACTS_EMPTY_TEXT.directorySearchHint : CONTACTS_EMPTY_TEXT.searchHint"
|
||||
data-contacts-empty="search"
|
||||
/>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="source.kind === 'group'"
|
||||
icon="users"
|
||||
:title="CONTACTS_EMPTY_TEXT.groupTitle(title)"
|
||||
:hint="CONTACTS_EMPTY_TEXT.groupHint"
|
||||
data-contacts-empty="group"
|
||||
>
|
||||
<button
|
||||
v-if="group"
|
||||
type="button"
|
||||
class="contacts-list__primary-button"
|
||||
@click="addMembers"
|
||||
>
|
||||
{{ CONTACTS_LIST_TEXT.addMembers }}
|
||||
</button>
|
||||
</StateMessage>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="directoryKind"
|
||||
:icon="directoryKind === 'person' ? 'user' : 'mail'"
|
||||
:title="CONTACTS_EMPTY_TEXT.directoryTitle[directoryKind]"
|
||||
data-contacts-empty="directory"
|
||||
/>
|
||||
|
||||
<StateMessage
|
||||
v-else-if="source.kind === 'all'"
|
||||
icon="book"
|
||||
:title="CONTACTS_EMPTY_TEXT.firstTitle"
|
||||
:hint="CONTACTS_EMPTY_TEXT.firstHint"
|
||||
data-contacts-empty="first"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="contacts-list__primary-button"
|
||||
:aria-disabled="store.isFull ? 'true' : undefined"
|
||||
@click="addContact"
|
||||
>
|
||||
<AppIcon
|
||||
name="user-plus"
|
||||
:size="15"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ CONTACTS_PANEL_TEXT.add }}</span>
|
||||
</button>
|
||||
<p class="contacts-list__first-note">
|
||||
{{ CONTACTS_EMPTY_TEXT.firstNote }}
|
||||
</p>
|
||||
</StateMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contacts-list {
|
||||
flex: 1 1 24rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 프로그램이 준 초점(지운 뒤)에는 고리를 그리지 않는다 — 칸 전체에 고리가 생기면 어지럽다 */
|
||||
.contacts-list:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 머리 묶음 — 스크롤해도 위에 남는다 */
|
||||
.contacts-list__top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 머리 52 — 안쪽 4 8 4 16 (디자인 원본 Contacts) */
|
||||
.contacts-list__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4) var(--space-8);
|
||||
min-height: var(--size-header-min);
|
||||
padding: var(--space-4) var(--space-8) var(--space-4) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.contacts-list__title {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-title-sm);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contacts-list__count {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.contacts-list__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* 사내 목록 — 머리 아래 회색 줄 (12.5px) */
|
||||
.contacts-list__note {
|
||||
margin: 0;
|
||||
padding: var(--space-8) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--color-surface-panel);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.contacts-list__items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.contacts-list__more {
|
||||
padding: var(--space-8) var(--space-12) var(--space-16);
|
||||
}
|
||||
|
||||
/* [더 보기] 44 */
|
||||
.contacts-list__more-button {
|
||||
width: 100%;
|
||||
min-height: var(--size-touch);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contacts-list__more-button:hover:not(:disabled) {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.contacts-list__more-button:disabled {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 불러오는 중 — 줄 56 의 뼈대 */
|
||||
.contacts-list__skeleton {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.contacts-list__skeleton-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
min-height: var(--space-56);
|
||||
padding: var(--space-6) var(--space-16);
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.contacts-list__skeleton-avatar {
|
||||
flex: 0 0 auto;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-round);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.contacts-list__skeleton-lines {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.contacts-list__skeleton-bar {
|
||||
width: 55%;
|
||||
height: var(--space-10);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-skeleton);
|
||||
}
|
||||
|
||||
.contacts-list__skeleton-bar--short {
|
||||
width: 35%;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* 상태 안의 단추 — 채운 단추([연락처 추가] · [구성원 넣기]) · 테두리 단추(사내 목록 [다시 시도]) · 글 단추 */
|
||||
.contacts-list__primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
margin-top: var(--space-6);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-16);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contacts-list__primary-button:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.contacts-list__primary-button[aria-disabled='true'] {
|
||||
background: var(--color-border-strong);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.contacts-list__outline-button {
|
||||
margin-top: var(--space-4);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: var(--border-width) solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contacts-list__outline-button:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.contacts-list__text-button {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-14);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contacts-list__text-button:hover {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.contacts-list__first-note {
|
||||
margin: var(--space-4) 0 0;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 목록 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.contacts-list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
// 주소록 목록의 한 줄 (56 — 디자인 원본 Contacts 가운데): 아바타 32(이름 첫 글자 — 공용 주소는 회색) · 이름(굵게) + 회사 · 부서(작게) / 대표 주소.
|
||||
// 고른 줄은 청록 바탕(aria-current). 누르면 그 연락처를 고른 주소로 간다 (기록이 쌓인다 — 뒤로 가기로 앞서 고른 연락처)
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import ContactAvatar from '@/components/contacts/ContactAvatar.vue'
|
||||
|
||||
interface Props {
|
||||
to: RouteLocationRaw
|
||||
name: string
|
||||
/** 이름 옆 작은 글 — "회사 · 부서" (공용 주소는 "공용 주소") */
|
||||
meta?: string
|
||||
/** 둘째 줄 — 대표 주소 (이름 자리에 주소를 보였으면 없다) */
|
||||
email?: string | null
|
||||
/** 아바타 첫 글자의 대신 글 — 이름이 없을 때 */
|
||||
fallback?: string
|
||||
shared?: boolean
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
meta: '',
|
||||
email: null,
|
||||
fallback: '',
|
||||
shared: false,
|
||||
selected: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="['contacts-row', { 'is-selected': selected }]">
|
||||
<RouterLink
|
||||
:to="to"
|
||||
class="contacts-row__link"
|
||||
:aria-current="selected ? 'true' : undefined"
|
||||
>
|
||||
<ContactAvatar
|
||||
:name="name"
|
||||
:fallback="fallback"
|
||||
:tone="shared ? 'neutral' : 'brand'"
|
||||
/>
|
||||
<span class="contacts-row__text">
|
||||
<span class="contacts-row__top">
|
||||
<span class="contacts-row__name">{{ name }}</span>
|
||||
<span
|
||||
v-if="meta"
|
||||
class="contacts-row__meta"
|
||||
>{{ meta }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="email"
|
||||
class="contacts-row__email"
|
||||
>{{ email }}</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contacts-row {
|
||||
border-bottom: var(--border-width) solid var(--color-border-subtle);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.contacts-row:hover {
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.contacts-row.is-selected {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
/* 줄 56 — 아바타 32 · 사이 12 · 안쪽 7 16 (디자인 원본 Contacts) */
|
||||
.contacts-row__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-12);
|
||||
min-height: var(--space-56);
|
||||
padding: var(--space-6) var(--space-16);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.contacts-row__link:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.contacts-row__link:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
|
||||
.contacts-row__text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: var(--line-height-heading);
|
||||
}
|
||||
|
||||
.contacts-row__top {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.contacts-row__name {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contacts-row__meta {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.contacts-row__email {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,401 @@
|
||||
<script setup lang="ts">
|
||||
// 주소록 모듈 패널 (208 — 디자인 원본 Contacts 왼쪽 · README "8단계 › 주소록 화면") — 메일함 패널과 같은 바탕 · 간격
|
||||
// [연락처 추가](채운 단추) → 「내 주소록」 전체 연락처(수) → 「그룹」 그룹마다 한 줄(수 · ⋯) + [새 그룹] → 「사내」 직원(수) · 공용 주소(수).
|
||||
// - 수는 뱃지가 아니라 오른쪽의 흐린 평범한 숫자. 모르면(못 받음) 숫자를 비운다 — 사내 목록을 받지 못하면 사내 묶음에 숫자가 없다
|
||||
// - 연락처 3,000개면 [연락처 추가]를 끄고(aria-disabled — 초점은 받는다) 아래에 까닭, 그룹 100개면 [새 그룹]도 같다
|
||||
// - 지금 보는 목록의 줄은 aria-current="page" (연락처를 골라도 그 목록에 있으면)
|
||||
// - 그룹 ⋯: 이름 바꾸기(창) · 그룹에 메일 쓰기(받는 사람을 채운 새 메일) · 그룹 지우기(확인 없이 — 알림의 [되돌리기])
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ContactsGroupRow from '@/components/contacts/ContactsGroupRow.vue'
|
||||
import type { ContactGroupAction } from '@/components/contacts/contacts-ui'
|
||||
import { useContactActions } from '@/composables/useContactActions'
|
||||
import { useContactsDialogs } from '@/composables/useContactsDialogs'
|
||||
import { CONTACTS_LIST_TEXT, CONTACTS_PANEL_TEXT, DIRECTORY_KIND_TEXT } from '@/shared/constants/contacts'
|
||||
import type { ContactGroup, DirectoryKind } from '@/shared/types/contacts'
|
||||
import {
|
||||
contactsListLocation,
|
||||
contactsRouteStateOf,
|
||||
contactsSourceKey,
|
||||
type ContactsListSource,
|
||||
} from '@/shared/utils/contacts-route'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import { useContactsStore } from '@/stores/contacts.store'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useContactsStore()
|
||||
const dialogs = useContactsDialogs()
|
||||
const actions = useContactActions()
|
||||
|
||||
const ALL: ContactsListSource = { kind: 'all' }
|
||||
const DIRECTORY_KINDS: readonly DirectoryKind[] = ['person', 'shared']
|
||||
|
||||
const activeKey = computed(() => contactsSourceKey(contactsRouteStateOf(route)?.source ?? null))
|
||||
const totalText = computed(() => (store.contactTotal === null ? '' : formatCount(store.contactTotal)))
|
||||
const directoryLinks = computed(() =>
|
||||
DIRECTORY_KINDS.map((kind) => {
|
||||
const source: ContactsListSource = { kind: 'directory', directoryKind: kind }
|
||||
const total = store.directoryStatus === 'ready' ? store.directoryTotals[kind] : null
|
||||
return {
|
||||
kind,
|
||||
key: contactsSourceKey(source),
|
||||
label: DIRECTORY_KIND_TEXT[kind],
|
||||
icon: kind === 'person' ? ('user' as const) : ('mail' as const),
|
||||
to: contactsListLocation(source),
|
||||
count: total === null ? '' : formatCount(total),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
function onAdd() {
|
||||
if (!store.isFull) dialogs.openContact(null)
|
||||
}
|
||||
|
||||
function onNewGroup() {
|
||||
if (!store.isGroupsFull) dialogs.openGroupName(null)
|
||||
}
|
||||
|
||||
function onGroupAction(action: ContactGroupAction, group: ContactGroup) {
|
||||
if (action === 'rename') dialogs.openGroupName(group)
|
||||
else if (action === 'compose') void actions.composeToGroup(group)
|
||||
else void actions.removeGroup(group)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="contacts-panel"
|
||||
:aria-label="CONTACTS_PANEL_TEXT.label"
|
||||
data-contacts-panel
|
||||
>
|
||||
<div class="contacts-panel__add">
|
||||
<button
|
||||
type="button"
|
||||
class="contacts-panel__primary"
|
||||
:aria-disabled="store.isFull ? 'true' : undefined"
|
||||
:aria-describedby="store.isFull ? 'contacts-full-note' : undefined"
|
||||
data-contacts-add
|
||||
@click="onAdd"
|
||||
>
|
||||
<AppIcon
|
||||
name="user-plus"
|
||||
:size="15"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<span>{{ CONTACTS_PANEL_TEXT.add }}</span>
|
||||
</button>
|
||||
<p
|
||||
v-if="store.isFull"
|
||||
id="contacts-full-note"
|
||||
class="contacts-panel__note"
|
||||
>
|
||||
{{ CONTACTS_PANEL_TEXT.fullNote }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="contacts-panel__section">
|
||||
<p
|
||||
id="contacts-panel-mine"
|
||||
class="contacts-panel__heading"
|
||||
>
|
||||
{{ CONTACTS_PANEL_TEXT.mine }}
|
||||
</p>
|
||||
<ul
|
||||
class="contacts-panel__list"
|
||||
aria-labelledby="contacts-panel-mine"
|
||||
>
|
||||
<li>
|
||||
<RouterLink
|
||||
:to="contactsListLocation(ALL)"
|
||||
:class="['contacts-panel__link', { 'is-active': activeKey === 'all' }]"
|
||||
:aria-current="activeKey === 'all' ? 'page' : undefined"
|
||||
data-contacts-source="all"
|
||||
>
|
||||
<AppIcon name="book" />
|
||||
<span class="contacts-panel__name">{{ CONTACTS_PANEL_TEXT.all }}</span>
|
||||
<span class="contacts-panel__count">{{ totalText }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="contacts-panel__section">
|
||||
<p
|
||||
id="contacts-panel-groups"
|
||||
class="contacts-panel__heading"
|
||||
>
|
||||
{{ CONTACTS_PANEL_TEXT.groups }}
|
||||
</p>
|
||||
<ul
|
||||
v-if="store.groups.length > 0"
|
||||
class="contacts-panel__list"
|
||||
aria-labelledby="contacts-panel-groups"
|
||||
>
|
||||
<ContactsGroupRow
|
||||
v-for="group in store.groups"
|
||||
:key="group.id"
|
||||
:group="group"
|
||||
:active="activeKey === `group:${group.id}`"
|
||||
@action="onGroupAction"
|
||||
/>
|
||||
</ul>
|
||||
<ul
|
||||
v-else-if="store.groupsStatus === 'loading'"
|
||||
class="contacts-panel__list"
|
||||
:aria-label="CONTACTS_PANEL_TEXT.groupsLoading"
|
||||
aria-busy="true"
|
||||
>
|
||||
<li
|
||||
v-for="row in 2"
|
||||
:key="row"
|
||||
class="contacts-panel__placeholder"
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
v-else-if="store.groupsStatus === 'error' && store.groupsError"
|
||||
class="contacts-panel__error"
|
||||
role="alert"
|
||||
>
|
||||
<p class="contacts-panel__error-text">
|
||||
{{ store.groupsError.message }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="contacts-panel__retry"
|
||||
@click="store.loadGroups()"
|
||||
>
|
||||
{{ CONTACTS_LIST_TEXT.retry }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="contacts-panel__new"
|
||||
:aria-disabled="store.isGroupsFull ? 'true' : undefined"
|
||||
:aria-describedby="store.isGroupsFull ? 'contacts-groups-full-note' : undefined"
|
||||
data-contacts-new-group
|
||||
@click="onNewGroup"
|
||||
>
|
||||
<AppIcon
|
||||
name="plus"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<span>{{ CONTACTS_PANEL_TEXT.newGroup }}</span>
|
||||
</button>
|
||||
<p
|
||||
v-if="store.isGroupsFull"
|
||||
id="contacts-groups-full-note"
|
||||
class="contacts-panel__note"
|
||||
>
|
||||
{{ CONTACTS_PANEL_TEXT.groupsFullNote }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="contacts-panel__section">
|
||||
<p
|
||||
id="contacts-panel-company"
|
||||
class="contacts-panel__heading"
|
||||
>
|
||||
{{ CONTACTS_PANEL_TEXT.company }}
|
||||
</p>
|
||||
<ul
|
||||
class="contacts-panel__list"
|
||||
aria-labelledby="contacts-panel-company"
|
||||
>
|
||||
<li
|
||||
v-for="link in directoryLinks"
|
||||
:key="link.kind"
|
||||
>
|
||||
<RouterLink
|
||||
:to="link.to"
|
||||
:class="['contacts-panel__link', { 'is-active': activeKey === link.key }]"
|
||||
:aria-current="activeKey === link.key ? 'page' : undefined"
|
||||
:data-contacts-source="link.key"
|
||||
>
|
||||
<AppIcon :name="link.icon" />
|
||||
<span class="contacts-panel__name">{{ link.label }}</span>
|
||||
<span class="contacts-panel__count">{{ link.count }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메일함 패널과 같은 바탕 · 간격 (디자인 원본 Contacts: 208 · #F8F9FB · 14 10) */
|
||||
.contacts-panel {
|
||||
flex: 1 1 13rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-14);
|
||||
padding: var(--space-14) var(--space-10);
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.contacts-panel__add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
/* [연락처 추가] — 메일함 패널의 [메일 쓰기]와 같은 채운 단추 44 */
|
||||
.contacts-panel__primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
font-size: var(--font-size-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contacts-panel__primary:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
/* 연락처 3,000개 — 회색으로 끈다 (디자인 원본 ContactsEdit "한도": #D0D5DD) */
|
||||
.contacts-panel__primary[aria-disabled='true'] {
|
||||
background: var(--color-border-strong);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.contacts-panel__note {
|
||||
margin: 0;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.contacts-panel__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* 묶음 이름 — 12px 굵게 (디자인 원본 Contacts) */
|
||||
.contacts-panel__heading {
|
||||
margin: 0;
|
||||
padding: 0 var(--space-10) var(--space-4);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.contacts-panel__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* 줄 40 — 메일함 패널의 줄과 같다 */
|
||||
.contacts-panel__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-10);
|
||||
border-radius: var(--radius-item);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.contacts-panel__link:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.contacts-panel__link.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contacts-panel__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contacts-panel__count {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* [새 그룹] 44 — 메일함 패널의 [새 메일함]과 같다 */
|
||||
.contacts-panel__new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
min-height: var(--size-touch);
|
||||
padding: 0 var(--space-10);
|
||||
border: 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: transparent;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.contacts-panel__new:hover:not([aria-disabled='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.contacts-panel__new[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.contacts-panel__placeholder {
|
||||
height: var(--size-row-min);
|
||||
margin: var(--space-2) 0;
|
||||
border-radius: var(--radius-item);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.contacts-panel__error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-10);
|
||||
}
|
||||
|
||||
.contacts-panel__error-text {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.contacts-panel__retry {
|
||||
min-height: var(--size-touch);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 패널만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
|
||||
@media (min-width: 70rem) {
|
||||
.contacts-panel {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
// 상단 바의 찾기 칸 — 주소록 화면에서는 "주소록에서 찾기" (디자인 원본 Contacts 머리 · README "8단계 › 주소록 화면")
|
||||
// - 지금 보는 목록 안에서 찾는다(전체 · 그룹 · 직원 · 공용 주소). 치는 대로 — 250ms 쉬면 주소의 ?q= 를 바꾸고(기록을 쌓지 않는다) 목록이 서버 q 로 다시 받는다
|
||||
// - 한글 조합 중인 글자도 찾는다(받는 사람 칸처럼 input · compositionupdate 의 보이는 글). 초성만 친 글(ㅂㅈㅎ)은 서버가 초성으로 찾는다.
|
||||
// 글자와 자모가 섞였으면(조합 중 — "박ㅈ") 보내지 않고 지난 결과를 둔다 (contacts.ts 의 contactSearchQueryOf)
|
||||
// - Enter 는 기다리지 않고 바로 찾는다(조합을 끝내는 Enter 는 무시 — isImeComposing). Esc · ✕(40×38 — 밀도 예외)는 찾을 글을 지운다
|
||||
// - 다른 목록으로 가면(패널) 그 주소의 찾을 글로 맞춘다 — 이 칸이 넣은 글이 돌아온 것이면(그사이 더 쳤을 수 있다) 칸을 덮지 않는다
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useClock, type ClockTimer } from '@/composables/useClock'
|
||||
import { CONTACTS_SEARCH_DELAY_MS, CONTACTS_SEARCH_MAX_LENGTH, CONTACTS_SEARCH_TEXT } from '@/shared/constants/contacts'
|
||||
import { contactSearchQueryOf } from '@/shared/utils/contacts'
|
||||
import { contactsLocationWithQuery, contactsRouteStateOf, contactsSourceKey } from '@/shared/utils/contacts-route'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const clock = useClock()
|
||||
|
||||
const state = computed(() => contactsRouteStateOf(route))
|
||||
/** 칸에 보이는 글 — 한글 조합 중인 글자 포함 */
|
||||
const text = ref<string>(state.value?.q ?? '')
|
||||
let timer: ClockTimer | null = null
|
||||
/** 이 칸이 마지막으로 주소에 넣은 찾을 글 */
|
||||
let applied: string | null = state.value?.q ?? null
|
||||
|
||||
const placeholder = computed(() =>
|
||||
state.value?.source.kind === 'directory'
|
||||
? CONTACTS_SEARCH_TEXT.directoryPlaceholder
|
||||
: CONTACTS_SEARCH_TEXT.placeholder,
|
||||
)
|
||||
|
||||
function cancel() {
|
||||
if (timer !== null) clock.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
/** 칸의 글을 주소에 넣는다 — 같은 목록 · 같은 고른 항목에 찾을 글만 바꾼다 (replace) */
|
||||
function apply() {
|
||||
timer = null
|
||||
const current = state.value
|
||||
if (!current) return
|
||||
const q = contactSearchQueryOf(text.value)
|
||||
if (q === undefined || q === current.q) return
|
||||
applied = q
|
||||
void router.replace(contactsLocationWithQuery(current, q))
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
text.value = (event.target as HTMLInputElement).value
|
||||
cancel()
|
||||
timer = clock.setTimeout(apply, CONTACTS_SEARCH_DELAY_MS)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
cancel()
|
||||
text.value = ''
|
||||
apply()
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (isImeComposing(event)) return
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
cancel()
|
||||
apply()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape' && text.value !== '') {
|
||||
event.preventDefault()
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
// 다른 목록으로 가거나(패널 · 뒤로 가기) 주소의 찾을 글이 바뀌면 칸을 맞춘다 — 이 칸이 넣은 글이 돌아온 것이면 그대로 둔다
|
||||
watch(
|
||||
[() => contactsSourceKey(state.value?.source ?? null), () => state.value?.q ?? null],
|
||||
([sourceKey, q], [previousKey]) => {
|
||||
if (sourceKey === previousKey && q === applied) return
|
||||
cancel()
|
||||
applied = q
|
||||
text.value = q ?? ''
|
||||
},
|
||||
)
|
||||
|
||||
onScopeDispose(cancel)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
role="search"
|
||||
:class="['contacts-search', { 'is-active': Boolean(state?.q) }]"
|
||||
@submit.prevent
|
||||
>
|
||||
<AppIcon
|
||||
name="search"
|
||||
class="contacts-search__icon"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
class="contacts-search__input"
|
||||
:aria-label="CONTACTS_SEARCH_TEXT.label"
|
||||
:placeholder="placeholder"
|
||||
:value="text"
|
||||
:maxlength="CONTACTS_SEARCH_MAX_LENGTH"
|
||||
autocomplete="off"
|
||||
enterkeyhint="search"
|
||||
data-contacts-search
|
||||
@input="onInput"
|
||||
@compositionupdate="onInput"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<button
|
||||
v-if="text !== ''"
|
||||
type="button"
|
||||
class="contacts-search__clear"
|
||||
:aria-label="CONTACTS_SEARCH_TEXT.clear"
|
||||
:title="CONTACTS_SEARCH_TEXT.clear"
|
||||
@click="clear"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="15"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 메일 검색 칸과 같은 모양 (MailSearchBox — 디자인 원본 Contacts 머리: 40 · 테두리 #8A94A3) */
|
||||
.contacts-search {
|
||||
flex: 1 1 17.5rem;
|
||||
max-width: 32.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
height: var(--size-search-box);
|
||||
margin: 0;
|
||||
padding: 0 var(--space-12);
|
||||
border: var(--border-width) solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 찾는 중 · 칸에 초점 — 강조색 1px 테두리 + 1px 그림자 */
|
||||
.contacts-search.is-active,
|
||||
.contacts-search:focus-within {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
|
||||
}
|
||||
|
||||
/* 끝의 ✕ 가 칸 오른쪽 끝까지 닿는다 */
|
||||
.contacts-search:has(.contacts-search__clear) {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.contacts-search__icon {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.contacts-search__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.contacts-search__input:focus,
|
||||
.contacts-search__input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 브라우저의 지우기 단추 대신 ✕ 하나만 */
|
||||
.contacts-search__input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* 40×38 — 칸(40) 안쪽 높이를 채운다 · 밀도 예외 (tokens.css — 메일 검색 칸의 ✕ 와 같다) */
|
||||
.contacts-search__clear {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size-search-clear-width);
|
||||
height: var(--size-search-clear-height);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0 calc(var(--radius-md) - var(--border-width)) calc(var(--radius-md) - var(--border-width)) 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.contacts-search__clear:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.contacts-search__clear:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
// 주소록 화면의 부품끼리 주고받는 값 — 패널의 그룹 줄(ContactsGroupRow) ↔ 패널, 연락처 창의 줄 묶음(ContactEntryRows) ↔ 창(ContactDialog)
|
||||
|
||||
/** 그룹 줄 ⋯ 메뉴가 할 수 있는 일 */
|
||||
export type ContactGroupAction = 'rename' | 'compose' | 'delete'
|
||||
|
||||
/** 줄 하나 — key 는 v-for 키 · 고정 id 의 끝 (줄을 빼도 다른 줄의 값 · 초점이 섞이지 않게) */
|
||||
export interface ContactEntryRowView {
|
||||
key: number
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 줄 아래의 막지 않는 안내 — 같은 주소가 다른 연락처에 있다 (+ [그 연락처 보기]) */
|
||||
export interface ContactEntryWarning {
|
||||
text: string
|
||||
/** [그 연락처 보기]가 갈 연락처 */
|
||||
contactId: string
|
||||
linkLabel: string
|
||||
}
|
||||
|
||||
/** 줄을 고친 것 — 종류 · 값 가운데 바뀐 것만 */
|
||||
export interface ContactEntryPatch {
|
||||
label?: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
/** 키 하나를 뺀 기록 — 같은 주소 안내를 지울 때 */
|
||||
export function withoutKey<T>(record: Readonly<Record<number, T>>, key: number): Record<number, T> {
|
||||
const next: Record<number, T> = { ...record }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
|
||||
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명 · 부재중 자동 응답 · 필터 고치기 창 위) ·
|
||||
// MailPurgeDialog(영구 삭제 확인) · 메일함 관리(MailboxNameDialog — 이름 칸 · MailboxDeleteDialog · MailboxEmptyDialog) ·
|
||||
// 메일 필터 고치기 창(MailFilterDialog — 넓은 창 620, wide)
|
||||
// 메일 필터 고치기 창(MailFilterDialog — 넓은 창 620, wide) · 주소록의 창(8단계 — 연락처 추가 · 고치기 · 구성원 넣기 620 wide,
|
||||
// 새 그룹 · 그룹 이름 바꾸기 400 medium)
|
||||
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
|
||||
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// 브라우저가 막지 못하게 스스로 닫았는데(Esc 를 거듭 누름) 쓰는 쪽이 닫지 않기로 했으면(나가기 확인의 "계속 고치기") reshow() 로 다시 띄운다 —
|
||||
@@ -26,6 +27,8 @@ interface Props {
|
||||
role?: 'alertdialog'
|
||||
/** 칸이 많은 창 — 폭 620 · 좌우 여백 20 (디자인 원본 SettingsFilter 의 고치기 창). 주지 않으면 공용 폭 360 */
|
||||
wide?: boolean
|
||||
/** 칸 하나의 창 — 폭 400 · 좌우 여백 20 (디자인 원본 ContactsEdit 의 새 그룹). wide 가 앞선다 */
|
||||
medium?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -39,6 +42,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
describedBy: undefined,
|
||||
role: undefined,
|
||||
wide: false,
|
||||
medium: false,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
@@ -125,7 +129,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
:class="['modal-dialog', { 'modal-dialog--wide': wide }]"
|
||||
:class="['modal-dialog', { 'modal-dialog--wide': wide, 'modal-dialog--medium': medium && !wide }]"
|
||||
:role="role"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="descriptionId"
|
||||
@@ -170,6 +174,12 @@ onMounted(() => {
|
||||
padding: var(--space-18) var(--space-20) var(--space-14);
|
||||
}
|
||||
|
||||
/* 칸 하나의 창 — 새 그룹 · 그룹 이름 바꾸기 (디자인 원본 ContactsEdit: 폭 400 · 안쪽 여백 18 20 14) */
|
||||
.modal-dialog--medium {
|
||||
width: min(25rem, calc(100vw - var(--space-48)));
|
||||
padding: var(--space-18) var(--space-20) var(--space-14);
|
||||
}
|
||||
|
||||
.modal-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 화면의 연락처 추가 창 — 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」가 연 창(useMailContactDialog)을 그린다. 메일 레이아웃이 둔다.
|
||||
// 메일이 바뀌어도(뒤로 · 이전/다음 · 바탕화면 알림) 창이 남는다 — 메일 화면을 떠날 때만 창의 나가기 확인이 묻는다(ContactDialog 의 useDialogLeaveConfirm)
|
||||
// 닫으면 초점을 연 ⋯ 단추로 돌려준다(그 단추가 사라졌으면 — 다른 메일로 바뀜 — 그대로 둔다).
|
||||
// 세션이 바뀌면(로그아웃 · 만료 · 다른 계정 로그인) 닫는다 — 주소록 화면의 창 묶음과 같다
|
||||
import { nextTick, watch } from 'vue'
|
||||
|
||||
import ContactDialog from '@/components/contacts/ContactDialog.vue'
|
||||
import { useMailContactDialog } from '@/composables/useMailContactDialog'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const handle = useMailContactDialog()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function onClose() {
|
||||
const returnFocus = handle?.request.value?.returnFocus ?? null
|
||||
handle?.close()
|
||||
void nextTick(() => {
|
||||
const active = document.activeElement
|
||||
if (active === null || active === document.body || !active.isConnected) returnFocus?.()
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => authStore.generation,
|
||||
() => handle?.close(),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContactDialog
|
||||
v-if="handle?.request.value"
|
||||
:open="true"
|
||||
:contact="null"
|
||||
:prefill="handle.request.value.prefill"
|
||||
@close="onClose"
|
||||
/>
|
||||
</template>
|
||||
@@ -2,6 +2,8 @@
|
||||
// 본문 툴바 버튼 — 분할 보기 읽기 · 목록 보기 읽기가 같은 모양을 쓴다 (디자인 원본 Main · MainListRead)
|
||||
// 안쪽 좌우 여백은 --toolbar-button-inline(10px) — 툴바 안쪽 여백(22px)과 더해 첫 아이콘이 콘텐츠 열 32px 선에 온다.
|
||||
// 사용 예: <MailToolButton icon="reply" label="답장" :to="{ name: 'mail-compose', query: { replyTo: id } }" />
|
||||
// 누를 수 없는 까닭을 알려야 하면 disabled 대신 unavailable(aria-disabled — 초점은 받고 누르면 아무것도 하지 않는다) + describedby(까닭 글의 id).
|
||||
// 하는 중이면 busy(aria-busy — 누르면 아무것도 하지 않는다)
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -17,6 +19,12 @@ interface Props {
|
||||
/** accent: 강조 글자(목록으로) · danger: 삭제 */
|
||||
tone?: 'default' | 'accent' | 'danger'
|
||||
disabled?: boolean
|
||||
/** 누를 수 없지만 초점은 받는다 — aria-disabled (까닭은 describedby 로) */
|
||||
unavailable?: boolean
|
||||
/** 하는 중 — aria-busy, 누르면 아무것도 하지 않는다 */
|
||||
busy?: boolean
|
||||
/** 까닭 · 설명 글의 id — aria-describedby */
|
||||
describedby?: string
|
||||
iconStrokeWidth?: number
|
||||
}
|
||||
|
||||
@@ -24,15 +32,23 @@ interface Emits {
|
||||
(e: 'click', event: MouseEvent): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
to: null,
|
||||
href: null,
|
||||
tone: 'default',
|
||||
disabled: false,
|
||||
unavailable: false,
|
||||
busy: false,
|
||||
describedby: undefined,
|
||||
iconStrokeWidth: 1.75,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function onButtonClick(event: MouseEvent) {
|
||||
if (props.unavailable || props.busy) return
|
||||
emit('click', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,7 +82,10 @@ const emit = defineEmits<Emits>()
|
||||
type="button"
|
||||
:class="['tool-button', `tool-button--${tone}`]"
|
||||
:disabled="disabled"
|
||||
@click="emit('click', $event)"
|
||||
:aria-disabled="unavailable ? 'true' : undefined"
|
||||
:aria-busy="busy ? 'true' : undefined"
|
||||
:aria-describedby="describedby"
|
||||
@click="onButtonClick"
|
||||
>
|
||||
<AppIcon
|
||||
:name="icon"
|
||||
@@ -92,21 +111,26 @@ const emit = defineEmits<Emits>()
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tool-button:hover:not(:disabled) {
|
||||
.tool-button:hover:not(:disabled, [aria-disabled='true']) {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tool-button:disabled {
|
||||
.tool-button:disabled,
|
||||
.tool-button[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tool-button[aria-busy='true'] {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.tool-button--accent {
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-button--accent:hover:not(:disabled) {
|
||||
.tool-button--accent:hover:not(:disabled, [aria-disabled='true']) {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
@@ -114,7 +138,7 @@ const emit = defineEmits<Emits>()
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.tool-button--danger:hover:not(:disabled) {
|
||||
.tool-button--danger:hover:not(:disabled, [aria-disabled='true']) {
|
||||
background: var(--color-danger-surface);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// Esc 는 메뉴만 닫고 이 단추로 돌아간다. 항목을 고르면 메뉴를 닫고 이 단추에 초점을 둔 뒤 알린다 —
|
||||
// 대화상자가 열리면 닫힐 때 초점이 이 단추로 돌아온다
|
||||
// - 메뉴는 아래로 열되, 메일함 패널(스크롤되는 칸)의 보이는 영역 아래가 모자라면 위로 연다 — 아래쪽 줄의 메뉴가 잘리지 않게 (popup-placement.ts)
|
||||
// - 주소록 패널의 그룹 줄 ⋯ 도 이 단추를 쓴다(8단계 — 같은 32×40 밀도 예외) — 단추 · 메뉴 이름은 label · menuLabel 로 바꾼다
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
@@ -18,6 +19,10 @@ interface Props {
|
||||
/** 메일함 이름 — 단추 · 메뉴의 이름 */
|
||||
mailboxName: string
|
||||
items: readonly EditorMenuItem[]
|
||||
/** 단추 이름 — 주지 않으면 "{이름} 메일함 메뉴" (주소록 그룹 줄은 "‘거래처’ 그룹 메뉴") */
|
||||
label?: string
|
||||
/** 메뉴 이름 — 주지 않으면 "{이름} 메일함" */
|
||||
menuLabel?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -25,7 +30,10 @@ interface Emits {
|
||||
(e: 'open-change', open: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
label: undefined,
|
||||
menuLabel: undefined,
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 잘라 내는 overflow — 이 조상 밖은 보이지 않는다 */
|
||||
@@ -40,6 +48,8 @@ const byKeyboard = ref<boolean>(false)
|
||||
const upward = ref<boolean>(false)
|
||||
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
|
||||
const buttonLabel = computed(() => props.label ?? MAILBOX_PANEL_TEXT.more(props.mailboxName))
|
||||
const menuName = computed(() => props.menuLabel ?? MAILBOX_PANEL_TEXT.menu(props.mailboxName))
|
||||
|
||||
function setOpen(next: boolean) {
|
||||
if (open.value === next) return
|
||||
@@ -123,8 +133,8 @@ watch(
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
class="mailbox-more__button"
|
||||
:aria-label="MAILBOX_PANEL_TEXT.more(props.mailboxName)"
|
||||
:title="MAILBOX_PANEL_TEXT.more(props.mailboxName)"
|
||||
:aria-label="buttonLabel"
|
||||
:title="buttonLabel"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
data-mailbox-more
|
||||
@@ -140,7 +150,7 @@ watch(
|
||||
v-if="open"
|
||||
ref="menuRef"
|
||||
:class="['mailbox-more__menu', { 'mailbox-more__menu--up': upward }]"
|
||||
:label="MAILBOX_PANEL_TEXT.menu(props.mailboxName)"
|
||||
:label="menuName"
|
||||
:items="items"
|
||||
:trigger="trigger"
|
||||
align="end"
|
||||
|
||||
@@ -1,64 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
// 읽기 화면 도구 줄 끝의 ⋯ (더 보기 40×44) — 메뉴에 "원문 내려받기 (.eml)" (디자인 원본 MailActions4 · 계약 2절 "스팸 · 원문")
|
||||
// 과 그 아래 「이런 메일로 필터 만들기」(깔때기 — 디자인 원본 MailFilterStates · 계약 2절 "메일 필터"의 "클라이언트")
|
||||
// 그 아래 둘째 묶음에 「보낸 사람을 주소록에 추가」(8단계 — 디자인 원본 ContactsMail · 계약 3절 "메일과 함께") ·
|
||||
// 「이런 메일로 필터 만들기」(깔때기 — 디자인 원본 MailFilterStates · 계약 2절 "메일 필터"의 "클라이언트")
|
||||
// 메뉴는 이동 ▾ 과 같은 편집기 메뉴(EditorMenu — 항목 40)를 쓴다. 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에,
|
||||
// 마우스로 열면 메뉴 자체에 초점. Esc 는 메뉴만 닫고 이 단추로 돌아간다.
|
||||
// 원문 내려받기는 첨부 내려받기처럼 이동하기 전에 세션을 확인한다 (useMail 의 downloadMessageSource — confirmSession)
|
||||
// 주소록: 보낸 사람 주소가 내 주소록에 없으면 「보낸 사람을 주소록에 추가」 — 이름 · 주소를 채운 연락처 추가 창을 이 화면에서 연다
|
||||
// (창은 메일 레이아웃에 있다 — useMailContactDialog. 메일을 바꿔도 창이 남는다).
|
||||
// 있으면 「주소록에서 보기」 — 주소록의 그 연락처로. 판단은 메뉴를 열 때 알림 없이 하고(받아 둔 결과가 있으면 그것으로 두고 다시 본다),
|
||||
// 결과가 오기 전에는 「추가」로 둔다 (저장할 때 같은 주소가 있으면 창 안에 「이미 주소록에 있는 주소」가 보인다).
|
||||
// 연락처가 3,000개면 「추가」를 끄고 까닭을 아래에 (수를 모르거나 3,000개로 받아 두었으면 열 때 다시 받아 본다)
|
||||
// 필터 만들기는 설정 › 필터로 간다 — /settings/mail/filters?from=<보낸 사람 주소> (새 필터 창이 그 주소로 채워져 열린다).
|
||||
// 보낸 사람 주소가 없는 메일에는 두지 않는다. 필터를 50개 다 만들었으면 끄고 까닭을 아래에 보인다 —
|
||||
// 보낸 사람 주소가 없는 메일에는 두 항목을 두지 않는다. 필터를 50개 다 만들었으면 끄고 까닭을 아래에 보인다 —
|
||||
// 메뉴를 열 때 필터를 아직 받지 않았으면 받아 본다(알리지 않는다 — 모르는 동안은 켜 두고, 설정 화면이 다시 본다)
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import EditorMenu from '@/components/mail/editor/EditorMenu.vue'
|
||||
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { useMailContactDialog } from '@/composables/useMailContactDialog'
|
||||
import { CONTACT_READER_TEXT, CONTACTS_PANEL_TEXT } from '@/shared/constants/contacts'
|
||||
import { MAIL_READER_MORE_TEXT } from '@/shared/constants/mail'
|
||||
import { MAIL_FILTER_FROM_QUERY, MAIL_FILTER_TEXT } from '@/shared/constants/mail-filter'
|
||||
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
|
||||
import type { Contact } from '@/shared/types/contacts'
|
||||
import { contactLocation } from '@/shared/utils/contacts-route'
|
||||
import { normalizeEmail } from '@/shared/utils/mail-address'
|
||||
import { useContactsStore } from '@/stores/contacts.store'
|
||||
import { useMailFilterStore } from '@/stores/mail-filter.store'
|
||||
|
||||
interface Props {
|
||||
messageId: string
|
||||
/** 보낸 사람 주소 — 없으면 「이런 메일로 필터 만들기」를 두지 않는다 */
|
||||
/** 보낸 사람 주소 — 없으면 주소록 · 필터 항목을 두지 않는다 */
|
||||
senderEmail?: string | null
|
||||
/** 보낸 사람 표시 이름 — 주소록에 추가할 때 이름 칸을 채운다 */
|
||||
senderName?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
senderEmail: null,
|
||||
senderName: null,
|
||||
})
|
||||
|
||||
const { downloadMessageSource } = useMail()
|
||||
const filterStore = useMailFilterStore()
|
||||
const contactsStore = useContactsStore()
|
||||
const contactDialog = useMailContactDialog()
|
||||
const router = useRouter()
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const open = ref<boolean>(false)
|
||||
/** 키보드로 열었다 — 첫 항목에 초점 */
|
||||
const byKeyboard = ref<boolean>(false)
|
||||
/** 보낸 사람의 연락처 — 내 주소록에 있으면 그 연락처, 없거나 아직 모르면 null */
|
||||
const senderContact = ref<Contact | null>(null)
|
||||
let lookupSeq = 0
|
||||
|
||||
const sender = computed(() => props.senderEmail?.trim() ?? '')
|
||||
const items = computed<EditorMenuItem[]>(() => {
|
||||
const list: EditorMenuItem[] = [{ id: 'source', label: MAIL_READER_MORE_TEXT.downloadSource, icon: 'download' }]
|
||||
if (sender.value) {
|
||||
if (!sender.value) return list
|
||||
list.push(menuSeparator('sender'))
|
||||
if (senderContact.value) {
|
||||
list.push({ id: 'contact-view', label: CONTACT_READER_TEXT.view, icon: 'book' })
|
||||
} else {
|
||||
list.push({
|
||||
id: 'filter',
|
||||
label: MAIL_READER_MORE_TEXT.createFilter,
|
||||
icon: 'filter',
|
||||
disabled: filterStore.isFull,
|
||||
note: filterStore.isFull ? MAIL_FILTER_TEXT.fullNote : undefined,
|
||||
id: 'contact-add',
|
||||
label: CONTACT_READER_TEXT.add,
|
||||
icon: 'user-plus',
|
||||
disabled: contactsStore.isFull,
|
||||
note: contactsStore.isFull ? CONTACTS_PANEL_TEXT.fullNote : undefined,
|
||||
})
|
||||
}
|
||||
list.push({
|
||||
id: 'filter',
|
||||
label: MAIL_READER_MORE_TEXT.createFilter,
|
||||
icon: 'filter',
|
||||
disabled: filterStore.isFull,
|
||||
note: filterStore.isFull ? MAIL_FILTER_TEXT.fullNote : undefined,
|
||||
})
|
||||
return list
|
||||
})
|
||||
const trigger = computed<HTMLElement | null>(() => buttonRef.value)
|
||||
|
||||
/** 보낸 사람 주소가 내 주소록에 있는지 — 알림 없이. 모르게 되면(실패) 지난 결과를 둔다 */
|
||||
async function lookUpSender() {
|
||||
const email = sender.value
|
||||
lookupSeq += 1
|
||||
const seq = lookupSeq
|
||||
const found = await contactsStore.findContactByEmail(email)
|
||||
if (seq !== lookupSeq || sender.value !== email || found === undefined) return
|
||||
senderContact.value = found
|
||||
}
|
||||
|
||||
function openMenu(keyboard: boolean) {
|
||||
byKeyboard.value = keyboard
|
||||
open.value = true
|
||||
if (!sender.value) return
|
||||
// 필터를 50개 다 만들었는지 — 받아 둔 것이 없을 때만 받아 본다
|
||||
if (sender.value) filterStore.ensureLoaded()
|
||||
filterStore.ensureLoaded()
|
||||
// 주소록에 있는지 · 연락처가 3,000개인지 (알리지 않는다)
|
||||
void lookUpSender()
|
||||
contactsStore.ensureContactTotal()
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
@@ -85,10 +130,29 @@ function onSelect(id: string) {
|
||||
open.value = false
|
||||
buttonRef.value?.focus()
|
||||
if (id === 'source') void downloadMessageSource(props.messageId)
|
||||
if (id === 'contact-view' && senderContact.value) void router.push(contactLocation(senderContact.value.id))
|
||||
if (id === 'contact-add' && sender.value && !contactsStore.isFull) {
|
||||
contactDialog?.open({ name: props.senderName?.trim() || null, email: sender.value, label: 'work' }, focusButton)
|
||||
}
|
||||
if (id === 'filter' && sender.value && !filterStore.isFull) {
|
||||
void router.push({ name: SETTINGS_ROUTES.filters, query: { [MAIL_FILTER_FROM_QUERY]: sender.value } })
|
||||
}
|
||||
}
|
||||
|
||||
/** 연락처 창을 닫은 뒤 — 이 단추가 아직 있으면 초점을 돌려준다 (다른 메일로 바뀌어 단추가 사라졌으면 그대로) */
|
||||
function focusButton() {
|
||||
const button = buttonRef.value
|
||||
if (button?.isConnected) button.focus()
|
||||
}
|
||||
|
||||
// 다른 메일로 바뀌면 지난 보낸 사람의 결과를 버린다
|
||||
watch(
|
||||
() => normalizeEmail(sender.value),
|
||||
() => {
|
||||
lookupSeq += 1
|
||||
senderContact.value = null
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -146,7 +210,7 @@ function onSelect(id: string) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 메뉴 폭 200 (디자인 원본 MailActions4) — 필터 50개의 까닭(note)은 280 안에서 줄을 바꾼다 */
|
||||
/* 메뉴 폭 280 (디자인 원본 ContactsMail) — 필터 50개 · 연락처 3,000개의 까닭(note)은 그 안에서 줄을 바꾼다 */
|
||||
.reader-more__menu {
|
||||
min-width: 12.5rem;
|
||||
max-width: 17.5rem;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
import { defineComponent, h, provide } from 'vue'
|
||||
|
||||
import MailContactDialogHost from '@/components/mail/MailContactDialogHost.vue'
|
||||
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
|
||||
import type { Contact } from '@/shared/types/contacts'
|
||||
import type { Mailbox, MailFilterRule, MailFilterSet, MessageDetail, MessagesPatch } from '@/shared/types/mail'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
import type { MailListSource } from '@/shared/utils/mail-list-source'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -19,7 +24,7 @@ import {
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import MessageReader from './MessageReader.vue'
|
||||
|
||||
const DETAIL: MessageDetail = {
|
||||
@@ -50,6 +55,11 @@ const DETAIL: MessageDetail = {
|
||||
/** 필터가 없는 계정 — 읽기 화면 ⋯ 를 열면 50개 여부를 알려고 받는다 */
|
||||
const NO_FILTERS: MailFilterSet = { rules: [], version: 'v1', isActive: true }
|
||||
|
||||
/** 주소록 (8단계) — 읽기 화면 ⋯ 를 열면 보낸 사람이 주소록에 있는지 · 연락처 수를 알려고 받는다 */
|
||||
function contactPage(items: Contact[], total = items.length): PaginatedResult<Contact> {
|
||||
return { items, pageInfo: { hasNext: false, nextCursor: null, total } }
|
||||
}
|
||||
|
||||
describe('MessageReader 도구 모음', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
@@ -497,6 +507,12 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
refresh?: MockResult
|
||||
filters?: MailFilterSet
|
||||
from?: MessageDetail['from']
|
||||
/** GET /contacts?q=<보낸 사람> — 주소록에서 찾은 것 */
|
||||
contacts?: () => MockResult | Promise<MockResult>
|
||||
/** GET /contacts?limit=1 — 연락처 수 */
|
||||
contactTotal?: number
|
||||
/** 이동 가드가 걸리게 지금 경로를 준다 — 연락처 추가 창(나가기 확인)을 띄울 때 */
|
||||
routeView?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const box = options.box ?? 'inbox'
|
||||
@@ -504,6 +520,11 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
requests = installMockApi((request) => {
|
||||
if (request.url === '/auth/me') return options.me ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
if (request.url === '/mail-filters') return ok(options.filters ?? NO_FILTERS)
|
||||
if (request.url === '/contacts' && request.params.q === undefined) {
|
||||
return ok(contactPage([], options.contactTotal ?? 0))
|
||||
}
|
||||
if (request.url === '/contacts') return options.contacts?.() ?? ok(contactPage([]))
|
||||
if (request.url === '/contact-groups') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
if (request.url === '/auth/refresh') return options.refresh ?? ok({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
if (request.url === '/mailboxes') return ok({ items: mailboxes, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
|
||||
if (request.url === '/messages' && request.method === 'GET') {
|
||||
@@ -525,7 +546,17 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
const router = createTestRouter()
|
||||
await router.push(`/mail/${box}/m1`)
|
||||
await router.isReady()
|
||||
const wrapper = mount(MessageReader, { global: { plugins: [pinia, router] }, attachTo: document.body })
|
||||
// 메일 레이아웃처럼 — 읽기 화면 ⋯ 의 연락처 추가 창은 레이아웃이 그린다 (useMailContactDialog)
|
||||
const ReaderInLayout = defineComponent({
|
||||
setup() {
|
||||
provide(MAIL_CONTACT_DIALOG, createMailContactDialog())
|
||||
return () => h('div', [h(MessageReader), h(MailContactDialogHost)])
|
||||
},
|
||||
})
|
||||
const wrapper = mount(ReaderInLayout, {
|
||||
global: { plugins: [pinia, router], provide: options.routeView ? routeViewProvide(router) : {} },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, router, store }
|
||||
}
|
||||
|
||||
@@ -591,14 +622,29 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
await more.trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menu"] [role="menuitem"]')
|
||||
expect(items.map((item) => item.text())).toEqual(['원문 내려받기 (.eml)', '이런 메일로 필터 만들기'])
|
||||
expect(items.map((item) => item.text())).toEqual([
|
||||
'원문 내려받기 (.eml)',
|
||||
'보낸 사람을 주소록에 추가',
|
||||
'이런 메일로 필터 만들기',
|
||||
])
|
||||
// 원문 아래가 둘째 묶음 (디자인 원본 ContactsMail)
|
||||
expect(wrapper.findAll('[role="menu"] hr')).toHaveLength(1)
|
||||
expect(document.activeElement).toBe(items[0]?.element)
|
||||
|
||||
await items[0]?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// 메뉴를 열 때 필터를 한 번 받아 본다 (50개를 다 만들었는지 — 「이런 메일로 필터 만들기」)
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual(['GET /mail-filters', 'GET /auth/me'])
|
||||
// 메뉴를 열 때 필터(50개인지) · 주소록(보낸 사람이 있는지 · 연락처 수)을 알림 없이 한 번 받아 본다
|
||||
expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual([
|
||||
'GET /mail-filters',
|
||||
'GET /contacts',
|
||||
'GET /contacts',
|
||||
'GET /auth/me',
|
||||
])
|
||||
expect(requests.filter((request) => request.url === '/contacts').map((request) => request.params)).toEqual([
|
||||
{ q: 'doyun.lee@arasystem.it.kr', limit: 100 },
|
||||
{ limit: 1 },
|
||||
])
|
||||
expect(downloads).toEqual(['/api/messages/m1/source'])
|
||||
expect(wrapper.find('[role="menu"]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(more.element)
|
||||
@@ -621,7 +667,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
const item = wrapper.findAll('[role="menuitem"]')[1]
|
||||
const item = wrapper.findAll('[role="menuitem"]')[2]
|
||||
expect(item?.find('polygon').attributes('points')).toBe('22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3')
|
||||
expect(item?.attributes('disabled')).toBeUndefined()
|
||||
|
||||
@@ -647,7 +693,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
await wrapper.get('[data-reader-more]').trigger('keydown', { key: 'ArrowDown' })
|
||||
await flushPromises()
|
||||
const items = wrapper.findAll('[role="menuitem"]')
|
||||
const item = items[1]
|
||||
const item = items[2]
|
||||
// 꺼진 항목은 disabled 가 아니라 aria-disabled — 화살표로 가서 초점을 받는다
|
||||
expect(item?.attributes('aria-disabled')).toBe('true')
|
||||
expect(item?.attributes('disabled')).toBeUndefined()
|
||||
@@ -656,6 +702,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
expect(item?.attributes('aria-describedby')).toBe(note?.attributes('id'))
|
||||
expect(document.activeElement).toBe(items[0]?.element)
|
||||
await items[0]?.trigger('keydown', { key: 'ArrowDown' })
|
||||
await items[1]?.trigger('keydown', { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(item?.element)
|
||||
|
||||
await item?.trigger('click')
|
||||
@@ -673,7 +720,7 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
},
|
||||
})
|
||||
const more = wrapper.get('[data-reader-more]')
|
||||
const filterItem = () => wrapper.findAll('[role="menuitem"]')[1]
|
||||
const filterItem = () => wrapper.findAll('[role="menuitem"]')[2]
|
||||
const filterGets = () => requests.filter((request) => request.url === '/mail-filters')
|
||||
|
||||
await more.trigger('click')
|
||||
@@ -696,13 +743,135 @@ describe('MessageReader 스팸 신고 · 원문 내려받기 (4단계 — 디자
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('보낸 사람 주소가 없는 메일에는 「이런 메일로 필터 만들기」를 두지 않는다 (필터도 받지 않는다)', async () => {
|
||||
it('보낸 사람 주소가 없는 메일에는 주소록 · 필터 항목을 두지 않는다 (필터 · 주소록도 받지 않는다)', async () => {
|
||||
const { wrapper } = await mountWith({ from: null })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('[role="menuitem"]').map((item) => item.text())).toEqual(['원문 내려받기 (.eml)'])
|
||||
expect(wrapper.find('[role="menu"] hr').exists()).toBe(false)
|
||||
expect(requests.some((request) => request.url === '/mail-filters')).toBe(false)
|
||||
expect(requests.some((request) => request.url === '/contacts')).toBe(false)
|
||||
})
|
||||
|
||||
describe('보낸 사람을 주소록에 (8단계 — 디자인 원본 ContactsMail · 계약 3절 "메일과 함께")', () => {
|
||||
const DOYUN_CONTACT: Contact = {
|
||||
id: 'c7',
|
||||
name: '이도윤',
|
||||
emails: [{ address: 'Doyun.Lee@arasystem.it.kr', label: 'work' }],
|
||||
phones: [],
|
||||
company: null,
|
||||
department: null,
|
||||
title: null,
|
||||
note: null,
|
||||
groupIds: [],
|
||||
}
|
||||
const contactItem = (wrapper: ReturnType<typeof mount>) => wrapper.findAll('[role="menuitem"]')[1]
|
||||
|
||||
it('내 주소록에 없으면 「보낸 사람을 주소록에 추가」 — 보낸 사람의 이름 · 주소를 채운 연락처 추가 창을 이 화면에서 연다', async () => {
|
||||
const { wrapper, router } = await mountWith({ routeView: true })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(contactItem(wrapper)?.text()).toBe('보낸 사람을 주소록에 추가')
|
||||
await contactItem(wrapper)?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
expect(wrapper.get('#contact-dialog').attributes('open')).toBeDefined()
|
||||
expect(wrapper.get('#contact-dialog-title').text()).toBe('연락처 추가')
|
||||
expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('이도윤')
|
||||
const email = wrapper.get('[id^="contact-email-"]').element as HTMLInputElement
|
||||
expect(email.value).toBe('doyun.lee@arasystem.it.kr')
|
||||
// 채운 그대로 [취소] — 바꾼 것이 없으니 묻지 않고 닫고, 초점은 ⋯ 로 돌아온다
|
||||
await wrapper.get('#contact-cancel').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#contact-dialog').exists()).toBe(false)
|
||||
expect(wrapper.find('#contact-leave-dialog[open]').exists()).toBe(false)
|
||||
expect(document.activeElement).toBe(wrapper.get('[data-reader-more]').element)
|
||||
})
|
||||
|
||||
it('메일을 바꿔도(이전/다음 · 뒤로 · 바탕화면 알림 · 다시 받는 동안) 연락처 추가 창은 남는다 — 창은 메일 레이아웃에 있다', async () => {
|
||||
const { wrapper, router, store } = await mountWith({ routeView: true })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
await contactItem(wrapper)?.trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('#contact-title').setValue('수석')
|
||||
|
||||
// 다음 메일을 받는 동안(선택이 비었다) — 읽기 화면의 도구 줄 · ⋯ 가 사라진다
|
||||
store.selected = null
|
||||
store.selectedId = 'm2'
|
||||
store.selectedStatus = 'loading'
|
||||
await router.push('/mail/inbox/m2')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-reader-more]').exists()).toBe(false)
|
||||
expect(wrapper.get('#contact-dialog').attributes('open')).toBeDefined()
|
||||
expect((wrapper.get('#contact-title').element as HTMLInputElement).value).toBe('수석')
|
||||
expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('이도윤')
|
||||
|
||||
store.selected = { ...DETAIL, id: 'm2', subject: '다음 메일', from: { name: '박민수', email: 'minsu.park@arasystem.it.kr' } }
|
||||
store.selectedStatus = 'ready'
|
||||
await flushPromises()
|
||||
expect((wrapper.get('#contact-name').element as HTMLInputElement).value).toBe('이도윤')
|
||||
expect(wrapper.find('#contact-leave-dialog[open]').exists()).toBe(false)
|
||||
|
||||
// 세션이 끝나면 닫는다 (주소록 화면의 창 묶음과 같다)
|
||||
useAuthStore().expireSession()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#contact-dialog').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('내 주소록에 있으면(대소문자 무시) 「주소록에서 보기」 — 그 연락처를 고른 주소록으로 간다', async () => {
|
||||
const { wrapper, router } = await mountWith({ contacts: () => ok(contactPage([DOYUN_CONTACT])) })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(contactItem(wrapper)?.text()).toBe('주소록에서 보기')
|
||||
await contactItem(wrapper)?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('contacts-contact')
|
||||
expect(router.currentRoute.value.params).toEqual({ contactId: 'c7' })
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('결과가 오기 전에는 「보낸 사람을 주소록에 추가」로 둔다 — 오면 바꾼다 · 못 받으면 그대로 (알리지 않는다)', async () => {
|
||||
let finish: (result: MockResult) => void = () => undefined
|
||||
const late = new Promise<MockResult>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const { wrapper } = await mountWith({ contacts: () => late })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(contactItem(wrapper)?.text()).toBe('보낸 사람을 주소록에 추가')
|
||||
finish(ok(contactPage([DOYUN_CONTACT])))
|
||||
await flushPromises()
|
||||
expect(contactItem(wrapper)?.text()).toBe('주소록에서 보기')
|
||||
|
||||
const failing = await mountWith({ contacts: () => fail(503, 'MAIL_001') })
|
||||
await failing.wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(failing.wrapper.findAll('[role="menuitem"]')[1]?.text()).toBe('보낸 사람을 주소록에 추가')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('연락처가 3,000개면 「보낸 사람을 주소록에 추가」를 끄고 이름 아래에 까닭 — 골라도 창을 열지 않는다', async () => {
|
||||
const { wrapper } = await mountWith({ contactTotal: 3000 })
|
||||
|
||||
await wrapper.get('[data-reader-more]').trigger('click')
|
||||
await flushPromises()
|
||||
const item = contactItem(wrapper)
|
||||
expect(item?.attributes('aria-disabled')).toBe('true')
|
||||
expect(item?.get('.menu__note').text()).toBe(
|
||||
'연락처는 3,000개까지 둘 수 있습니다. 쓰지 않는 연락처를 지운 뒤 추가해 주세요.',
|
||||
)
|
||||
await item?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#contact-dialog').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 본문 HTML 은 MailBodyFrame(격리된 iframe)에만 그린다.
|
||||
// 툴바와 본문은 콘텐츠 열(tokens.css --content-*)을 따른다 — 분할 보기 읽기 · 목록 보기 읽기 · 쓰기 공통.
|
||||
// 검색 결과에서 연 메일은 받는 사람 아래에 "메일함 받은편지함" 한 줄을 더한다 (디자인 README "3단계 › 검색").
|
||||
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기 · 이런 메일로 필터 만들기)
|
||||
// 도구 줄: 답장 · 전체 답장 · 전달 | 스팸 신고(스팸메일함에서는 스팸 아님) · 삭제 · ⋯(더 보기 — 원문 내려받기 · 보낸 사람을 주소록에 추가(8단계) · 이런 메일로 필터 만들기)
|
||||
// (디자인 README "4단계 › 스팸 · 원문" · "6단계 › 상태 · 들어오는 길").
|
||||
// 「삭제」 · 「영구 삭제」는 MessageDeleteButton — 휴지통인지는 그 메일의 메일함으로 정한다. 「스팸 신고」는 MessageSpamButton, ⋯ 는 MessageMoreMenu.
|
||||
// 삭제 뒤 · 없는 메일에서 돌아가는 곳은 지금 목록(메일함 또는 검색 결과)이다.
|
||||
@@ -114,6 +114,7 @@ function leaveToList() {
|
||||
<MessageMoreMenu
|
||||
:message-id="message.id"
|
||||
:sender-email="message.from?.email ?? null"
|
||||
:sender-name="message.from?.name ?? null"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -3,39 +3,56 @@ import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import { RECIPIENT_SUGGESTIONS, type RecipientSuggestionsHandle } from '@/composables/useRecipientSuggestions'
|
||||
import type { ContactGroup } from '@/shared/types/contacts'
|
||||
import type { RecipientSuggestion } from '@/shared/types/mail'
|
||||
import { normalizeEmail, type RecipientToken } from '@/shared/utils/mail-address'
|
||||
import { filterRecipientSuggestions } from '@/shared/utils/recipient-match'
|
||||
import type { GroupRecipients } from '@/shared/utils/contacts'
|
||||
import { normalizeEmail, tokenFromAddress, type RecipientToken } from '@/shared/utils/mail-address'
|
||||
import {
|
||||
arrangeRecipientOptions,
|
||||
filterRecipientSuggestions,
|
||||
matchContactGroups,
|
||||
} from '@/shared/utils/recipient-match'
|
||||
import { flushPromises } from '@/test-utils/mock-api'
|
||||
import RecipientField from './RecipientField.vue'
|
||||
|
||||
/** v-model 처럼 갱신 이벤트를 받아 다시 props 로 넣는다 — 마지막 값은 models 에서 읽는다 */
|
||||
const models = new WeakMap<VueWrapper, RecipientToken[]>()
|
||||
|
||||
/** 쓰기 화면이 주는 추천 — 받아 둔 목록에서 거르고, 이미 넣은 주소(이 칸의 토큰)는 뺀다 */
|
||||
function suggestionsOf(pool: RecipientSuggestion[]) {
|
||||
/**
|
||||
* 쓰기 화면이 주는 추천 — 받아 둔 목록에서 거르고, 이미 넣은 주소(이 칸의 토큰 + others — 다른 칸에 넣은 주소)는 뺀다.
|
||||
* 그룹(8단계)은 groups 로 받아 두고, 구성원은 expansions 에서 꺼낸다(없으면 받지 못한 것 — null)
|
||||
*/
|
||||
function suggestionsOf(pool: RecipientSuggestion[], groupList: ContactGroup[] = []) {
|
||||
const items = shallowRef(pool)
|
||||
const groups = shallowRef(groupList)
|
||||
const searches: string[] = []
|
||||
const preloads: number[] = []
|
||||
const expansions = new Map<string, () => Promise<GroupRecipients | null>>()
|
||||
const others = new Set<string>()
|
||||
let current: () => RecipientToken[] = () => []
|
||||
const excluded = () =>
|
||||
new Set([...others, ...current().map((token) => normalizeEmail(token.address.email))])
|
||||
const handle: RecipientSuggestionsHandle = {
|
||||
preload: () => {
|
||||
preloads.push(preloads.length + 1)
|
||||
},
|
||||
matchesFor: (query) =>
|
||||
filterRecipientSuggestions(
|
||||
query,
|
||||
items.value,
|
||||
new Set(current().map((token) => normalizeEmail(token.address.email))),
|
||||
),
|
||||
matchesFor: (query) => filterRecipientSuggestions(query, items.value, excluded()),
|
||||
optionsFor: (query) =>
|
||||
arrangeRecipientOptions(handle.matchesFor(query), matchContactGroups(query, groups.value)),
|
||||
search: (query) => {
|
||||
searches.push(query)
|
||||
},
|
||||
expandGroup: (group) => expansions.get(group.id)?.() ?? Promise.resolve(null),
|
||||
isAdded: (email) => excluded().has(normalizeEmail(email)),
|
||||
}
|
||||
return {
|
||||
handle,
|
||||
items,
|
||||
groups,
|
||||
searches,
|
||||
preloads,
|
||||
expansions,
|
||||
others,
|
||||
bind: (tokens: () => RecipientToken[]) => {
|
||||
current = tokens
|
||||
},
|
||||
@@ -389,4 +406,223 @@ describe('RecipientField', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('주소록 · 그룹 (8단계 — 디자인 원본 ContactsMail)', () => {
|
||||
const JIHOON_WORK: RecipientSuggestion = {
|
||||
name: '박지훈',
|
||||
email: 'jihoon.park@hanbit.example',
|
||||
source: 'contact',
|
||||
company: '(주)한빛상사',
|
||||
emailLabel: 'work',
|
||||
}
|
||||
const JIHOON_PERSONAL: RecipientSuggestion = {
|
||||
name: '박지훈',
|
||||
email: 'jihoon.p@mail.example',
|
||||
source: 'contact',
|
||||
company: '(주)한빛상사',
|
||||
emailLabel: 'personal',
|
||||
}
|
||||
const JUNHO: RecipientSuggestion = { name: '박준호', email: 'junho.park@arasystem.it.kr', source: 'directory' }
|
||||
const SOYOUNG: RecipientSuggestion = { name: '박소영', email: 'soyoung.park@print.example', source: 'recent' }
|
||||
const VENDORS: ContactGroup = { id: 'g1', name: '거래처', memberCount: 4 }
|
||||
const EMPTY_GROUP: ContactGroup = { id: 'g2', name: '거래 준비', memberCount: 0 }
|
||||
|
||||
async function typed(wrapper: VueWrapper, text: string) {
|
||||
const input = wrapper.get('input')
|
||||
;(input.element as HTMLInputElement).focus()
|
||||
await input.trigger('focus')
|
||||
await input.setValue(text)
|
||||
return input
|
||||
}
|
||||
|
||||
function optionTexts(wrapper: VueWrapper): string[] {
|
||||
return wrapper.findAll('[role="option"]').map((option) =>
|
||||
option
|
||||
.findAll('.recipient-suggestions__name, .recipient-suggestions__email')
|
||||
.map((part) => part.text())
|
||||
.join(' '),
|
||||
)
|
||||
}
|
||||
|
||||
const status = (wrapper: VueWrapper) => wrapper.get('#compose-to-group-status').text()
|
||||
|
||||
function deferred() {
|
||||
let finish: (value: GroupRecipients | null) => void = () => undefined
|
||||
const promise = new Promise<GroupRecipients | null>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
return { promise, finish }
|
||||
}
|
||||
|
||||
it('묶음은 주소록 → 그룹 → 사내 → 최근 — 주소록은 주소마다 한 줄, 회사 주소는 "주소 · 회사" · 그 밖은 "주소 · 개인"', async () => {
|
||||
const wrapper = mountField([], suggestionsOf([SOYOUNG, JUNHO, JIHOON_WORK, JIHOON_PERSONAL], [VENDORS]))
|
||||
await typed(wrapper, '박')
|
||||
|
||||
expect(wrapper.findAll('[role="group"]').map((group) => group.attributes('aria-label'))).toEqual([
|
||||
'주소록',
|
||||
'사내',
|
||||
'최근 주고받은 주소',
|
||||
])
|
||||
expect(optionTexts(wrapper)).toEqual([
|
||||
'박지훈 jihoon.park@hanbit.example · (주)한빛상사',
|
||||
'박지훈 jihoon.p@mail.example · 개인',
|
||||
'박준호 junho.park@arasystem.it.kr',
|
||||
'박소영 soyoung.park@print.example',
|
||||
])
|
||||
// 화살표 차례도 묶음 차례 그대로 — 처음 골라 둔 것은 주소록의 첫 줄
|
||||
expect(wrapper.get('[role="option"][aria-selected="true"]').attributes('id')).toBe('compose-to-option-0')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('그룹 이름을 치면 그룹 줄 — 아이콘 + 이름(맞는 글자 굵게) + "4명 · 고르면 한꺼번에 넣습니다", 초성도 된다 · 구성원이 없는 그룹은 없다', async () => {
|
||||
const wrapper = mountField([], suggestionsOf([JUNHO], [VENDORS, EMPTY_GROUP]))
|
||||
await typed(wrapper, 'ㄱㄹ')
|
||||
|
||||
expect(wrapper.findAll('[role="group"]').map((group) => group.attributes('aria-label'))).toEqual(['그룹'])
|
||||
const option = wrapper.get('[role="option"]')
|
||||
expect(option.attributes('data-suggestion-group')).toBeDefined()
|
||||
expect(option.find('svg').exists()).toBe(true)
|
||||
expect(option.get('.recipient-suggestions__name strong').text()).toBe('거래')
|
||||
expect(option.get('.recipient-suggestions__email').text()).toBe('4명 · 고르면 한꺼번에 넣습니다')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('그룹을 고르면 구성원의 대표 주소를 칩으로 넣고 칸 아래에 알린다 — 이메일이 없는 구성원 · 이미 넣은 주소(다른 칸 포함)는 뺀다', async () => {
|
||||
const suggestions = suggestionsOf([], [VENDORS])
|
||||
const reply = deferred()
|
||||
suggestions.expansions.set('g1', () => reply.promise)
|
||||
suggestions.others.add('minho.jung@hanbit.example')
|
||||
const seoyun = tokenFromAddress({ name: '강서윤', email: 'seoyun.kang@hanbit.example' })
|
||||
const wrapper = mountField([seoyun], suggestions)
|
||||
const input = await typed(wrapper, '거래')
|
||||
|
||||
const enter = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })
|
||||
input.element.dispatchEvent(enter)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(enter.defaultPrevented).toBe(true)
|
||||
expect((input.element as HTMLInputElement).value).toBe('')
|
||||
expect(status(wrapper)).toBe('‘거래처’ 그룹의 구성원을 불러오는 중…')
|
||||
|
||||
reply.finish({
|
||||
addresses: [
|
||||
{ name: '강서윤', email: 'seoyun.kang@hanbit.example' },
|
||||
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
|
||||
{ name: '정민호', email: 'minho.jung@hanbit.example' },
|
||||
{ name: '최유진', email: 'yujin.choi@greencloud.example' },
|
||||
],
|
||||
withoutEmail: 1,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual([
|
||||
'강서윤 <seoyun.kang@hanbit.example>',
|
||||
'박지훈 <jihoon.park@hanbit.example>',
|
||||
'최유진 <yujin.choi@greencloud.example>',
|
||||
])
|
||||
expect(status(wrapper)).toBe(
|
||||
'‘거래처’ 그룹의 2명을 받는 사람에 넣었습니다. 주소가 없는 1명 · 이미 넣은 2명은 뺐습니다.',
|
||||
)
|
||||
expect(wrapper.get('#compose-to-group-status').attributes('role')).toBe('status')
|
||||
|
||||
// 다시 치기 시작하면 칸 아래 글을 지운다
|
||||
await input.setValue('박')
|
||||
expect(status(wrapper)).toBe('')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('구성원을 받지 못하면 칸 아래 글을 지운다 (알림은 인터셉터) · 넣을 주소가 없으면 그렇게 알린다', async () => {
|
||||
const suggestions = suggestionsOf([], [VENDORS])
|
||||
const wrapper = mountField([], suggestions)
|
||||
let input = await typed(wrapper, '거래처')
|
||||
await wrapper.get('[role="option"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(status(wrapper)).toBe('')
|
||||
expect(tokensOf(wrapper)).toEqual([])
|
||||
|
||||
suggestions.expansions.set('g1', () => Promise.resolve({ addresses: [], withoutEmail: 4 }))
|
||||
input = await typed(wrapper, '거래처')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
expect(status(wrapper)).toBe('‘거래처’ 그룹에서 받는 사람에 더 넣을 주소가 없습니다. 주소가 없는 4명은 뺐습니다.')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('그룹을 이어 고르면 그룹마다 따로 기다려 끝나는 대로 넣는다 — 앞 그룹을 버리지 않는다 · 칸 아래 글은 받는 중인 마지막 그룹, 다 받으면 마지막에 끝난 그룹', async () => {
|
||||
const ALPHA: ContactGroup = { id: 'g3', name: '프로젝트 알파', memberCount: 2 }
|
||||
const suggestions = suggestionsOf([], [VENDORS, ALPHA])
|
||||
const vendors = deferred()
|
||||
const alpha = deferred()
|
||||
suggestions.expansions.set('g1', () => vendors.promise)
|
||||
suggestions.expansions.set('g3', () => alpha.promise)
|
||||
const wrapper = mountField([], suggestions)
|
||||
|
||||
let input = await typed(wrapper, '거래처')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
input = await typed(wrapper, '프로젝트')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
expect(status(wrapper)).toBe('‘프로젝트 알파’ 그룹의 구성원을 불러오는 중…')
|
||||
|
||||
// 나중에 고른 그룹이 먼저 끝났다 — 넣고, 아직 받는 앞 그룹의 글을 둔다
|
||||
alpha.finish({ addresses: [{ name: '최유진', email: 'yujin.choi@greencloud.example' }], withoutEmail: 0 })
|
||||
await flushPromises()
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['최유진 <yujin.choi@greencloud.example>'])
|
||||
expect(status(wrapper)).toBe('‘거래처’ 그룹의 구성원을 불러오는 중…')
|
||||
|
||||
vendors.finish({
|
||||
addresses: [
|
||||
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
|
||||
{ name: '최유진', email: 'yujin.choi@greencloud.example' },
|
||||
],
|
||||
withoutEmail: 0,
|
||||
})
|
||||
await flushPromises()
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual([
|
||||
'최유진 <yujin.choi@greencloud.example>',
|
||||
'박지훈 <jihoon.park@hanbit.example>',
|
||||
])
|
||||
expect(status(wrapper)).toBe('‘거래처’ 그룹의 1명을 받는 사람에 넣었습니다. 이미 넣은 1명은 뺐습니다.')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('두 그룹이 같은 틱에 끝나도 앞 그룹의 칩을 잃지 않는다 (아직 돌아오지 않은 값 위에 넣는다)', async () => {
|
||||
const ALPHA: ContactGroup = { id: 'g3', name: '프로젝트 알파', memberCount: 1 }
|
||||
const suggestions = suggestionsOf([], [VENDORS, ALPHA])
|
||||
const vendors = deferred()
|
||||
const alpha = deferred()
|
||||
suggestions.expansions.set('g1', () => vendors.promise)
|
||||
suggestions.expansions.set('g3', () => alpha.promise)
|
||||
const wrapper = mountField([], suggestions)
|
||||
let input = await typed(wrapper, '거래처')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
input = await typed(wrapper, '프로젝트')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
|
||||
vendors.finish({ addresses: [{ name: '박지훈', email: 'jihoon.park@hanbit.example' }], withoutEmail: 0 })
|
||||
alpha.finish({ addresses: [{ name: '최유진', email: 'yujin.choi@greencloud.example' }], withoutEmail: 0 })
|
||||
await flushPromises()
|
||||
|
||||
expect(tokensOf(wrapper).map((token) => token.label)).toEqual([
|
||||
'박지훈 <jihoon.park@hanbit.example>',
|
||||
'최유진 <yujin.choi@greencloud.example>',
|
||||
])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('구성원을 기다리는 사이 쓰기 화면이 양식을 새로 채우면(clearDraft) 늦게 온 구성원은 넣지 않는다', async () => {
|
||||
const suggestions = suggestionsOf([], [VENDORS])
|
||||
const reply = deferred()
|
||||
suggestions.expansions.set('g1', () => reply.promise)
|
||||
const wrapper = mountField([], suggestions)
|
||||
const input = await typed(wrapper, '거래')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
|
||||
;(wrapper.vm as unknown as { clearDraft: () => void }).clearDraft()
|
||||
reply.finish({ addresses: [{ name: '박지훈', email: 'jihoon.park@hanbit.example' }], withoutEmail: 0 })
|
||||
await flushPromises()
|
||||
|
||||
expect(tokensOf(wrapper)).toEqual([])
|
||||
expect(status(wrapper)).toBe('')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
// 형식이 틀린 주소는 빨간 물결 밑줄로 바로 표시한다. 오류 문구(MAIL_002)는 쓰기 화면이 사전에서 가져와 보인다.
|
||||
// 서버가 받는 사람을 거절하면(MAIL_002 · MAIL_010 — rejected) 주소가 든 칸은 형식이 맞아도 잘못된 입력으로 두고 같은 안내 띠를 가리킨다
|
||||
// (어느 주소인지는 모른다 — 밑줄은 긋지 않는다).
|
||||
// 자동 완성 (디자인 원본 ComposeAssist · README "2단계 › 받는 사람 자동 완성") — 쓰기 화면이 추천(RECIPIENT_SUGGESTIONS)을 주면:
|
||||
// - 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8) — "사내" · "최근 주고받은 주소" 두 묶음, 맞는 글자는 굵게
|
||||
// 자동 완성 (디자인 원본 ComposeAssist · ContactsMail · README "2단계 › 받는 사람 자동 완성" · "8단계 › 메일과 함께") — 쓰기 화면이 추천(RECIPIENT_SUGGESTIONS)을 주면:
|
||||
// - 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8) — "주소록" · "그룹" · "사내" · "최근 주고받은 주소" 묶음, 맞는 글자는 굵게
|
||||
// - 그룹 줄을 고르면 구성원의 대표 주소를 칩으로 넣고 칸 아래에 "‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다." — 이메일이 없는 구성원 ·
|
||||
// 이미 넣은 주소(받는 사람 · 참조 · 숨은참조 모두)는 빼고 뺀 것을 덧붙인다. 구성원을 받는 동안은 "…불러오는 중…". 다시 치면 그 글을 지운다.
|
||||
// 그룹을 이어 고르면 그룹마다 따로 기다려 끝나는 대로 넣는다(앞 그룹을 버리지 않는다). 칸 아래 글은 받는 중인 마지막 그룹, 다 받았으면 마지막에 끝난 그룹의 것.
|
||||
// 양식을 새로 채우면(clearDraft) 기다리던 그룹을 모두 버린다
|
||||
// - ↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기 · 눌러 넣기. 처음에는 첫 추천을 골라 둔다 —
|
||||
// 친 글자가 완전한 메일 주소면 고르지 않는다: Enter 는 친 주소를 그대로 넣고, 화살표로 고른 뒤에만 추천이 들어간다.
|
||||
// Shift+Tab 은 추천을 넣지 않는다 (원래대로 앞 칸으로 — 칸을 벗어나며 친 글자를 확정한다)
|
||||
@@ -14,7 +18,7 @@
|
||||
// 목록은 RecipientSuggestionList 가 그린다 — 고르기 · 넣기 키는 이 입력칸이 받는다(초점은 늘 입력칸).
|
||||
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
|
||||
// 사용 예: <RecipientField v-model="to" label="받는 사람" input-id="compose-to" error-id="compose-recipient-error" />
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import RecipientSuggestionList from '@/components/mail/RecipientSuggestionList.vue'
|
||||
@@ -24,6 +28,8 @@ import {
|
||||
suggestionOptionId,
|
||||
} from '@/components/mail/recipient-suggestion-ids'
|
||||
import { RECIPIENT_SUGGESTIONS } from '@/composables/useRecipientSuggestions'
|
||||
import { RECIPIENT_CONTACT_TEXT } from '@/shared/constants/contacts'
|
||||
import { groupInsertedText } from '@/shared/utils/contacts'
|
||||
import {
|
||||
hasOpenRecipientGroup,
|
||||
isValidEmail,
|
||||
@@ -35,7 +41,7 @@ import {
|
||||
type RecipientToken,
|
||||
} from '@/shared/utils/mail-address'
|
||||
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
|
||||
import type { RecipientMatch } from '@/shared/utils/recipient-match'
|
||||
import type { RecipientGroupMatch, RecipientOption } from '@/shared/utils/recipient-match'
|
||||
|
||||
interface Props {
|
||||
modelValue: RecipientToken[]
|
||||
@@ -79,6 +85,21 @@ const focused = ref<boolean>(false)
|
||||
const dismissed = ref<boolean>(false)
|
||||
/** 고른 추천 — -1 이면 고르지 않았다 (Enter 는 친 글자를 넣는다) */
|
||||
const activeIndex = ref<number>(-1)
|
||||
/** 그룹을 넣은 뒤(또는 구성원을 받는 동안) 칸 아래 글 — 다시 치면 지운다 */
|
||||
const groupStatus = ref<string>('')
|
||||
/** 양식 차례 — 양식을 새로 채우면(clearDraft) 올라 기다리던 그룹 넣기를 모두 버린다 (그룹을 고르는 것으로는 오르지 않는다) */
|
||||
let draftEpoch = 0
|
||||
/** 구성원을 받는 중인 그룹 — 고른 차례대로 (번호 → 그룹 이름) */
|
||||
const pendingGroups = new Map<number, string>()
|
||||
let groupPickCount = 0
|
||||
/** 마지막에 끝난 그룹 넣기의 글 — 받는 중인 그룹이 없을 때 칸 아래에 보인다 */
|
||||
let lastGroupResult = ''
|
||||
/**
|
||||
* 내보냈지만 아직 v-model 로 돌아오지 않은 값 — 두 그룹이 같은 틱에 끝나도 앞 그룹의 칩을 잃지 않게
|
||||
* (부모가 값을 바꿔 다시 그리기 전에는 props.modelValue 가 옛 값이다)
|
||||
*/
|
||||
let emittedTokens: RecipientToken[] | null = null
|
||||
let unmounted = false
|
||||
|
||||
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
|
||||
/** 잘못된 입력 — 형식이 틀린 주소가 있거나, 서버가 거절한 받는 사람이 이 칸에 들었을 수 있다 (rejected · 주소가 있는 칸) */
|
||||
@@ -88,10 +109,11 @@ const hasDraft = computed(() => draft.value.trim() !== '' || query.value.trim()
|
||||
|
||||
const listId = computed(() => suggestionListId(props.inputId))
|
||||
const hintId = computed(() => suggestionHintId(props.inputId))
|
||||
const matches = computed<RecipientMatch[]>(() => (suggestions ? suggestions.matchesFor(query.value) : []))
|
||||
const statusId = computed(() => `${props.inputId}-group-status`)
|
||||
const options = computed<RecipientOption[]>(() => (suggestions ? suggestions.optionsFor(query.value) : []))
|
||||
const listOpen = computed(
|
||||
() =>
|
||||
focused.value && !props.disabled && !dismissed.value && query.value.trim() !== '' && matches.value.length > 0,
|
||||
focused.value && !props.disabled && !dismissed.value && query.value.trim() !== '' && options.value.length > 0,
|
||||
)
|
||||
/** 친 글자가 이미 완전한 메일 주소다 — 추천을 미리 고르지 않는다 */
|
||||
const typedFullAddress = computed(() => isValidEmail(query.value))
|
||||
@@ -110,21 +132,76 @@ function commit(text: string = draft.value) {
|
||||
const tokens = splitRecipientInput(text)
|
||||
.map(parseRecipient)
|
||||
.filter((token): token is RecipientToken => token !== null)
|
||||
clearDraft()
|
||||
clearInput()
|
||||
if (tokens.length === 0) return
|
||||
emit('update:modelValue', mergeRecipientTokens(props.modelValue, tokens))
|
||||
}
|
||||
|
||||
/** 추천을 넣는다 — "이름 <주소>" 토큰으로. 초점은 입력칸에 남아 다음 주소를 이어 친다 */
|
||||
function pick(match: RecipientMatch) {
|
||||
const { name, email } = match.suggestion
|
||||
clearDraft()
|
||||
/** 칸 아래 글 — 받는 중인 그룹이 있으면 마지막으로 고른 그룹의 "불러오는 중", 없으면 마지막에 끝난 그룹의 결과 */
|
||||
function showGroupStatus() {
|
||||
const loading = [...pendingGroups.values()].at(-1)
|
||||
groupStatus.value = loading ? RECIPIENT_CONTACT_TEXT.groupLoading(loading) : lastGroupResult
|
||||
}
|
||||
|
||||
/** 지금 칸의 값 — 내보낸 값이 아직 돌아오지 않았으면 그것 */
|
||||
function currentTokens(): RecipientToken[] {
|
||||
return emittedTokens ?? props.modelValue
|
||||
}
|
||||
|
||||
/** 그룹의 칩을 넣는다 — 다음 그리기가 끝나면(nextTick) props 가 따라잡는다 */
|
||||
function emitTokens(tokens: RecipientToken[]) {
|
||||
emittedTokens = tokens
|
||||
emit('update:modelValue', tokens)
|
||||
void nextTick(() => {
|
||||
emittedTokens = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹을 넣는다 — 구성원을 받아 대표 주소를 칩으로. 이메일이 없는 구성원 · 이미 넣은 주소(세 칸 모두)는 빼고 칸 아래에 알린다.
|
||||
* 받지 못하면(인터셉터가 알렸다) 그 그룹의 글을 지운다. 그룹마다 따로 기다려 끝나는 대로 넣는다 — 양식을 새로 채웠으면(clearDraft) 결과를 버린다
|
||||
*/
|
||||
async function pickGroup(match: RecipientGroupMatch) {
|
||||
if (!suggestions) return
|
||||
const { group } = match
|
||||
clearInput()
|
||||
const epoch = draftEpoch
|
||||
groupPickCount += 1
|
||||
const pick = groupPickCount
|
||||
pendingGroups.set(pick, group.name)
|
||||
showGroupStatus()
|
||||
const result = await suggestions.expandGroup(group)
|
||||
if (epoch !== draftEpoch || unmounted) return
|
||||
pendingGroups.delete(pick)
|
||||
if (!result) {
|
||||
lastGroupResult = ''
|
||||
showGroupStatus()
|
||||
return
|
||||
}
|
||||
const fresh = result.addresses.filter((address) => !suggestions.isAdded(address.email))
|
||||
if (fresh.length > 0) emitTokens(mergeRecipientTokens(currentTokens(), fresh.map(tokenFromAddress)))
|
||||
lastGroupResult = groupInsertedText(group.name, props.label, {
|
||||
added: fresh.length,
|
||||
withoutEmail: result.withoutEmail,
|
||||
already: result.addresses.length - fresh.length,
|
||||
})
|
||||
showGroupStatus()
|
||||
}
|
||||
|
||||
/** 추천을 넣는다 — 주소는 "이름 <주소>" 토큰으로, 그룹은 구성원 모두. 초점은 입력칸에 남아 다음 주소를 이어 친다 */
|
||||
function pick(option: RecipientOption) {
|
||||
if (option.kind === 'group') {
|
||||
void pickGroup(option.match)
|
||||
return
|
||||
}
|
||||
const { name, email } = option.match.suggestion
|
||||
clearInput()
|
||||
emit('update:modelValue', mergeRecipientTokens(props.modelValue, [tokenFromAddress({ name, email })]))
|
||||
}
|
||||
|
||||
/** 화살표로 고른다 — 끝에서 처음으로 돈다 */
|
||||
function move(step: 1 | -1) {
|
||||
const count = matches.value.length
|
||||
const count = options.value.length
|
||||
if (count === 0) return
|
||||
if (activeIndex.value < 0) {
|
||||
activeIndex.value = step === 1 ? 0 : count - 1
|
||||
@@ -146,7 +223,7 @@ function onListKeydown(event: KeyboardEvent): boolean {
|
||||
dismissed.value = true
|
||||
return true
|
||||
}
|
||||
const active = matches.value[activeIndex.value]
|
||||
const active = options.value[activeIndex.value]
|
||||
// Shift+Tab 은 넣지 않는다 — 원래대로 앞 칸으로 간다
|
||||
if ((event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey)) && active) {
|
||||
// Enter 가 폼을 제출(메일 발송)하지 않고, Tab 이 칸을 벗어나지 않게 — 넣은 뒤 이어 친다
|
||||
@@ -212,108 +289,142 @@ function focus() {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 아직 토큰이 되지 않은 입력을 버린다 — 확정 · 추천을 넣은 뒤, 쓰기 화면이 양식을 새로 채울 때 */
|
||||
function clearDraft() {
|
||||
/** 아직 토큰이 되지 않은 입력을 버린다 — 확정 · 추천을 넣은 뒤 */
|
||||
function clearInput() {
|
||||
draft.value = ''
|
||||
query.value = ''
|
||||
}
|
||||
|
||||
/** 쓰기 화면이 양식을 새로 채울 때 — 쓰다 만 입력 · 그룹 넣기(기다리는 것 포함) · 칸 아래 글을 버린다 */
|
||||
function clearDraft() {
|
||||
clearInput()
|
||||
draftEpoch += 1
|
||||
pendingGroups.clear()
|
||||
lastGroupResult = ''
|
||||
groupStatus.value = ''
|
||||
}
|
||||
|
||||
// v-model 이 바뀌면(조합이 끝남 · 확정 · 비움) 검색어도 그 값으로
|
||||
watch(draft, (text) => {
|
||||
query.value = text
|
||||
})
|
||||
|
||||
// 친 글자가 바뀌면 — 닫았던 목록을 다시 열고, 첫 추천을 골라 둔다(완전한 주소면 고르지 않는다). 모자라면 서버에 묻는다
|
||||
// 다시 치기 시작하면 그룹을 넣었다는 칸 아래 글을 지운다 (넣은 뒤 입력칸을 비운 것으로는 지우지 않는다)
|
||||
watch(query, (text) => {
|
||||
dismissed.value = false
|
||||
activeIndex.value = isValidEmail(text) ? -1 : 0
|
||||
if (text.trim() !== '') {
|
||||
lastGroupResult = ''
|
||||
groupStatus.value = ''
|
||||
}
|
||||
suggestions?.search(text)
|
||||
})
|
||||
|
||||
// 추천이 늘거나 줄면(서버 결과 · 이미 넣은 주소) 고른 자리를 목록 안에 둔다
|
||||
watch(
|
||||
() => matches.value.length,
|
||||
() => options.value.length,
|
||||
(count) => {
|
||||
if (activeIndex.value >= count) activeIndex.value = count - 1
|
||||
if (activeIndex.value < 0 && count > 0 && !typedFullAddress.value) activeIndex.value = 0
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
})
|
||||
|
||||
defineExpose({ commit, focus, clearDraft, hasDraft })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recipient-field compose-field">
|
||||
<label
|
||||
:for="inputId"
|
||||
class="recipient-field__label"
|
||||
>{{ label }}</label>
|
||||
<div class="recipient-field__control">
|
||||
<span
|
||||
v-for="token in modelValue"
|
||||
:key="recipientTokenKey(token)"
|
||||
:class="['recipient-chip', { 'is-invalid': !token.valid }]"
|
||||
>
|
||||
<span class="recipient-chip__label">{{ token.label }}</span>
|
||||
<div class="recipient-field-wrap">
|
||||
<div class="recipient-field compose-field">
|
||||
<label
|
||||
:for="inputId"
|
||||
class="recipient-field__label"
|
||||
>{{ label }}</label>
|
||||
<div class="recipient-field__control">
|
||||
<span
|
||||
v-if="!token.valid"
|
||||
class="visually-hidden"
|
||||
>(주소 형식 오류)</span>
|
||||
<button
|
||||
type="button"
|
||||
class="recipient-chip__remove"
|
||||
:aria-label="`${token.label} 삭제`"
|
||||
:disabled="disabled"
|
||||
@click="remove(token)"
|
||||
v-for="token in modelValue"
|
||||
:key="recipientTokenKey(token)"
|
||||
:class="['recipient-chip', { 'is-invalid': !token.valid }]"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="12"
|
||||
:stroke-width="2.25"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-model="draft"
|
||||
type="text"
|
||||
inputmode="email"
|
||||
autocomplete="off"
|
||||
class="recipient-field__input"
|
||||
:placeholder="modelValue.length === 0 ? placeholder : ''"
|
||||
:disabled="disabled"
|
||||
:role="suggestions ? 'combobox' : undefined"
|
||||
:aria-autocomplete="suggestions ? 'list' : undefined"
|
||||
:aria-expanded="suggestions ? listOpen : undefined"
|
||||
:aria-controls="listOpen ? listId : undefined"
|
||||
:aria-activedescendant="activeOptionId"
|
||||
:aria-invalid="invalid"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onTextInput"
|
||||
@compositionupdate="onTextInput"
|
||||
@keydown="onKeydown"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@paste="onPaste"
|
||||
>
|
||||
</div>
|
||||
<slot name="trailing" />
|
||||
<span class="recipient-chip__label">{{ token.label }}</span>
|
||||
<span
|
||||
v-if="!token.valid"
|
||||
class="visually-hidden"
|
||||
>(주소 형식 오류)</span>
|
||||
<button
|
||||
type="button"
|
||||
class="recipient-chip__remove"
|
||||
:aria-label="`${token.label} 삭제`"
|
||||
:disabled="disabled"
|
||||
@click="remove(token)"
|
||||
>
|
||||
<AppIcon
|
||||
name="x"
|
||||
:size="12"
|
||||
:stroke-width="2.25"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="inputRef"
|
||||
v-model="draft"
|
||||
type="text"
|
||||
inputmode="email"
|
||||
autocomplete="off"
|
||||
class="recipient-field__input"
|
||||
:placeholder="modelValue.length === 0 ? placeholder : ''"
|
||||
:disabled="disabled"
|
||||
:role="suggestions ? 'combobox' : undefined"
|
||||
:aria-autocomplete="suggestions ? 'list' : undefined"
|
||||
:aria-expanded="suggestions ? listOpen : undefined"
|
||||
:aria-controls="listOpen ? listId : undefined"
|
||||
:aria-activedescendant="activeOptionId"
|
||||
:aria-invalid="invalid"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onTextInput"
|
||||
@compositionupdate="onTextInput"
|
||||
@keydown="onKeydown"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@paste="onPaste"
|
||||
>
|
||||
</div>
|
||||
<slot name="trailing" />
|
||||
|
||||
<RecipientSuggestionList
|
||||
v-if="listOpen"
|
||||
:input-id="inputId"
|
||||
:label="label"
|
||||
:matches="matches"
|
||||
:active-index="activeIndex"
|
||||
:hint="hint"
|
||||
@activate="activeIndex = $event"
|
||||
@pick="pick"
|
||||
/>
|
||||
<RecipientSuggestionList
|
||||
v-if="listOpen"
|
||||
:input-id="inputId"
|
||||
:label="label"
|
||||
:options="options"
|
||||
:active-index="activeIndex"
|
||||
:hint="hint"
|
||||
@activate="activeIndex = $event"
|
||||
@pick="pick"
|
||||
/>
|
||||
</div>
|
||||
<!-- 그룹을 넣은 결과 — 칸 아래(선 밖)에. 읽어 주는 곳이라 비어 있어도 둔다 (디자인 원본 ContactsMail "고른 뒤") -->
|
||||
<p
|
||||
v-if="suggestions"
|
||||
:id="statusId"
|
||||
class="recipient-field__status"
|
||||
role="status"
|
||||
>
|
||||
{{ groupStatus }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipient-field-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipient-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -353,6 +464,18 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
|
||||
|
||||
/* 포커스는 행 전체의 아래 선으로 보인다 — styles/base.css 의 .compose-field 규칙 */
|
||||
|
||||
/* 그룹을 넣은 결과 — 칸의 32px 선에서 12.5px (디자인 원본 ContactsMail). 비었을 때는 자리를 차지하지 않는다 */
|
||||
.recipient-field__status {
|
||||
margin: 0;
|
||||
padding: 0 var(--content-toolbar-start) 0 var(--content-inline);
|
||||
font-size: var(--font-size-label);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.recipient-field__status:not(:empty) {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
|
||||
.recipient-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
// 받는 사람 추천 목록 (디자인 원본 ComposeAssist · README "2단계 › 받는 사람 자동 완성") — RecipientField 가 칸 바로 아래에 연다
|
||||
// - "사내" · "최근 주고받은 주소" 두 묶음, 줄마다 이름(맞는 글자 굵게) + 주소, 고른 줄은 바탕색. 번호는 화살표 순서다
|
||||
// 받는 사람 추천 목록 (디자인 원본 ComposeAssist · ContactsMail · README "2단계 › 받는 사람 자동 완성" · "8단계 › 메일과 함께") — RecipientField 가 칸 바로 아래에 연다
|
||||
// - 묶음은 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 줄마다 이름(맞는 글자 굵게) + 주소, 고른 줄은 바탕색. 번호는 화살표 순서다
|
||||
// 주소록 줄은 주소 뒤에 설명 — 회사 주소는 회사 이름, 그 밖은 종류("개인") (contacts.ts 의 contactSuggestionDetail)
|
||||
// 그룹 줄은 아이콘 + 그룹 이름(맞는 글자 굵게) + "4명 · 고르면 한꺼번에 넣습니다"
|
||||
// - 고르기 · 넣기 키는 입력칸이 받는다(초점은 늘 입력칸) — 이 목록은 그리기와 마우스(올리면 고르기 · 누르면 넣기)만 한다.
|
||||
// 누르는 동안 입력칸의 초점을 지킨다(mousedown)
|
||||
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import {
|
||||
suggestionHintId,
|
||||
suggestionListId,
|
||||
suggestionOptionId,
|
||||
} from '@/components/mail/recipient-suggestion-ids'
|
||||
import { highlightParts, type HighlightPart, type RecipientMatch } from '@/shared/utils/recipient-match'
|
||||
import { RECIPIENT_CONTACT_TEXT } from '@/shared/constants/contacts'
|
||||
import { contactSuggestionDetail } from '@/shared/utils/contacts'
|
||||
import { formatCount } from '@/shared/utils/format'
|
||||
import {
|
||||
highlightParts,
|
||||
recipientOptionGroup,
|
||||
type HighlightPart,
|
||||
type RecipientOption,
|
||||
type RecipientOptionGroup,
|
||||
} from '@/shared/utils/recipient-match'
|
||||
|
||||
interface Props {
|
||||
/** 입력칸 id — 목록 · 추천 · 안내 id 의 앞부분 */
|
||||
inputId: string
|
||||
/** 칸 이름 — 목록의 이름("받는 사람 추천") */
|
||||
label: string
|
||||
matches: RecipientMatch[]
|
||||
options: RecipientOption[]
|
||||
/** 고른 추천 — -1 이면 고르지 않았다 */
|
||||
activeIndex: number
|
||||
/** 목록 아래 안내 */
|
||||
@@ -27,7 +39,7 @@ interface Props {
|
||||
|
||||
interface Emits {
|
||||
/** 누른 추천을 넣는다 */
|
||||
(e: 'pick', match: RecipientMatch): void
|
||||
(e: 'pick', option: RecipientOption): void
|
||||
/** 마우스를 올린 줄을 고른다 */
|
||||
(e: 'activate', index: number): void
|
||||
}
|
||||
@@ -35,40 +47,74 @@ interface Emits {
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 추천 묶음 이름 (디자인 원본 ComposeAssist) */
|
||||
const GROUP_LABELS = { directory: '사내', recent: '최근 주고받은 주소' } as const
|
||||
/** 추천 묶음 — 차례와 이름 (디자인 원본 ContactsMail) */
|
||||
const GROUP_ORDER: readonly RecipientOptionGroup[] = ['contact', 'group', 'directory', 'recent']
|
||||
const GROUP_LABELS: Readonly<Record<RecipientOptionGroup, string>> = {
|
||||
contact: '주소록',
|
||||
group: '그룹',
|
||||
directory: '사내',
|
||||
recent: '최근 주고받은 주소',
|
||||
}
|
||||
|
||||
interface OptionView {
|
||||
index: number
|
||||
id: string
|
||||
match: RecipientMatch
|
||||
/** 첫 줄 — 이름(없으면 주소) */
|
||||
option: RecipientOption
|
||||
/** 그룹 줄 — 아이콘을 앞에 */
|
||||
isGroup: boolean
|
||||
/** 첫 줄 — 이름(없으면 주소) · 그룹 이름 */
|
||||
primary: HighlightPart[]
|
||||
/** 둘째 줄 — 주소 (이름이 없으면 없다) */
|
||||
/** 둘째 줄 — 주소(+ 주소록 줄의 설명) · 그룹의 구성원 수. 없으면 null */
|
||||
secondary: HighlightPart[] | null
|
||||
}
|
||||
|
||||
function optionView(match: RecipientMatch, index: number): OptionView {
|
||||
const { name, email } = match.suggestion
|
||||
function withDetail(parts: HighlightPart[], detail: string | null): HighlightPart[] {
|
||||
return detail ? [...parts, { text: ` · ${detail}`, strong: false }] : parts
|
||||
}
|
||||
|
||||
function optionView(option: RecipientOption, index: number): OptionView {
|
||||
const id = suggestionOptionId(props.inputId, index)
|
||||
if (option.kind === 'group') {
|
||||
const { group, name } = option.match
|
||||
return {
|
||||
index,
|
||||
id,
|
||||
option,
|
||||
isGroup: true,
|
||||
primary: highlightParts(group.name, name),
|
||||
secondary: [{ text: RECIPIENT_CONTACT_TEXT.groupDetail(formatCount(group.memberCount)), strong: false }],
|
||||
}
|
||||
}
|
||||
const { suggestion } = option.match
|
||||
const detail = contactSuggestionDetail(suggestion)
|
||||
if (suggestion.name) {
|
||||
return {
|
||||
index,
|
||||
id,
|
||||
option,
|
||||
isGroup: false,
|
||||
primary: highlightParts(suggestion.name, option.match.name),
|
||||
secondary: withDetail(highlightParts(suggestion.email, option.match.email), detail),
|
||||
}
|
||||
}
|
||||
return {
|
||||
index,
|
||||
id: suggestionOptionId(props.inputId, index),
|
||||
match,
|
||||
primary: name ? highlightParts(name, match.name) : highlightParts(email, match.email),
|
||||
secondary: name ? highlightParts(email, match.email) : null,
|
||||
id,
|
||||
option,
|
||||
isGroup: false,
|
||||
primary: highlightParts(suggestion.email, option.match.email),
|
||||
secondary: detail ? [{ text: detail, strong: false }] : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 묶음별 추천 — 사내 → 최근 주고받은 주소, 번호는 화살표 순서 */
|
||||
/** 묶음별 추천 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 번호는 화살표 순서 */
|
||||
const groups = computed(() => {
|
||||
const options = props.matches.map(optionView)
|
||||
return (['directory', 'recent'] as const)
|
||||
.map((source) => ({
|
||||
source,
|
||||
label: GROUP_LABELS[source],
|
||||
options: options.filter((option) => option.match.suggestion.source === source),
|
||||
}))
|
||||
.filter((group) => group.options.length > 0)
|
||||
const views = props.options.map(optionView)
|
||||
return GROUP_ORDER.map((source) => ({
|
||||
source,
|
||||
label: GROUP_LABELS[source],
|
||||
options: views.filter((view) => recipientOptionGroup(view.option) === source),
|
||||
})).filter((group) => group.options.length > 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -93,19 +139,29 @@ const groups = computed(() => {
|
||||
class="recipient-suggestions__group-label"
|
||||
>{{ group.label }}</span>
|
||||
<div
|
||||
v-for="option in group.options"
|
||||
:id="option.id"
|
||||
:key="option.id"
|
||||
v-for="view in group.options"
|
||||
:id="view.id"
|
||||
:key="view.id"
|
||||
role="option"
|
||||
:aria-selected="option.index === activeIndex"
|
||||
:class="['recipient-suggestions__option', { 'is-active': option.index === activeIndex }]"
|
||||
:aria-selected="view.index === activeIndex"
|
||||
:class="[
|
||||
'recipient-suggestions__option',
|
||||
{ 'is-active': view.index === activeIndex, 'recipient-suggestions__option--group': view.isGroup },
|
||||
]"
|
||||
:data-suggestion-group="view.isGroup ? '' : undefined"
|
||||
@mousedown.prevent
|
||||
@mousemove="emit('activate', option.index)"
|
||||
@click="emit('pick', option.match)"
|
||||
@mousemove="emit('activate', view.index)"
|
||||
@click="emit('pick', view.option)"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="view.isGroup"
|
||||
name="users"
|
||||
:size="15"
|
||||
class="recipient-suggestions__group-icon"
|
||||
/>
|
||||
<span class="recipient-suggestions__name">
|
||||
<template
|
||||
v-for="(part, partIndex) in option.primary"
|
||||
v-for="(part, partIndex) in view.primary"
|
||||
:key="partIndex"
|
||||
>
|
||||
<strong v-if="part.strong">{{ part.text }}</strong>
|
||||
@@ -113,11 +169,11 @@ const groups = computed(() => {
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="option.secondary"
|
||||
v-if="view.secondary"
|
||||
class="recipient-suggestions__email"
|
||||
>
|
||||
<template
|
||||
v-for="(part, partIndex) in option.secondary"
|
||||
v-for="(part, partIndex) in view.secondary"
|
||||
:key="partIndex"
|
||||
>
|
||||
<strong v-if="part.strong">{{ part.text }}</strong>
|
||||
@@ -138,7 +194,7 @@ const groups = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 추천 목록 (디자인 원본 ComposeAssist) — 칸(.recipient-field, position: relative) 바로 아래에 겹쳐 연다. 편집기 툴바(z-index 2) 위에 온다.
|
||||
* 추천 목록 (디자인 원본 ComposeAssist · ContactsMail) — 칸(.recipient-field, position: relative) 바로 아래에 겹쳐 연다. 편집기 툴바(z-index 2) 위에 온다.
|
||||
* 줄 높이 40 · 이름(맞는 글자 굵게) + 주소(12px #4A5363) · 고른 줄 #E3F2F3 · 묶음 이름 12px #667085 굵게
|
||||
*/
|
||||
.recipient-suggestions {
|
||||
@@ -187,10 +243,19 @@ const groups = computed(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 그룹 줄 — 아이콘이 있어 가운데로 맞춘다 (디자인 원본 ContactsMail) */
|
||||
.recipient-suggestions__option--group {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recipient-suggestions__option.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
}
|
||||
|
||||
.recipient-suggestions__group-icon {
|
||||
color: var(--color-brand-strong);
|
||||
}
|
||||
|
||||
.recipient-suggestions__name {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -20,16 +20,17 @@ describe('AppRail', () => {
|
||||
expect(labels).toEqual(APP_MODULES.map((module) => module.label))
|
||||
})
|
||||
|
||||
it('메일 · 설정이 링크이고, 지금 모듈이 현재로 표시된다', async () => {
|
||||
it('메일 · 주소록(8단계) · 설정이 링크이고, 지금 모듈이 현재로 표시된다', async () => {
|
||||
const wrapper = await mountRail()
|
||||
const current = wrapper.get('a[aria-current="page"]')
|
||||
const links = wrapper.findAll('a.rail-item').map((link) => [link.text(), link.attributes('href')])
|
||||
|
||||
expect(current.text()).toBe('메일')
|
||||
expect(current.attributes('href')).toBe('/mail')
|
||||
// 설정은 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다
|
||||
// 주소록은 전체 연락처, 설정은 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다
|
||||
expect(links).toEqual([
|
||||
['메일', '/mail'],
|
||||
['주소록', '/contacts'],
|
||||
['설정', '/settings'],
|
||||
])
|
||||
})
|
||||
@@ -38,7 +39,7 @@ describe('AppRail', () => {
|
||||
const wrapper = await mountRail()
|
||||
const disabled = wrapper.findAll('button[aria-disabled="true"]')
|
||||
|
||||
expect(disabled).toHaveLength(APP_MODULES.length - 2)
|
||||
expect(disabled).toHaveLength(APP_MODULES.length - 3)
|
||||
for (const item of disabled) {
|
||||
expect(item.attributes('title')).toContain('준비 중')
|
||||
expect(item.get('.visually-hidden').text()).toBe('(준비 중)')
|
||||
|
||||
Reference in New Issue
Block a user