feat(frontend): 주소록 화면 · 메일 연동

레일의 주소록 모듈 — 내 연락처 · 그룹 · 사내 목록(직원 · 공용 주소), 초성 찾기, 연락처 추가 · 고치기 창, 그룹 · 구성원 넣기 —
과 메일 연동(받는 사람 추천의 주소록 · 그룹, 연락처 · 그룹 · 사내의 [메일 쓰기], 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」)을 더한다.
디자인 README "8단계", 계약 3절.

리뷰로 고친 것:
- 쓰기 화면을 채우는 라우터 상태에 연 직원을 싣는다 — 같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가면 앞 직원의 받는 사람이 채워졌다.
- [고치기]는 창을 열 때 새로 받고 바꾼 칸만 보낸다 — 받아 둔 값으로 다른 탭 · 휴대폰의 변경을 덮었다. 검사도 바꾼 칸만.
- [되돌리기]는 서버의 되돌리기 표로 — 다시 만들기로는 다른 프로그램의 칸 · uid 를 잃었다.
- 읽기 화면에서 연 연락처 추가 창을 메일 레이아웃으로 옮겼다 — 메일을 바꾸면 쓴 것이 확인 없이 사라졌다.
- 그룹을 이어 고르면 앞 그룹이 빠지던 것, 저장 중 창을 닫으면 거절이 묻히던 것, 구성원 넣기의 첫 쪽이 모두 구성원이면 막히던 것(excludeGroupId).
- 받는 사람 추천은 초성 · 조합 중 글자도 서버에 묻는다(서버가 같은 맞추기 — 같은 표본 표를 두 쪽 테스트에 둔다).
- 바꿀 때마다 불러온 쪽을 모두 다시 받지 않고 응답을 서버 차례로 그 자리에 넣는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 14:13:20 +09:00
parent 08cf31fbe6
commit e2c41d38bb
65 changed files with 11130 additions and 214 deletions
@@ -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()
})
})
})
+203 -80
View File
@@ -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('(준비 중)')
+2
View File
@@ -74,6 +74,8 @@ describe('에러 코드 사전', () => {
// 웹메일 6단계
'MAIL_012', // 그사이 다른 곳에서 필터가 바뀜 — 필터 화면이 알림 없이 목록 위 줄 · 고치기 창 안에 사전 문구를 직접 꺼내 보이고 [다시 불러오기]를 둔다
'MAIL_013', // 필터가 엔진 한도를 넘음 — 필터 고치기 창이 알림 없이 창 안에 보인다
// 웹메일 8단계 (주소록)
'CONTACT_001', // 같은 이름의 그룹 — 새 그룹 · 이름 바꾸기 창이 알림 없이 칸 아래에 보인다
]
for (const code of usedByClient) {
@@ -0,0 +1,254 @@
import { nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { toApiError } from './useApi'
import { useContacts } from './useContacts'
import { useToast } from './useToast'
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
import { CONTACTS_TOAST_TEXT } from '@/shared/constants/contacts'
import type {
Contact,
ContactGroup,
ContactGroupDeleted,
ContactGroupMembersAdded,
ContactGroupMembersPayload,
ContactInput,
ContactPatch,
} from '@/shared/types/contacts'
import type { Address } from '@/shared/types/mail'
import { copyText } from '@/shared/utils/clipboard'
import { composePrefillState } from '@/shared/utils/compose-prefill'
import { contactDisplayName, groupRecipientsOf } from '@/shared/utils/contacts'
import { contactsListLocation, contactsRouteStateOf } from '@/shared/utils/contacts-route'
import { formatCount } from '@/shared/utils/format'
import { useAuthStore } from '@/stores/auth.store'
import { useContactsStore } from '@/stores/contacts.store'
// 주소록에서 하는 일과 그 알림 (디자인 README "8단계 › 상태 · 알림" · 디자인 원본 ContactsEdit "알림")
// - 저장 "연락처를 저장했습니다." · 지우기는 확인 없이 하고 "‘박지훈’ 연락처를 지웠습니다." + [되돌리기](10초 — 띄운 세션에서만 움직인다, useToast).
// 되돌리기는 지우기 응답의 표로(POST /contact-restorations — 서버가 지운 그대로 다시 만든다, 새 id) → "연락처를 되돌렸습니다".
// 표가 없으면(서버가 기억하지 못함 — null) [되돌리기] 없이 알린다. 되돌리기의 실패(표가 지남 · 같은 이름 · 한도 · 장애)는 인터셉터가 알린다
// - 그룹 지우기 "‘거래처’ 그룹을 지웠습니다 — 연락처 4명은 그대로 있습니다." + [되돌리기](같은 표 — 그때의 이름 · 지금도 있는 구성원으로)
// - [고치기]는 그 연락처를 새로 받아 창을 연다(loadForEdit — 창을 연 사이 다른 곳에서 바꾼 칸을 덮지 않게, 저장은 바꾼 칸만)
// - 구성원 넣기 "2명을 ‘거래처’ 그룹에 넣었습니다."
// - 이미 지워진 것(다른 곳에서 지움 — 404 RES_001)을 지우면 알리지 않고 지운 것으로 본다(메일함 지우기와 같다)
// - 칸 안에 보일 거절(연락처 VAL_001 · 그룹 이름 CONTACT_001 · VAL_001)은 알림을 끄고 그대로 던진다 — 창이 칸 아래에 보인다.
// 한도(CONTACT_002) · 엔진 장애(MAIL_001) 등은 인터셉터가 사전 문구로 알린다
// - 보던 연락처 · 그룹을 지우면 그 목록으로 주소를 바꾸고(기록을 쌓지 않는다), 누른 단추가 사라져 초점을 잃었으면 목록 칸으로 옮긴다
// - [메일 쓰기]는 받는 사람을 채운 새 쓰기 화면 — 주소는 URL 이 아니라 라우터 상태로 (compose-prefill.ts)
/** 연락처 창이 칸 아래에 보이는 거절 */
export const CONTACT_SAVE_INLINE_CODES: readonly ErrorCode[] = ['VAL_001']
/** 그룹 이름 창이 칸 아래에 보이는 거절 — 같은 이름 · 형식 */
export const CONTACT_GROUP_NAME_INLINE_CODES: readonly ErrorCode[] = ['CONTACT_001', 'VAL_001']
/** 목록 칸 (ContactsList 의 data-contacts-list) — 지운 뒤 초점을 잃으면 여기로 */
const CONTACTS_LIST_SELECTOR = '[data-contacts-list]'
function focusIsLost(): boolean {
const active = document.activeElement
return active === null || active === document.body || !active.isConnected
}
export function useContactActions() {
const store = useContactsStore()
const contactsApi = useContacts()
const authStore = useAuthStore()
const router = useRouter()
const toast = useToast()
/** 누른 단추가 사라져 초점을 잃었으면 목록 칸으로 — 다른 곳으로 옮긴 초점은 빼앗지 않는다 */
async function recoverFocus(): Promise<void> {
await nextTick()
if (focusIsLost()) document.querySelector<HTMLElement>(CONTACTS_LIST_SELECTOR)?.focus({ preventScroll: true })
}
/** 지금 주소록 화면이 그 연락처 · 그 그룹을 보고 있으면 그 목록(고른 것 없이)으로 바꿔 끼운다 */
async function leaveRemoved(target: { contactId?: string; groupId?: string }): Promise<void> {
const state = contactsRouteStateOf(router.currentRoute.value)
if (!state) return
if (target.groupId && state.source.kind === 'group' && state.source.groupId === target.groupId) {
await router.replace(contactsListLocation({ kind: 'all' }))
} else if (target.contactId && state.selectedId === target.contactId && state.source.kind !== 'directory') {
await router.replace(contactsListLocation(state.source, state.q))
}
}
// ── 연락처 ─────────────────────────────────────────
/** 새 연락처 저장 — 세션이 바뀌었으면 null (알리지 않는다). 실패는 그대로 던진다 (VAL_001 은 알림을 끈다 — 창이 보인다) */
async function createContact(input: ContactInput): Promise<Contact | null> {
const saved = await store.createContact(input, { silent: CONTACT_SAVE_INLINE_CODES })
if (saved) toast.success(CONTACTS_TOAST_TEXT.saved)
return saved
}
/** 고친 연락처 저장 — 바꾼 칸만(patch). 세션이 바뀌었으면 null. 실패는 그대로 던진다 */
async function updateContact(contact: Contact, patch: ContactPatch): Promise<Contact | null> {
const saved = await store.updateContact(contact.id, patch, { silent: CONTACT_SAVE_INLINE_CODES })
if (saved) toast.success(CONTACTS_TOAST_TEXT.saved)
return saved
}
/**
* [고치기] — 창에 채울 그 연락처를 새로 받는다(받는 동안 store.editLoadingId). 받으면 목록 · 상세도 그 값으로 고친다.
* 못 받으면 null — 창을 열지 않는다: 없는 연락처(RES_001 — 알림 없이)는 목록에서 빼 상세가 "찾을 수 없음" + [목록으로]를 그리고,
* 그 밖의 실패는 인터셉터가 알린다. 기다리는 사이 세션이 바뀌었어도 null
*/
async function loadForEdit(contact: Contact): Promise<Contact | null> {
if (store.editLoadingId === contact.id) return null
const session = authStore.generation
store.editLoadingId = contact.id
try {
const fresh = await contactsApi.getContact(contact.id, { silent: ['RES_001'] })
if (authStore.generation !== session) return null
store.replaceContact(fresh)
return fresh
} catch (caught) {
if (authStore.generation === session && toApiError(caught).code === 'RES_001') store.forgetContact(contact.id)
return null
} finally {
if (store.editLoadingId === contact.id) store.editLoadingId = null
}
}
/** [되돌리기] — 표로 다시 만든다 (memberIds: 그룹을 지울 때의 구성원). 실패는 인터셉터가 알렸다 */
async function restore(token: string, memberIds: readonly string[] = []): Promise<void> {
try {
const restored = await store.restore(token, memberIds)
if (!restored) return
toast.success(restored.kind === 'contact' ? CONTACTS_TOAST_TEXT.restored : CONTACTS_TOAST_TEXT.groupRestored)
} catch {
// 표가 지남 · 이미 씀(RES_001), 같은 이름의 그룹(CONTACT_001), 한도(CONTACT_002), 장애 — 인터셉터가 사전 문구로 알렸다
}
}
/** 표가 있으면 [되돌리기] 단추 (10초 — 띄운 세션에서만), 없으면 단추 없이 */
function undoAction(token: string | null, memberIds: readonly string[] = []) {
return token ? { label: CONTACTS_TOAST_TEXT.undo, run: () => void restore(token, memberIds) } : undefined
}
/** 지우기 — 확인 없이 하고 [되돌리기]를 준다. 지웠으면(이미 없었던 것 포함) true */
async function removeContact(contact: Contact): Promise<boolean> {
let deleted: Awaited<ReturnType<typeof store.deleteContact>>
try {
deleted = await store.deleteContact(contact.id, { silent: ['RES_001'] })
} catch (caught) {
if (toApiError(caught).code !== 'RES_001') return false
store.forgetContact(contact.id)
await leaveRemoved({ contactId: contact.id })
await recoverFocus()
return true
}
if (!deleted) return false
toast.success(CONTACTS_TOAST_TEXT.removed(contactDisplayName(deleted.contact)), undoAction(deleted.restoreToken))
await leaveRemoved({ contactId: contact.id })
await recoverFocus()
return true
}
// ── 그룹 ──────────────────────────────────────────
/** 새 그룹 — 같은 이름(CONTACT_001) · 형식(VAL_001)은 알림을 끄고 던진다 */
async function createGroup(name: string): Promise<ContactGroup | null> {
const created = await store.createGroup(name, { silent: CONTACT_GROUP_NAME_INLINE_CODES })
if (created) toast.success(CONTACTS_TOAST_TEXT.groupCreated(created.name))
return created
}
async function renameGroup(group: ContactGroup, name: string): Promise<ContactGroup | null> {
const renamed = await store.renameGroup(group.id, name, { silent: CONTACT_GROUP_NAME_INLINE_CODES })
if (renamed) toast.success(CONTACTS_TOAST_TEXT.groupRenamed)
return renamed
}
/** 그룹 지우기 — 확인 없이 하고 [되돌리기]를 준다. 연락처는 그대로 있다 */
async function removeGroup(group: ContactGroup): Promise<boolean> {
let deleted: ContactGroupDeleted | null
try {
deleted = await store.deleteGroup(group.id, { silent: ['RES_001'] })
} catch (caught) {
if (toApiError(caught).code !== 'RES_001') return false
store.forgetGroup(group.id)
await leaveRemoved({ groupId: group.id })
await recoverFocus()
return true
}
if (!deleted) return false
const count = deleted.memberIds.length > 0 ? formatCount(deleted.memberIds.length) : null
toast.success(
CONTACTS_TOAST_TEXT.groupRemoved(deleted.name, count),
undoAction(deleted.restoreToken, deleted.memberIds),
)
await leaveRemoved({ groupId: group.id })
await recoverFocus()
return true
}
/**
* 구성원 넣기 — "2명을 ‘거래처’ 그룹에 넣었습니다." 수는 응답의 addedCount(고른 수와 다를 수 있다 — 서버가 이미 구성원 ·
* 그룹에 넣을 수 없는 카드를 건너뛴다). 실패는 인터셉터가 알리고 그대로 던진다
*/
async function addMembers(group: ContactGroup, payload: ContactGroupMembersPayload): Promise<ContactGroupMembersAdded | null> {
const added = await store.addMembers(group.id, payload)
if (!added) return null
toast.success(
added.addedCount > 0
? CONTACTS_TOAST_TEXT.membersAdded(formatCount(added.addedCount), added.group.name)
: CONTACTS_TOAST_TEXT.membersNone(added.group.name),
)
return added
}
// ── 메일 쓰기 · 주소 복사 ──────────────────────────────
/** 받는 사람을 채운 새 쓰기 화면 — 쓰다 만 메일은 쓰기 화면의 임시 저장 규칙대로 남는다. 상태에 연 직원을 싣는다(compose-prefill.ts) */
async function composeTo(addresses: readonly Address[]): Promise<void> {
await router.push({ name: 'mail-compose', state: composePrefillState(addresses, authStore.user?.email ?? '') })
}
/**
* [그룹에 메일 쓰기] — 구성원의 대표 주소로 (이메일이 없는 구성원은 뺀다). 받는 동안은 store.composingGroupId(단추의 바쁜 표시 — 거듭 누르면 무시).
* 받지 못하면 인터셉터가 알린다. 받는 사이 다른 화면 · 목록으로 갔거나 세션이 바뀌었으면 쓰기 화면으로 끌고 가지 않는다
*/
async function composeToGroup(group: ContactGroup): Promise<void> {
if (store.composingGroupId !== null) return
const session = authStore.generation
const startedAt = router.currentRoute.value.fullPath
store.composingGroupId = group.id
let members: Contact[]
try {
members = await contactsApi.listGroupMembers(group.id)
} catch {
return
} finally {
if (store.composingGroupId === group.id) store.composingGroupId = null
}
if (authStore.generation !== session || router.currentRoute.value.fullPath !== startedAt) return
const { addresses } = groupRecipientsOf(members)
if (addresses.length === 0) {
toast.error(CONTACTS_TOAST_TEXT.groupNoAddress(group.name))
return
}
await composeTo(addresses)
}
/** 주소 복사 — 결과를 알린다 (브라우저가 막으면 실패 알림) */
async function copyAddress(address: string): Promise<void> {
if (await copyText(address)) toast.success(CONTACTS_TOAST_TEXT.copied)
else toast.error(CONTACTS_TOAST_TEXT.copyFailed)
}
return {
createContact,
updateContact,
loadForEdit,
removeContact,
createGroup,
renameGroup,
removeGroup,
addMembers,
composeTo,
composeToGroup,
copyAddress,
}
}
@@ -0,0 +1,82 @@
import { computed, ref, type ComputedRef } from 'vue'
import { withoutKey, type ContactEntryWarning } from '@/components/contacts/contacts-ui'
import { CONTACT_DIALOG_TEXT } from '@/shared/constants/contacts'
import { duplicateWho, type ContactEmailRow } from '@/shared/utils/contacts'
import { isValidEmail, normalizeEmail } from '@/shared/utils/mail-address'
import { useContactsStore } from '@/stores/contacts.store'
// 연락처 창의 같은 주소 안내 (디자인 원본 ContactsEdit · README "8단계 › 연락처 추가 · 고치기") — 막지 않는 갈색 글 + [그 연락처 보기]
// - 이메일 칸을 떠날 때(check) 내 주소록에 같은 주소(다른 연락처 — 대소문자 무시)가 있는지 알림 없이 찾는다(GET /contacts?q=<주소>)
// - 칸을 고치면(forget) 그 줄의 안내를 내리고 기다리던 찾기를 버린다 — 늦게 온 결과가 옛 주소의 안내를 다시 띄우지 않게
// - 창을 다시 열면(reset) 모두 버린다. 찾지 못했으면(실패 · 세션이 바뀜) 안내 없이 둔다
// 안내는 늦게 뜨므로 그리는 쪽(ContactEntryRows)이 늘 있는 알림 영역 안에 그린다
export interface ContactDuplicateCheckOptions {
/** 지금 창의 이메일 줄 */
rows: () => readonly ContactEmailRow[]
/** 고치는 연락처 — 자기 자신의 주소는 안내하지 않는다 */
exceptId: () => string | null
}
export interface ContactDuplicateCheck {
/** 이메일 줄마다 안내 — 없으면 null (줄 차례) */
warnings: ComputedRef<(ContactEntryWarning | null)[]>
check: (rowKey: number) => Promise<void>
forget: (rowKey: number) => void
reset: () => void
}
export function useContactDuplicateCheck(options: ContactDuplicateCheckOptions): ContactDuplicateCheck {
const contactsStore = useContactsStore()
/** 안내 — 이메일 줄 키마다 */
const byKey = ref<Record<number, ContactEntryWarning>>({})
/** 줄마다의 찾기 차례 — 칸을 고치면 오른다 */
const seqs = new Map<number, number>()
/** 창을 연 차례 — 다시 열면 오른다 */
let epoch = 0
function bump(rowKey: number): number {
const seq = (seqs.get(rowKey) ?? 0) + 1
seqs.set(rowKey, seq)
return seq
}
function forget(rowKey: number) {
bump(rowKey)
byKey.value = withoutKey(byKey.value, rowKey)
}
function reset() {
epoch += 1
seqs.clear()
byKey.value = {}
}
async function check(rowKey: number): Promise<void> {
const address = options.rows().find((row) => row.key === rowKey)?.address.trim() ?? ''
const seq = bump(rowKey)
if (!isValidEmail(address)) return
const opened = epoch
const found = await contactsStore.findContactsWithEmail(address, options.exceptId())
if (opened !== epoch || seqs.get(rowKey) !== seq || found === undefined) return
const current = options.rows().find((row) => row.key === rowKey)
if (!current || normalizeEmail(current.address) !== normalizeEmail(address)) return
const other = found[0]
const rest = withoutKey(byKey.value, rowKey)
byKey.value = other
? {
...rest,
[rowKey]: {
text: CONTACT_DIALOG_TEXT.duplicate(duplicateWho(other, contactsStore.groups)),
contactId: other.id,
linkLabel: CONTACT_DIALOG_TEXT.duplicateView,
},
}
: rest
}
const warnings = computed(() => options.rows().map((row) => byKey.value[row.key] ?? null))
return { warnings, check, forget, reset }
}
+160
View File
@@ -0,0 +1,160 @@
import type { AxiosRequestConfig } from 'axios'
import { useApi } from './useApi'
import { CONTACT_GROUP_MEMBER_PAGES_MAX, CONTACTS_PAGE_SIZE } from '@/shared/constants/contacts'
import type {
Contact,
ContactDeleted,
ContactGroup,
ContactGroupCreatePayload,
ContactGroupDeleted,
ContactGroupListQuery,
ContactGroupMembersAdded,
ContactGroupMembersPayload,
ContactGroupRenamePayload,
ContactInput,
ContactListQuery,
ContactPatch,
ContactRestored,
DirectoryEntry,
DirectoryListQuery,
} from '@/shared/types/contacts'
import type { PaginatedResult } from '@/shared/types/pagination'
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
export type ContactsRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
/** 경로에 넣는 id 는 항상 인코딩한다 */
const segment = (value: string) => encodeURIComponent(value)
// 주소록 API Composable — docs/mail/api-contract.md 3절 "주소록 — 8단계" 의 엔드포인트 (모두 로그인 필요)
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
// 컴포넌트는 이것을 직접 부르기보다 스토어(contacts.store) · 하는 일(useContactActions)을 거친다
export function useContacts() {
const api = useApi()
/** 연락처 — 이름 차례, total 포함. groupId 면 그 그룹의 구성원, q 면 찾은 것 (cursor 는 받은 그 q · groupId 로만) */
function listContacts(query: ContactListQuery, options?: ContactsRequestOptions): Promise<PaginatedResult<Contact>> {
return api.get<PaginatedResult<Contact>, PaginatedResult<Contact>>('/contacts', { ...options, params: query })
}
function getContact(id: string, options?: ContactsRequestOptions): Promise<Contact> {
return api.get<Contact, Contact>(`/contacts/${segment(id)}`, options)
}
/** 만들기 — 연락처 3,000개 · 그룹 구성원 500명을 넘으면 409 CONTACT_002, 없는 groupId 는 404 RES_001 */
function createContact(input: ContactInput, options?: ContactsRequestOptions): Promise<Contact> {
return api.post<Contact, Contact>('/contacts', input, options)
}
/**
* 고치기 — 연 때와 달라진 칸만 보낸다(보내지 않은 칸 · 엔진 카드의 다른 칸은 서버가 그대로 둔다 — 창을 연 사이 다른 곳에서 바꾼 칸을 덮지 않게).
* emails · phones · groupIds 는 보내면 통째로 바뀐다
*/
function updateContact(id: string, patch: ContactPatch, options?: ContactsRequestOptions): Promise<Contact> {
return api.patch<Contact, Contact>(`/contacts/${segment(id)}`, patch, options)
}
/** 지우기 — 영구 삭제라 지운 연락처와 [되돌리기] 표를 돌려준다 (표가 null 이면 되돌릴 수 없다) */
function deleteContact(id: string, options?: ContactsRequestOptions): Promise<ContactDeleted> {
return api.delete<ContactDeleted, ContactDeleted>(`/contacts/${segment(id)}`, options)
}
/**
* 되돌리기 — 지우기 응답의 표로 지운 그대로(다른 프로그램의 칸 · 그룹 · 구성원까지) 다시 만든다. id 는 새로 받는다.
* 표가 없음 · 지남 · 이미 씀은 404 RES_001, 같은 이름의 그룹 409 CONTACT_001, 한도 409 CONTACT_002 (계약 3절 "되돌리기")
*/
function restoreDeleted(token: string, options?: ContactsRequestOptions): Promise<ContactRestored> {
return api.post<ContactRestored, ContactRestored>('/contact-restorations', { token }, options)
}
function listContactGroups(
query: ContactGroupListQuery,
options?: ContactsRequestOptions,
): Promise<PaginatedResult<ContactGroup>> {
return api.get<PaginatedResult<ContactGroup>, PaginatedResult<ContactGroup>>('/contact-groups', {
...options,
params: query,
})
}
/** 새 그룹 — 같은 이름이면 409 CONTACT_001, 그룹 100개면 409 CONTACT_002 */
function createContactGroup(payload: ContactGroupCreatePayload, options?: ContactsRequestOptions): Promise<ContactGroup> {
return api.post<ContactGroup, ContactGroup>('/contact-groups', payload, options)
}
function renameContactGroup(
id: string,
payload: ContactGroupRenamePayload,
options?: ContactsRequestOptions,
): Promise<ContactGroup> {
return api.patch<ContactGroup, ContactGroup>(`/contact-groups/${segment(id)}`, payload, options)
}
/** 그룹 지우기 — 연락처는 그대로다. 응답의 표로 [되돌리기]가 다시 만든다 */
function deleteContactGroup(id: string, options?: ContactsRequestOptions): Promise<ContactGroupDeleted> {
return api.delete<ContactGroupDeleted, ContactGroupDeleted>(`/contact-groups/${segment(id)}`, options)
}
/** 구성원 넣기 — 내 연락처(contactIds) · 사내 목록(directoryIds — 내 주소록에도 만든다) 합쳐 1~100개 */
function addContactGroupMembers(
id: string,
payload: ContactGroupMembersPayload,
options?: ContactsRequestOptions,
): Promise<ContactGroupMembersAdded> {
return api.post<ContactGroupMembersAdded, ContactGroupMembersAdded>(
`/contact-groups/${segment(id)}/members`,
payload,
options,
)
}
/** 사내 목록 — 이름 차례, total 포함. excludeGroupId 면 그 그룹 구성원의 주소를 뺀다. 사내 목록을 읽지 못하면 503 MAIL_001 */
function listDirectoryEntries(
query: DirectoryListQuery,
options?: ContactsRequestOptions,
): Promise<PaginatedResult<DirectoryEntry>> {
return api.get<PaginatedResult<DirectoryEntry>, PaginatedResult<DirectoryEntry>>('/directory-entries', {
...options,
params: query,
})
}
/** 사내 항목 하나 — 주소로 연 사내 항목 (없거나 빼는 계정이면 404 RES_001) */
function getDirectoryEntry(id: string, options?: ContactsRequestOptions): Promise<DirectoryEntry> {
return api.get<DirectoryEntry, DirectoryEntry>(`/directory-entries/${segment(id)}`, options)
}
/**
* 그룹의 구성원 전부 — 100명씩 다음 쪽까지 받는다 (그룹 하나에 500명까지 — 계약 3절 "메일과 함께").
* 받는 사람 추천의 그룹 줄 · [그룹에 메일 쓰기]가 쓴다
*/
async function listGroupMembers(groupId: string, options?: ContactsRequestOptions): Promise<Contact[]> {
const members: Contact[] = []
let cursor: string | undefined
for (let page = 0; page < CONTACT_GROUP_MEMBER_PAGES_MAX; page += 1) {
const result = await listContacts({ groupId, limit: CONTACTS_PAGE_SIZE, ...(cursor ? { cursor } : {}) }, options)
members.push(...result.items)
if (!result.pageInfo.hasNext || !result.pageInfo.nextCursor) break
cursor = result.pageInfo.nextCursor
}
return members
}
return {
listContacts,
getContact,
createContact,
updateContact,
deleteContact,
restoreDeleted,
listContactGroups,
createContactGroup,
renameContactGroup,
deleteContactGroup,
addContactGroupMembers,
listDirectoryEntries,
getDirectoryEntry,
listGroupMembers,
}
}
@@ -0,0 +1,53 @@
import { computed, inject, shallowRef, type ComputedRef, type InjectionKey } from 'vue'
import type { Contact, ContactGroup } from '@/shared/types/contacts'
import type { ContactPrefill } from '@/shared/utils/contacts'
// 주소록 화면의 창 — 패널 · 목록 · 상세가 열고, 레이아웃의 창 묶음(ContactsDialogs)이 그린다 (메일함 패널의 MailboxDialogs 와 같은 결)
// 레이아웃(ContactsLayout)이 만들어 provide 하고, 아래 화면은 useContactsDialogs() 로 받는다.
// 창은 레이아웃에 있어 주소록 안에서 목록 · 고른 연락처가 바뀌어도 닫히지 않는다 — 주소록을 떠날 때만 나가기 확인이 묻는다
export type ContactsDialogRequest =
/** 연락처 추가(contact 가 null — prefill 이 있으면 채워서) · 고치기 */
| { kind: 'contact'; contact: Contact | null; prefill: ContactPrefill | null }
/** 새 그룹(group 이 null) · 이름 바꾸기 */
| { kind: 'group-name'; group: ContactGroup | null }
/** 구성원 넣기 */
| { kind: 'members'; group: ContactGroup }
export interface ContactsDialogsHandle {
/** 지금 연 창 — 없으면 null (읽기만) */
request: ComputedRef<ContactsDialogRequest | null>
openContact: (contact: Contact | null, prefill?: ContactPrefill | null) => void
openGroupName: (group: ContactGroup | null) => void
openMembers: (group: ContactGroup) => void
close: () => void
}
export const CONTACTS_DIALOGS: InjectionKey<ContactsDialogsHandle> = Symbol('contacts-dialogs')
export function createContactsDialogs(): ContactsDialogsHandle {
const request = shallowRef<ContactsDialogRequest | null>(null)
return {
request: computed(() => request.value),
openContact: (contact, prefill = null) => {
request.value = { kind: 'contact', contact, prefill }
},
openGroupName: (group) => {
request.value = { kind: 'group-name', group }
},
openMembers: (group) => {
request.value = { kind: 'members', group }
},
close: () => {
request.value = null
},
}
}
/** 주소록 레이아웃 아래의 화면에서 부른다 */
export function useContactsDialogs(): ContactsDialogsHandle {
const handle = inject(CONTACTS_DIALOGS, null)
if (!handle) throw new Error('주소록 창은 주소록 레이아웃(ContactsLayout) 안에서만 열 수 있습니다.')
return handle
}
+4 -1
View File
@@ -299,7 +299,10 @@ export function useMail() {
// ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ──────────────
/** 사내 → 최근 주고받은 주소 순. q 가 있으면 이름 · 주소에 그 글자가 든 것만 */
/**
* 주소록(8단계 — 주소마다 한 줄, 대표 주소가 먼저) → 사내 → 최근 주고받은 주소 순. q 가 있으면 이름 · 주소에 그 글자가 든 것만.
* 그룹은 여기에 없다 — 쓰기 화면이 그룹 목록을 따로 받는다 (useRecipientSuggestions)
*/
function listRecipientSuggestions(
query: RecipientSuggestionQuery,
options?: MailRequestOptions,
@@ -0,0 +1,42 @@
import { computed, inject, shallowRef, type ComputedRef, type InjectionKey } from 'vue'
import type { ContactPrefill } from '@/shared/utils/contacts'
// 메일 화면의 연락처 추가 창 — 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」가 열고, 메일 레이아웃(MailLayout)의 MailContactDialogHost 가 그린다
// (계약 3절 "메일과 함께" · 디자인 README "8단계 › 메일과 함께").
// 창을 읽기 화면 안(v-if="message")에 두면 메일을 바꿀 때(뒤로 · 이전/다음 · 바탕화면 알림 · 다시 받는 동안) 확인 없이 사라진다 —
// 그래서 메일 레이아웃에 둔다: 메일 화면 안에서는 메일이 바뀌어도 창이 남고(채운 값은 연 때의 보낸 사람이라 메일과 상관없다),
// 메일 화면을 떠날 때만 창의 나가기 확인이 묻는다(주소록 화면의 창 묶음과 같은 결 — 2026-10-29 리뷰 M3)
export interface MailContactDialogRequest {
prefill: ContactPrefill
/** 창을 닫은 뒤 초점을 돌려줄 곳 — 연 ⋯ 단추가 아직 있으면 그것 */
returnFocus: (() => void) | null
}
export interface MailContactDialogHandle {
/** 지금 연 창 — 없으면 null (읽기만) */
request: ComputedRef<MailContactDialogRequest | null>
open: (prefill: ContactPrefill, returnFocus?: () => void) => void
close: () => void
}
export const MAIL_CONTACT_DIALOG: InjectionKey<MailContactDialogHandle> = Symbol('mail-contact-dialog')
export function createMailContactDialog(): MailContactDialogHandle {
const request = shallowRef<MailContactDialogRequest | null>(null)
return {
request: computed(() => request.value),
open: (prefill, returnFocus) => {
request.value = { prefill, returnFocus: returnFocus ?? null }
},
close: () => {
request.value = null
},
}
}
/** 메일 레이아웃 아래에서 부른다 — 레이아웃 밖(테스트 등)이면 null (창을 열지 않는다) */
export function useMailContactDialog(): MailContactDialogHandle | null {
return inject(MAIL_CONTACT_DIALOG, null)
}
@@ -6,6 +6,7 @@ import {
RECIPIENT_SEARCH_DELAY_MS,
useRecipientSuggestions,
} from '@/composables/useRecipientSuggestions'
import type { Contact, ContactGroup } from '@/shared/types/contacts'
import type { RecipientSuggestion } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
@@ -46,15 +47,20 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
})
function setup(complete: boolean, searchResult: RecipientSuggestion[] = []) {
requests = installMockApi((request) =>
request.params.q === undefined ? page([DOYUN, JUNHO], !complete) : page(searchResult, false),
)
requests = installMockApi((request) => {
if (request.url === '/contact-groups') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
return request.params.q === undefined ? page([DOYUN, JUNHO], !complete) : page(searchResult, false)
})
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
if (!handle) throw new Error('scope')
return handle
}
const searches = () => requests.filter((request) => request.params.q !== undefined).map((request) => request.params.q)
const suggestionRequests = () => requests.filter((request) => request.url === '/recipient-suggestions')
const searches = () =>
suggestionRequests()
.filter((request) => request.params.q !== undefined)
.map((request) => request.params.q)
it('쓰기 화면을 열 때 q 없이 100개를 한 번 받아 두고 화면에서 거른다', async () => {
const handle = setup(true)
@@ -62,7 +68,7 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
handle.preload()
await flushPromises()
expect(requests.map((request) => request.params)).toEqual([{ limit: 100 }])
expect(suggestionRequests().map((request) => request.params)).toEqual([{ limit: 100 }])
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
excluded = new Set([DOYUN.email])
@@ -80,7 +86,7 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
expect(searches()).toEqual([])
})
it('모자라면 250ms 기다린 뒤 마지막 글자로 묻고, 받은 것을 더한다 — 초성은 묻지 않는다', async () => {
it('모자라고 더 있으면(hasNext) 250ms 기다린 뒤 마지막 글자로 묻고, 받은 것을 더한다 · 같은 글자는 다시 묻지 않는다', async () => {
const handle = setup(false, [VENDOR])
handle.preload()
await flushPromises()
@@ -104,9 +110,33 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
expect(searches()).toEqual(['vendor'])
})
it('미리 받기가 실패하면 치는 대로 q 로 묻고(자모만 친 글자는 묻지 않는다), 다음 preload(칸에 초점)가 다시 받는다', async () => {
it('초성 · 조합 중 글자도 서버에 묻는다 — 8단계부터 서버의 q 가 같은 맞추기다 (주소록으로 찬 100줄 밖의 사내 · 최근 주소)', async () => {
const SEOHA: RecipientSuggestion = { name: '도서하', email: 'seoha.do@arasystem.it.kr', source: 'directory' }
const handle = setup(false, [SEOHA])
handle.preload()
await flushPromises()
handle.search('ㄷㅅ')
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
await flushPromises()
handle.search('도ㅅ')
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
await flushPromises()
// 조합 중(윤을 치는 중)도 — 그사이 또 치면 마지막 글자만
handle.search('도서')
clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
handle.search('도서ㅎ')
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
await flushPromises()
expect(searches()).toEqual(['ㄷㅅ', '도ㅅ', '도서ㅎ'])
expect(handle.matchesFor('ㄷㅅ').map((match) => match.suggestion)).toEqual([SEOHA])
})
it('미리 받기가 실패하면 치는 대로 q 로 묻고(초성도), 다음 preload(칸에 초점)가 다시 받는다', async () => {
let failPreload = true
requests = installMockApi((request) => {
if (request.url === '/contact-groups') return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
if (request.params.q !== undefined) return page([VENDOR], false)
if (failPreload) return fail(503, 'MAIL_001')
return page([DOYUN, JUNHO], false)
@@ -119,11 +149,11 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
handle.search('ㄷㅇ')
clock.advance(1_000)
await flushPromises()
expect(searches()).toEqual([])
expect(searches()).toEqual(['ㄷㅇ'])
handle.search('vendor')
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
await flushPromises()
expect(searches()).toEqual(['vendor'])
expect(searches()).toEqual(['ㄷㅇ', 'vendor'])
expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
failPreload = false
@@ -131,7 +161,7 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
await flushPromises()
handle.preload()
await flushPromises()
expect(requests.filter((request) => request.params.q === undefined)).toHaveLength(2)
expect(suggestionRequests().filter((request) => request.params.q === undefined)).toHaveLength(2)
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
})
@@ -141,5 +171,133 @@ describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () =>
handle?.preload()
await flushPromises()
expect(handle?.matchesFor('도윤')).toEqual([])
expect(handle?.optionsFor('도윤')).toEqual([])
})
describe('주소록 · 그룹 (8단계 — 계약 3절 "메일과 함께")', () => {
const VENDORS: ContactGroup = { id: 'g1', name: '거래처', memberCount: 3 }
const JIHOON: RecipientSuggestion = {
name: '박지훈',
email: 'jihoon.park@hanbit.example',
source: 'contact',
company: '(주)한빛상사',
emailLabel: 'work',
}
function member(id: string, name: string | null, emails: string[]): Contact {
return {
id,
name,
emails: emails.map((address) => ({ address, label: 'work' as const })),
phones: [],
company: null,
department: null,
title: null,
note: null,
groupIds: ['g1'],
}
}
function setupWithGroups(members: (cursor: unknown) => ReturnType<typeof ok> | ReturnType<typeof fail>) {
requests = installMockApi((request) => {
if (request.url === '/contact-groups') {
return ok({ items: [VENDORS], pageInfo: { hasNext: false, nextCursor: null, total: 1 } })
}
if (request.url === '/contacts') return members(request.params.cursor)
return page([JIHOON, DOYUN], false)
})
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
if (!handle) throw new Error('scope')
return handle
}
it('열 때 그룹 목록을 한 번 받아 두고(알림 없이) 그룹 이름으로 찾는다 — 묶음은 주소록 → 그룹 → 사내', async () => {
const handle = setupWithGroups(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
handle.preload()
handle.preload()
await flushPromises()
const groupRequests = requests.filter((request) => request.url === '/contact-groups')
expect(groupRequests.map((request) => request.params)).toEqual([{ limit: 100 }])
expect(groupRequests[0]?.config.silent).toBe(true)
expect(handle.optionsFor('ㄱㄹ').map((option) => option.kind)).toEqual(['group'])
// 주소 추천만 보는 matchesFor 에는 그룹이 없다 (서버에 더 물을지는 주소 추천의 수로 정한다)
expect(handle.matchesFor('ㄱㄹ')).toEqual([])
})
it('주소록 → 그룹 → 사내 차례로 섞어 보인다', async () => {
requests = installMockApi((request) => {
if (request.url === '/contact-groups') {
return ok({
items: [{ id: 'g2', name: '박팀', memberCount: 2 }],
pageInfo: { hasNext: false, nextCursor: null, total: 1 },
})
}
return page([JUNHO, JIHOON], false)
})
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
handle?.preload()
await flushPromises()
expect(
handle
?.optionsFor('박')
.map((option) => (option.kind === 'group' ? `그룹 ${option.match.group.name}` : option.match.suggestion.email)),
).toEqual(['jihoon.park@hanbit.example', '그룹 박팀', JUNHO.email])
})
it('그룹을 고르면 구성원을 100명씩 다음 쪽까지 받아 대표 주소를 돌려준다 — 이메일이 없는 구성원은 센다', async () => {
const handle = setupWithGroups((cursor) =>
cursor === undefined
? ok({
items: [member('c1', '박지훈', ['jihoon.park@hanbit.example', 'jihoon.p@mail.example']), member('c2', '메모만', [])],
pageInfo: { hasNext: true, nextCursor: 'page-2', total: 3 },
})
: ok({
items: [member('c3', null, ['ops@vendor.example'])],
pageInfo: { hasNext: false, nextCursor: null, total: 3 },
}),
)
const result = await handle.expandGroup(VENDORS)
const memberRequests = requests.filter((request) => request.url === '/contacts')
expect(memberRequests.map((request) => request.params)).toEqual([
{ groupId: 'g1', limit: 100 },
{ groupId: 'g1', limit: 100, cursor: 'page-2' },
])
expect(result).toEqual({
addresses: [
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
{ name: null, email: 'ops@vendor.example' },
],
withoutEmail: 1,
})
})
it('구성원을 받지 못하면 null (알림은 인터셉터) · 기다리는 사이 세션이 바뀌면 결과를 버린다', async () => {
const failing = setupWithGroups(() => fail(503, 'MAIL_001'))
expect(await failing.expandGroup(VENDORS)).toBeNull()
let finish: (result: ReturnType<typeof ok>) => void = () => undefined
const late = new Promise<ReturnType<typeof ok>>((resolve) => {
finish = resolve
})
requests = installMockApi((request) =>
request.url === '/contacts' ? late : ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }),
)
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
const pending = handle?.expandGroup(VENDORS)
useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' })
finish(ok({ items: [member('c1', '박지훈', ['jihoon.park@hanbit.example'])], pageInfo: { hasNext: false, nextCursor: null, total: 1 } }))
expect(await pending).toBeNull()
})
it('이미 넣은 주소인지는 지금 칸 값으로 본다 (대소문자 무시)', () => {
const handle = setup(true)
excluded = new Set(['jihoon.park@hanbit.example'])
expect(handle.isAdded('Jihoon.Park@hanbit.example')).toBe(true)
expect(handle.isAdded('ops@vendor.example')).toBe(false)
})
})
})
@@ -1,22 +1,33 @@
import { onScopeDispose, ref, shallowRef, type InjectionKey } from 'vue'
import { systemClock, type Clock, type ClockTimer } from './useClock'
import { useContacts } from './useContacts'
import { useMail } from './useMail'
import { CONTACTS_PAGE_SIZE } from '@/shared/constants/contacts'
import type { ContactGroup } from '@/shared/types/contacts'
import type { RecipientSuggestion } from '@/shared/types/mail'
import { groupRecipientsOf, type GroupRecipients } from '@/shared/utils/contacts'
import { normalizeEmail } from '@/shared/utils/mail-address'
import {
RECIPIENT_SUGGESTION_LIMIT,
arrangeRecipientOptions,
filterRecipientSuggestions,
hasHangulJamo,
matchContactGroups,
type RecipientMatch,
type RecipientOption,
} from '@/shared/utils/recipient-match'
import { useAuthStore } from '@/stores/auth.store'
// 받는 사람 추천 (계약 2절 "받는 사람 추천" · 디자인 README "2단계 › 받는 사람 자동 완성")
// - 쓰기 화면을 열 때 q 없이 limit=100 으로 한 번 받아 두고, 치는 대로 화면에서 거른다(초성 포함 — recipient-match.ts)
// - 맞는 것이 8개보다 모자라고 받아 둔 목록이 전부가 아닐 때(hasNext)만 250ms 기다린 뒤 q 로 서버에 다시 묻는다.
// 받은 것은 받아 둔 목록에 더한다. 서버는 초성을 모르므로 초성 · 홀로 쓴 자모가 든 글자는 묻지 않는다
// - 맞는 것이 8개보다 모자라고 받아 둔 목록이 전부가 아닐 때(hasNext)만 250ms 기다린 뒤 q 로 서버에 다시 묻는다(늦은 응답은 버린다).
// 받은 것은 받아 둔 목록에 더한다. 8단계부터 서버의 q 도 이 화면과 같은 맞추기라(초성 · 조합 중 글자 — 계약 2절 "받는 사람 추천") 그런 글자도 묻는다 —
// 주소록이 크면 받아 둔 100줄이 주소록으로 차서 사내 · 최근 주소가 그 안에 없다
// - 추천은 보조 정보다 — 못 받아도 알리지 않는다 (silent). 주소는 그대로 칠 수 있다.
// 미리 받기가 실패하면 치는 대로 q 로 묻고(초성 · 자모만 친 글자는 여전히 묻지 않는다), 다음에 칸에 초점이 올 때 다시 받아 본다
// 미리 받기가 실패하면 치는 대로 q 로 묻고, 다음에 칸에 초점이 올 때 다시 받아 본다
// - 8단계(계약 3절 "메일과 함께"): 서버 추천에 내 주소록(source contact)이 먼저 온다. 그룹은 서버 추천에 없어 열 때 그룹 목록을 한 번 받아 두고
// 이름으로 찾는다(주소록 → 그룹 → 사내 → 최근 — optionsFor). 그룹을 고르면 구성원을 받아(100명씩 다음 쪽까지 — expandGroup)
// 대표 주소를 넣는다 — 그룹 목록 · 구성원을 못 받아도 주소 추천은 그대로다. 구성원 받기의 실패는 고른 일이라 인터셉터가 알린다
// 쓰기 화면이 만들어 provide 하고(RECIPIENT_SUGGESTIONS), 받는 사람 칸(RecipientField)이 inject 해 쓴다.
/** 처음 받아 두는 개수 — 계약의 최대 limit */
@@ -35,18 +46,31 @@ export interface RecipientSuggestionsOptions {
export interface RecipientSuggestionsHandle {
/** 쓰기 화면을 열 때 · 받는 사람 칸에 초점이 올 때 — 받는 중이거나 받았으면 아무것도 하지 않는다 (실패했으면 다시 받는다) */
preload: () => void
/** 친 글자에 맞는 추천 — 최대 8개, 반응형으로 읽는다 (받아 둔 목록 · 이미 넣은 주소가 바뀌면 다시 계산된다) */
/** 친 글자에 맞는 주소 추천 — 최대 8개, 반응형으로 읽는다 (받아 둔 목록 · 이미 넣은 주소가 바뀌면 다시 계산된다) */
matchesFor: (query: string) => RecipientMatch[]
/** 추천 목록의 줄 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 모두 합쳐 최대 8개 (반응형) */
optionsFor: (query: string) => RecipientOption[]
/** 맞는 것이 모자라면 서버에 묻는다 (기다렸다가 — 그사이 또 치면 마지막 글자만) */
search: (query: string) => void
/** 그룹의 구성원 → 넣을 대표 주소 · 이메일이 없는 구성원 수. 못 받았거나 그사이 세션이 바뀌었으면 null (실패는 인터셉터가 알렸다) */
expandGroup: (group: ContactGroup) => Promise<GroupRecipients | null>
/** 받는 사람 · 참조 · 숨은참조 가운데 이미 넣은 주소인가 — 지금 칸 값으로 본다 */
isAdded: (email: string) => boolean
}
export const RECIPIENT_SUGGESTIONS: InjectionKey<RecipientSuggestionsHandle> = Symbol('recipient-suggestions')
export function useRecipientSuggestions(options: RecipientSuggestionsOptions): RecipientSuggestionsHandle {
const { listRecipientSuggestions } = useMail()
const { listContactGroups, listGroupMembers } = useContacts()
const authStore = useAuthStore()
const clock = options.clock ?? systemClock
/** 받아 둔 그룹 — 이름으로 찾는다 (그룹은 100개까지 — 한 쪽) */
const groups = shallowRef<readonly ContactGroup[]>([])
/** 그룹 받기 — 실패했으면 다음 preload 가 다시 받는다 */
let groupsState: 'idle' | 'loading' | 'done' | 'failed' = 'idle'
/** 받아 둔 추천 — 처음 받은 것 + 검색으로 더 받은 것 (주소가 겹치지 않게) */
const pool = shallowRef<readonly RecipientSuggestion[]>([])
/** 처음 받은 목록이 전부다 — 서버에 더 물을 것이 없다 */
@@ -69,7 +93,22 @@ export function useRecipientSuggestions(options: RecipientSuggestionsOptions): R
if (added.length > 0) pool.value = [...pool.value, ...added]
}
function preloadGroups() {
if (groupsState === 'loading' || groupsState === 'done') return
groupsState = 'loading'
void listContactGroups({ limit: CONTACTS_PAGE_SIZE }, { silent: true })
.then((page) => {
groupsState = 'done'
groups.value = Array.isArray(page?.items) ? page.items : []
})
.catch(() => {
// 그룹 줄 없이 주소 추천만 — 다음에 칸에 초점이 오면 다시 받아 본다
groupsState = 'failed'
})
}
function preload() {
preloadGroups()
if (preloadState === 'loading' || preloadState === 'done') return
preloadState = 'loading'
void listRecipientSuggestions({ limit: RECIPIENT_SUGGESTION_PRELOAD }, { silent: true })
@@ -88,6 +127,24 @@ export function useRecipientSuggestions(options: RecipientSuggestionsOptions): R
return filterRecipientSuggestions(query, pool.value, options.excluded())
}
function optionsFor(query: string): RecipientOption[] {
return arrangeRecipientOptions(matchesFor(query), matchContactGroups(query, groups.value))
}
function isAdded(email: string): boolean {
return options.excluded().has(normalizeEmail(email))
}
async function expandGroup(group: ContactGroup): Promise<GroupRecipients | null> {
const session = authStore.generation
try {
const members = await listGroupMembers(group.id)
return authStore.generation === session ? groupRecipientsOf(members) : null
} catch {
return null
}
}
function cancelSearch() {
if (searchTimer !== null) clock.clearTimeout(searchTimer)
searchTimer = null
@@ -116,12 +173,12 @@ export function useRecipientSuggestions(options: RecipientSuggestionsOptions): R
cancelSearch()
const text = query.trim()
if (text === '' || text.length > SEARCH_QUERY_MAX_LENGTH || complete.value) return
if (hasHangulJamo(text) || asked.has(text)) return
if (asked.has(text)) return
if (matchesFor(text).length >= RECIPIENT_SUGGESTION_LIMIT) return
searchTimer = clock.setTimeout(() => void ask(text), RECIPIENT_SEARCH_DELAY_MS)
}
onScopeDispose(cancelSearch)
return { preload, matchesFor, search }
return { preload, matchesFor, optionsFor, search, expandGroup, isAdded }
}
@@ -0,0 +1,43 @@
<script setup lang="ts">
// 주소록 모듈 레이아웃 (8단계 — 디자인 원본 Contacts · README "8단계 — 주소록") — 전역 셸 + 모듈 패널(내 주소록 · 그룹 · 사내) + 하위 화면(목록 · 상세)
// - 상단 바의 찾기 칸은 "주소록에서 찾기" — 지금 보는 목록 안에서 찾는다 (ContactsSearchBox)
// - 들어올 때마다 그룹 · 연락처 수 · 사내 목록 수를 새로 받는다 (다른 탭 · 휴대폰 · 메일 프로그램에서 바꿨을 수 있다)
// - 창(연락처 · 그룹 이름 · 구성원 넣기)은 여기서 그린다 — 주소록 안에서 목록 · 고른 연락처가 바뀌어도 닫히지 않고,
// 주소록을 떠날 때만 나가기 확인이 묻는다. 세션이 바뀌면(로그아웃 · 만료) 닫는다 — 세션 만료 대화상자와 겹치지 않게
import { provide, watch } from 'vue'
import ContactsDialogs from '@/components/contacts/ContactsDialogs.vue'
import ContactsPanel from '@/components/contacts/ContactsPanel.vue'
import ContactsSearchBox from '@/components/contacts/ContactsSearchBox.vue'
import AppShell from '@/components/shell/AppShell.vue'
import { CONTACTS_DIALOGS, createContactsDialogs } from '@/composables/useContactsDialogs'
import { useAuthStore } from '@/stores/auth.store'
import { useContactsStore } from '@/stores/contacts.store'
const authStore = useAuthStore()
const contactsStore = useContactsStore()
const dialogs = createContactsDialogs()
provide(CONTACTS_DIALOGS, dialogs)
contactsStore.enterModule()
watch(
() => authStore.generation,
() => dialogs.close(),
)
</script>
<template>
<AppShell module-id="contacts">
<template #search>
<ContactsSearchBox />
</template>
<ContactsPanel />
<RouterView />
</AppShell>
<!-- 창은 셸 밖에 둔다 — 열리면 맨 위 층에 뜬다 -->
<ContactsDialogs
:request="dialogs.request.value"
@close="dialogs.close()"
/>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
<script setup lang="ts">
// 주소록 목록 · 상세 화면 (/contacts · /contacts/all/:contactId · /contacts/groups/:groupId[/:contactId] · /contacts/directory/:kind[/:entryId], 모두 ?q=)
// 화면 상태는 주소가 기준이다: 주소의 목록(전체 · 그룹 · 사내) · 찾을 글이 바뀌면 그 목록을 새로 받고, 고른 항목이 바뀌면 상세만 바꾼다.
// - 고른 연락처 · 사내 항목이 받은 목록에 없으면(주소로 바로 들어옴 · 다음 쪽 · 목록을 못 받음 · 그사이 목록에서 빠짐) 그것만 따로 받는다
// (GET /contacts/{id} · /directory-entries/{id})
// - 목록(ContactsList) 384 · 상세(ContactDetail) — 메일의 분할 보기와 같다 (넓은 화면에서는 칸마다 스크롤)
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import ContactDetail from '@/components/contacts/ContactDetail.vue'
import ContactsList from '@/components/contacts/ContactsList.vue'
import { contactsRouteStateOf, contactsSourceKey, isContactSource } from '@/shared/utils/contacts-route'
import { useContactsStore } from '@/stores/contacts.store'
const route = useRoute()
const store = useContactsStore()
const state = computed(() => contactsRouteStateOf(route))
/** 고른 항목이 받아 둔 것(목록 · 따로 받은 연락처)에 있다 */
const selectedPresent = computed(() => {
const current = state.value
if (!current?.selectedId) return false
return isContactSource(current.source)
? store.contactById(current.selectedId) !== null
: store.entryById(current.selectedId) !== null
})
// 목록 · 찾을 글이 바뀔 때만 다시 받는다 — 연락처를 고르는 것으로는 다시 받지 않는다 (값마다 견준다)
watch(
[() => contactsSourceKey(state.value?.source ?? null), () => state.value?.q ?? null],
() => {
const current = state.value
if (current) void store.openList(current.source, current.q)
},
{ immediate: true },
)
// 고른 항목이 받아 둔 것에 없으면 그것만 따로 받는다(목록을 못 받았어도) — 목록을 받는 동안은 기다린다(첫 쪽에 있을 수 있다)
watch(
[() => state.value?.selectedId ?? null, () => store.listStatus, selectedPresent],
([selectedId, listStatus, present]) => {
const current = state.value
if (!current || !selectedId) {
store.closeDetail()
store.closeEntry()
return
}
if (present || listStatus === 'loading' || listStatus === 'idle') return
if (isContactSource(current.source)) void store.openDetail(selectedId)
else void store.openEntry(selectedId)
},
{ immediate: true },
)
</script>
<template>
<main class="contacts-view">
<template v-if="state">
<ContactsList :state="state" />
<ContactDetail :state="state" />
</template>
</main>
</template>
<style scoped>
.contacts-view {
flex: 999 1 35rem;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
/* 넓은 화면에서는 목록 · 상세가 한 줄에 놓이고 칸마다 스크롤한다 (메일 화면과 같은 이유로 줄바꿈을 끈다) */
@media (min-width: 70rem) {
.contacts-view {
flex-wrap: nowrap;
min-height: 0;
}
}
</style>
@@ -18,6 +18,7 @@ import {
} from '@/shared/constants/mail'
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import type { Mailbox, MessageDetail, RejectedRecipient } from '@/shared/types/mail'
import { composePrefillState } from '@/shared/utils/compose-prefill'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
@@ -1126,6 +1127,67 @@ describe('ComposePage', () => {
wrapper.unmount()
})
it('주소록에서 받는 사람을 채워 연 새 메일(라우터 상태 — 주소는 URL 에 없다) — 칩으로 채우고 제목에 초점, 손대지 않으면 저장하지도 묻지도 않는다', async () => {
const requests = installMockApi(savable(replyApi))
const router = createTestRouter()
await router.push({
name: 'mail-compose',
state: composePrefillState(
[
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
{ name: '최유진', email: 'yujin.choi@greencloud.example' },
// 상태는 브라우저가 기억하는 값이라 다시 본다 — 형식이 틀린 주소는 넣지 않는다
{ name: null, email: 'not-an-address' },
],
USER.email,
),
})
await router.isReady()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
const wrapper = mount(ComposePage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: screenProvide(router) },
})
mountedWrappers.push(wrapper)
await flushPromises()
await editorLoaded(wrapper)
expect(wrapper.get('h1').text()).toBe('새 메일')
expect(chips(wrapper, 'compose-to')).toEqual([
'박지훈 <jihoon.park@hanbit.example>',
'최유진 <yujin.choi@greencloud.example>',
])
expect(document.activeElement?.id).toBe('compose-subject')
expect(blocksUnload()).toBe(false)
await router.push('/mail/inbox')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(dialogOpen(wrapper)).toBe(false)
expect(draftSaves(requests)).toEqual([])
})
it('채운 받는 사람을 실은 직원이 지금 직원과 다르면(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기) 채우지 않는다 — 빈 새 메일, 초점은 받는 사람', async () => {
installMockApi(savable(replyApi))
const router = createTestRouter()
await router.push({
name: 'mail-compose',
state: composePrefillState([{ name: '박지훈', email: 'jihoon.park@hanbit.example' }], 'doyun.lee@arasystem.it.kr'),
})
await router.isReady()
const wrapper = mount(ComposePage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: screenProvide(router) },
})
mountedWrappers.push(wrapper)
await flushPromises()
await editorLoaded(wrapper)
expect(chips(wrapper, 'compose-to')).toEqual([])
expect(document.activeElement?.id).toBe('compose-to')
})
it('손대지 않은 전달 초안(원본 첨부 포함)은 저장하지도 묻지도 않는다 — 원본 첨부를 빼면 바꾼 것이라 떠나기 전에 저장한다', async () => {
const first = await mountCompose('/mail/compose?forward=m1', savable(forwardApi))
expect(blocksUnload()).toBe(false)
+11 -1
View File
@@ -3,6 +3,8 @@
// 답장: ?replyTo=<메일 id> (전체 답장은 &all=1) → 받는 사람 · "Re:" 제목 · 원문 인용을 채운다.
// 전달: ?forward=<메일 id> → "Fwd:" 제목 · 원문 인용 · 원본 첨부(하나씩 뺄 수 있다)를 채운다. inReplyTo 는 보내지 않는다.
// 이어 쓰기: ?draft=<임시 저장본 id> → 저장본을 불러와 채운다 (서명은 다시 넣지 않는다). 제목은 답장 · 전달 · 새 메일 중 하나.
// 받는 사람을 채운 새 메일(8단계 — 주소록의 [메일 쓰기] · 주소 링크 · [그룹에 메일 쓰기]): 주소는 URL 이 아니라 라우터 상태로 온다(compose-prefill.ts).
// 채운 받는 사람은 "바꿈"이 아니다(답장처럼 — 손대지 않고 닫으면 저장하지 않는다). 초점은 제목으로 (받는 사람은 이미 있다)
// 서명: 열 때 설정의 서명을 보통 글로 넣어 둔다 — 새 메일은 본문 끝(빈 줄 하나 뒤), 답장 · 전달은 인용 위 (디자인 README "2단계 › 서명").
// 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장", useDraftSaving):
// - 바꾼 내용이 있으면 입력이 3초 멈출 때(10초에 한 번까지 — 쉬지 않고 쓰면 처음 바꾼 뒤 30초에) · 「임시 저장」을 누를 때 ·
@@ -89,6 +91,7 @@ import {
snapshotComposeForm,
type ComposeFormSnapshot,
} from '@/shared/utils/compose-changes'
import { composePrefillOf } from '@/shared/utils/compose-prefill'
import { isImageFile } from '@/shared/utils/editor-files'
import {
createIdempotencyKey,
@@ -973,8 +976,15 @@ watch(
} else if (id && next !== 'new' && next !== 'draft') {
void prepareFrom(id, next)
} else {
// 주소록에서 받는 사람을 채워 연 새 메일 — 처음 채운 받는 사람은 바꿈이 아니다 (저장한 양식으로 둔다).
// 상태를 실은 직원이 지금 직원과 다르면 쓰지 않는다 (같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기 — compose-prefill.ts)
const prefilled = next === 'new' ? composePrefillOf(router.options.history.state, authStore.user?.email) : []
if (prefilled.length > 0) {
to.value = prefilled.map(tokenFromAddress)
savedForm.value = snapshotForm()
}
void prepareNew()
void nextTick(() => toField.value?.focus())
void nextTick(() => (prefilled.length > 0 ? subjectInputRef.value?.focus() : toField.value?.focus()))
}
},
{ immediate: true },
+7
View File
@@ -4,10 +4,15 @@
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 새 메일 · 다른 창의 변화를 맞춘다(실시간 소식 — 끊겼으면 1분 확인),
// 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계" · "5단계 › 실시간 반영"). 실시간 연결 자체는 앱 전체에서 돈다(App.vue)
// 들어올 때 부재중 자동 응답을 한 번 받는다 — 켜져 있으면 메일함 패널 아래에 안내 (계약 2절 "부재중 자동 응답")
// 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」의 창은 여기에 둔다 — 메일이 바뀌어도 사라지지 않게 (useMailContactDialog)
import { provide } from 'vue'
import AppShell from '@/components/shell/AppShell.vue'
import MailContactDialogHost from '@/components/mail/MailContactDialogHost.vue'
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
import { useClock } from '@/composables/useClock'
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
@@ -15,6 +20,7 @@ import { useMailVacationStore } from '@/stores/mail-vacation.store'
import { useMailStore } from '@/stores/mail.store'
const mailStore = useMailStore()
provide(MAIL_CONTACT_DIALOG, createMailContactDialog())
// 하위 화면(목록·본문)보다 먼저 요청을 보낸다 — 메일함 개수가 본문 읽음 처리보다 늦게 도착하는 일을 줄인다
void mailStore.ensureMailboxes()
@@ -46,4 +52,5 @@ useMailTabTitle()
<MailboxPanel />
<RouterView />
</AppShell>
<MailContactDialogHost />
</template>
+24
View File
@@ -1,5 +1,6 @@
import type { RouteRecordRaw } from 'vue-router'
import { CONTACTS_ROUTES } from '@/shared/constants/contacts'
import { SETTINGS_ROUTES } from '@/shared/constants/settings-menu'
import { MAIL_SEARCH_MESSAGE_ROUTE, MAIL_SEARCH_ROUTE } from '@/shared/utils/mail-list-source'
@@ -7,6 +8,10 @@ import { MAIL_SEARCH_MESSAGE_ROUTE, MAIL_SEARCH_ROUTE } from '@/shared/utils/mai
// 목록·본문 라우트가 같은 컴포넌트를 쓰도록 한 번만 정의한다 (메일을 바꿔도 화면을 다시 만들지 않는다)
const MailboxPage = () => import('@/pages/mail/MailboxPage.vue')
// 주소록도 같다 — 목록(전체 · 그룹 · 사내)과 고른 연락처가 바뀌어도 한 화면이 주소만 다시 읽는다
const ContactsPage = () => import('@/pages/contacts/ContactsPage.vue')
/** 사내 목록 종류 — 주소의 people · shared 만 (constants/contacts.ts 의 DIRECTORY_KIND_SEGMENTS) */
const DIRECTORY_KIND_PARAM = ':kind(people|shared)'
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import 처리
export const routes: RouteRecordRaw[] = [
@@ -46,6 +51,25 @@ export const routes: RouteRecordRaw[] = [
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
],
},
{
// 주소록 (8단계 — 디자인 README "8단계 — 주소록") — 모듈 패널(내 주소록 · 그룹 · 사내) + 목록 + 상세.
// 고른 연락처 · 사내 항목은 주소에 남는다(새로 고침 · 뒤로 가기). 모두 ?q= (지금 목록 안에서 찾기)를 받는다
path: '/contacts',
component: () => import('@/pages/contacts/ContactsLayout.vue'),
meta: { requiresAuth: true, title: '주소록' },
children: [
{ path: '', name: CONTACTS_ROUTES.all, component: ContactsPage },
{ path: 'all/:contactId', name: CONTACTS_ROUTES.contact, component: ContactsPage },
{ path: 'groups/:groupId', name: CONTACTS_ROUTES.group, component: ContactsPage },
{ path: 'groups/:groupId/:contactId', name: CONTACTS_ROUTES.groupContact, component: ContactsPage },
{ path: `directory/${DIRECTORY_KIND_PARAM}`, name: CONTACTS_ROUTES.directory, component: ContactsPage },
{
path: `directory/${DIRECTORY_KIND_PARAM}/:entryId`,
name: CONTACTS_ROUTES.directoryEntry,
component: ContactsPage,
},
],
},
{
// 설정 — 왼쪽 메뉴(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터 · 알림) + 하위 화면.
// /settings 는 첫 메뉴(계정 › 비밀번호)로 바꿔 끼운다 (앱 레일의 "설정")
+2 -1
View File
@@ -25,7 +25,8 @@ export const APP_MODULES: readonly AppModule[] = [
{ id: 'calendar', label: '캘린더', icon: 'calendar', routeName: null, placement: 'main' },
{ id: 'files', label: '파일', icon: 'folder', routeName: null, placement: 'main' },
{ id: 'erp', label: 'ERP', icon: 'briefcase', routeName: null, placement: 'main' },
{ id: 'contacts', label: '주소록', icon: 'users', routeName: null, placement: 'main' },
// 8단계 — 주소록 (첫 화면은 전체 연락처, constants/contacts.ts 의 CONTACTS_ROUTES.all)
{ id: 'contacts', label: '주소록', icon: 'users', routeName: 'contacts', placement: 'main' },
{ id: 'settings', label: '설정', icon: 'settings', routeName: 'settings', placement: 'end' },
]
+328
View File
@@ -0,0 +1,328 @@
// 주소록 상수 — 계약 3절 "주소록 — 8단계" · 디자인 README "8단계 — 주소록" (디자인 원본 Contacts · ContactsEdit · ContactsMail)
// 한도는 서버와 같은 값이다 — 서버가 최종 검증한다. 문구는 보드의 글 그대로다(보드에 없던 것은 README "구현하며 정한 보완"으로 옮긴다).
import type { ContactEmailLabel, ContactPhoneLabel, DirectoryKind } from '@/shared/types/contacts'
/**
* 주소록 라우트 이름 — 라우트 표(router/routes.ts) · 레일(app-modules.ts) · 화면 안의 링크 · 읽기 화면 ⋯ 「주소록에서 보기」가 함께 쓴다.
* /contacts all 전체 연락처
* /contacts/all/:contactId contact 전체 연락처 · 고른 연락처
* /contacts/groups/:groupId group 그룹
* /contacts/groups/:groupId/:contactId groupContact 그룹 · 고른 연락처
* /contacts/directory/:kind directory 사내 직원(people) · 공용 주소(shared)
* /contacts/directory/:kind/:entryId directoryEntry 사내 · 고른 직원 · 공용 주소
* 모두 ?q= (지금 보는 목록 안에서 찾기)를 받는다 — 서버 · nginx 로그는 q 를 가린다 (계약 3절 "로그")
*/
export const CONTACTS_ROUTES = {
all: 'contacts',
contact: 'contacts-contact',
group: 'contacts-group',
groupContact: 'contacts-group-contact',
directory: 'contacts-directory',
directoryEntry: 'contacts-directory-entry',
} as const
/** 주소의 사내 목록 종류 — 계약의 person · shared 를 주소에서는 people · shared 로 쓴다 */
export const DIRECTORY_KIND_SEGMENTS: Readonly<Record<DirectoryKind, string>> = {
person: 'people',
shared: 'shared',
}
/** 찾을 글의 쿼리 이름 */
export const CONTACTS_QUERY_KEY = 'q'
// ── 한도 (계약 3절 "타입" · "엔드포인트") ──────────────────────────────
/** 연락처 수 — 넘으면 409 CONTACT_002 */
export const CONTACTS_MAX = 3000
/** 그룹 수 — 넘으면 409 CONTACT_002 */
export const CONTACT_GROUPS_MAX = 100
/** 이름 · 회사 · 부서 · 직책 */
export const CONTACT_TEXT_MAX_LENGTH = 100
export const CONTACT_EMAIL_MAX_LENGTH = 254
export const CONTACT_PHONE_MAX_LENGTH = 40
export const CONTACT_NOTE_MAX_LENGTH = 1000
/** 고치기 창이 더할 수 있는 이메일 · 전화번호 수 — 다른 프로그램이 넣은 그보다 많은 것은 그대로 보이고 저장된다 (서버는 20개까지) */
export const CONTACT_FIELD_ROWS_MAX = 5
/** 서버가 받는 이메일 · 전화번호 수 — 그 칸을 보낼 때만 본다(고치기는 바꾼 칸만 보낸다 — 계약 3절 "고치기") */
export const CONTACT_EMAILS_MAX = 20
export const CONTACT_PHONES_MAX = 20
export const CONTACT_GROUP_NAME_MAX_LENGTH = 50
/** 목록 한 번에 받는 수 — [더 보기]로 이어 받는다 (계약의 최대 limit) */
export const CONTACTS_PAGE_SIZE = 100
/** 구성원 넣기 한 번에 고를 수 있는 수 — contactIds · directoryIds 합쳐 (계약 3절) */
export const CONTACT_MEMBERS_PICK_MAX = 100
/** 그룹 구성원을 받을 때 따라가는 쪽 수 — 그룹 하나에 500명(100명씩 5쪽)까지라 넉넉히 */
export const CONTACT_GROUP_MEMBER_PAGES_MAX = 10
/** 찾기 칸 — 치고 쉬는 시간 · 서버가 받는 길이 (q 1~254자 — 주소 하나를 통째로 찾을 수 있게) */
export const CONTACTS_SEARCH_DELAY_MS = 250
export const CONTACTS_SEARCH_MAX_LENGTH = 254
// ── 종류 이름 ─────────────────────────────────────────────
export const CONTACT_EMAIL_LABEL_TEXT: Readonly<Record<ContactEmailLabel, string>> = {
work: '회사',
personal: '개인',
other: '기타',
}
export const CONTACT_PHONE_LABEL_TEXT: Readonly<Record<ContactPhoneLabel, string>> = {
mobile: '휴대전화',
work: '회사',
home: '집',
other: '기타',
}
export const CONTACT_EMAIL_LABELS: readonly ContactEmailLabel[] = ['work', 'personal', 'other']
export const CONTACT_PHONE_LABELS: readonly ContactPhoneLabel[] = ['mobile', 'work', 'home', 'other']
/** 사내 목록의 종류 이름 — 패널 · 목록 머리 */
export const DIRECTORY_KIND_TEXT: Readonly<Record<DirectoryKind, string>> = {
person: '직원',
shared: '공용 주소',
}
// ── 화면 문구 ─────────────────────────────────────────────
/** 모듈 패널 (208 — 디자인 원본 Contacts 왼쪽) */
export const CONTACTS_PANEL_TEXT = {
label: '주소록',
add: '연락처 추가',
mine: '내 주소록',
all: '전체 연락처',
groups: '그룹',
newGroup: '새 그룹',
groupsLoading: '그룹 불러오는 중',
company: '사내',
/** 연락처 3,000개 — [연락처 추가] 아래 까닭 (디자인 원본 ContactsEdit "한도") */
fullNote: '연락처는 3,000개까지 둘 수 있습니다. 쓰지 않는 연락처를 지운 뒤 추가해 주세요.',
/** 그룹 100개 — [새 그룹] 아래 까닭 */
groupsFullNote: '그룹은 100개까지 만들 수 있습니다.',
groupMore: (name: string) => `‘${name}’ 그룹 메뉴`,
groupMenu: (name: string) => `‘${name}’ 그룹`,
} as const
/** 그룹 ⋯ 메뉴 항목 */
export const CONTACT_GROUP_MENU_TEXT = {
rename: '이름 바꾸기',
compose: '그룹에 메일 쓰기',
delete: '그룹 지우기',
/** 구성원이 없는 그룹의 「그룹에 메일 쓰기」 — 꺼진 까닭 */
composeEmptyNote: '아직 구성원이 없습니다.',
} as const
/** 상단 바의 찾기 칸 — 지금 보는 목록 안에서 찾는다 */
export const CONTACTS_SEARCH_TEXT = {
label: '주소록에서 찾기',
placeholder: '이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ)',
/** 사내 목록은 이름 · 주소로만 찾는다 (계약 3절 "찾기") */
directoryPlaceholder: '이름 · 주소 — 초성도 됩니다 (ㅂㅈㅎ)',
clear: '찾을 글 지우기',
} as const
/** 목록 칸 (384) */
export const CONTACTS_LIST_TEXT = {
/** 공용 주소는 "N개", 그 밖은 "N명" */
count: (count: string, shared: boolean) => `${count}${shared ? '개' : '명'}`,
found: (count: string, shared: boolean) => `찾은 결과 ${count}${shared ? '개' : '명'}`,
directoryNote: {
person: '메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다.',
shared: '여러 사람이 함께 받는 사내 주소입니다.',
} satisfies Record<DirectoryKind, string>,
addMembers: '구성원 넣기',
composeGroup: '그룹에 메일 쓰기',
more: '더 보기',
loadingMore: '불러오는 중…',
loading: '연락처 불러오는 중',
/** 없는 그룹(다른 곳에서 지움 · 옛 주소) — 다시 시도 대신 */
leaveToAll: '전체 연락처로',
retry: '다시 시도',
} as const
/** 목록의 빈 상태 · 못 받음 (디자인 원본 Contacts · ContactsEdit "상태") */
export const CONTACTS_EMPTY_TEXT = {
searchTitle: (query: string, directory: boolean) => `‘${query}’에 맞는 ${directory ? '주소가' : '연락처가'} 없습니다`,
searchHint: '이름 · 주소 · 회사 · 전화번호의 일부나 이름의 초성으로 찾습니다.',
directorySearchHint: '이름 · 주소의 일부나 이름의 초성으로 찾습니다.',
firstTitle: '아직 연락처가 없습니다',
firstHint: '자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾습니다.',
firstNote: '메일을 읽다가 ⋯ › 「보낸 사람을 주소록에 추가」로도 넣을 수 있습니다.',
groupTitle: (name: string) => `‘${name}’ 그룹에 아직 아무도 없습니다`,
groupHint: '구성원을 넣으면 그룹 이름으로 한 번에 메일을 보낼 수 있습니다.',
directoryTitle: {
person: '사내 직원이 없습니다',
shared: '공용 주소가 없습니다',
} satisfies Record<DirectoryKind, string>,
directoryErrorTitle: '사내 목록을 불러오지 못했습니다',
directoryErrorHint: '잠시 후 다시 시도해 주세요. 내 주소록은 그대로 쓸 수 있습니다.',
} as const
/** 상세 칸 */
export const CONTACT_DETAIL_TEXT = {
compose: '메일 쓰기',
edit: '고치기',
more: '더 보기',
menu: '연락처 메뉴',
/** 상세 머리 줄(단추 묶음)의 이름 */
actions: '연락처 동작',
addToMine: '내 주소록에 추가',
noOrg: '소속 없음',
noName: '(이름 없음)',
person: '사내 직원',
shared: '공용 주소',
email: '이메일',
phone: '전화',
org: '소속',
title: '직책',
groups: '그룹',
note: '메모',
/** 주소 링크의 이름 — 누르면 그 주소로 새 메일 */
composeTo: (address: string) => `${address}에 메일 쓰기`,
/** 메일 주소가 없는 연락처의 [메일 쓰기] — 꺼진 까닭 */
composeNoAddress: '메일 주소가 없는 연락처입니다.',
/** 그룹 단추의 이름 — 누르면 그 그룹의 목록 */
openGroup: (name: string) => `‘${name}’ 그룹 보기`,
directoryHint:
'사내 목록은 메일 서버의 계정에서 옵니다 — 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호 · 메모를 적어 두려면 내 주소록에 추가하세요.',
noneTitle: '연락처를 고르세요',
noneHint: '목록에서 고르면 주소 · 전화번호가 여기에 보입니다.',
leaveToList: '목록으로',
loading: '연락처 불러오는 중',
} as const
/** 상세 ⋯ 메뉴 */
export const CONTACT_MENU_TEXT = {
copy: '주소 복사',
delete: '연락처 지우기',
/** 메일 주소가 없는 연락처의 「주소 복사」 — 꺼진 까닭 */
copyEmptyNote: '메일 주소가 없습니다.',
} as const
/** 연락처 추가 · 고치기 창 (모달 620 — 디자인 원본 ContactsEdit) */
export const CONTACT_DIALOG_TEXT = {
createTitle: '연락처 추가',
editTitle: '연락처 고치기',
name: '이름',
email: '이메일',
emailKind: (ordinal: string) => `${ordinal} 이메일의 종류`,
emailAddress: (ordinal: string) => `${ordinal} 이메일`,
emailRemove: (ordinal: string) => `${ordinal} 이메일 빼기`,
addEmail: '이메일 더하기',
emailHint: '5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다',
phone: '전화번호',
phoneKind: (ordinal: string) => `${ordinal} 전화번호의 종류`,
phoneNumber: (ordinal: string) => `${ordinal} 전화번호`,
phoneRemove: (ordinal: string) => `${ordinal} 전화번호 빼기`,
addPhone: '전화번호 더하기',
phoneHint: '5개까지',
company: '회사',
department: '부서',
title: '직책',
note: '메모',
noteCount: (length: string, max: string) => `${length} / ${max}`,
/** 다른 프로그램이 넣은 긴 메모를 고쳤다 — 입력칸의 maxlength 는 친 글만 막는다 */
noteTooLong: (max: string) => `메모는 ${max}자까지 쓸 수 있습니다.`,
/** 다른 프로그램이 넣은 긴 이름 · 회사 · 부서 · 직책을 고쳤다 */
textTooLong: (max: string) => `${max}자까지 쓸 수 있습니다.`,
/** 다른 프로그램이 넣은 21개 넘는 이메일 · 전화번호를 고쳤다 — 그 칸은 통째로 보내므로 서버 한도에 맞춰야 한다 */
emailsTooMany: (max: string) => `이메일은 ${max}개까지 넣을 수 있습니다. 몇 개를 빼 주세요.`,
phonesTooMany: (max: string) => `전화번호는 ${max}개까지 넣을 수 있습니다. 몇 개를 빼 주세요.`,
groups: '그룹',
required: '이름이나 이메일 가운데 하나는 있어야 합니다.',
save: '저장',
cancel: '취소',
remove: '이 연락처 지우기',
emailInvalid: '이메일 형식이 올바르지 않습니다.',
emailRepeated: '같은 주소를 두 번 넣었습니다.',
phoneInvalid: '전화번호 형식이 올바르지 않습니다. 숫자와 띄어쓰기 · + - ( ) . # * 만 쓸 수 있습니다.',
/** 같은 주소가 이미 주소록에 있다 — 막지 않는다 (who: "강서윤(거래처)") */
duplicate: (who: string) => `이미 주소록에 있는 주소입니다 — ${who}. 그래도 저장할 수 있습니다.`,
duplicateView: '그 연락처 보기',
} as const
/** 연락처 창의 나가기 확인 — 필터 고치기 창과 같은 모양 (useDialogLeaveConfirm) */
export const CONTACT_LEAVE_DIALOG = {
close: {
title: '저장하지 않은 연락처가 있습니다',
message: '창을 닫으면 고친 내용이 사라집니다.',
stay: '계속 고치기',
leave: '닫기',
},
route: {
title: '저장하지 않은 연락처가 있습니다',
message: '이 화면을 나가면 고친 내용이 사라집니다.',
stay: '계속 고치기',
leave: '나가기',
},
} as const
/** 새 그룹 · 이름 바꾸기 창 (모달 400) */
export const CONTACT_GROUP_DIALOG_TEXT = {
createTitle: '새 그룹',
renameTitle: '그룹 이름 바꾸기',
name: '그룹 이름',
create: '만들기',
save: '저장',
cancel: '취소',
} as const
/** 구성원 넣기 창 (모달 620) */
export const CONTACT_MEMBERS_TEXT = {
title: (group: string) => `‘${group}’에 구성원 넣기`,
sourceLabel: '어디에서 고를지',
mine: '내 연락처',
staff: '사내 직원',
search: '구성원으로 넣을 연락처 찾기',
searchPlaceholder: '이름 · 주소 — 초성도 됩니다',
note: '이미 이 그룹에 있는 연락처는 보이지 않습니다. 「사내 직원」에서 고르면 그 직원을 내 주소록에도 추가합니다(이름 · 주소).',
picked: (count: string) => `${count}명 고름`,
pickMax: '한 번에 100명까지 넣을 수 있습니다.',
add: '넣기',
cancel: '취소',
more: '더 보기',
loading: '불러오는 중',
emptyMine: '넣을 수 있는 연락처가 없습니다',
emptyStaff: '넣을 수 있는 직원이 없습니다',
searchEmpty: (query: string) => `‘${query}’에 맞는 사람이 없습니다`,
} as const
/** 알림 (디자인 원본 ContactsEdit "알림" — 단추가 있으면 10초) */
export const CONTACTS_TOAST_TEXT = {
saved: '연락처를 저장했습니다.',
removed: (name: string) => `‘${name}’ 연락처를 지웠습니다.`,
restored: '연락처를 되돌렸습니다.',
groupCreated: (name: string) => `‘${name}’ 그룹을 만들었습니다.`,
groupRenamed: '그룹 이름을 바꿨습니다.',
groupRemoved: (name: string, count: string | null) =>
count === null ? `‘${name}’ 그룹을 지웠습니다.` : `‘${name}’ 그룹을 지웠습니다 — 연락처 ${count}명은 그대로 있습니다.`,
groupRestored: '그룹을 되돌렸습니다.',
/** 응답의 addedCount — 고른 수와 다를 수 있다(이미 구성원 · 그룹에 넣을 수 없는 카드는 서버가 건너뛴다) */
membersAdded: (count: string, group: string) => `${count}명을 ‘${group}’ 그룹에 넣었습니다.`,
/** addedCount 0 — 그사이 다른 곳에서 넣었거나, 다른 메일 프로그램이 uid 없이 만든 카드(그룹에 넣을 수 없다 — 계약 3절) */
membersNone: (group: string) =>
`‘${group}’ 그룹에 새로 넣은 사람이 없습니다. 이미 구성원이거나 그룹에 넣을 수 없는 연락처입니다.`,
/** 그룹에 메일 쓰기 — 주소가 있는 구성원이 없다 */
groupNoAddress: (group: string) => `‘${group}’ 그룹에는 메일 주소가 있는 구성원이 없습니다.`,
copied: '주소를 복사했습니다.',
copyFailed: '주소를 복사하지 못했습니다.',
undo: '되돌리기',
} as const
/** 읽기 화면 ⋯ 의 주소록 항목 (디자인 원본 ContactsMail "읽기 화면 ⋯") */
export const CONTACT_READER_TEXT = {
add: '보낸 사람을 주소록에 추가',
view: '주소록에서 보기',
} as const
/** 받는 사람 추천의 주소록 · 그룹 (디자인 원본 ContactsMail "받는 사람 추천") */
export const RECIPIENT_CONTACT_TEXT = {
/** 그룹 줄의 설명 */
groupDetail: (count: string) => `${count}명 · 고르면 한꺼번에 넣습니다`,
groupLoading: (group: string) => `‘${group}’ 그룹의 구성원을 불러오는 중…`,
groupAdded: (group: string, count: string, field: string) => `‘${group}’ 그룹의 ${count}명을 ${field}에 넣었습니다.`,
groupNone: (group: string, field: string) => `‘${group}’ 그룹에서 ${field}에 더 넣을 주소가 없습니다.`,
withoutEmail: (count: string) => `주소가 없는 ${count}명`,
alreadyAdded: (count: string) => `이미 넣은 ${count}명`,
/** "주소가 없는 1명 · 이미 넣은 2명은 뺐습니다." */
skipped: (parts: string) => `${parts}은 뺐습니다.`,
} as const
+23
View File
@@ -260,6 +260,29 @@ export const ICONS = {
'chevron-up': [polyline('18 15 12 9 6 15')],
/** 목록 위 줄 "다른 곳에서 필터가 바뀌어…" (한 바퀴 화살표) */
'rotate-cw': [polyline('23 4 23 10 17 10'), path('M20.49 15a9 9 0 1 1-2.12-9.36L23 10')],
// ── 8단계: 주소록 (디자인 원본 Contacts · ContactsEdit · ContactsMail) ──
/** 사내 직원 · 연락처를 고르세요 (사람 하나) */
user: [path('M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'), circle(12, 7, 4)],
/** [연락처 추가] · 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」 */
'user-plus': [
path('M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2'),
circle(8.5, 7, 4),
line(20, 8, 20, 14),
line(23, 11, 17, 11),
],
/** 전체 연락처 · 아직 연락처가 없을 때 · 읽기 화면 ⋯ 「주소록에서 보기」 (책) */
book: [
path('M4 19.5A2.5 2.5 0 0 1 6.5 17H20'),
path('M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z'),
],
/** 연락처 [고치기] · 그룹 이름 바꾸기 (네모 안의 연필) */
'edit-square': [
path('M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7'),
path('M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z'),
],
/** 연락처 ⋯ 「주소 복사」 */
copy: [rect(9, 9, 13, 13, 2), path('M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1')],
} as const satisfies Record<string, readonly IconShape[]>
export type IconName = keyof typeof ICONS
+140
View File
@@ -0,0 +1,140 @@
// 주소록 API 공유 타입 — docs/mail/api-contract.md 3절 "주소록 — 8단계"와 같은 구조
// 계약이 바뀌면 이 파일부터 맞춘다 (백엔드 Swagger 가 최종 기준).
import type { CursorPaginationQuery } from '@/shared/types/pagination'
/** 이메일 종류 — 회사 · 개인 · 기타 */
export type ContactEmailLabel = 'work' | 'personal' | 'other'
/** 전화번호 종류 — 휴대전화 · 회사 · 집 · 기타 */
export type ContactPhoneLabel = 'mobile' | 'work' | 'home' | 'other'
export interface ContactEmail {
address: string
label: ContactEmailLabel
}
export interface ContactPhone {
number: string
label: ContactPhoneLabel
}
export interface Contact {
id: string
/** 표시 이름 — 없을 수 있다 (이메일만 있는 연락처) */
name: string | null
/** 앞의 것이 대표 주소 — 받는 사람 추천의 첫 줄 · 그룹으로 보낼 때 쓴다 */
emails: ContactEmail[]
phones: ContactPhone[]
company: string | null
department: string | null
/** 직책 */
title: string | null
/** 메모 (줄바꿈 있음) */
note: string | null
/** 속한 그룹 — 이 주소록의 그룹만 */
groupIds: string[]
}
/**
* POST 본문(고치기는 아래 ContactPatch) — 서버는 앞뒤 공백을 빼고 저장한다(빈 글은 null). 이름이나 이메일 가운데 하나는 있어야 한다.
* 길이는 JS 문자열 길이(UTF-16) — 이름 · 회사 · 부서 · 직책 100자, 주소 254자, 번호 40자, 메모 1,000자, 이메일 · 전화번호 20개 (constants/contacts.ts)
*/
export interface ContactInput {
name: string | null
emails: ContactEmail[]
phones: ContactPhone[]
company: string | null
department: string | null
title: string | null
note: string | null
groupIds: string[]
}
/**
* PATCH 본문 — 연 때와 달라진 칸만(하나 이상). 보낸 칸만 서버가 규칙으로 보고, "이름이나 이메일"은 합친 결과로 본다.
* emails · phones · groupIds 는 보내면 그 칸이 통째로 바뀐다 — 보내지 않은 칸은 검사하지도 바꾸지도 않는다 (계약 3절 "고치기")
*/
export type ContactPatch = Partial<ContactInput>
/** 연락처 지우기의 응답 — restoreToken 은 [되돌리기] 표 (서버가 기억하지 못했으면 null — [되돌리기] 없이 알린다) */
export interface ContactDeleted {
contact: Contact
restoreToken: string | null
}
export interface ContactGroup {
id: string
/** 1~50자 — 이 주소록 안에서 겹치지 않는다 (앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다) */
name: string
/** 이 주소록에 있는 연락처 가운데 구성원 수 */
memberCount: number
}
/** 그룹 지우기의 응답 — restoreToken 은 [되돌리기] 표 (null 이면 [되돌리기] 없이 알린다) */
export interface ContactGroupDeleted {
id: string
name: string
memberIds: string[]
restoreToken: string | null
}
/** 되돌리기(POST /contact-restorations)의 응답 — 지운 그대로 다시 만든 것 (id 는 새로 받는다) */
export type ContactRestored = { kind: 'contact'; contact: Contact } | { kind: 'group'; group: ContactGroup }
export interface ContactGroupMembersAdded {
group: ContactGroup
/** 이번에 그룹에 들어간 수 (이미 구성원이던 것은 세지 않는다) */
addedCount: number
/** 사내 목록에서 골라 내 주소록에 새로 만든 연락처 수 */
createdCount: number
}
/** 사내 목록의 종류 — 직원(개인 계정) · 공용 주소(그룹 계정) */
export type DirectoryKind = 'person' | 'shared'
export interface DirectoryEntry {
/** 엔진의 계정 id */
id: string
/** 엔진의 표시 이름 — 없으면 주소의 @ 앞 */
name: string
email: string
kind: DirectoryKind
}
/** GET /contacts 쿼리 — cursor 는 받은 그 q · groupId · excludeGroupId 로만 쓴다 */
export interface ContactListQuery extends CursorPaginationQuery {
/** 1~254자 — 초성만 친 글이면 이름의 초성과 견준다 */
q?: string
/** 그 그룹의 구성원만 */
groupId?: string
/** 그 그룹의 구성원을 빼고 — 구성원 넣기 창 (groupId 와 함께 쓰지 않는다) */
excludeGroupId?: string
}
/** GET /contact-groups 쿼리 */
export type ContactGroupListQuery = CursorPaginationQuery
/** POST /contact-groups 본문 */
export interface ContactGroupCreatePayload {
name: string
}
/** PATCH /contact-groups/{id} 본문 */
export interface ContactGroupRenamePayload {
name: string
}
/** POST /contact-groups/{id}/members 본문 — 합쳐 1~100개 */
export interface ContactGroupMembersPayload {
contactIds?: string[]
directoryIds?: string[]
}
/** GET /directory-entries 쿼리 — cursor 는 받은 그 q · kind · excludeGroupId 로만 쓴다 */
export interface DirectoryListQuery extends CursorPaginationQuery {
/** 1~254자 */
q?: string
kind?: DirectoryKind
/** 그 그룹 구성원이 가진 주소를 뺀다 — 구성원 넣기 창 */
excludeGroupId?: string
}
+11 -3
View File
@@ -1,6 +1,7 @@
// 메일 API 공유 타입 — docs/mail/api-contract.md 2절과 같은 구조
// 계약이 바뀌면 이 파일부터 맞춘다 (백엔드 Swagger 가 최종 기준).
import type { ContactEmailLabel } from '@/shared/types/contacts'
import type { CursorPaginationQuery } from '@/shared/types/pagination'
export interface Address {
@@ -417,15 +418,22 @@ export interface SaveSignaturePayload {
isForReply: boolean
}
// ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ────────────────
// ── 받는 사람 추천 (계약 2절 "받는 사람 추천" · 3절 "메일과 함께") ────────────────
/** directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소 */
export type RecipientSuggestionSource = 'directory' | 'recent'
/**
* contact = 내 주소록(8단계 — 주소마다 한 줄, 대표 주소가 먼저), directory = 사내(8단계부터 엔진의 사내 목록 — 직원 · 공용 주소),
* recent = 최근 주고받은 주소. 차례는 서버가 이 순서로 준다
*/
export type RecipientSuggestionSource = 'contact' | 'directory' | 'recent'
export interface RecipientSuggestion {
name: string | null
email: string
source: RecipientSuggestionSource
/** contact 만 — 그 연락처의 회사. 필드가 없으면(8단계 앞 서버) null 로 본다 */
company?: string | null
/** contact 만 — 그 주소의 종류. 필드가 없으면(8단계 앞 서버) null 로 본다 */
emailLabel?: ContactEmailLabel | null
}
/** GET /recipient-suggestions 쿼리 */
+13
View File
@@ -0,0 +1,13 @@
// 글을 클립보드에 복사한다 — 주소록 상세 ⋯ 「주소 복사」
// navigator.clipboard 는 보안 컨텍스트(HTTPS · localhost)에서만 있다. 없거나 브라우저가 막으면 false — 화면이 실패를 알린다
export async function copyText(text: string): Promise<boolean> {
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
if (!clipboard || typeof clipboard.writeText !== 'function') return false
try {
await clipboard.writeText(text)
return true
} catch {
return false
}
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { COMPOSE_PREFILL_STATE_KEY, composePrefillOf, composePrefillState } from './compose-prefill'
// 받는 사람을 채워 여는 새 메일 — 주소는 URL 이 아니라 라우터 상태로, 연 직원을 함께 싣는다 (계약 3절 "메일과 함께")
const ARA = 'ara.kim@arasystem.it.kr'
describe('compose-prefill', () => {
it('라우터 상태에 연 직원(정규화) · 이름 · 주소만 싣고, 같은 직원이면 그대로 읽는다 (대소문자 무시)', () => {
const state = composePrefillState(
[
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
{ name: null, email: 'ops@vendor.example' },
],
' Ara.Kim@arasystem.it.kr ',
)
expect(Object.keys(state)).toEqual([COMPOSE_PREFILL_STATE_KEY])
expect(state[COMPOSE_PREFILL_STATE_KEY]).toMatchObject({ owner: ARA })
expect(composePrefillOf(state, 'ARA.KIM@arasystem.it.kr')).toEqual([
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
{ name: null, email: 'ops@vendor.example' },
])
})
it('연 직원이 지금 직원과 다르면(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기) 쓰지 않는다 — 로그인 전 · 주인 없는 옛 모양도', () => {
const state = composePrefillState([{ name: '박지훈', email: 'jihoon.park@hanbit.example' }], ARA)
expect(composePrefillOf(state, 'doyun.lee@arasystem.it.kr')).toEqual([])
expect(composePrefillOf(state, null)).toEqual([])
expect(composePrefillOf(state, undefined)).toEqual([])
// 주인 없이 주소 목록만 싣던 옛 모양
expect(composePrefillOf({ [COMPOSE_PREFILL_STATE_KEY]: [{ name: null, email: 'ops@vendor.example' }] }, ARA)).toEqual([])
expect(
composePrefillOf({ [COMPOSE_PREFILL_STATE_KEY]: { owner: '', addresses: [{ name: null, email: 'ops@vendor.example' }] } }, ARA),
).toEqual([])
})
it('상태는 믿지 않고 다시 본다 — 모양이 틀리면 빈 목록, 형식이 틀린 주소는 빼고 같은 주소는 한 번만', () => {
expect(composePrefillOf(null, ARA)).toEqual([])
expect(composePrefillOf({ back: '/contacts', current: '/mail/compose' }, ARA)).toEqual([])
expect(composePrefillOf({ [COMPOSE_PREFILL_STATE_KEY]: 'jihoon@x.example' }, ARA)).toEqual([])
expect(composePrefillOf({ [COMPOSE_PREFILL_STATE_KEY]: { owner: ARA, addresses: 'x' } }, ARA)).toEqual([])
expect(
composePrefillOf(
{
[COMPOSE_PREFILL_STATE_KEY]: {
owner: ARA,
addresses: [
{ name: ' 박지훈 ', email: ' jihoon.park@hanbit.example ' },
{ name: '같은 주소', email: 'JIHOON.PARK@hanbit.example' },
{ name: '형식 오류', email: 'jihoon@' },
{ email: 42 },
null,
{ name: 7, email: 'ops@vendor.example' },
],
},
},
ARA,
),
).toEqual([
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
{ name: null, email: 'ops@vendor.example' },
])
})
})
@@ -0,0 +1,47 @@
// 받는 사람을 채워 여는 새 메일 — 라우터 상태(history.state)로 넘긴다 (계약 3절 "메일과 함께")
// 주소는 화면 주소(URL)에 싣지 않는다 — 브라우저 기록의 주소 · 서버 로그에 남지 않게. 주소록의 [메일 쓰기] · 주소 링크 · [그룹에 메일 쓰기]가 쓴다.
// 새로 고침해도 같은 방문 기록 칸의 상태는 남는다 — 쓰기 화면이 저장본으로 주소를 바꾼 뒤(?draft=)에는 저장본을 연다 (이 상태는 읽지 않는다)
// 상태에는 연 직원의 주소(정규화)를 함께 싣고, 쓰기 화면은 지금 로그인한 직원과 같을 때만 쓴다 — 같은 탭에서 다른 직원이 로그인한 뒤
// 뒤로 가기로 그 칸에 돌아와도 앞 직원의 받는 사람이 채워지지 않게(2026-10-29 리뷰). 세션 세대(generation)는 새로 고침하면 0 으로 돌아가 쓸 수 없다
import type { HistoryState } from 'vue-router'
import type { Address } from '@/shared/types/mail'
import { isValidEmail, normalizeEmail, uniqueAddresses } from '@/shared/utils/mail-address'
/** 라우터 상태의 이름 — vue-router 가 쓰는 이름(back · current · forward · position · replaced · scroll)과 겹치지 않게 */
export const COMPOSE_PREFILL_STATE_KEY = 'composeTo'
/**
* router.push({ name: 'mail-compose', state }) 의 state — owner 는 연 직원의 주소(authStore.user.email).
* 모양: { composeTo: { owner: '<정규화한 주소>', addresses: [{ name, email }] } }
*/
export function composePrefillState(addresses: readonly Address[], owner: string): HistoryState {
return {
[COMPOSE_PREFILL_STATE_KEY]: {
owner: normalizeEmail(owner),
addresses: addresses.map(({ name, email }) => ({ name, email })),
},
}
}
function addressOf(value: unknown): Address | null {
if (typeof value !== 'object' || value === null) return null
const { name, email } = value as { name?: unknown; email?: unknown }
if (typeof email !== 'string' || !isValidEmail(email)) return null
return { name: typeof name === 'string' && name.trim() !== '' ? name.trim() : null, email: email.trim() }
}
/**
* 라우터 상태에 실린 받는 사람 — 없거나 모양이 틀리거나 연 직원이 지금 직원(currentUser)과 다르면 빈 목록.
* 형식이 틀린 주소는 빼고, 같은 주소는 한 번만. 상태는 브라우저가 기억하는 값이라 믿지 않고 다시 본다 (다른 판의 앱이 남긴 값일 수 있다)
*/
export function composePrefillOf(state: unknown, currentUser: string | null | undefined): Address[] {
if (typeof state !== 'object' || state === null || !currentUser) return []
const entry = (state as Record<string, unknown>)[COMPOSE_PREFILL_STATE_KEY]
if (typeof entry !== 'object' || entry === null) return []
const { owner, addresses } = entry as { owner?: unknown; addresses?: unknown }
if (typeof owner !== 'string' || owner === '' || owner !== normalizeEmail(currentUser)) return []
if (!Array.isArray(addresses)) return []
return uniqueAddresses(addresses.map(addressOf).filter((address): address is Address => address !== null))
}
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import { routes } from '@/router/routes'
import {
contactLocation,
contactsItemLocation,
contactsListLocation,
contactsLocationWithQuery,
contactsRouteStateOf,
contactsSourceKey,
sameContactsSource,
} from './contacts-route'
// 주소록 화면의 주소 — 고른 연락처 · 찾을 글이 주소에 남는다 (새로 고침 · 뒤로 가기, 디자인 README "8단계 › 주소록 화면")
function routerOf() {
return createRouter({ history: createMemoryHistory(), routes })
}
describe('주소록 라우트 (앱의 라우트 표 그대로)', () => {
it.each([
['/contacts', 'contacts', { source: { kind: 'all' }, selectedId: null, q: null }],
['/contacts/all/c2', 'contacts-contact', { source: { kind: 'all' }, selectedId: 'c2', q: null }],
['/contacts/groups/g1', 'contacts-group', { source: { kind: 'group', groupId: 'g1' }, selectedId: null, q: null }],
[
'/contacts/groups/g1/c2?q=%EB%B0%95',
'contacts-group-contact',
{ source: { kind: 'group', groupId: 'g1' }, selectedId: 'c2', q: '박' },
],
[
'/contacts/directory/people',
'contacts-directory',
{ source: { kind: 'directory', directoryKind: 'person' }, selectedId: null, q: null },
],
[
'/contacts/directory/shared/p4?q=%E3%85%82',
'contacts-directory-entry',
{ source: { kind: 'directory', directoryKind: 'shared' }, selectedId: 'p4', q: 'ㅂ' },
],
])('%s → %s', async (path, name, state) => {
const router = routerOf()
await router.push(path)
expect(router.currentRoute.value.name).toBe(name)
expect(router.currentRoute.value.meta).toMatchObject({ requiresAuth: true, title: '주소록' })
expect(contactsRouteStateOf(router.currentRoute.value)).toEqual(state)
})
it('사내 목록 종류는 people · shared 만 — 그 밖은 없는 화면', async () => {
const router = routerOf()
await router.push('/contacts/directory/admins')
expect(router.currentRoute.value.name).toBe('not-found')
})
it('목록 · 고른 항목 · 찾을 글의 주소를 만든다 — 빈 찾을 글은 쿼리에 싣지 않는다', () => {
const router = routerOf()
const href = (location: Parameters<typeof router.resolve>[0]) => router.resolve(location).fullPath
expect(href(contactsListLocation({ kind: 'all' }))).toBe('/contacts')
expect(href(contactsListLocation({ kind: 'group', groupId: 'g1' }, '거래'))).toBe(
`/contacts/groups/g1?q=${encodeURIComponent('거래')}`,
)
expect(href(contactsListLocation({ kind: 'directory', directoryKind: 'person' }))).toBe('/contacts/directory/people')
expect(href(contactsItemLocation({ kind: 'all' }, 'c2'))).toBe('/contacts/all/c2')
expect(href(contactsItemLocation({ kind: 'group', groupId: 'g1' }, 'c2'))).toBe('/contacts/groups/g1/c2')
expect(href(contactsItemLocation({ kind: 'directory', directoryKind: 'shared' }, 'p4'))).toBe(
'/contacts/directory/shared/p4',
)
expect(href(contactLocation('c7'))).toBe('/contacts/all/c7')
expect(
href(contactsLocationWithQuery({ source: { kind: 'group', groupId: 'g1' }, selectedId: 'c2', q: null }, 'ㅂ')),
).toBe(`/contacts/groups/g1/c2?q=${encodeURIComponent('ㅂ')}`)
expect(href(contactsLocationWithQuery({ source: { kind: 'all' }, selectedId: null, q: '박' }, null))).toBe(
'/contacts',
)
})
it('같은 목록인지 — 종류 · 그룹 id · 사내 종류로 견준다', () => {
expect(sameContactsSource({ kind: 'group', groupId: 'g1' }, { kind: 'group', groupId: 'g1' })).toBe(true)
expect(sameContactsSource({ kind: 'group', groupId: 'g1' }, { kind: 'group', groupId: 'g2' })).toBe(false)
expect(contactsSourceKey({ kind: 'directory', directoryKind: 'shared' })).toBe('directory:shared')
expect(contactsSourceKey(null)).toBe('')
expect(contactsRouteStateOf({ name: 'mail', params: {}, query: {} })).toBeNull()
})
})
+131
View File
@@ -0,0 +1,131 @@
// 주소록 화면의 주소 ↔ 보는 목록 · 고른 항목 · 찾을 글 — 순수 함수 (라우트 표는 router/routes.ts, 이름은 constants/contacts.ts)
// 화면 상태는 주소가 기준이다: 패널의 목록을 고르거나 목록의 줄을 누르면 주소가 바뀌고, 화면은 주소를 읽어 그린다 (새로 고침 · 뒤로 가기)
import type { LocationQuery, RouteLocationRaw, RouteParamsGeneric, RouteRecordNameGeneric } from 'vue-router'
import { CONTACTS_QUERY_KEY, CONTACTS_ROUTES, DIRECTORY_KIND_SEGMENTS } from '@/shared/constants/contacts'
import type { DirectoryKind } from '@/shared/types/contacts'
/** 보는 목록 — 전체 연락처 · 그룹 · 사내(직원 · 공용 주소) */
export type ContactsListSource =
| { kind: 'all' }
| { kind: 'group'; groupId: string }
| { kind: 'directory'; directoryKind: DirectoryKind }
export interface ContactsRouteState {
source: ContactsListSource
/** 고른 연락처 · 사내 항목 id — 없으면 null */
selectedId: string | null
/** 찾을 글 — 없으면 null */
q: string | null
}
/** 라우트에서 읽는 것만 — useRoute() 의 값을 그대로 넘긴다 */
export interface ContactsRouteLike {
name?: RouteRecordNameGeneric | null
params: RouteParamsGeneric
query: LocationQuery
}
const ALL: ContactsListSource = { kind: 'all' }
function paramOf(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null
}
function queryOf(query: LocationQuery): string | null {
const value = query[CONTACTS_QUERY_KEY]
const text = typeof value === 'string' ? value.trim() : ''
return text === '' ? null : text
}
/** 주소의 사내 목록 종류(people · shared) → 계약의 종류 */
export function directoryKindOfSegment(segment: unknown): DirectoryKind | null {
if (segment === DIRECTORY_KIND_SEGMENTS.person) return 'person'
if (segment === DIRECTORY_KIND_SEGMENTS.shared) return 'shared'
return null
}
/** 주소록 라우트면 보는 목록 · 고른 항목 · 찾을 글, 아니면 null */
export function contactsRouteStateOf(route: ContactsRouteLike): ContactsRouteState | null {
const q = queryOf(route.query)
switch (route.name) {
case CONTACTS_ROUTES.all:
return { source: ALL, selectedId: null, q }
case CONTACTS_ROUTES.contact:
return { source: ALL, selectedId: paramOf(route.params.contactId), q }
case CONTACTS_ROUTES.group:
case CONTACTS_ROUTES.groupContact: {
const groupId = paramOf(route.params.groupId)
if (!groupId) return null
return { source: { kind: 'group', groupId }, selectedId: paramOf(route.params.contactId), q }
}
case CONTACTS_ROUTES.directory:
case CONTACTS_ROUTES.directoryEntry: {
const directoryKind = directoryKindOfSegment(route.params.kind)
if (!directoryKind) return null
return { source: { kind: 'directory', directoryKind }, selectedId: paramOf(route.params.entryId), q }
}
default:
return null
}
}
export function sameContactsSource(a: ContactsListSource | null, b: ContactsListSource | null): boolean {
return contactsSourceKey(a) === contactsSourceKey(b)
}
/** 목록을 가르는 열쇠 — 같은 목록인지 견준다 */
export function contactsSourceKey(source: ContactsListSource | null): string {
if (!source) return ''
if (source.kind === 'group') return `group:${source.groupId}`
if (source.kind === 'directory') return `directory:${source.directoryKind}`
return 'all'
}
/** 내 주소록(연락처)의 목록인가 — 사내 목록이 아니면 */
export function isContactSource(source: ContactsListSource): boolean {
return source.kind !== 'directory'
}
function withQuery(q: string | null | undefined): LocationQuery {
return q ? { [CONTACTS_QUERY_KEY]: q } : {}
}
/** 목록 주소 — 고른 것 없이 */
export function contactsListLocation(source: ContactsListSource, q: string | null = null): RouteLocationRaw {
const query = withQuery(q)
if (source.kind === 'group') return { name: CONTACTS_ROUTES.group, params: { groupId: source.groupId }, query }
if (source.kind === 'directory') {
return { name: CONTACTS_ROUTES.directory, params: { kind: DIRECTORY_KIND_SEGMENTS[source.directoryKind] }, query }
}
return { name: CONTACTS_ROUTES.all, query }
}
/** 그 목록에서 항목 하나를 고른 주소 */
export function contactsItemLocation(source: ContactsListSource, id: string, q: string | null = null): RouteLocationRaw {
const query = withQuery(q)
if (source.kind === 'group') {
return { name: CONTACTS_ROUTES.groupContact, params: { groupId: source.groupId, contactId: id }, query }
}
if (source.kind === 'directory') {
return {
name: CONTACTS_ROUTES.directoryEntry,
params: { kind: DIRECTORY_KIND_SEGMENTS[source.directoryKind], entryId: id },
query,
}
}
return { name: CONTACTS_ROUTES.contact, params: { contactId: id }, query }
}
/** 같은 목록 · 같은 고른 항목에 찾을 글만 바꾼 주소 */
export function contactsLocationWithQuery(state: ContactsRouteState, q: string | null): RouteLocationRaw {
return state.selectedId
? contactsItemLocation(state.source, state.selectedId, q)
: contactsListLocation(state.source, q)
}
/** 주소록에서 그 연락처를 고른 주소 — 읽기 화면 ⋯ 「주소록에서 보기」 · 같은 주소 경고의 [그 연락처 보기] */
export function contactLocation(contactId: string): RouteLocationRaw {
return contactsItemLocation(ALL, contactId)
}
+421
View File
@@ -0,0 +1,421 @@
import { describe, expect, it } from 'vitest'
import type { Contact, ContactGroup } from '@/shared/types/contacts'
import {
changedContactFields,
compareContacts,
contactDisplayName,
contactDraftOf,
contactDraftProblems,
contactInputOf,
contactListMeta,
contactNoteText,
contactOrgLine,
contactPatchOf,
contactSearchQueryOf,
contactSubtitle,
contactSuggestionDetail,
contactsWithAddress,
copyContactDraft,
duplicateWho,
emptyEmailRow,
groupInsertedText,
groupRecipientsOf,
hasContactProblems,
isValidContactPhone,
koreanOrdinal,
primaryEmail,
sameContactDraft,
singleLineText,
sortGroups,
} from './contacts'
// 주소록 규칙 — 계약 3절 "주소록" · 디자인 README "8단계" (디자인 원본 Contacts · ContactsEdit · ContactsMail)
function contact(patch: Partial<Contact> = {}): Contact {
return {
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: '견적 문의는 박지훈 팀장 → 강서윤 과장 차례로.\n월말 마감 주간에는 회신이 늦다.',
groupIds: ['g1', 'g2'],
...patch,
}
}
const GROUPS: ContactGroup[] = [
{ id: 'g1', name: '거래처', memberCount: 4 },
{ id: 'g2', name: '프로젝트 알파', memberCount: 4 },
]
describe('보이는 이름 · 소속', () => {
it('이름이 없으면 대표 주소, 그것도 없으면 "(이름 없음)"', () => {
expect(contactDisplayName(contact())).toBe('박지훈')
expect(contactDisplayName(contact({ name: ' ' }))).toBe('jihoon.park@hanbit.example')
expect(contactDisplayName(contact({ name: null, emails: [] }))).toBe('(이름 없음)')
expect(primaryEmail(contact({ emails: [] }))).toBeNull()
})
it('목록 줄 "회사 · 부서" · 상세 머리 "회사 · 부서 · 직책"(없으면 "소속 없음") · 소속 칸 "회사 부서"', () => {
expect(contactListMeta(contact())).toBe('(주)한빛상사 · 영업팀')
expect(contactListMeta(contact({ department: null }))).toBe('(주)한빛상사')
expect(contactSubtitle(contact())).toBe('(주)한빛상사 · 영업팀 · 팀장')
expect(contactSubtitle(contact({ company: null, department: ' ', title: null }))).toBe('소속 없음')
expect(contactOrgLine(contact())).toBe('(주)한빛상사 영업팀')
})
it('연락처 차례 — 서버와 같다: 이름(없으면 대표 주소) → 대표 주소 → id, 이름도 주소도 없으면 맨 뒤', () => {
const items = [
contact({ id: 'c5', name: null, emails: [] }),
contact({ id: 'c4', name: null, emails: [{ address: 'zeta@x.example', label: 'work' }] }),
contact({ id: 'c3', name: '박지훈', emails: [{ address: 'b@x.example', label: 'work' }] }),
contact({ id: 'c2', name: '박지훈', emails: [{ address: 'a@x.example', label: 'work' }] }),
contact({ id: 'c1', name: '강서윤', emails: [] }),
]
expect([...items].sort(compareContacts).map((item) => item.id)).toEqual(['c1', 'c2', 'c3', 'c4', 'c5'])
})
it('그룹은 이름 차례 — 서버와 같은 Intl.Collator(ko): 한글이 로마자보다 앞', () => {
const sorted = sortGroups([
{ id: 'b', name: '프로젝트 알파', memberCount: 0 },
{ id: 'a', name: '거래처', memberCount: 0 },
{ id: 'c', name: 'Alpha', memberCount: 0 },
])
expect(sorted.map((group) => group.name)).toEqual(['거래처', '프로젝트 알파', 'Alpha'])
})
it('칸 이름의 차례 — 첫째 · 둘째 … 스무째, 그 뒤는 "21번째"', () => {
expect([1, 2, 5, 20, 21].map(koreanOrdinal)).toEqual(['첫째', '둘째', '다섯째', '스무째', '21번째'])
})
})
describe('연락처 창의 칸 값 ↔ 보낼 본문', () => {
it('새 연락처는 빈 이메일 · 전화번호 줄 하나씩, 채울 값이 있으면 이름 · 주소(회사)로', () => {
const blank = contactDraftOf(null)
expect(blank.emails.map((row) => [row.label, row.address])).toEqual([['work', '']])
expect(blank.phones.map((row) => [row.label, row.number])).toEqual([['mobile', '']])
const filled = contactDraftOf(null, { name: ' 이도윤 ', email: 'doyun.lee@arasystem.it.kr' })
expect(filled.name).toBe('이도윤')
expect(filled.emails.map((row) => [row.label, row.address])).toEqual([['work', 'doyun.lee@arasystem.it.kr']])
})
it('고치기는 그 연락처 그대로 — 다른 프로그램이 넣은 6개 이상의 주소도 모두', () => {
const many = contact({
emails: Array.from({ length: 7 }, (_, index) => ({ address: `a${index}@x.example`, label: 'other' as const })),
})
const draft = contactDraftOf(many)
expect(draft.emails).toHaveLength(7)
expect(contactInputOf(draft).emails).toHaveLength(7)
})
it('보낼 때 앞뒤 공백을 빼고 빈 글은 null, 빈 줄은 뺀다 — 메모의 줄바꿈은 그대로', () => {
const draft = contactDraftOf(null)
draft.name = ' 박지훈 '
draft.emails = [
{ key: 1, label: 'work', address: ' jihoon.park@hanbit.example ' },
{ key: 2, label: 'personal', address: ' ' },
]
draft.phones = [{ key: 3, label: 'work', number: ' 02-555-0147 ' }]
draft.company = ' '
draft.note = '\n첫 줄\n둘째 줄\n'
draft.groupIds = ['g1', 'g1']
expect(contactInputOf(draft)).toEqual({
name: '박지훈',
emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }],
phones: [{ number: '02-555-0147', label: 'work' }],
company: null,
department: null,
title: null,
note: '첫 줄\n둘째 줄',
groupIds: ['g1'],
})
})
it('창을 연 때와 견준다 — 빈 줄을 더하거나 빼는 것 · 그룹의 차례는 바꾼 것이 아니다', () => {
const opened = contactDraftOf(contact())
const draft = copyContactDraft(opened)
expect(sameContactDraft(draft, opened)).toBe(true)
draft.emails.push(emptyEmailRow())
draft.groupIds = ['g2', 'g1']
expect(sameContactDraft(draft, opened)).toBe(true)
draft.title = '부장'
expect(sameContactDraft(draft, opened)).toBe(false)
})
it('견주기는 보낼 값으로 — 앞뒤 공백 · 빈 줄만 바꾼 것은 바꾼 것이 아니다', () => {
const opened = contactDraftOf(contact())
const draft = copyContactDraft(opened)
draft.name = ' 박지훈 '
draft.phones.push({ key: 99, label: 'home', number: ' ' })
expect(sameContactDraft(draft, opened)).toBe(true)
expect(contactPatchOf(draft, opened)).toEqual({})
})
it('제어 문자 — 한 줄 칸은 띄어쓰기 하나로, 메모는 CR LF · CR 을 줄바꿈으로 맞추고 그 밖은 띄어쓰기로 (서버가 받지 않는다)', () => {
expect(singleLineText('박지훈\t(주)한빛상사\t\t영업팀')).toBe('박지훈 (주)한빛상사 영업팀')
expect(singleLineText('널\u0000 · DEL\u007f · C1\u0085')).toBe('널 · DEL · C1 ')
expect(singleLineText('그대로 — 한글 · 이모지 😀')).toBe('그대로 — 한글 · 이모지 😀')
expect(contactNoteText('첫 줄\r\n둘째\t줄\r셋째 줄\n')).toBe('첫 줄\n둘째 줄\n셋째 줄\n')
})
it('다른 프로그램이 넣은 제어 문자는 창을 열 때 고친다 — 고친 것만으로는 바꾼 것이 아니고(보내지 않는다), 고친 칸은 고친 값으로 보낸다', () => {
const imported = contact({ name: '박지훈\t', company: '한빛\u0000상사', note: '첫 줄\r\n둘째\t줄' })
const opened = contactDraftOf(imported)
expect(opened.name).toBe('박지훈 ')
expect(opened.company).toBe('한빛 상사')
expect(opened.note).toBe('첫 줄\n둘째 줄')
expect(sameContactDraft(copyContactDraft(opened), opened)).toBe(true)
// 표에서 붙여 넣은 탭
const pasted = copyContactDraft(opened)
pasted.title = '팀장\t과장'
pasted.note = '칸\t나눔'
expect(hasContactProblems(contactDraftProblems(pasted, opened))).toBe(false)
expect(contactInputOf(pasted)).toMatchObject({ name: '박지훈', company: '한빛 상사', title: '팀장 과장', note: '칸 나눔' })
// 고친 칸만 — 이름 · 회사는 손대지 않았다
expect(contactPatchOf(pasted, opened)).toEqual({ title: '팀장 과장', note: '칸 나눔' })
})
it('보낸 사람 이름으로 채울 때도 제어 문자를 고치고 앞뒤 공백을 뺀다', () => {
expect(contactDraftOf(null, { name: ' 박지훈\t', email: ' jihoon.park@hanbit.example ' })).toMatchObject({
name: '박지훈',
emails: [{ label: 'work', address: 'jihoon.park@hanbit.example' }],
})
})
})
describe('바꾼 칸만 — 고치기는 PATCH (계약 3절 "고치기")', () => {
it('연 때와 달라진 칸만 보낸다 — 이메일 · 전화번호 · 그룹은 바뀌면 통째로, 그룹은 차례를 보지 않는다', () => {
const opened = contactDraftOf(contact())
const draft = copyContactDraft(opened)
draft.title = ' 부장 '
draft.groupIds = ['g2', 'g1']
expect([...changedContactFields(draft, opened)]).toEqual(['title'])
expect(contactPatchOf(draft, opened)).toEqual({ title: '부장' })
draft.emails = [
{ key: 91, label: 'personal', address: 'jihoon.p@mail.example' },
{ key: 92, label: 'work', address: 'jihoon.park@hanbit.example' },
]
draft.groupIds = ['g1']
expect(contactPatchOf(draft, opened)).toEqual({
title: '부장',
emails: [
{ address: 'jihoon.p@mail.example', label: 'personal' },
{ address: 'jihoon.park@hanbit.example', label: 'work' },
],
groupIds: ['g1'],
})
draft.company = ''
draft.note = ''
expect(contactPatchOf(draft, opened)).toMatchObject({ company: null, note: null })
})
it('새 연락처(연 것이 없음)는 모든 칸이 바꾼 칸이다', () => {
expect(changedContactFields(contactDraftOf(null), null).size).toBe(8)
})
})
describe('검사 — 서버와 같거나 더 좁은 규칙 (계약 3절 "ContactInput")', () => {
it('이름이나 이메일 가운데 하나는 있어야 한다', () => {
const draft = contactDraftOf(null)
expect(contactDraftProblems(draft).required).toBe(true)
draft.name = '박지훈'
expect(contactDraftProblems(draft).required).toBe(false)
draft.name = ''
draft.emails = [{ key: 1, label: 'work', address: 'a@b.kr' }]
expect(hasContactProblems(contactDraftProblems(draft))).toBe(false)
})
it('주소 형식(서버보다 좁게 — mail-address 의 isStrictEmail) · 254자 · 같은 연락처 안에서 겹침(대소문자 무시) — 빈 줄은 보지 않는다', () => {
const draft = contactDraftOf(null)
draft.emails = [
{ key: 1, label: 'work', address: 'jihoon.park@hanbit' },
{ key: 2, label: 'work', address: 'jihoon.park@hanbit.example' },
{ key: 3, label: 'personal', address: 'JIHOON.PARK@hanbit.example' },
{ key: 4, label: 'other', address: '' },
{ key: 5, label: 'other', address: `${'a'.repeat(250)}@b.kr` },
{ key: 6, label: 'other', address: 'jihoon..park@hanbit.example' },
{ key: 7, label: 'other', address: 'jihoon@hanbit.example.' },
{ key: 8, label: 'other', address: 'jihoon@hanbit.k' },
]
expect(contactDraftProblems(draft).emails).toEqual([
'invalid',
null,
'repeated',
null,
'invalid',
'invalid',
'invalid',
'invalid',
])
})
it('고치기는 바꾼 칸만 본다 — 손대지 않은 남의 값(21번째 주소 · "내선" 번호 · 긴 메모 · 100자 넘는 칸)은 그대로 통과', () => {
const imported = contact({
emails: Array.from({ length: 21 }, (_, index) => ({ address: `a${index}@x.example`, label: 'other' as const })),
phones: [{ number: '내선 12', label: 'work' }],
company: '한'.repeat(150),
note: '가'.repeat(1200),
})
const opened = contactDraftOf(imported)
const draft = copyContactDraft(opened)
draft.title = '부장'
expect(hasContactProblems(contactDraftProblems(draft, opened))).toBe(false)
expect(contactPatchOf(draft, opened)).toEqual({ title: '부장' })
})
it('그 칸을 고치면 그 칸 전부가 규칙에 맞아야 한다 — 이메일 · 전화번호 20개, 남의 "내선" 번호, 100자 · 1,000자', () => {
const imported = contact({
emails: Array.from({ length: 21 }, (_, index) => ({ address: `a${index}@x.example`, label: 'other' as const })),
phones: [
{ number: '내선 12', label: 'work' },
{ number: '010-1111-2222', label: 'mobile' },
],
company: '한'.repeat(150),
note: '가'.repeat(1200),
})
const opened = contactDraftOf(imported)
const draft = copyContactDraft(opened)
draft.emails = draft.emails.map((row, index) => (index === 0 ? { ...row, label: 'work' as const } : row))
draft.phones = draft.phones.map((row, index) => (index === 1 ? { ...row, number: '010-1111-3333' } : row))
draft.company = '한'.repeat(149)
draft.note = '가'.repeat(1100)
const found = contactDraftProblems(draft, opened)
expect(found.emailCount).toBe(true)
expect(found.phones).toEqual(['invalid', null])
expect(found.phoneCount).toBe(false)
expect(found.texts).toEqual({ name: false, company: true, department: false, title: false })
expect(found.note).toBe(true)
})
it('"이름이나 이메일"은 합친 결과로 — 이름을 지워도 손대지 않은 주소가 있으면 된다', () => {
const opened = contactDraftOf(contact())
const draft = copyContactDraft(opened)
draft.name = ''
expect(contactDraftProblems(draft, opened).required).toBe(false)
draft.emails = []
expect(contactDraftProblems(draft, opened).required).toBe(true)
})
it('번호 — 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만, 40자까지', () => {
expect(isValidContactPhone('010-3192-5540')).toBe(true)
expect(isValidContactPhone('+82 (2) 555.0147 #12*')).toBe(true)
expect(isValidContactPhone('---')).toBe(false)
expect(isValidContactPhone('010-한빛')).toBe(false)
expect(isValidContactPhone('1'.repeat(41))).toBe(false)
const draft = contactDraftOf(null)
draft.name = '박지훈'
draft.phones = [
{ key: 1, label: 'mobile', number: '내선 12' },
{ key: 2, label: 'work', number: '' },
]
expect(contactDraftProblems(draft).phones).toEqual(['invalid', null])
})
it('메모는 앞뒤 공백을 뺀 1,000자까지 (새 연락처)', () => {
const draft = contactDraftOf(null)
draft.name = '박지훈'
draft.note = ` ${'가'.repeat(1000)}\n `
expect(contactDraftProblems(draft).note).toBe(false)
draft.note = '가'.repeat(1001)
expect(contactDraftProblems(draft).note).toBe(true)
expect(hasContactProblems(contactDraftProblems(draft))).toBe(true)
})
it('이름이 제어 문자뿐이면 없는 것으로 본다', () => {
const draft = contactDraftOf(null)
draft.name = '\u0000\t'
expect(contactDraftProblems(draft).required).toBe(true)
})
})
describe('같은 주소 경고', () => {
it('찾기 결과에서 주소가 같은(대소문자 무시) 다른 연락처만 — 일부만 맞는 주소 · 자기 자신은 뺀다', () => {
const seoyun = contact({ id: 'c1', name: '강서윤', emails: [{ address: 'Seoyun.Kang@hanbit.example', label: 'work' }], groupIds: ['g1'] })
const longer = contact({ id: 'c9', name: '김서윤', emails: [{ address: 'xseoyun.kang@hanbit.example', label: 'work' }] })
expect(contactsWithAddress([longer, seoyun], 'seoyun.kang@hanbit.example', null).map((item) => item.id)).toEqual(['c1'])
expect(contactsWithAddress([seoyun], 'seoyun.kang@hanbit.example', 'c1')).toEqual([])
})
it('"강서윤(거래처)" — 괄호는 첫 그룹, 그룹이 없으면 회사, 둘 다 없으면 이름만', () => {
const seoyun = contact({ name: '강서윤', groupIds: ['g1'] })
expect(duplicateWho(seoyun, GROUPS)).toBe('강서윤(거래처)')
expect(duplicateWho(contact({ name: '강서윤', groupIds: [] }), GROUPS)).toBe('강서윤((주)한빛상사)')
expect(duplicateWho(contact({ name: '강서윤', groupIds: ['gone'], company: null }), GROUPS)).toBe('강서윤')
})
})
describe('받는 사람 추천 · 그룹', () => {
it('주소록 줄의 설명 — 회사 주소는 회사 이름(없으면 "회사"), 그 밖은 종류', () => {
const base = { source: 'contact' as const, company: '(주)한빛상사' }
expect(contactSuggestionDetail({ ...base, emailLabel: 'work' })).toBe('(주)한빛상사')
expect(contactSuggestionDetail({ ...base, emailLabel: 'personal' })).toBe('개인')
expect(contactSuggestionDetail({ ...base, emailLabel: 'other' })).toBe('기타')
expect(contactSuggestionDetail({ source: 'contact', company: null, emailLabel: 'work' })).toBe('회사')
// 8단계 앞 서버 · 종류가 없는 줄 — 회사만
expect(contactSuggestionDetail({ source: 'contact', company: '그린클라우드' })).toBe('그린클라우드')
expect(contactSuggestionDetail({ source: 'directory', company: '무시', emailLabel: 'work' })).toBeNull()
})
it('그룹 구성원 → 대표 주소(겹치면 한 번) · 이메일이 없는 구성원 수', () => {
const result = groupRecipientsOf([
contact({ id: 'c1', name: '강서윤', emails: [{ address: 'seoyun.kang@hanbit.example', label: 'work' }] }),
contact({ id: 'c2' }),
contact({ id: 'c3', name: '메모만', emails: [] }),
contact({ id: 'c4', name: '같은 주소', emails: [{ address: 'SEOYUN.KANG@hanbit.example', label: 'work' }] }),
])
expect(result).toEqual({
addresses: [
{ name: '강서윤', email: 'seoyun.kang@hanbit.example' },
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
],
withoutEmail: 1,
})
})
it('그룹을 넣은 뒤의 글 — 뺀 것이 있으면 덧붙인다', () => {
expect(groupInsertedText('거래처', '받는 사람', { added: 4, withoutEmail: 0, already: 0 })).toBe(
'‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다.',
)
expect(groupInsertedText('거래처', '참조', { added: 3, withoutEmail: 1, already: 0 })).toBe(
'‘거래처’ 그룹의 3명을 참조에 넣었습니다. 주소가 없는 1명은 뺐습니다.',
)
expect(groupInsertedText('거래처', '받는 사람', { added: 0, withoutEmail: 0, already: 2 })).toBe(
'‘거래처’ 그룹에서 받는 사람에 더 넣을 주소가 없습니다. 이미 넣은 2명은 뺐습니다.',
)
})
})
describe('찾기 칸의 글 → 서버의 q', () => {
it.each([
['', null],
[' ', null],
[' 박지훈 ', '박지훈'],
['ㅂㅈㅎ', 'ㅂㅈㅎ'],
['ㄲ', 'ㄲ'],
['3192', '3192'],
// 조합 중 — 글자와 자모가 섞였다: 보내지 않고 지난 결과를 둔다
['박ㅈ', undefined],
['ㅂ지', undefined],
['ㅏ', undefined],
])('"%s" → %s', (text, expected) => {
expect(contactSearchQueryOf(text)).toBe(expected)
})
it('254자까지만 보낸다 (계약: q 1~254자 — 주소 하나를 통째로)', () => {
expect(contactSearchQueryOf('a'.repeat(300))).toBe('a'.repeat(254))
})
})
+511
View File
@@ -0,0 +1,511 @@
// 주소록 화면의 규칙 — 순수 함수만 둔다 (계약 3절 "주소록" · 디자인 README "8단계 — 주소록")
// - 보이는 이름 · 소속 줄 · 대표 주소
// - 연락처 추가 · 고치기 창의 칸 값(초안) ↔ 보낼 본문(ContactInput · 고치기는 바꾼 칸만 — ContactPatch), 서버와 같거나 더 좁은 검사
// - 같은 주소 경고 · 받는 사람 추천의 주소록 줄 설명 · 찾기 칸의 글 · 그룹 구성원의 대표 주소
import {
CONTACT_DETAIL_TEXT,
CONTACT_EMAIL_LABEL_TEXT,
CONTACT_EMAIL_MAX_LENGTH,
CONTACT_EMAILS_MAX,
CONTACT_NOTE_MAX_LENGTH,
CONTACT_PHONE_MAX_LENGTH,
CONTACT_PHONES_MAX,
CONTACT_TEXT_MAX_LENGTH,
CONTACTS_SEARCH_MAX_LENGTH,
RECIPIENT_CONTACT_TEXT,
} from '@/shared/constants/contacts'
import type {
Contact,
ContactEmailLabel,
ContactGroup,
ContactInput,
ContactPatch,
ContactPhoneLabel,
} from '@/shared/types/contacts'
import type { Address, RecipientSuggestion } from '@/shared/types/mail'
import { formatCount } from '@/shared/utils/format'
import { isStrictEmail, normalizeEmail, uniqueAddresses } from '@/shared/utils/mail-address'
// ── 차례 · 보이는 이름 ─────────────────────────────────────
/** 이름 차례 — 서버와 같은 한국어 사전 차례 (계약 3절 "차례") */
const NAME_COLLATOR = new Intl.Collator('ko')
export function compareNames(a: string, b: string): number {
return NAME_COLLATOR.compare(a, b)
}
/** 둘 다 있으면 사전 차례, 하나만 없으면 없는 쪽이 뒤 */
function compareOptional(a: string | null, b: string | null): number {
if (a === null || b === null) return a === b ? 0 : a === null ? 1 : -1
return compareNames(a, b)
}
/**
* 연락처의 차례 — 서버와 같다(backend contact-search.ts 의 compareContacts — 한쪽을 고치면 둘 다).
* 이름(없으면 대표 주소)으로 견주고, 같으면 대표 주소 → id. 이름도 주소도 없는 연락처는 맨 뒤
*/
export function compareContacts(
a: Pick<Contact, 'id' | 'name' | 'emails'>,
b: Pick<Contact, 'id' | 'name' | 'emails'>,
): number {
const primaryA = a.emails[0]?.address ?? null
const primaryB = b.emails[0]?.address ?? null
return (
compareOptional(a.name ?? primaryA, b.name ?? primaryB) ||
compareOptional(primaryA, primaryB) ||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
)
}
/** 그룹을 이름 차례로 — 서버의 차례와 같다 (만들기 · 이름 바꾸기 뒤 자리를 맞춘다) */
export function sortGroups(groups: readonly ContactGroup[]): ContactGroup[] {
return [...groups].sort((a, b) => compareNames(a.name, b.name) || (a.id < b.id ? -1 : 1))
}
/** 대표 주소 — 앞의 것 (받는 사람 추천의 첫 줄 · 그룹으로 보낼 때 쓴다) */
export function primaryEmail(contact: Pick<Contact, 'emails'>): string | null {
return contact.emails[0]?.address ?? null
}
/** 보이는 이름 — 이름이 없으면 대표 주소, 그것도 없으면 "(이름 없음)" */
export function contactDisplayName(contact: Pick<Contact, 'name' | 'emails'>): string {
return contact.name?.trim() || primaryEmail(contact) || CONTACT_DETAIL_TEXT.noName
}
/** 목록 줄의 작은 글 — "회사 · 부서" */
export function contactListMeta(contact: Pick<Contact, 'company' | 'department'>): string {
return [contact.company, contact.department].filter((part): part is string => Boolean(part?.trim())).join(' · ')
}
/** 상세 머리의 소속 — "회사 · 부서 · 직책", 없으면 "소속 없음" */
export function contactSubtitle(contact: Pick<Contact, 'company' | 'department' | 'title'>): string {
const parts = [contact.company, contact.department, contact.title].filter((part): part is string =>
Boolean(part?.trim()),
)
return parts.length > 0 ? parts.join(' · ') : CONTACT_DETAIL_TEXT.noOrg
}
/** 상세 칸의 소속 — "회사 부서" (디자인 원본 Contacts) */
export function contactOrgLine(contact: Pick<Contact, 'company' | 'department'>): string {
return [contact.company, contact.department].filter((part): part is string => Boolean(part?.trim())).join(' ')
}
const ORDINALS = [
'첫째',
'둘째',
'셋째',
'넷째',
'다섯째',
'여섯째',
'일곱째',
'여덟째',
'아홉째',
'열째',
'열한째',
'열두째',
'열셋째',
'열넷째',
'열다섯째',
'열여섯째',
'열일곱째',
'열여덟째',
'열아홉째',
'스무째',
] as const
/** 칸 이름의 차례 — "첫째 이메일" (1부터). 스무째 뒤는 "21번째" */
export function koreanOrdinal(position: number): string {
return ORDINALS[position - 1] ?? `${position}번째`
}
// ── 연락처 추가 · 고치기 창의 칸 값 ─────────────────────────
/** 이메일 · 전화번호 칸 한 줄 — key 는 v-for 키(칸을 빼도 다른 줄의 초점 · 값이 섞이지 않게) */
export interface ContactEmailRow {
key: number
label: ContactEmailLabel
address: string
}
export interface ContactPhoneRow {
key: number
label: ContactPhoneLabel
number: string
}
/** 창의 칸 값 — 친 그대로 (보낼 때 앞뒤 공백을 뺀다) */
export interface ContactDraft {
name: string
emails: ContactEmailRow[]
phones: ContactPhoneRow[]
company: string
department: string
title: string
note: string
groupIds: string[]
}
/** 새 연락처를 채울 값 — 읽기 화면의 보낸 사람 · 사내 목록의 직원 */
export interface ContactPrefill {
name: string | null
email: string
label?: ContactEmailLabel
}
// ── 제어 문자 ───────────────────────────────────────────
// 서버는 한 줄 칸(이름 · 회사 · 부서 · 직책)의 제어 문자와, 메모의 줄바꿈(\n) 밖 제어 문자(탭 · \r 포함)를 받지 않는다 (계약 3절 "ContactInput").
// 표에서 붙여 넣은 탭 · 다른 프로그램이 넣은 \r\n 은 눈에 보이지 않아 사용자가 찾아 고칠 수 없다 — 거절하는 대신 칸 값을 만들 때와 보낼 때 고친다
/** C0(U+0000~U+001F) · DEL · C1(U+007F~U+009F) — 서버의 한 줄 글 규칙과 같은 범위 */
function isControlCode(code: number): boolean {
return code <= 0x1f || (code >= 0x7f && code <= 0x9f)
}
/** 한 줄 칸 — 제어 문자(탭 · 줄바꿈 등)를 띄어쓰기 하나로 바꾼다 (이어진 것은 하나로) */
export function singleLineText(value: string): string {
let result = ''
let inControl = false
for (let index = 0; index < value.length; index += 1) {
if (isControlCode(value.charCodeAt(index))) {
if (!inControl) result += ' '
inControl = true
} else {
result += value[index]
inControl = false
}
}
return result
}
/** 메모 — \r\n · \r 은 줄바꿈(\n)으로 맞추고, 줄마다 그 밖의 제어 문자는 띄어쓰기로 */
export function contactNoteText(value: string): string {
return value.replace(/\r\n?/g, '\n').split('\n').map(singleLineText).join('\n')
}
/** 줄 키 — 창 안에서 겹치지만 않으면 된다 */
let rowKey = 0
export function nextContactRowKey(): number {
rowKey += 1
return rowKey
}
export function emptyEmailRow(label: ContactEmailLabel = 'work'): ContactEmailRow {
return { key: nextContactRowKey(), label, address: '' }
}
export function emptyPhoneRow(label: ContactPhoneLabel = 'mobile'): ContactPhoneRow {
return { key: nextContactRowKey(), label, number: '' }
}
/**
* 창을 열 때의 칸 값 — 고치기는 그 연락처, 새 연락처는 빈 칸(채울 값이 있으면 이름 · 주소).
* 새 연락처는 이메일 · 전화번호 빈 줄을 하나씩 둔다 (보드 "연락처 추가").
* 다른 프로그램이 넣은 제어 문자는 여기서 고친다 — 연 때의 값과 견주므로 고친 것만으로는 "바꾼 것"이 아니다
*/
export function contactDraftOf(contact: Contact | null, prefill: ContactPrefill | null = null): ContactDraft {
if (contact) {
return {
name: singleLineText(contact.name ?? ''),
emails: contact.emails.map((email) => ({ key: nextContactRowKey(), label: email.label, address: email.address })),
phones: contact.phones.map((phone) => ({ key: nextContactRowKey(), label: phone.label, number: phone.number })),
company: singleLineText(contact.company ?? ''),
department: singleLineText(contact.department ?? ''),
title: singleLineText(contact.title ?? ''),
note: contactNoteText(contact.note ?? ''),
groupIds: [...contact.groupIds],
}
}
return {
name: singleLineText(prefill?.name ?? '').trim(),
emails: [
prefill
? { key: nextContactRowKey(), label: prefill.label ?? 'work', address: prefill.email.trim() }
: emptyEmailRow(),
],
phones: [emptyPhoneRow()],
company: '',
department: '',
title: '',
note: '',
groupIds: [],
}
}
export function copyContactDraft(draft: ContactDraft): ContactDraft {
return {
...draft,
emails: draft.emails.map((row) => ({ ...row })),
phones: draft.phones.map((row) => ({ ...row })),
groupIds: [...draft.groupIds],
}
}
function textOrNull(value: string): string | null {
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}
/** 보낼 본문 — 제어 문자를 고치고 앞뒤 공백을 빼고 빈 글은 null, 빈 줄은 뺀다 (서버도 같은 값으로 저장한다) */
export function contactInputOf(draft: ContactDraft): ContactInput {
return {
name: textOrNull(singleLineText(draft.name)),
emails: draft.emails
.map((row) => ({ address: row.address.trim(), label: row.label }))
.filter((email) => email.address !== ''),
phones: draft.phones
.map((row) => ({ number: row.number.trim(), label: row.label }))
.filter((phone) => phone.number !== ''),
company: textOrNull(singleLineText(draft.company)),
department: textOrNull(singleLineText(draft.department)),
title: textOrNull(singleLineText(draft.title)),
// 메모는 줄바꿈을 그대로 둔다 — 앞뒤 공백 · 빈 줄만 뺀다
note: textOrNull(contactNoteText(draft.note)),
groupIds: [...new Set(draft.groupIds)],
}
}
// ── 바꾼 칸 (고치기는 PATCH — 연 때와 달라진 칸만 보낸다, 계약 3절 "고치기") ──────────────
/** 견주는 칸 — 보낼 본문(contactInputOf)의 칸 */
export type ContactField = keyof ContactInput
const CONTACT_FIELDS: readonly ContactField[] = [
'name',
'emails',
'phones',
'company',
'department',
'title',
'note',
'groupIds',
]
/** 한 줄 칸 — 100자 (계약 3절 "ContactInput") */
export const CONTACT_TEXT_FIELDS = ['name', 'company', 'department', 'title'] as const
export type ContactTextField = (typeof CONTACT_TEXT_FIELDS)[number]
function sameRows<T extends object>(a: readonly T[], b: readonly T[]): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}
/** 한 칸이 같은가 — 보낼 값으로 견준다(앞뒤 공백 · 빈 줄 · 제어 문자는 바꾼 것이 아니다). 그룹은 차례를 보지 않는다 */
function sameField(field: ContactField, a: ContactInput, b: ContactInput): boolean {
if (field === 'groupIds') {
return a.groupIds.length === b.groupIds.length && a.groupIds.every((id) => b.groupIds.includes(id))
}
if (field === 'emails') return sameRows(a.emails, b.emails)
if (field === 'phones') return sameRows(a.phones, b.phones)
return a[field] === b[field]
}
/** 연 때와 달라진 칸 — 연 것이 없으면(새 연락처) 모든 칸 */
export function changedContactFields(draft: ContactDraft, opened: ContactDraft | null): ReadonlySet<ContactField> {
if (!opened) return new Set(CONTACT_FIELDS)
const now = contactInputOf(draft)
const before = contactInputOf(opened)
return new Set(CONTACT_FIELDS.filter((field) => !sameField(field, now, before)))
}
/** 창을 연 때와 같은가 — 나가기 확인은 다를 때만 묻는다 (보낼 값으로 견준다 — 빈 줄을 더하거나 뺀 것 · 그룹의 차례는 바꾼 것이 아니다) */
export function sameContactDraft(a: ContactDraft, b: ContactDraft): boolean {
return changedContactFields(a, b).size === 0
}
/** 고치기 본문 — 바꾼 칸만. emails · phones · groupIds 는 바뀌었으면 통째로 (바꾼 것이 없으면 빈 객체 — 보내지 않는다) */
export function contactPatchOf(draft: ContactDraft, opened: ContactDraft): ContactPatch {
const input = contactInputOf(draft)
const patch: ContactPatch = {}
for (const field of changedContactFields(draft, opened)) {
Object.assign(patch, { [field]: input[field] })
}
return patch
}
// ── 검사 — 서버와 같거나 더 좁은 규칙 (계약 3절 "ContactInput") ──────────────
// 화면이 통과시킨 값은 서버도 받는다. 이메일은 서버(IsEmail)보다 좁게 본다(mail-address.ts 의 isStrictEmail).
// 고치기는 바꾼 칸만 본다 — 손대지 않은 남의 값(21번째 주소 · "내선"이 든 번호 · 1,000자 넘는 메모 · 100자 넘는 한 줄 칸)은 보내지 않으니 그대로 둔다.
// 그 칸을 고치면 그 칸 전부가 규칙에 맞아야 한다(이메일 · 전화번호는 통째로 보낸다). "이름이나 이메일"은 합친 결과(지금 칸 값)로 본다
/** 이메일 줄의 잘못 — 형식(254자 포함) · 같은 연락처 안에서 겹침(대소문자 무시) */
export type ContactEmailProblem = 'invalid' | 'repeated'
export interface ContactDraftProblems {
/** 이름도 이메일도 없다 */
required: boolean
/** 칸 줄마다 — 잘못이 없으면 null */
emails: (ContactEmailProblem | null)[]
phones: ('invalid' | null)[]
/** 이메일 · 전화번호가 서버 한도(20개)를 넘는다 — 다른 프로그램이 넣은 것을 고쳤을 때 */
emailCount: boolean
phoneCount: boolean
/** 한 줄 칸이 100자를 넘는다 — 다른 프로그램이 넣은 긴 값을 고쳤을 때 (입력칸의 maxlength 는 친 글만 막는다) */
texts: Readonly<Record<ContactTextField, boolean>>
/** 메모가 1,000자(앞뒤 공백을 뺀 길이)를 넘는다 — 다른 프로그램이 넣은 긴 메모를 고쳤을 때 */
note: boolean
}
/** 번호 — 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만, 40자까지 */
const PHONE_PATTERN = /^[0-9 +\-().#*]+$/
const DIGIT = /[0-9]/
export function isValidContactPhone(value: string): boolean {
const trimmed = value.trim()
return trimmed.length <= CONTACT_PHONE_MAX_LENGTH && PHONE_PATTERN.test(trimmed) && DIGIT.test(trimmed)
}
const NO_TEXT_PROBLEMS: Readonly<Record<ContactTextField, boolean>> = {
name: false,
company: false,
department: false,
title: false,
}
export function noContactProblems(draft: Pick<ContactDraft, 'emails' | 'phones'>): ContactDraftProblems {
return {
required: false,
emails: draft.emails.map(() => null),
phones: draft.phones.map(() => null),
emailCount: false,
phoneCount: false,
texts: NO_TEXT_PROBLEMS,
note: false,
}
}
function emailRowProblems(draft: ContactDraft): (ContactEmailProblem | null)[] {
const seen = new Set<string>()
return draft.emails.map((row): ContactEmailProblem | null => {
const address = row.address.trim()
if (address === '') return null
if (address.length > CONTACT_EMAIL_MAX_LENGTH || !isStrictEmail(address)) return 'invalid'
const key = normalizeEmail(address)
if (seen.has(key)) return 'repeated'
seen.add(key)
return null
})
}
/**
* [저장]을 누를 때의 검사 — 빈 줄은 보지 않는다. opened 는 창을 연 때의 칸 값(고치기) — 주면 바꾼 칸만 본다, 새 연락처는 null(모든 칸)
*/
export function contactDraftProblems(draft: ContactDraft, opened: ContactDraft | null = null): ContactDraftProblems {
const changed = changedContactFields(draft, opened)
const input = contactInputOf(draft)
const texts = { ...NO_TEXT_PROBLEMS }
for (const field of CONTACT_TEXT_FIELDS) {
texts[field] = changed.has(field) && (input[field]?.length ?? 0) > CONTACT_TEXT_MAX_LENGTH
}
return {
required: input.name === null && input.emails.length === 0,
emails: changed.has('emails') ? emailRowProblems(draft) : draft.emails.map(() => null),
phones: draft.phones.map((row) =>
!changed.has('phones') || row.number.trim() === '' || isValidContactPhone(row.number) ? null : ('invalid' as const),
),
emailCount: changed.has('emails') && input.emails.length > CONTACT_EMAILS_MAX,
phoneCount: changed.has('phones') && input.phones.length > CONTACT_PHONES_MAX,
texts,
note: changed.has('note') && (input.note?.length ?? 0) > CONTACT_NOTE_MAX_LENGTH,
}
}
export function hasContactProblems(problems: ContactDraftProblems): boolean {
return (
problems.required ||
problems.note ||
problems.emailCount ||
problems.phoneCount ||
Object.values(problems.texts).some(Boolean) ||
problems.emails.some((problem) => problem !== null) ||
problems.phones.some((problem) => problem !== null)
)
}
// ── 같은 주소 경고 ───────────────────────────────────────
/** 그 주소가 든 다른 연락처 — 대소문자를 무시하고 견준다 (찾기 결과에는 주소가 일부만 맞는 연락처도 섞여 온다) */
export function contactsWithAddress(contacts: readonly Contact[], address: string, exceptId: string | null): Contact[] {
const key = normalizeEmail(address)
return contacts.filter(
(contact) => contact.id !== exceptId && contact.emails.some((email) => normalizeEmail(email.address) === key),
)
}
/** 경고에 보일 그 연락처 — "강서윤(거래처)": 이름 뒤 괄호는 첫 그룹, 그룹이 없으면 회사 */
export function duplicateWho(contact: Contact, groups: readonly ContactGroup[]): string {
const groupName = contact.groupIds
.map((id) => groups.find((group) => group.id === id)?.name)
.find((name): name is string => Boolean(name))
const detail = groupName ?? contact.company?.trim() ?? ''
const name = contactDisplayName(contact)
return detail ? `${name}(${detail})` : name
}
// ── 받는 사람 추천 · 그룹 ─────────────────────────────────
/**
* 주소록 줄의 주소 뒤 설명 — 회사 주소는 회사 이름(없으면 "회사"), 그 밖은 종류("개인" · "기타").
* 계약의 추천에는 대표 주소 표시가 없어 종류로 가른다 — 보통 대표 주소가 회사 주소다 (디자인 원본 ContactsMail)
*/
export function contactSuggestionDetail(suggestion: Pick<RecipientSuggestion, 'source' | 'company' | 'emailLabel'>): string | null {
if (suggestion.source !== 'contact') return null
const company = suggestion.company?.trim() || null
const label = suggestion.emailLabel ?? null
if (label === null) return company
if (label === 'work') return company ?? CONTACT_EMAIL_LABEL_TEXT.work
return CONTACT_EMAIL_LABEL_TEXT[label]
}
/** 그룹 구성원 → 받는 사람에 넣을 대표 주소 (겹치면 한 번) · 이메일이 없는 구성원 수 */
export interface GroupRecipients {
addresses: Address[]
withoutEmail: number
}
export function groupRecipientsOf(members: readonly Contact[]): GroupRecipients {
const addresses: Address[] = []
let withoutEmail = 0
for (const member of members) {
const email = primaryEmail(member)
if (email) addresses.push({ name: member.name?.trim() || null, email })
else withoutEmail += 1
}
return { addresses: uniqueAddresses(addresses), withoutEmail }
}
/** 그룹을 넣은 뒤 칸 아래 글 — "‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다. 주소가 없는 1명은 뺐습니다." */
export function groupInsertedText(
group: string,
field: string,
counts: { added: number; withoutEmail: number; already: number },
): string {
const head =
counts.added > 0
? RECIPIENT_CONTACT_TEXT.groupAdded(group, formatCount(counts.added), field)
: RECIPIENT_CONTACT_TEXT.groupNone(group, field)
const skipped = [
counts.withoutEmail > 0 ? RECIPIENT_CONTACT_TEXT.withoutEmail(formatCount(counts.withoutEmail)) : '',
counts.already > 0 ? RECIPIENT_CONTACT_TEXT.alreadyAdded(formatCount(counts.already)) : '',
].filter((part) => part !== '')
return skipped.length > 0 ? `${head} ${RECIPIENT_CONTACT_TEXT.skipped(skipped.join(' · '))}` : head
}
// ── 찾기 칸 ──────────────────────────────────────────────
/** 호환 자모 — ㄱ(U+3131) ~ ㆎ(U+318E) */
const JAMO = /[ㄱ-ㆎ]/
/** 자음만 — ㄱ ~ ㅎ (겹자음 포함, U+3131 ~ U+314E). 서버는 이것만 친 글을 초성 찾기로 본다 */
const CONSONANTS_ONLY = /^[ㄱ-ㅎ]+$/
/**
* 찾기 칸의 글 → 서버에 보낼 q. 빈 글이면 null(찾기를 푼다).
* 초성만 친 글(ㅂㅈㅎ)은 그대로 보낸다 — 서버가 이름의 초성과 견준다.
* 글자와 자모가 섞였으면(조합 중 — "박ㅈ") undefined: 보내지 않고 지난 결과를 둔다 (받는 사람 칸이 서버에 묻지 않는 글과 같은 규칙)
*/
export function contactSearchQueryOf(text: string): string | null | undefined {
const trimmed = text.trim().slice(0, CONTACTS_SEARCH_MAX_LENGTH).trim()
if (trimmed === '') return null
if (CONSONANTS_ONLY.test(trimmed)) return trimmed
return JAMO.test(trimmed) ? undefined : trimmed
}
@@ -4,6 +4,8 @@ import {
displayName,
formatAddress,
hasOpenRecipientGroup,
isDotAtomLocalPart,
isStrictEmail,
isValidEmail,
mergeRecipientTokens,
parseRecipient,
@@ -26,6 +28,52 @@ describe('isValidEmail', () => {
})
})
// 저장하는 주소(주소록 연락처) — 서버(class-validator IsEmail)보다 좁게: 화면이 통과시킨 주소는 서버도 받는다
// (2026-10-29 backend 의 validator 13.15 로 아래 표본을 돌려 "화면 O → 서버 O" 를 확인했다)
describe('isStrictEmail', () => {
it.each([
'jihoon.park@hanbit.example',
'a+tag@b.co',
"o'brien@x.example",
'x@한국.kr',
'x@xn--3e0b707e.kr',
'x@sub-domain.example.com',
` ${'a'.repeat(64)}@x.com `,
])('%s 는 받는다', (email) => {
expect(isStrictEmail(email)).toBe(true)
})
it.each([
['x@-bad.com', '칸 앞 하이픈'],
['x@bad-.com', '칸 뒤 하이픈'],
['x@a..b.com', '연달은 점'],
['x@a.b.', '끝 점'],
['x@x.c', 'TLD 한 글자'],
['x@a.123', '숫자 TLD'],
['x@localhost', '점 없는 도메인'],
['a..b@x.com', '@ 앞 연달은 점'],
['a.@x.com', '@ 앞 끝 점'],
['"q"@x.com', '따옴표 (서버는 받는다)'],
['이도윤@x.com', '유니코드 @ 앞 (서버는 받는다)'],
['a@b@x.com', '@ 둘'],
['x@a_b.com', '밑줄 도메인'],
['x@[1.2.3.4]', 'IP 도메인'],
['x@ab.com', '전각 글자'],
['x@emoji😀.com', '서로게이트 (서버는 받는다)'],
[`${'a'.repeat(65)}@x.com`, '@ 앞 65자'],
[`a@${'b'.repeat(64)}.com`, '도메인 칸 64자'],
[`a@${'한'.repeat(60)}.${'한'.repeat(30)}.kr`, '도메인 254바이트 넘음'],
])('%s 는 받지 않는다 (%s)', (email) => {
expect(isStrictEmail(email)).toBe(false)
})
it('isDotAtomLocalPart — 필터의 전달 주소와 같은 @ 앞 규칙 (64자까지)', () => {
expect(isDotAtomLocalPart('doyun.lee')).toBe(true)
expect(isDotAtomLocalPart('doyun..lee')).toBe(false)
expect(isDotAtomLocalPart('a'.repeat(65))).toBe(false)
})
})
describe('parseRecipient', () => {
it('"이름 <주소>" 를 이름과 주소로 나눈다', () => {
expect(parseRecipient('이도윤 <doyun.lee@arasystem.it.kr>')).toEqual({
+55
View File
@@ -16,6 +16,61 @@ export function isValidEmail(email: string): boolean {
return EMAIL_PATTERN.test(email.trim())
}
// ── 저장하는 주소의 검사 — 서버(class-validator IsEmail)보다 좁게 ──────────────
// 화면이 통과시킨 주소는 서버도 받는다 (서버가 받는 것을 화면이 모두 받지는 않는다 — 따옴표 · 유니코드가 든 @ 앞, IP 도메인 등).
// 주소록 연락처의 이메일(계약 3절 "ContactInput")과 필터의 전달 주소(2절 "메일 필터")가 쓴다.
/** 주소 길이 — 서버와 같다 (254자) */
const STRICT_EMAIL_MAX_LENGTH = 254
/** @ 앞 길이 — 서버는 UTF-8 64바이트, 여기는 ASCII 만 받으니 64자 */
const LOCAL_PART_MAX_LENGTH = 64
/** @ 뒤 길이 — 서버는 UTF-8 254바이트 */
const DOMAIN_MAX_BYTES = 254
/** 도메인 한 칸 길이 */
const DOMAIN_LABEL_MAX_LENGTH = 63
/**
* @ 앞 — RFC 5322 dot-atom: atext(영문 · 숫자 · ! # $ % & ' * + - / = ? ^ _ ` { | } ~) 덩어리를 점으로 잇는다(앞 · 뒤 · 연달은 점 없음)
*/
const DOT_ATOM_LOCAL_PART = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/
/** 도메인 한 칸 — 영문 · 숫자 · 하이픈과 BMP 의 비 ASCII 글자(IDN — 서로게이트 · 전각 기호는 뺀다) */
const DOMAIN_LABEL = /^[A-Za-z0-9\u00a1-\ud7ff\ue000-\uff00\uff5f-\uffff-]+$/
/** 마지막 칸(TLD) — 글자 2개 이상이거나 퓨니코드(xn--) — 서버(isFQDN)와 같은 범위에서 서로게이트를 뺐다 */
const TOP_LEVEL_DOMAIN = /^(?:[A-Za-z\u00a1-\u00a8\u00aa-\ud7ff\uf900-\ufdcf\ufdf0-\uff00\uff5f-\uffef]{2,}|xn--[a-z0-9-]+)$/i
/** @ 앞이 dot-atom 이고 64자 이하인가 */
export function isDotAtomLocalPart(local: string): boolean {
return local.length <= LOCAL_PART_MAX_LENGTH && DOT_ATOM_LOCAL_PART.test(local)
}
function isStrictDomain(domain: string): boolean {
if (new TextEncoder().encode(domain).length > DOMAIN_MAX_BYTES) return false
const labels = domain.split('.')
// 칸이 둘 이상 — 끝 점 · 연달은 점은 빈 칸이 되어 걸린다
if (labels.length < 2) return false
const ok = labels.every(
(label) =>
label.length > 0 &&
label.length <= DOMAIN_LABEL_MAX_LENGTH &&
!label.startsWith('-') &&
!label.endsWith('-') &&
DOMAIN_LABEL.test(label),
)
return ok && TOP_LEVEL_DOMAIN.test(labels[labels.length - 1] ?? '')
}
/**
* 저장할 메일 주소인가 — @ 하나, @ 앞은 dot-atom 64자, @ 뒤는 점으로 나눈 칸(끝 점 없음 · 칸마다 앞뒤 하이픈 없음 · 63자) +
* 글자 2개 이상의 마지막 칸, 모두 254자. 앞뒤 공백은 빼고 본다
*/
export function isStrictEmail(address: string): boolean {
const trimmed = address.trim()
if (trimmed.length === 0 || trimmed.length > STRICT_EMAIL_MAX_LENGTH) return false
const at = trimmed.indexOf('@')
if (at <= 0 || at !== trimmed.lastIndexOf('@')) return false
return isDotAtomLocalPart(trimmed.slice(0, at)) && isStrictDomain(trimmed.slice(at + 1))
}
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase()
}
+4 -7
View File
@@ -21,7 +21,7 @@ import type {
MailFilterMatch,
MailFilterRule,
} from '@/shared/types/mail'
import { isValidEmail, normalizeEmail } from '@/shared/utils/mail-address'
import { isDotAtomLocalPart, isValidEmail, normalizeEmail } from '@/shared/utils/mail-address'
import { hasControlChar, orderMailboxes } from '@/shared/utils/mailbox'
import { createUuid } from '@/shared/utils/uuid'
@@ -284,11 +284,8 @@ function conditionProblem(value: string): MailFilterConditionProblem | null {
return null
}
/**
* 전달 주소의 @ 앞 — RFC 5322 dot-atom: atext(영문 · 숫자 · ! # $ % & ' * + - / = ? ^ _ ` { | } ~) 덩어리를 점으로 잇는다
* (앞 · 뒤 · 연달은 점 없음). 서버(IsEmail)가 받는 것보다 좁다 — 화면이 통과시킨 주소는 서버도 받는다
*/
const DOT_ATOM_LOCAL_PART = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/
// 전달 주소의 @ 앞은 RFC 5322 dot-atom 만(mail-address.ts 의 isDotAtomLocalPart — 64자까지). 서버(IsEmail)가 받는 것보다 좁다 —
// 화면이 통과시킨 주소는 서버도 받는다. 도메인은 사내 도메인과 같아야 하므로 따로 보지 않는다
function forwardProblem(address: string, ownEmail: string): MailFilterForwardProblem | null {
const trimmed = address.trim()
@@ -300,7 +297,7 @@ function forwardProblem(address: string, ownEmail: string): MailFilterForwardPro
at > 0 &&
trimmed.length <= MAIL_FILTER_FORWARD_MAX_LENGTH &&
local.length <= MAIL_FILTER_FORWARD_LOCAL_MAX_LENGTH &&
DOT_ATOM_LOCAL_PART.test(local) &&
isDotAtomLocalPart(local) &&
isValidEmail(trimmed)
if (!wellFormed) return 'invalid'
const ownDomain = mailDomainOf(ownEmail)
@@ -1,13 +1,17 @@
import { describe, expect, it } from 'vitest'
import type { ContactGroup } from '@/shared/types/contacts'
import type { RecipientSuggestion } from '@/shared/types/mail'
import { RECIPIENT_MATCH_SAMPLES } from '@/test-utils/recipient-match-samples'
import {
RECIPIENT_SUGGESTION_LIMIT,
arrangeRecipientOptions,
filterRecipientSuggestions,
hasHangulJamo,
highlightParts,
matchContactGroups,
matchEmail,
matchName,
recipientOptionGroup,
} from './recipient-match'
const DOYUN: RecipientSuggestion = { name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }
@@ -20,6 +24,15 @@ function matched(text: string, range: { start: number; end: number } | null): st
return range ? text.slice(range.start, range.end) : null
}
// 서버의 q 맞추기와 같은 표 — backend recipient-match.spec.ts 의 표와 행 · 순서까지 같다 (test-utils/recipient-match-samples.ts)
describe('표본 표 — 서버(GET /recipient-suggestions?q=)와 같은 맞추기', () => {
it.each(RECIPIENT_MATCH_SAMPLES)('q=%j 이름=%j 주소=%j → %s', (query, name, email, matches) => {
const byName = matchName(query, name ?? '') !== null
const byEmail = matchEmail(query, email) !== null
expect(byName || byEmail).toBe(matches)
})
})
describe('matchName — 이름 (대소문자 무시 · 초성 · 조합 중인 마지막 글자)', () => {
it.each([
['도윤', '이도윤', '도윤'],
@@ -82,6 +95,17 @@ describe('filterRecipientSuggestions', () => {
expect(result.map((match) => match.suggestion.email)).toEqual([DOYUN.email, DOYUN_KIM.email, PARTNER.email])
})
it('내 주소록(8단계)이 사내보다 먼저 — 주소록 → 사내 → 최근, 묶음 안은 받은 순서대로', () => {
const contact: RecipientSuggestion = { name: '도윤 거래처', email: 'doyun@vendor.example', source: 'contact' }
const result = filterRecipientSuggestions('도윤', [PARTNER, DOYUN, contact, DOYUN_KIM], new Set())
expect(result.map((match) => match.suggestion.email)).toEqual([
contact.email,
DOYUN.email,
DOYUN_KIM.email,
PARTNER.email,
])
})
it('이미 넣은 주소(대소문자 무시)는 빼고, 같은 주소는 한 번만', () => {
const result = filterRecipientSuggestions('도윤', [...pool, { ...DOYUN, source: 'recent' }], new Set([DOYUN_KIM.email]))
expect(result.map((match) => match.suggestion.email)).toEqual([DOYUN.email, PARTNER.email])
@@ -102,6 +126,35 @@ describe('filterRecipientSuggestions', () => {
})
})
describe('그룹 (8단계) — matchContactGroups · arrangeRecipientOptions', () => {
const VENDORS: ContactGroup = { id: 'g1', name: '거래처', memberCount: 4 }
const TEAM: ContactGroup = { id: 'g2', name: '도윤 팀', memberCount: 2 }
const EMPTY: ContactGroup = { id: 'g3', name: '거래 준비', memberCount: 0 }
const CONTACT: RecipientSuggestion = { name: '이도윤', email: 'doyun@vendor.example', source: 'contact' }
it('그룹 이름으로 찾는다 — 초성 · 조합 중 글자도, 구성원이 없는 그룹은 뺀다', () => {
expect(matchContactGroups('거래', [VENDORS, TEAM, EMPTY]).map((match) => match.group.id)).toEqual(['g1'])
expect(matchContactGroups('ㄱㄹ', [VENDORS, EMPTY])).toEqual([{ group: VENDORS, name: { start: 0, end: 2 } }])
expect(matchContactGroups(' ', [VENDORS])).toEqual([])
})
it('줄의 차례는 주소록 → 그룹 → 사내 → 최근, 모두 합쳐 8개까지', () => {
const addresses = filterRecipientSuggestions('도윤', [PARTNER, DOYUN, CONTACT], new Set())
const options = arrangeRecipientOptions(addresses, matchContactGroups('도윤', [TEAM]))
expect(options.map(recipientOptionGroup)).toEqual(['contact', 'group', 'directory', 'recent'])
const many = Array.from({ length: 10 }, (_, index): RecipientSuggestion => ({
name: `도윤${index}`,
email: `doyun${index}@vendor.example`,
source: 'contact',
}))
const full = arrangeRecipientOptions(filterRecipientSuggestions('도윤', many, new Set()), matchContactGroups('도윤', [TEAM]))
// 주소록 줄이 8개를 채우면 그룹 줄은 밀려난다 (차례가 앞선다)
expect(full).toHaveLength(RECIPIENT_SUGGESTION_LIMIT)
expect(full.every((option) => option.kind === 'address')).toBe(true)
})
})
describe('보조 함수', () => {
it('highlightParts — 맞은 부분만 strong', () => {
expect(highlightParts('이도윤', { start: 1, end: 3 })).toEqual([
@@ -110,11 +163,4 @@ describe('보조 함수', () => {
])
expect(highlightParts('abc', null)).toEqual([{ text: 'abc', strong: false }])
})
it('hasHangulJamo — 초성 · 홀로 쓴 모음', () => {
expect(hasHangulJamo('ㄷㅇ')).toBe(true)
expect(hasHangulJamo('도ㅇ')).toBe(true)
expect(hasHangulJamo('도윤')).toBe(false)
expect(hasHangulJamo('doyun')).toBe(false)
})
})
+57 -11
View File
@@ -3,9 +3,11 @@
// - 이름은 초성으로도 찾는다: ㄷㅇ → 이"도윤". 초성과 글자를 섞어도 된다: 도ㅇ → 이"도윤".
// - 마지막 글자는 아직 조합 중일 수 있다 — "이도유"(윤을 치는 중) · "이동"(윤의 ㅇ 이 받침으로 붙은 순간)도 "이도윤"에 맞는다.
// - 친 글자가 완전한 주소면 같은 아이디(@ 앞)의 주소도 보인다 — 쓰기 화면은 그때 아무것도 미리 고르지 않는다.
// - 이미 넣은 주소는 뺀다. 순서는 받은 그대로(서버: 사내 → 최근 주고받은 주소, 주고받은 횟수 · 최근성), 최대 8개.
// - 이미 넣은 주소는 뺀다. 묶음은 주소록 → 그룹 → 사내 → 최근 주고받은 주소(8단계 — 계약 3절 "메일과 함께"), 묶음 안은 받은 차례 그대로, 모두 합쳐 최대 8개.
// - 그룹(8단계)은 서버 추천에 없다 — 쓰기 화면이 받아 둔 그룹을 이름으로 찾는다(초성 · 조합 중 글자 · 강조는 이름과 같은 규칙). 구성원이 없는 그룹은 뺀다
import type { RecipientSuggestion } from '@/shared/types/mail'
import type { ContactGroup } from '@/shared/types/contacts'
import type { RecipientSuggestion, RecipientSuggestionSource } from '@/shared/types/mail'
import { normalizeEmail } from '@/shared/utils/mail-address'
/** 추천 목록에 보이는 최대 개수 */
@@ -84,11 +86,6 @@ function isInitialJamo(char: string): boolean {
return INITIALS.includes(char)
}
/** 호환 자모(ㄱ~ㅎ · ㅏ~ㅣ)가 들었는가 — 서버 검색은 초성을 모른다 */
export function hasHangulJamo(text: string): boolean {
return /[ㄱ-ㆎ]/.test(text)
}
function initialOf(char: string): string | null {
const syllable = syllableOf(char)
return syllable ? (INITIALS[syllable.initial] ?? null) : null
@@ -177,8 +174,16 @@ export function matchRecipient(query: string, suggestion: RecipientSuggestion):
return name || email ? { suggestion, name, email } : null
}
/** 주소 추천의 묶음 차례 — 주소록 → 사내 → 최근 주고받은 주소 (그룹은 주소록과 사내 사이 — arrangeRecipientOptions) */
const SOURCE_RANK: Readonly<Record<RecipientSuggestionSource, number>> = { contact: 0, directory: 1, recent: 2 }
function sourceRank(suggestion: RecipientSuggestion): number {
return SOURCE_RANK[suggestion.source] ?? SOURCE_RANK.recent
}
/**
* 친 글자에 맞는 추천 — 이미 넣은 주소(excluded, 정규화한 주소)는 빼고, 같은 주소는 처음 것만, 사내를 먼저, 최대 limit 개.
* 친 글자에 맞는 추천 — 이미 넣은 주소(excluded, 정규화한 주소)는 빼고, 같은 주소는 처음 것만(서버가 앞 묶음에 한 번만 준다),
* 주소록 → 사내 → 최근 주고받은 주소 차례로(묶음 안은 받은 차례 그대로), 최대 limit 개.
* 빈 글자면 아무것도 보이지 않는다 (한 글자 이상 쳤을 때만 목록을 연다).
*/
export function filterRecipientSuggestions(
@@ -198,9 +203,50 @@ export function filterRecipientSuggestions(
seen.add(key)
matches.push(match)
}
const directory = matches.filter((match) => match.suggestion.source === 'directory')
const recent = matches.filter((match) => match.suggestion.source !== 'directory')
return [...directory, ...recent].slice(0, limit)
// 묶음 차례로 — 같은 묶음 안은 받은 차례를 지킨다 (Array.prototype.sort 는 안정 정렬)
return matches.sort((a, b) => sourceRank(a.suggestion) - sourceRank(b.suggestion)).slice(0, limit)
}
// ── 그룹 (8단계) ─────────────────────────────────────────
/** 그룹 이름에 맞은 그룹 */
export interface RecipientGroupMatch {
group: ContactGroup
/** 이름에서 맞은 범위 */
name: MatchRange
}
/** 추천 목록의 한 줄 — 주소 하나 또는 그룹 하나 */
export type RecipientOption = { kind: 'address'; match: RecipientMatch } | { kind: 'group'; match: RecipientGroupMatch }
/** 목록의 묶음 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소 */
export type RecipientOptionGroup = RecipientSuggestionSource | 'group'
export function recipientOptionGroup(option: RecipientOption): RecipientOptionGroup {
return option.kind === 'group' ? 'group' : option.match.suggestion.source
}
/** 친 글자가 이름에 맞는 그룹 — 구성원이 없는 그룹은 뺀다(넣을 주소가 없다). 받은 차례(이름 차례) 그대로 */
export function matchContactGroups(query: string, groups: readonly ContactGroup[]): RecipientGroupMatch[] {
if (query.trim() === '') return []
return groups.flatMap((group) => {
if (group.memberCount <= 0) return []
const name = matchName(query, group.name)
return name ? [{ group, name }] : []
})
}
/** 추천 목록의 줄 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 모두 합쳐 최대 limit 개 (화살표 차례도 이대로) */
export function arrangeRecipientOptions(
addresses: readonly RecipientMatch[],
groups: readonly RecipientGroupMatch[],
limit = RECIPIENT_SUGGESTION_LIMIT,
): RecipientOption[] {
const asAddress = (match: RecipientMatch): RecipientOption => ({ kind: 'address', match })
const contacts = addresses.filter((match) => match.suggestion.source === 'contact').map(asAddress)
const others = addresses.filter((match) => match.suggestion.source !== 'contact').map(asAddress)
const groupOptions = groups.map((match): RecipientOption => ({ kind: 'group', match }))
return [...contacts, ...groupOptions, ...others].slice(0, limit)
}
/** 굵게 보일 조각으로 나눈다 */
+464
View File
@@ -0,0 +1,464 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import type {
Contact,
ContactDeleted,
ContactGroup,
ContactGroupDeleted,
ContactInput,
DirectoryEntry,
} from '@/shared/types/contacts'
import type { PaginatedResult } from '@/shared/types/pagination'
import { useAuthStore } from '@/stores/auth.store'
import { useToastStore } from '@/stores/toast.store'
import {
fail,
flushPromises,
installMockApi,
ok,
restoreApiAdapter,
type MockHandler,
type MockRequest,
type MockResult,
} from '@/test-utils/mock-api'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { useContactsStore } from './contacts.store'
// 주소록 스토어 — 계약 3절 "주소록" (목록 · 수 · 바꾸기 · 늦게 온 응답 버리기)
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
function contact(id: string, name: string | null, email: string | null, groupIds: string[] = []): Contact {
return {
id,
name,
emails: email ? [{ address: email, label: 'work' }] : [],
phones: [],
company: null,
department: null,
title: null,
note: null,
groupIds,
}
}
function pageOf<T>(items: T[], total: number | null = items.length, nextCursor: string | null = null): PaginatedResult<T> {
return { items, pageInfo: { hasNext: nextCursor !== null, nextCursor, total } }
}
function deferred() {
let finish: (result: MockResult) => void = () => undefined
const promise = new Promise<MockResult>((resolve) => {
finish = resolve
})
return { promise, finish }
}
const VENDORS: ContactGroup = { id: 'g1', name: '거래처', memberCount: 2 }
const ALPHA: ContactGroup = { id: 'g2', name: '프로젝트 알파', memberCount: 1 }
const SEOYUN = contact('c1', '강서윤', 'seoyun.kang@hanbit.example', ['g1'])
const JIHOON = contact('c2', '박지훈', 'jihoon.park@hanbit.example', ['g1', 'g2'])
const DOYUN: DirectoryEntry = { id: 's5', name: '이도윤', email: 'doyun.lee@arasystem.it.kr', kind: 'person' }
describe('contacts.store', () => {
let requests: MockRequest[]
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().startSession(USER)
})
afterEach(() => {
useToastStore().clear()
restoreApiAdapter()
})
function install(handler: MockHandler) {
requests = installMockApi(handler)
}
const calls = () => requests.map((request) => `${request.method} ${request.url}`)
it('들어올 때 그룹 · 연락처 수 · 사내 목록 수를 알림 없이 받는다 — 패널의 숫자 · 3,000개 한도', async () => {
install((request) => {
if (request.url === '/contact-groups') return ok(pageOf([VENDORS, ALPHA]))
if (request.url === '/contacts') return ok(pageOf([SEOYUN], 3000))
if (request.url === '/directory-entries') return ok(pageOf([DOYUN], request.params.kind === 'person' ? 7 : 5))
return ok(null)
})
const store = useContactsStore()
store.enterModule()
await flushPromises()
expect(requests.map((request) => [request.url, request.params, request.config.silent])).toEqual([
['/contact-groups', { limit: 100 }, true],
['/contacts', { limit: 1 }, true],
['/directory-entries', { kind: 'person', limit: 1 }, true],
['/directory-entries', { kind: 'shared', limit: 1 }, true],
])
expect(store.groups.map((group) => group.name)).toEqual(['거래처', '프로젝트 알파'])
expect(store.contactTotal).toBe(3000)
expect(store.isFull).toBe(true)
expect(store.directoryTotals).toEqual({ person: 7, shared: 5 })
expect(store.directoryStatus).toBe('ready')
})
it('사내 목록을 받지 못하면(사내 목록 계정이 없음 — 503) 사내 숫자 없이 둔다 · 알리지 않는다', async () => {
install((request) => (request.url === '/directory-entries' ? fail(503, 'MAIL_001') : ok(pageOf([]))))
const store = useContactsStore()
store.enterModule()
await flushPromises()
expect(store.directoryStatus).toBe('error')
expect(store.directoryTotals).toEqual({ person: null, shared: null })
expect(useToastStore().toasts).toEqual([])
})
it('목록 — 100명씩 알림 없이 받고, 찾지 않은 전체 연락처의 total 은 패널 숫자다 · 그룹 · 찾기 · 사내는 그 쿼리로', async () => {
install((request) => {
if (request.url === '/directory-entries') return ok(pageOf([DOYUN], 7))
return ok(pageOf([SEOYUN, JIHOON], 2))
})
const store = useContactsStore()
await store.openList({ kind: 'all' }, null)
expect(store.contacts.map((item) => item.id)).toEqual(['c1', 'c2'])
expect(store.contactTotal).toBe(2)
await store.openList({ kind: 'group', groupId: 'g1' }, '박')
await store.openList({ kind: 'directory', directoryKind: 'person' }, null)
expect(requests.map((request) => [request.url, request.params, request.config.silent])).toEqual([
['/contacts', { limit: 100 }, true],
['/contacts', { limit: 100, q: '박', groupId: 'g1' }, true],
['/directory-entries', { limit: 100, kind: 'person' }, true],
])
expect(store.entries).toEqual([DOYUN])
expect(store.contacts).toEqual([])
expect(store.directoryTotals.person).toBe(7)
})
it('늦게 온 앞 목록의 응답은 버린다 — 다른 목록을 연 뒤 도착해도 지금 목록을 덮지 않는다', async () => {
const slow = deferred()
install((request) => (request.params.groupId === undefined ? slow.promise : ok(pageOf([JIHOON], 1))))
const store = useContactsStore()
const first = store.openList({ kind: 'all' }, null)
await store.openList({ kind: 'group', groupId: 'g2' }, null)
slow.finish(ok(pageOf([SEOYUN, JIHOON], 2)))
await first
expect(store.listSource).toEqual({ kind: 'group', groupId: 'g2' })
expect(store.contacts.map((item) => item.id)).toEqual(['c2'])
expect(store.contactTotal).toBeNull()
})
it('[더 보기] — 다음 쪽을 이어 붙이고 겹친 id 는 한 번, 실패하면 인터셉터가 알린다', async () => {
let failMore = false
install((request) => {
if (request.params.cursor === undefined) return ok(pageOf([SEOYUN], 3, 'next'))
return failMore ? fail(503, 'MAIL_001') : ok(pageOf([SEOYUN, JIHOON], 3))
})
const store = useContactsStore()
await store.openList({ kind: 'all' }, null)
expect(store.hasMore).toBe(true)
failMore = true
expect(await store.loadMore()).toBe(false)
expect(useToastStore().toasts.map((toast) => toast.message)).toHaveLength(1)
failMore = false
expect(await store.loadMore()).toBe(true)
expect(store.contacts.map((item) => item.id)).toEqual(['c1', 'c2'])
expect(store.hasMore).toBe(false)
expect(requests.at(-1)?.params).toEqual({ limit: 100, cursor: 'next' })
})
const INPUT: ContactInput = {
name: '한도경',
emails: [{ address: 'dokyung.han@mail.example', label: 'work' }],
phones: [],
company: null,
department: null,
title: null,
note: null,
groupIds: ['g2'],
}
it('만들기 — 수를 늘리고 응답의 연락처를 서버 차례의 자리에 넣는다(다시 받지 않는다) · 그룹의 구성원 수는 그룹을 다시 받는다', async () => {
const created = contact('c9', '김하늘', 'haneul.kim@mail.example', ['g2'])
install((request) => {
if (request.method === 'POST') return ok(created, 201)
if (request.url === '/contact-groups') return ok(pageOf([VENDORS, { ...ALPHA, memberCount: 2 }]))
return ok(pageOf([SEOYUN, JIHOON]))
})
const store = useContactsStore()
await store.openList({ kind: 'all' }, null)
expect(await store.createContact(INPUT)).toEqual(created)
expect(store.contactTotal).toBe(3)
await flushPromises()
expect(requests.find((request) => request.method === 'POST')?.data).toEqual(INPUT)
// 강서윤 · 김하늘 · 박지훈 — 이름 차례 (서버와 같은 compareContacts)
expect(store.contacts.map((item) => item.id)).toEqual(['c1', 'c9', 'c2'])
expect(store.pageInfo?.total).toBe(3)
expect(calls()).toEqual(['GET /contacts', 'POST /contacts', 'GET /contact-groups'])
expect(store.groups.find((group) => group.id === 'g2')?.memberCount).toBe(2)
})
it('만들기 — 받은 쪽 끝보다 뒤에 들 연락처는 넣지 않는다([더 보기]가 가져온다) · 찾은 목록은 들어가는지 몰라 첫 쪽만 다시 받는다', async () => {
const created = contact('c9', '황보영', 'young.hwangbo@mail.example')
let made = 0
install((request) => {
if (request.method === 'POST') {
made += 1
return ok(created, 201)
}
// 찾은 목록 — 두 번째로 만든 뒤에야 서버의 찾기에 든다
if (request.params.q) return ok(pageOf(made >= 2 ? [created] : []))
return ok(pageOf([SEOYUN, JIHOON], 120, 'next'))
})
const store = useContactsStore()
await store.openList({ kind: 'all' }, null)
await store.createContact({ ...INPUT, groupIds: [] })
expect(store.contacts.map((item) => item.id)).toEqual(['c1', 'c2'])
expect(store.pageInfo?.total).toBe(121)
await store.openList({ kind: 'all' }, '황보')
await store.createContact({ ...INPUT, groupIds: [] })
await flushPromises()
expect(calls().filter((call) => call === 'GET /contacts')).toHaveLength(3)
expect(requests.at(-1)?.params).toEqual({ limit: 100, q: '황보' })
expect(store.contacts.map((item) => item.id)).toEqual(['c9'])
})
it('고치기 — PATCH 로 바꾼 칸만 보내고, 응답으로 자리를 옮긴다 · 그룹을 빼면 그 그룹 목록에서 빠진다(구성원 수도)', async () => {
install((request) => {
if (request.method === 'PATCH') return ok({ ...SEOYUN, name: '하서윤', groupIds: [] })
if (request.url === '/contact-groups') return ok(pageOf([{ ...VENDORS, memberCount: 1 }, ALPHA]))
return ok(pageOf([SEOYUN, JIHOON]))
})
const store = useContactsStore()
await store.loadGroups()
await store.openList({ kind: 'group', groupId: 'g1' }, null)
await store.updateContact('c1', { name: '하서윤', groupIds: [] })
expect(requests.find((request) => request.method === 'PATCH')).toMatchObject({
url: '/contacts/c1',
data: { name: '하서윤', groupIds: [] },
})
expect(store.contacts.map((item) => item.id)).toEqual(['c2'])
expect(store.pageInfo?.total).toBe(1)
await flushPromises()
expect(store.groups.find((group) => group.id === 'g1')?.memberCount).toBe(1)
// 전체 목록에서는 이름 차례로 자리를 옮긴다
await store.openList({ kind: 'all' }, null)
await store.updateContact('c1', { name: '하서윤' })
expect(store.contacts.map((item) => item.id)).toEqual(['c2', 'c1'])
expect(calls().filter((call) => call === 'GET /contacts')).toHaveLength(2)
})
it('바꾸기의 응답이 세션이 바뀐 뒤(로그아웃 · 다른 계정) 오면 스토어에 쓰지 않고 null', async () => {
const reply = deferred()
install((request) => (request.method === 'PATCH' ? reply.promise : ok(pageOf([SEOYUN]))))
const store = useContactsStore()
await store.openList({ kind: 'all' }, null)
const saving = store.updateContact('c1', { name: '강서윤 과장' })
useAuthStore().startSession({ email: 'doyun.lee@arasystem.it.kr', name: '이도윤' })
reply.finish(ok({ ...SEOYUN, name: '강서윤 과장' }))
expect(await saving).toBeNull()
expect(store.contacts).toEqual([])
expect(requests.filter((request) => request.method === 'GET' && request.url === '/contacts')).toHaveLength(1)
})
it('지우기 — 목록에서 빼고 수를 줄인다 · 되돌리기는 표로(POST /contact-restorations) — 되돌린 연락처를 자리에 넣는다(새 id)', async () => {
const deletedReply: ContactDeleted = { contact: JIHOON, restoreToken: 't-c2' }
install((request) => {
if (request.method === 'DELETE') return ok(deletedReply)
if (request.url === '/contact-restorations') return ok({ kind: 'contact', contact: { ...JIHOON, id: 'c20' } }, 201)
if (request.url === '/contact-groups') return ok(pageOf([VENDORS, ALPHA]))
if (request.params.limit === 1) return ok(pageOf([], 2))
return ok(pageOf([SEOYUN, JIHOON], 2))
})
const store = useContactsStore()
store.enterModule()
await flushPromises()
await store.openList({ kind: 'all' }, null)
const deleted = await store.deleteContact('c2')
expect(deleted).toEqual(deletedReply)
expect(store.contacts.map((item) => item.id)).toEqual(['c1'])
expect(store.pageInfo?.total).toBe(1)
expect(store.contactTotal).toBe(1)
expect(await store.restore('t-c2')).toEqual({ kind: 'contact', contact: { ...JIHOON, id: 'c20' } })
expect(requests.find((request) => request.url === '/contact-restorations')).toMatchObject({
method: 'POST',
data: { token: 't-c2' },
})
expect(store.contacts.map((item) => item.id)).toEqual(['c1', 'c20'])
expect(store.contactTotal).toBe(2)
})
it('그룹 지우기 — 그룹 · 연락처의 groupIds 에서 빼고, 되돌리기는 표로 — 다시 만든 그룹(새 id)을 그때의 구성원에 더한다', async () => {
const removed: ContactGroupDeleted = { id: 'g1', name: '거래처', memberIds: ['c1', 'c2'], restoreToken: 't-g1' }
const restoredGroup: ContactGroup = { id: 'g7', name: '거래처', memberCount: 2 }
let restored = false
install((request) => {
if (request.method === 'DELETE') return ok(removed)
if (request.url === '/contact-restorations') {
restored = true
return ok({ kind: 'group', group: restoredGroup }, 201)
}
if (request.url === '/contact-groups') return ok(pageOf(restored ? [restoredGroup, ALPHA] : [ALPHA]))
return ok(pageOf([SEOYUN, JIHOON]))
})
const store = useContactsStore()
await store.loadGroups()
await store.openList({ kind: 'all' }, null)
expect(await store.deleteGroup('g1')).toEqual(removed)
expect(store.groups.map((group) => group.id)).toEqual(['g2'])
expect(store.contacts.map((item) => item.groupIds)).toEqual([[], ['g2']])
await store.restore('t-g1', removed.memberIds)
expect(requests.find((request) => request.url === '/contact-restorations')?.data).toEqual({ token: 't-g1' })
expect(store.groups.map((group) => group.id)).toContain('g7')
expect(store.contacts.map((item) => item.groupIds)).toEqual([['g7'], ['g2', 'g7']])
// 그룹 만들기 본문에 구성원을 싣던 옛 길은 없다
expect(requests.some((request) => request.method === 'POST' && request.url === '/contact-groups')).toBe(false)
})
it('되돌리기가 실패하면(표가 지남 RES_001 · 같은 이름 CONTACT_001) 던지고 목록은 그대로 — 알림은 인터셉터', async () => {
install((request) => (request.url === '/contact-restorations' ? fail(409, 'CONTACT_001') : ok(pageOf([SEOYUN]))))
const store = useContactsStore()
await store.openList({ kind: 'all' }, null)
await expect(store.restore('t-old')).rejects.toMatchObject({ code: 'CONTACT_001' })
expect(store.contacts.map((item) => item.id)).toEqual(['c1'])
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.CONTACT_001])
})
it('구성원 넣기 — 그 그룹을 보고 있거나 새 연락처가 생겼으면(사내 직원) 첫 쪽만 한 번 다시 받고, 아니면 고른 연락처에 그룹을 더해 둔다', async () => {
install((request) => {
if (request.url === '/contact-groups/g2/members') {
const created = (request.data as { directoryIds?: string[] }).directoryIds?.length ?? 0
return ok({ group: { ...ALPHA, memberCount: 3 }, addedCount: 1 + created, createdCount: created })
}
if (request.url === '/contact-groups') return ok(pageOf([VENDORS, { ...ALPHA, memberCount: 3 }]))
return ok(pageOf([SEOYUN, JIHOON], 2, 'next'))
})
const store = useContactsStore()
await store.loadGroups()
await store.openList({ kind: 'all' }, null)
await store.addMembers('g2', { contactIds: ['c1'] })
expect(store.contacts.find((item) => item.id === 'c1')?.groupIds).toEqual(['g1', 'g2'])
expect(calls().filter((call) => call === 'GET /contacts')).toHaveLength(1)
await store.addMembers('g2', { directoryIds: ['s5'] })
await flushPromises()
expect(calls().filter((call) => call === 'GET /contacts')).toHaveLength(2)
expect(requests.filter((request) => request.url === '/contacts').at(-1)?.params).toEqual({ limit: 100 })
await store.openList({ kind: 'group', groupId: 'g2' }, null)
await store.addMembers('g2', { contactIds: ['c1'] })
await flushPromises()
expect(calls().filter((call) => call === 'GET /contacts')).toHaveLength(4)
})
it('보낸 사람 찾기 — 알림 없이 q 로 찾고 주소가 같은(대소문자 무시) 연락처만, 실패하면 모른다(undefined)', async () => {
let failing = false
install(() =>
failing
? fail(503, 'MAIL_001')
: ok(pageOf([contact('c3', '김서윤', 'xseoyun.kang@hanbit.example'), { ...SEOYUN, emails: [{ address: 'Seoyun.Kang@hanbit.example', label: 'work' }] }])),
)
const store = useContactsStore()
expect((await store.findContactByEmail('seoyun.kang@hanbit.example'))?.id).toBe('c1')
expect(requests[0]?.params).toEqual({ q: 'seoyun.kang@hanbit.example', limit: 100 })
expect(requests[0]?.config.silent).toBe(true)
expect(await store.findContactByEmail('nobody@hanbit.example')).toBeNull()
failing = true
expect(await store.findContactByEmail('seoyun.kang@hanbit.example')).toBeUndefined()
expect(useToastStore().toasts).toEqual([])
})
it('읽기 화면의 수 확인 — 모르거나 3,000개로 받아 두었으면 다시 받고, 그 밖에는 받지 않는다', async () => {
let total = 3000
install(() => ok(pageOf([], total)))
const store = useContactsStore()
store.ensureContactTotal()
await flushPromises()
expect(store.isFull).toBe(true)
total = 2999
store.ensureContactTotal()
await flushPromises()
expect(store.isFull).toBe(false)
store.ensureContactTotal()
await flushPromises()
expect(requests).toHaveLength(2)
})
it('고른 사내 항목이 받은 쪽에 없으면 그 항목만 따로 받는다(GET /directory-entries/{id}) — 없으면 알리지 않고 상태에 담는다', async () => {
install((request) => {
if (request.url === '/directory-entries/s5') return ok(DOYUN)
if (request.url === '/directory-entries/gone') return fail(404, 'RES_001')
return ok(pageOf([{ ...DOYUN, id: 's1' }], 2, 'next'))
})
const store = useContactsStore()
await store.openList({ kind: 'directory', directoryKind: 'person' }, null)
await store.openEntry('s5')
expect(store.entryById('s5')).toEqual(DOYUN)
await store.openEntry('gone')
expect(store.entryDetailStatus).toBe('error')
expect(store.entryDetailError?.code).toBe('RES_001')
expect(calls()).toEqual(['GET /directory-entries', 'GET /directory-entries/s5', 'GET /directory-entries/gone'])
expect(useToastStore().toasts).toEqual([])
})
it('읽기 화면 ⋯ 의 보낸 사람 찾기 — 254자 넘는 주소는 묻지 않고 "없음"', async () => {
install(() => ok(pageOf([SEOYUN])))
const store = useContactsStore()
expect(await store.findContactByEmail(`${'a'.repeat(250)}@hanbit.example`)).toBeNull()
expect(requests).toEqual([])
})
it('목록에 없는 고른 연락처는 따로 받는다 — 없으면(RES_001) 알리지 않고 상태에 담는다', async () => {
install((request) => (request.url === '/contacts/c2' ? ok(JIHOON) : fail(404, 'RES_001')))
const store = useContactsStore()
await store.openDetail('c2')
expect(store.contactById('c2')).toEqual(JIHOON)
await store.openDetail('gone')
expect(store.detailStatus).toBe('error')
expect(store.detailError?.code).toBe('RES_001')
expect(useToastStore().toasts).toEqual([])
})
it('로그아웃 · 다른 계정 로그인 — 이전 사용자의 주소록을 비운다', async () => {
install((request) => (request.url === '/contact-groups' ? ok(pageOf([VENDORS])) : ok(pageOf([SEOYUN], 1))))
const store = useContactsStore()
store.enterModule()
await store.openList({ kind: 'all' }, null)
await flushPromises()
useAuthStore().clearAuth()
await flushPromises()
expect(store.groups).toEqual([])
expect(store.contacts).toEqual([])
expect(store.contactTotal).toBeNull()
expect(store.listStatus).toBe('idle')
})
})
+712
View File
@@ -0,0 +1,712 @@
import { computed, ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { getErrorMessage, toApiError, type ApiError } from '@/composables/useApi'
import { useContacts, type ContactsRequestOptions } from '@/composables/useContacts'
import { CONTACT_GROUPS_MAX, CONTACTS_MAX, CONTACTS_PAGE_SIZE, CONTACTS_SEARCH_MAX_LENGTH } from '@/shared/constants/contacts'
import type {
Contact,
ContactDeleted,
ContactGroup,
ContactGroupDeleted,
ContactGroupMembersAdded,
ContactGroupMembersPayload,
ContactInput,
ContactPatch,
ContactRestored,
DirectoryEntry,
DirectoryKind,
} from '@/shared/types/contacts'
import type { PageInfo, PaginatedResult } from '@/shared/types/pagination'
import { compareContacts, contactsWithAddress, sortGroups } from '@/shared/utils/contacts'
import type { ContactsListSource } from '@/shared/utils/contacts-route'
import { useAuthStore } from '@/stores/auth.store'
import type { LoadStatus } from '@/stores/mail.store'
// 주소록 스토어 — 계약 3절 "주소록" (내 주소록의 연락처 · 그룹, 사내 목록의 직원 · 공용 주소)
// - 주소록 화면에 들어올 때 그룹 · 연락처 수 · 사내 목록 수를 받는다(enterModule — 패널의 숫자 · 3,000개 한도). 모두 알림 없이 —
// 못 받은 숫자는 비우고, 사내 목록을 받지 못하면 사내 묶음에 숫자 없이 둔다 (목록 칸이 "불러오지 못했습니다"를 그린다)
// - 보는 목록은 하나 — 전체 연락처 · 그룹 · 사내(contacts-route.ts 의 ContactsListSource) + 찾을 글. 100명씩 받고 [더 보기]로 잇는다.
// 첫 쪽은 알림 없이(화면이 상태를 그린다), [더 보기]는 실패하면 인터셉터가 알린다
// - 고른 연락처 · 사내 항목이 목록에 없으면(주소로 바로 들어옴 · 아직 받지 않은 쪽) 그것을 따로 받는다(openDetail · openEntry)
// - 바꾸기(만들기 · 고치기 · 지우기 · 되돌리기 · 그룹 · 구성원)는 응답으로 보는 목록 · 그룹을 바로 고친다 — 다시 받지 않는다.
// 바꾼 연락처는 서버 차례(compareContacts — 서버와 같은 규칙)로 받은 쪽 안의 자리에 넣는다(placeContact — 뒤쪽이면 [더 보기]가 가져온다).
// 응답으로 알 수 없는 것만 첫 쪽을 한 번 다시 받는다(reloadFirstPage): 찾은 목록에 새 연락처 · 구성원 넣기로 보는 그룹이 바뀜 · 사내 직원으로 새로 만든 연락처.
// 그룹 수 · 구성원 수는 그룹을 다시 받는다(한 쪽)
// 늦게 온 응답 버리기:
// - 목록 · 그룹 · 수 · 고른 연락처 받기는 순번(listSeq · groupsSeq · totalSeq · directorySeq · detailSeq) — 새로 받기 · 바꾸기 · reset 이 올린다
// - 바꾸기는 세션 세대(authStore.generation) — 기다리는 사이 로그아웃 · 만료 · 다른 계정 로그인이 있었으면 스토어에 쓰지 않고 null 을 돌려준다
/** 그룹을 받을 때 따라가는 쪽 수 — 그룹은 100개까지라 한 쪽이면 된다 */
const GROUP_PAGES_MAX = 2
type FetchedPage =
| { kind: 'contacts'; page: PaginatedResult<Contact> }
| { kind: 'entries'; page: PaginatedResult<DirectoryEntry> }
/** 같은 id 는 처음 것만 — 쪽 사이에 연락처가 바뀌면 다음 쪽에서 한두 개가 겹칠 수 있다 (계약 3절) */
function uniqueById<T extends { id: string }>(items: readonly T[]): T[] {
const seen = new Set<string>()
return items.filter((item) => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}
export const useContactsStore = defineStore('contacts', () => {
const contactsApi = useContacts()
const authStore = useAuthStore()
// ── 그룹 ─────────────────────────────────────────────
const groups = shallowRef<ContactGroup[]>([])
const groupsStatus = ref<LoadStatus>('idle')
const groupsError = ref<ApiError | null>(null)
let groupsSeq = 0
// ── 수 (패널의 숫자 · 한도) ──────────────────────────────
/** 내 주소록의 연락처 수 — 모르면 null (한도로 막지 않는다) */
const contactTotal = ref<number | null>(null)
const directoryTotals = ref<Record<DirectoryKind, number | null>>({ person: null, shared: null })
/** 사내 목록의 수를 받았는지 — error 면 사내 목록 계정이 없거나 읽지 못했다 (503 MAIL_001) */
const directoryStatus = ref<LoadStatus>('idle')
let totalSeq = 0
let directorySeq = 0
// ── 보는 목록 ─────────────────────────────────────────
const listSource = shallowRef<ContactsListSource | null>(null)
const listQuery = ref<string | null>(null)
const contacts = shallowRef<Contact[]>([])
const entries = shallowRef<DirectoryEntry[]>([])
const pageInfo = ref<PageInfo | null>(null)
const listStatus = ref<LoadStatus>('idle')
const listError = ref<ApiError | null>(null)
const loadingMore = ref<boolean>(false)
let listSeq = 0
// ── 목록에 없는 고른 연락처 ────────────────────────────────
const detail = shallowRef<Contact | null>(null)
const detailStatus = ref<LoadStatus>('idle')
const detailError = ref<ApiError | null>(null)
let detailId: string | null = null
let detailSeq = 0
/** 이 탭에서 지운(또는 이미 지워진 것을 안) 연락처 — 고른 채 남은 주소가 다시 받지 않고 "찾을 수 없음"을 그리게 */
const goneIds = new Set<string>()
// ── 목록에 없는 고른 사내 항목 (GET /directory-entries/{id}) ──────────────
const entryDetail = shallowRef<DirectoryEntry | null>(null)
const entryDetailStatus = ref<LoadStatus>('idle')
const entryDetailError = ref<ApiError | null>(null)
let entryDetailId: string | null = null
let entryDetailSeq = 0
// ── 하는 중 (단추의 바쁜 표시) ─────────────────────────────
/** [고치기]가 그 연락처를 새로 받는 중 */
const editLoadingId = ref<string | null>(null)
/** [그룹에 메일 쓰기]가 그 그룹의 구성원을 받는 중 */
const composingGroupId = ref<string | null>(null)
/** 연락처 3,000개 — [연락처 추가] · 「보낸 사람을 주소록에 추가」 · [내 주소록에 추가]를 끈다 */
const isFull = computed<boolean>(() => (contactTotal.value ?? 0) >= CONTACTS_MAX)
/** 그룹 100개 — [새 그룹]을 끈다 */
const isGroupsFull = computed<boolean>(() => groups.value.length >= CONTACT_GROUPS_MAX)
const hasMore = computed<boolean>(() => pageInfo.value?.hasNext === true && Boolean(pageInfo.value.nextCursor))
/** 기다리는 사이 세션이 바뀌었다 (로그아웃 · 만료 · 다른 계정) — 결과를 스토어에 쓰지 않는다 */
function sessionChanged(session: number): boolean {
return authStore.generation !== session
}
// ── 그룹 받기 ─────────────────────────────────────────
/** 그룹 전부를 받는다 — 실패하면 받아 둔 것을 그대로 둔다 (처음이면 error) */
async function loadGroups(): Promise<boolean> {
groupsSeq += 1
const seq = groupsSeq
if (groupsStatus.value !== 'ready') groupsStatus.value = 'loading'
try {
const all: ContactGroup[] = []
let cursor: string | null = null
for (let page = 0; page < GROUP_PAGES_MAX; page += 1) {
const result: PaginatedResult<ContactGroup> = await contactsApi.listContactGroups(
{ limit: CONTACTS_PAGE_SIZE, ...(cursor ? { cursor } : {}) },
{ silent: true },
)
if (seq !== groupsSeq) return false
all.push(...result.items)
cursor = result.pageInfo.nextCursor
if (!result.pageInfo.hasNext || !cursor) break
}
groups.value = uniqueById(all)
groupsStatus.value = 'ready'
groupsError.value = null
return true
} catch (caught) {
if (seq !== groupsSeq) return false
if (groupsStatus.value !== 'ready') {
groupsStatus.value = 'error'
groupsError.value = toApiError(caught)
}
return false
}
}
/** 아직 받지 않았거나 못 받았으면 받는다 — 연락처 창의 「그룹」 칸 (읽기 화면에서 열 때) */
function ensureGroups(): void {
if (groupsStatus.value === 'idle' || groupsStatus.value === 'error') void loadGroups()
}
function groupById(id: string): ContactGroup | null {
return groups.value.find((group) => group.id === id) ?? null
}
/** 그룹을 바로 고친다 — 서버 차례 · 수로 뒤에서 다시 받는다 (그 전에 떠난 받기는 순번이 올라 버려진다) */
function setGroups(next: readonly ContactGroup[]) {
groups.value = sortGroups(next)
void loadGroups()
}
// ── 수 받기 ───────────────────────────────────────────
async function loadContactTotal(): Promise<void> {
totalSeq += 1
const seq = totalSeq
try {
const page = await contactsApi.listContacts({ limit: 1 }, { silent: true })
if (seq === totalSeq) contactTotal.value = page.pageInfo.total
} catch {
// 보조 정보 — 모르면 숫자를 비우고 한도로 막지 않는다 (서버가 CONTACT_002 로 막는다)
}
}
/** 읽기 화면 ⋯ — 3,000개인지. 모르거나 받아 둔 수가 3,000이면 다시 받아 본다 (다른 곳에서 지웠으면 항목이 다시 켜지게) */
function ensureContactTotal(): void {
if (contactTotal.value === null || isFull.value) void loadContactTotal()
}
function adjustContactTotal(delta: number) {
totalSeq += 1
if (contactTotal.value !== null) contactTotal.value = Math.max(0, contactTotal.value + delta)
}
/** 사내 목록의 직원 · 공용 주소 수 — 못 받으면 사내 목록을 읽지 못한 것으로 둔다 (지난 숫자는 그대로) */
async function loadDirectoryTotals(): Promise<void> {
directorySeq += 1
const seq = directorySeq
if (directoryStatus.value !== 'ready') directoryStatus.value = 'loading'
try {
const [people, shared] = await Promise.all([
contactsApi.listDirectoryEntries({ kind: 'person', limit: 1 }, { silent: true }),
contactsApi.listDirectoryEntries({ kind: 'shared', limit: 1 }, { silent: true }),
])
if (seq !== directorySeq) return
directoryTotals.value = { person: people.pageInfo.total, shared: shared.pageInfo.total }
directoryStatus.value = 'ready'
} catch {
if (seq === directorySeq) directoryStatus.value = 'error'
}
}
/** 주소록 화면에 들어올 때 — 패널의 그룹 · 숫자 (들어올 때마다 새로 받는다 — 다른 탭 · 프로그램에서 바꿨을 수 있다) */
function enterModule(): void {
void loadGroups()
void loadContactTotal()
void loadDirectoryTotals()
}
// ── 보는 목록 ─────────────────────────────────────────
async function fetchPage(
source: ContactsListSource,
q: string | null,
cursor: string | null,
options?: ContactsRequestOptions,
): Promise<FetchedPage> {
const base = { limit: CONTACTS_PAGE_SIZE, ...(q ? { q } : {}), ...(cursor ? { cursor } : {}) }
if (source.kind === 'directory') {
return {
kind: 'entries',
page: await contactsApi.listDirectoryEntries({ ...base, kind: source.directoryKind }, options),
}
}
const query = source.kind === 'group' ? { ...base, groupId: source.groupId } : base
return { kind: 'contacts', page: await contactsApi.listContacts(query, options) }
}
function applyFetched(fetched: FetchedPage, append: boolean) {
if (fetched.kind === 'entries') {
entries.value = uniqueById(append ? [...entries.value, ...fetched.page.items] : fetched.page.items)
} else {
contacts.value = uniqueById(append ? [...contacts.value, ...fetched.page.items] : fetched.page.items)
}
pageInfo.value = fetched.page.pageInfo
}
/** 찾지 않은 목록의 total 은 패널 숫자다 — 전체 연락처 · 사내 목록 · 그 그룹의 구성원 수 */
function noteTotal(source: ContactsListSource, q: string | null, total: number | null) {
if (q !== null || total === null) return
if (source.kind === 'all') {
totalSeq += 1
contactTotal.value = total
} else if (source.kind === 'directory') {
directoryTotals.value = { ...directoryTotals.value, [source.directoryKind]: total }
} else if (groups.value.some((group) => group.id === source.groupId && group.memberCount !== total)) {
groups.value = groups.value.map((group) => (group.id === source.groupId ? { ...group, memberCount: total } : group))
}
}
/** 목록을 새로 연다 — 그 전 목록의 요청은 버린다. 첫 쪽은 알림 없이 (화면이 상태를 그린다) */
async function openList(source: ContactsListSource, q: string | null): Promise<void> {
listSeq += 1
const seq = listSeq
listSource.value = source
listQuery.value = q
listStatus.value = 'loading'
listError.value = null
contacts.value = []
entries.value = []
pageInfo.value = null
loadingMore.value = false
try {
const fetched = await fetchPage(source, q, null, { silent: true })
if (seq !== listSeq) return
applyFetched(fetched, false)
listStatus.value = 'ready'
noteTotal(source, q, fetched.page.pageInfo.total)
} catch (caught) {
if (seq !== listSeq) return
listStatus.value = 'error'
listError.value = toApiError(caught)
}
}
/** [더 보기] — 다음 쪽을 이어 붙인다. 실패하면 인터셉터가 알린다(silent 로 부르면 알리지 않는다) */
async function loadMore(options?: ContactsRequestOptions): Promise<boolean> {
const source = listSource.value
const cursor = pageInfo.value?.nextCursor ?? null
if (!source || !cursor || !hasMore.value || loadingMore.value || listStatus.value !== 'ready') return false
const seq = listSeq
loadingMore.value = true
try {
const fetched = await fetchPage(source, listQuery.value, cursor, options)
if (seq !== listSeq) return false
applyFetched(fetched, true)
return true
} catch {
return false
} finally {
if (seq === listSeq) loadingMore.value = false
}
}
/**
* 보는 목록의 첫 쪽을 알림 없이 다시 받는다 — 응답으로 자리를 알 수 없는 바꾸기 뒤에만(찾은 목록의 새 연락처 · 구성원 넣기).
* 받은 쪽 수와 상관없이 한 번만 묻는다. 그동안 화면은 지금 목록을 그대로 보이고, 못 받으면 그대로 둔다(알리지 않는다)
*/
async function reloadFirstPage(): Promise<void> {
const source = listSource.value
if (!source || listStatus.value !== 'ready') return
const q = listQuery.value
listSeq += 1
const seq = listSeq
loadingMore.value = false
try {
const fetched = await fetchPage(source, q, null, { silent: true })
if (seq !== listSeq) return
applyFetched(fetched, false)
noteTotal(source, q, fetched.page.pageInfo.total)
} catch {
// 보이는 목록은 그대로 둔다 — 다음에 목록을 열면 맞춰진다
}
}
/** 보는 목록의 total 을 고친다 (찾지 않은 목록이면 패널 숫자도) */
function adjustListTotal(delta: number) {
const info = pageInfo.value
if (delta === 0 || !info || typeof info.total !== 'number') return
pageInfo.value = { ...info, total: Math.max(0, info.total + delta) }
const source = listSource.value
if (source?.kind === 'group' && listQuery.value === null) {
groups.value = groups.value.map((group) =>
group.id === source.groupId ? { ...group, memberCount: Math.max(0, group.memberCount + delta) } : group,
)
}
}
/** 지금 보는 목록에 드는 연락처인가 — 찾은 목록이면 모른다(null — 서버의 맞추기를 따라 하지 않는다) */
function belongsToList(contact: Contact): boolean | null {
const source = listSource.value
if (!source || source.kind === 'directory') return false
if (listQuery.value !== null) return null
return source.kind === 'all' || contact.groupIds.includes(source.groupId)
}
/**
* 바꾼 연락처를 보는 목록의 자리에 둔다 — previous 는 바꾸기 전(새 연락처는 null). 서버 차례로 받은 쪽 안에 들 때만 넣고,
* 받은 쪽 끝보다 뒤면 넣지 않는다([더 보기]가 가져온다). 목록에 들지 않게 됐으면 뺀다. 찾은 목록은 있던 자리만 고친다.
* 목록에 새로 드는지 모르면(찾은 목록의 새 연락처) false — 부르는 쪽이 첫 쪽을 다시 받는다
*/
function placeContact(contact: Contact, previous: Contact | null): boolean {
if (detail.value?.id === contact.id) detail.value = contact
if (listStatus.value !== 'ready') return true
const index = contacts.value.findIndex((item) => item.id === contact.id)
const belongs = belongsToList(contact)
if (belongs === null) {
if (index >= 0) contacts.value = contacts.value.map((item) => (item.id === contact.id ? contact : item))
return index >= 0 || previous !== null
}
const belonged = previous ? belongsToList(previous) === true : false
const others = index >= 0 ? contacts.value.filter((item) => item.id !== contact.id) : contacts.value
let next = others
if (belongs) {
const at = others.findIndex((item) => compareContacts(contact, item) < 0)
if (at >= 0 || !hasMore.value) {
next = [...others]
next.splice(at < 0 ? next.length : at, 0, contact)
}
}
if (next !== contacts.value) contacts.value = next
adjustListTotal((belongs ? 1 : 0) - (belonged ? 1 : 0))
return true
}
/** 받아 둔 연락처들에 그룹을 더한다 (구성원 넣기 · 그룹 되돌리기 — 응답에 연락처가 없다) */
function addGroupLocally(groupId: string, contactIds: readonly string[]) {
if (contactIds.length === 0) return
const ids = new Set(contactIds)
const add = (contact: Contact) =>
ids.has(contact.id) && !contact.groupIds.includes(groupId)
? { ...contact, groupIds: [...contact.groupIds, groupId] }
: contact
contacts.value = contacts.value.map(add)
if (detail.value) detail.value = add(detail.value)
}
// ── 고른 항목 ─────────────────────────────────────────
function contactById(id: string | null): Contact | null {
if (!id) return null
return contacts.value.find((contact) => contact.id === id) ?? (detail.value?.id === id ? detail.value : null)
}
function entryById(id: string | null): DirectoryEntry | null {
if (!id) return null
return entries.value.find((entry) => entry.id === id) ?? (entryDetail.value?.id === id ? entryDetail.value : null)
}
/**
* 목록에 없는 고른 연락처를 받는다 — 알림 없이 (없는 연락처면 화면이 "찾을 수 없음"을 그린다).
* 같은 연락처를 받는 중이거나 받았으면 다시 받지 않는다 (못 받았으면 [다시 시도]가 다시 받는다). 이 탭에서 지운 연락처는 묻지 않고 없는 것으로
*/
async function openDetail(id: string): Promise<void> {
if (detailId === id && (detailStatus.value === 'loading' || detailStatus.value === 'ready')) return
detailSeq += 1
const seq = detailSeq
detailId = id
detail.value = null
detailError.value = null
if (goneIds.has(id)) {
detailStatus.value = 'error'
detailError.value = { code: 'RES_001', message: getErrorMessage('RES_001') }
return
}
detailStatus.value = 'loading'
try {
const contact = await contactsApi.getContact(id, { silent: true })
if (seq !== detailSeq) return
detail.value = contact
detailStatus.value = 'ready'
} catch (caught) {
if (seq !== detailSeq) return
detailStatus.value = 'error'
detailError.value = toApiError(caught)
}
}
function closeDetail(): void {
detailSeq += 1
detailId = null
detail.value = null
detailStatus.value = 'idle'
detailError.value = null
}
/**
* 목록에 없는 고른 사내 항목을 받는다 — 알림 없이 (없는 항목이면 화면이 "찾을 수 없음"을 그린다).
* 같은 항목을 받는 중이거나 받았으면 다시 받지 않는다 (못 받았으면 [다시 시도]가 다시 받는다)
*/
async function openEntry(id: string): Promise<void> {
if (entryDetailId === id && (entryDetailStatus.value === 'loading' || entryDetailStatus.value === 'ready')) return
entryDetailSeq += 1
const seq = entryDetailSeq
entryDetailId = id
entryDetail.value = null
entryDetailError.value = null
entryDetailStatus.value = 'loading'
try {
const entry = await contactsApi.getDirectoryEntry(id, { silent: true })
if (seq !== entryDetailSeq) return
entryDetail.value = entry
entryDetailStatus.value = 'ready'
} catch (caught) {
if (seq !== entryDetailSeq) return
entryDetailStatus.value = 'error'
entryDetailError.value = toApiError(caught)
}
}
function closeEntry(): void {
entryDetailSeq += 1
entryDetailId = null
entryDetail.value = null
entryDetailStatus.value = 'idle'
entryDetailError.value = null
}
/** 그 주소가 든 연락처 (exceptId 는 뺀다) — 알림 없이 찾는다. 모르면(실패 · 세션이 바뀜) undefined */
async function findContactsWithEmail(email: string, exceptId: string | null = null): Promise<Contact[] | undefined> {
const session = authStore.generation
const q = email.trim()
// 서버가 찾는 글은 254자까지 — 그보다 긴 주소는 주소록에 넣을 수도 없으니 묻지 않고 "없음"
if (q === '' || q.length > CONTACTS_SEARCH_MAX_LENGTH) return []
try {
const page = await contactsApi.listContacts({ q, limit: CONTACTS_PAGE_SIZE }, { silent: true })
if (sessionChanged(session)) return undefined
return contactsWithAddress(Array.isArray(page?.items) ? page.items : [], email, exceptId)
} catch {
return undefined
}
}
/** 읽기 화면 ⋯ — 보낸 사람 주소가 내 주소록에 있는가. 있으면 그 연락처, 없으면 null, 모르면 undefined */
async function findContactByEmail(email: string): Promise<Contact | null | undefined> {
const found = await findContactsWithEmail(email)
return found === undefined ? undefined : (found[0] ?? null)
}
// ── 연락처 바꾸기 ──────────────────────────────────────
/** 받아 둔 것을 서버의 지금 값으로 — [고치기]가 새로 받은 연락처 */
function replaceContact(contact: Contact) {
placeContact(contact, contactById(contact.id))
}
/** 목록에서 뺀다 — 이미 지워진 연락처(다른 곳에서 지움)도 이것으로 맞춘다 */
function forgetContact(id: string): void {
goneIds.add(id)
const before = contacts.value.length
contacts.value = contacts.value.filter((contact) => contact.id !== id)
if (contacts.value.length < before) adjustListTotal(-1)
if (detail.value?.id === id) closeDetail()
}
/** 새로 생긴 연락처(만들기 · 되돌리기)를 목록에 — 찾은 목록이면 들어가는지 몰라 첫 쪽을 다시 받는다 */
function addCreated(contact: Contact) {
adjustContactTotal(1)
if (contact.groupIds.length > 0) void loadGroups()
if (!placeContact(contact, null)) void reloadFirstPage()
}
/** 만들기 — 기다리는 사이 세션이 바뀌었으면 null. 실패는 그대로 던진다 (칸 안 안내 · 알림은 부르는 쪽 · 인터셉터) */
async function createContact(input: ContactInput, options?: ContactsRequestOptions): Promise<Contact | null> {
const session = authStore.generation
const created = await contactsApi.createContact(input, options)
if (sessionChanged(session)) return null
addCreated(created)
return created
}
/** 고치기 — 바꾼 칸만 보낸다(contactPatchOf). 그룹을 바꿨으면 그룹 수를 다시 받는다 */
async function updateContact(id: string, patch: ContactPatch, options?: ContactsRequestOptions): Promise<Contact | null> {
const session = authStore.generation
const before = contactById(id)
const updated = await contactsApi.updateContact(id, patch, options)
if (sessionChanged(session)) return null
placeContact(updated, before)
if (patch.groupIds !== undefined) void loadGroups()
return updated
}
/** 지우기 — 지운 연락처와 [되돌리기] 표를 돌려준다 */
async function deleteContact(id: string, options?: ContactsRequestOptions): Promise<ContactDeleted | null> {
const session = authStore.generation
const deleted = await contactsApi.deleteContact(id, options)
if (sessionChanged(session)) return null
forgetContact(id)
adjustContactTotal(-1)
if (deleted.contact.groupIds.length > 0) void loadGroups()
return deleted
}
/**
* 되돌리기 — 지우기 응답의 표로 서버가 지운 그대로 다시 만든다(새 id). memberIds 는 그룹을 지울 때의 구성원(받아 둔 연락처에 그룹을 더한다).
* 세션이 바뀌었으면 null. 실패(표 없음 RES_001 · 같은 이름 CONTACT_001 · 한도 CONTACT_002 · 장애)는 인터셉터가 알리고 던진다
*/
async function restore(token: string, memberIds: readonly string[] = []): Promise<ContactRestored | null> {
const session = authStore.generation
const restored = await contactsApi.restoreDeleted(token)
if (sessionChanged(session)) return null
if (restored.kind === 'contact') {
addCreated(restored.contact)
} else {
setGroups([...groups.value.filter((group) => group.id !== restored.group.id), restored.group])
addGroupLocally(restored.group.id, memberIds)
}
return restored
}
// ── 그룹 바꾸기 ───────────────────────────────────────
async function createGroup(name: string, options?: ContactsRequestOptions): Promise<ContactGroup | null> {
const session = authStore.generation
const created = await contactsApi.createContactGroup({ name }, options)
if (sessionChanged(session)) return null
setGroups([...groups.value.filter((group) => group.id !== created.id), created])
return created
}
async function renameGroup(id: string, name: string, options?: ContactsRequestOptions): Promise<ContactGroup | null> {
const session = authStore.generation
const renamed = await contactsApi.renameContactGroup(id, { name }, options)
if (sessionChanged(session)) return null
setGroups(groups.value.map((group) => (group.id === id ? renamed : group)))
return renamed
}
/** 그룹을 목록 · 연락처에서 뺀다 — 이미 지워진 그룹(다른 곳에서 지움)도 이것으로 맞춘다 */
function forgetGroup(id: string): void {
setGroups(groups.value.filter((group) => group.id !== id))
const strip = (contact: Contact) =>
contact.groupIds.includes(id) ? { ...contact, groupIds: contact.groupIds.filter((groupId) => groupId !== id) } : contact
contacts.value = contacts.value.map(strip)
if (detail.value) detail.value = strip(detail.value)
}
/** 그룹 지우기 — 연락처는 그대로. 응답의 표로 [되돌리기]가 다시 만든다 */
async function deleteGroup(id: string, options?: ContactsRequestOptions): Promise<ContactGroupDeleted | null> {
const session = authStore.generation
const deleted = await contactsApi.deleteContactGroup(id, options)
if (sessionChanged(session)) return null
forgetGroup(id)
return deleted
}
/**
* 구성원 넣기 — 사내 직원은 내 주소록에도 만든다(createdCount). 응답에 들어간 연락처가 없어,
* 그 그룹을 보고 있거나 새 연락처가 생겼으면(전체 · 찾은 목록) 첫 쪽을 다시 받고, 아니면 고른 연락처에 그룹을 더해 둔다
*/
async function addMembers(
groupId: string,
payload: ContactGroupMembersPayload,
options?: ContactsRequestOptions,
): Promise<ContactGroupMembersAdded | null> {
const session = authStore.generation
const added = await contactsApi.addContactGroupMembers(groupId, payload, options)
if (sessionChanged(session)) return null
if (added.createdCount > 0) adjustContactTotal(added.createdCount)
setGroups(groups.value.map((group) => (group.id === groupId ? added.group : group)))
const source = listSource.value
const viewsGroup = source?.kind === 'group' && source.groupId === groupId
if (viewsGroup || (added.createdCount > 0 && source?.kind === 'all')) void reloadFirstPage()
else addGroupLocally(groupId, payload.contactIds ?? [])
return added
}
function reset() {
groupsSeq += 1
totalSeq += 1
directorySeq += 1
listSeq += 1
detailSeq += 1
groups.value = []
groupsStatus.value = 'idle'
groupsError.value = null
contactTotal.value = null
directoryTotals.value = { person: null, shared: null }
directoryStatus.value = 'idle'
listSource.value = null
listQuery.value = null
contacts.value = []
entries.value = []
pageInfo.value = null
listStatus.value = 'idle'
listError.value = null
loadingMore.value = false
detailId = null
detail.value = null
detailStatus.value = 'idle'
detailError.value = null
goneIds.clear()
entryDetailSeq += 1
entryDetailId = null
entryDetail.value = null
entryDetailStatus.value = 'idle'
entryDetailError.value = null
editLoadingId.value = null
composingGroupId.value = null
}
// 로그아웃 · 다른 계정 로그인 — 이전 사용자의 주소록이 남지 않게
watch(
() => authStore.user?.email ?? null,
() => reset(),
)
return {
groups,
groupsStatus,
groupsError,
contactTotal,
directoryTotals,
directoryStatus,
listSource,
listQuery,
contacts,
entries,
pageInfo,
listStatus,
listError,
loadingMore,
detail,
detailStatus,
detailError,
entryDetail,
entryDetailStatus,
entryDetailError,
editLoadingId,
composingGroupId,
isFull,
isGroupsFull,
hasMore,
loadGroups,
ensureGroups,
groupById,
loadContactTotal,
ensureContactTotal,
loadDirectoryTotals,
enterModule,
openList,
loadMore,
reloadFirstPage,
contactById,
entryById,
openDetail,
closeDetail,
openEntry,
closeEntry,
findContactsWithEmail,
findContactByEmail,
replaceContact,
forgetContact,
createContact,
updateContact,
deleteContact,
restore,
createGroup,
renameGroup,
forgetGroup,
deleteGroup,
addMembers,
reset,
}
})
+21
View File
@@ -92,6 +92,27 @@ a:hover {
box-shadow: var(--shadow-segment);
}
/*
* 테두리 전환 — 주소록 구성원 넣기 창의 「내 연락처 | 사내 직원」 (디자인 원본 ContactsEdit): 흰 바탕 · 테두리, 고른 칸은 청록 옅은 바탕.
* 높이 · 밀도 예외(36)는 .segmented 와 같다. 사용: <div role="group" class="segmented segmented--outline">…</div>
*/
.segmented--outline {
border: var(--border-width) solid var(--color-border-strong);
background: var(--color-surface);
}
.segmented--outline .segmented__item {
color: var(--color-text-strong);
font-weight: 500;
}
.segmented--outline .segmented__item[aria-pressed='true'] {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
font-weight: 600;
box-shadow: none;
}
/*
* 테두리 있는 입력칸 — 설정 화면 · 메일함 이름 대화상자 (디자인 원본 SettingsAccount · SettingsVacation · MailboxManage 의 .field · .pw-box)
* 높이 44 · 테두리 #D0D5DD. 초점은 강조색 1px 테두리 + 1px 그림자(상단 검색 칸과 같다), 오류는 빨간 테두리, 꺼진 칸은 흐리게.
+6
View File
@@ -171,6 +171,12 @@
*/
--size-filter-control: 2.5rem; /* 40px — 고치기 창의 조건 줄 · 옮길 메일함 · 전달 주소 칸 · [조건 더하기] 높이 · 밀도 예외 */
--size-filter-remove: 2.25rem; /* 36px — 조건 ✕ 폭 (높이는 --size-filter-control) · 밀도 예외 */
/*
* 8단계(주소록) 밀도 예외 — 새 토큰 없이 위의 것을 다시 쓴다 (디자인 README "8단계 — 주소록", 사용자 확정 캔버스).
* 패널의 그룹 줄 ⋯ 32×40(--size-mailbox-more × --size-row-min — 메일함 ⋯ 와 같다, MailboxMoreButton) ·
* 연락처 창의 이메일 · 전화번호 줄(종류 · 값) · [이메일 · 전화번호 더하기] · 회사 · 부서 · 직책 칸 높이 40(--size-filter-control) · 줄 ✕ 36×40(--size-filter-remove) ·
* 구성원 넣기 창의 「내 연락처 | 사내 직원」 36(--size-segment-item-lg — .segmented) · 찾기 칸 40(--size-filter-control)
*/
--size-status-dot: 0.5rem; /* 8px — 설정 › 알림의 실시간 연결 점 */
--size-option-input: 1.125rem; /* 18px — 설정 화면의 고르기 상자 · 라디오 (줄 전체가 44px 로 눌린다) */
--form-max-width: 26.25rem; /* 420px — 비밀번호 칸 (디자인 원본 SettingsAccount) */
@@ -0,0 +1,86 @@
// 받는 사람 맞추기 표본 — 단위 테스트 전용 (앱 코드에서 import 하지 않는다)
// 쓰기 화면의 맞추기(shared/utils/recipient-match.ts 의 matchName · matchEmail)와 서버의 q 맞추기(계약 2절 "받는 사람 추천")를 같은 표로 본다.
/**
* 표본 표 — [찾는 글(q), 이름(없으면 null), 주소, 맞는가]. 답은 화면의 matchName · matchEmail 로 만들었다(손으로 적지 않았다).
* ⚠️ backend `src/modules/mail/recipient-match.spec.ts` 의 RECIPIENT_MATCH_SAMPLES 와 **같은 표**다(행과 순서까지 그대로).
* 한쪽을 고치면 둘 다 고친다 — 맞추기 규칙을 바꾸면 두 recipient-match.ts 와 두 표를 함께 (서버가 다르게 맞추면
* 쓰기 화면이 받아 둔 것에서 찾은 것과 서버의 답이 어긋난다). 서버도 NFC 로 맞추지 않는다 — 화면의 강조 범위가 원래 글 기준이라
*/
export const RECIPIENT_MATCH_SAMPLES: readonly (readonly [
query: string,
name: string | null,
email: string,
matches: boolean,
])[] = [
['도윤', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['DOYUN', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['Lee', 'Doyun Lee', 'd.lee@example.com', true],
['lEE', 'Doyun Lee', 'd.lee@example.com', true],
['윤도', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['박', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['ㄷㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['ㅇㄷㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['ㅂㅈㅎ', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['ㅈㅎ', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['ㅎㅈ', '박지훈', 'jihoon.park@hanbit.co.kr', false],
['ㄲ', '꽃님', 'flower@example.com', true],
['ㄱ', '꽃님', 'flower@example.com', false],
['ㅃ', '빵집', 'bread@example.com', true],
['도ㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['ㄷ윤', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['이ㄷ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['도유', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['이도유', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['동', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['이동', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['돈', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['도요', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['유', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['윤', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['도윤이', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['지후', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['박지후', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['박짛', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['박지', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['닭', '달걀', 'egg@example.com', true],
['넋', '너서', 'neo@example.com', false],
['값', '가비', 'gabi@example.com', false],
['값', '갑시다', 'gapsi@example.com', true],
['갑', '갑시다', 'gapsi@example.com', true],
['ㄳ', '넋', 'neok@example.com', false],
['ㄱ', '넋', 'neok@example.com', false],
['김 아', '김 아라', 'ara.kim@arasystem.it.kr', true],
['김아', '김 아라', 'ara.kim@arasystem.it.kr', false],
['ㄱ ㅇ', '김 아라', 'ara.kim@arasystem.it.kr', true],
['ㄱㅇ', '김 아라', 'ara.kim@arasystem.it.kr', false],
[' 도윤 ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['arasystem', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['DOYUN.LEE@', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['doyun.lee@arasystem.it.kr', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['doyun.lee@other.example', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['doyun.leee@other.example', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['@arasystem', '', 'doyun.lee@arasystem.it.kr', true],
['lee@', 'Doyun', 'doyun.lee@arasystem.it.kr', true],
['a@b@c', '', 'a@x.example', true],
['Doyun.Lee@AraSystem.it.kr', null, 'doyun.lee@arasystem.it.kr', true],
['kr', null, 'doyun.lee@arasystem.it.kr', true],
['istanbul', null, 'İstanbul@example.com', false],
['example', null, 'İstanbul@example.com', false],
['도윤', null, 'doyun.lee@arasystem.it.kr', false],
['도윤', '', 'x@example.com', false],
['😀', '팀😀', 'team@example.com', true],
['팀😀', '팀😀', 'team@example.com', true],
['😀팀', '팀😀', 'team@example.com', false],
['𠀋', '𠀋田', 'kanji@example.com', true],
['ㅏ', '아라', 'ara@example.com', false],
['ㅇㅏ', '아라', 'ara@example.com', false],
['2팀', '영업2팀', 'sales2@example.com', true],
['(주)', '(주)한빛', 'hanbit@example.com', true],
['도윤'.normalize('NFD'), '이도윤', 'doyun.lee@arasystem.it.kr', false],
['도윤', '이도윤'.normalize('NFD'), 'x@example.com', false],
['ㄱ'.repeat(33), 'ㄱㄱㄱㄱㄱ가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가', 'long@example.com', true],
['가'.repeat(32) + '나', '가'.repeat(40) + '나', 'long@example.com', true],
['가'.repeat(31) + '나다', '가'.repeat(40) + '나다', 'long@example.com', true],
['가'.repeat(31) + '나라', '가'.repeat(40) + '나다', 'long@example.com', false],
]
+22
View File
@@ -66,6 +66,28 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
// 주소록 (8단계 — router/routes.ts 와 같은 이름 · 모양)
{ path: '/contacts', name: 'contacts', component: Empty, meta: { requiresAuth: true } },
{ path: '/contacts/all/:contactId', name: 'contacts-contact', component: Empty, meta: { requiresAuth: true } },
{ path: '/contacts/groups/:groupId', name: 'contacts-group', component: Empty, meta: { requiresAuth: true } },
{
path: '/contacts/groups/:groupId/:contactId',
name: 'contacts-group-contact',
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/contacts/directory/:kind(people|shared)',
name: 'contacts-directory',
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/contacts/directory/:kind(people|shared)/:entryId',
name: 'contacts-directory-entry',
component: Empty,
meta: { requiresAuth: true },
},
],
})
}