fix: 할 일 화면 입력·삭제·동기화 문제 수정
할 일 기능 전반을 재검토해 사용자가 바로 겪는 문제를 고친다. - 이름 수정 시 입력에 포커스를 주지 않아 타이핑도 blur 확정도 되지 않던 문제 - 한글 조합 중 Enter 가 미완성 문자열을 저장하던 문제(추가·수정 양쪽) - 되돌릴 수 없는 할 일 삭제에 확인 다이얼로그 추가 - 추가 실패 시 입력이 사라지던 문제 — 성공했을 때만 비운다 - 수정 입력에 빠져 있던 maxlength 추가 - 체크 토글을 낙관적으로 반영하고 실패 시 되돌린다 - 같은 항목의 중복 요청을 막아 응답 역순 도착으로 인한 덮어쓰기 방지 - 편집 중인 행이 목록에서 사라지면 편집 상태를 정리 - 처리되지 않은 Promise 거부 제거 - 삭제 버튼이 키보드 포커스 시에도 보이도록 수정 - 업무에서 만든 라벨에 원본 업무 링크 표시(직접 만든 라벨은 표시하지 않음) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// 라벨 카드 — 헤더(색점 + 이름 + 진행 + 편집/삭제) + 할 일 행 목록 + 인라인 추가 입력.
|
||||
// 전역 .list 카드 규격을 그대로 쓰고, 행은 카드 안에서 구분선으로 나눈다.
|
||||
import { ref } from 'vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useClickOutside } from '@/composables/useClickOutside'
|
||||
import type { ApiTodoLabel, ApiTodoItem } from '@/types/todo'
|
||||
|
||||
const props = defineProps<{ label: ApiTodoLabel }>()
|
||||
// 추가는 emit 이 아니라 함수 prop 으로 받는다 —
|
||||
// 성공 여부를 알아야 실패 시 입력을 남겨 둘 수 있기 때문(emit 은 반환값이 없다).
|
||||
const props = defineProps<{
|
||||
label: ApiTodoLabel
|
||||
addItem: (title: string) => Promise<void>
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'add', title: string): void
|
||||
(e: 'toggle', item: ApiTodoItem): void
|
||||
(e: 'rename', item: ApiTodoItem, title: string): void
|
||||
(e: 'remove', item: ApiTodoItem): void
|
||||
@@ -20,23 +25,54 @@ const menuOpen = ref(false)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
useClickOutside(menuOpen, () => (menuOpen.value = false), [menuRef])
|
||||
|
||||
// 인라인 추가 입력
|
||||
// 한글 등 IME 조합 중의 Enter 는 조합 확정용이라 제출로 보면 안 된다
|
||||
// (조합 중 Enter 를 제출로 처리하면 미완성 문자열이 저장된다)
|
||||
function isComposing(e: KeyboardEvent): boolean {
|
||||
return e.isComposing || e.keyCode === 229
|
||||
}
|
||||
|
||||
// 인라인 추가 입력 — 성공했을 때만 입력을 비운다(실패 시 다시 타이핑하지 않도록)
|
||||
const draft = ref('')
|
||||
function submitDraft(): void {
|
||||
const adding = ref(false)
|
||||
async function submitDraft(e: KeyboardEvent): Promise<void> {
|
||||
if (isComposing(e)) return
|
||||
const title = draft.value.trim()
|
||||
if (!title) return
|
||||
emit('add', title)
|
||||
draft.value = ''
|
||||
if (!title || adding.value) return
|
||||
adding.value = true
|
||||
try {
|
||||
await props.addItem(title)
|
||||
draft.value = ''
|
||||
} catch {
|
||||
// 오류 안내는 인터셉터가 처리 — 입력한 내용은 그대로 남긴다
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 항목 인라인 수정 — 한 번에 한 행만 편집 상태가 된다
|
||||
const editingId = ref<string | null>(null)
|
||||
const editDraft = ref('')
|
||||
function startEdit(item: ApiTodoItem): void {
|
||||
const editInput = ref<HTMLInputElement | null>(null)
|
||||
async function startEdit(item: ApiTodoItem): Promise<void> {
|
||||
editingId.value = item.id
|
||||
editDraft.value = item.title
|
||||
// 입력이 그려진 뒤 포커스를 줘야 한다. 포커스가 없으면 타이핑도 blur 확정도 되지 않는다.
|
||||
await nextTick()
|
||||
editInput.value?.focus()
|
||||
editInput.value?.select()
|
||||
}
|
||||
function commitEdit(item: ApiTodoItem): void {
|
||||
|
||||
// 편집 중인 행이 목록에서 사라지면(삭제·검색 필터) 편집 상태를 정리한다
|
||||
watch(
|
||||
() => props.label.items,
|
||||
(items) => {
|
||||
if (editingId.value && !items.some((i) => i.id === editingId.value)) {
|
||||
cancelEdit()
|
||||
}
|
||||
},
|
||||
)
|
||||
function commitEdit(item: ApiTodoItem, e?: KeyboardEvent): void {
|
||||
if (e && isComposing(e)) return
|
||||
// Enter 로 확정하면 입력이 사라지며 blur 가 뒤따라 한 번 더 호출된다.
|
||||
// 편집 중인 행이 아니면 무시해 같은 요청이 두 번 나가지 않게 한다.
|
||||
if (editingId.value !== item.id) return
|
||||
@@ -61,6 +97,23 @@ function cancelEdit(): void {
|
||||
<h2 class="tl-name">
|
||||
{{ props.label.name }}
|
||||
</h2>
|
||||
<!-- 원본 업무 링크 — 업무에서 만든 라벨에만 있다(직접 만든 라벨은 없음) -->
|
||||
<RouterLink
|
||||
v-if="props.label.sourceTask"
|
||||
class="tl-src"
|
||||
:to="`/projects/${props.label.sourceTask.projectId}/tasks/${props.label.sourceTask.seq}`"
|
||||
:title="`원본 업무: ${props.label.sourceTask.title}`"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.5 1.5" /><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7L12 19" /></svg>
|
||||
업무 #{{ props.label.sourceTask.seq }}
|
||||
</RouterLink>
|
||||
<span class="tl-progress">{{ props.label.doneCount }}/{{ props.label.totalCount }}</span>
|
||||
<div
|
||||
ref="menuRef"
|
||||
@@ -140,9 +193,11 @@ function cancelEdit(): void {
|
||||
</button>
|
||||
<input
|
||||
v-if="editingId === item.id"
|
||||
ref="editInput"
|
||||
v-model="editDraft"
|
||||
class="tl-edit"
|
||||
@keydown.enter="commitEdit(item)"
|
||||
maxlength="200"
|
||||
@keydown.enter="commitEdit(item, $event)"
|
||||
@keydown.esc="cancelEdit"
|
||||
@blur="commitEdit(item)"
|
||||
>
|
||||
@@ -191,6 +246,11 @@ function cancelEdit(): void {
|
||||
maxlength="200"
|
||||
@keydown.enter="submitDraft"
|
||||
>
|
||||
<span
|
||||
v-if="adding"
|
||||
class="tl-add-busy"
|
||||
aria-live="polite"
|
||||
>추가 중…</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -369,13 +429,46 @@ function cancelEdit(): void {
|
||||
display: none;
|
||||
place-items: center;
|
||||
}
|
||||
.tl-row:hover .tl-del {
|
||||
/* 키보드로 탭 이동했을 때도 보이게 한다 — 보이지 않는 포커스 정거장을 만들지 않는다 */
|
||||
.tl-row:hover .tl-del,
|
||||
.tl-del:focus-visible {
|
||||
display: grid;
|
||||
}
|
||||
.tl-del:hover {
|
||||
background: #eceef1;
|
||||
color: var(--text);
|
||||
}
|
||||
/* 원본 업무 링크 — 라벨 이름 옆의 보조 정보라 작고 낮은 대비로 둔다 */
|
||||
.tl-src {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.4375rem;
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tl-src:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.tl-src svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.tl-add-busy {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.tl-del svg {
|
||||
width: 0.813rem;
|
||||
height: 0.813rem;
|
||||
|
||||
@@ -49,17 +49,31 @@ async function onDeleteLabel(label: ApiTodoLabel): Promise<void> {
|
||||
}
|
||||
|
||||
// ----- 할 일 -----
|
||||
async function onAddItem(labelId: string, title: string): Promise<void> {
|
||||
await store.addItem(labelId, title)
|
||||
// 오류 안내는 API 인터셉터가 토스트로 처리하므로 여기서는 거부만 삼킨다.
|
||||
// (삼키지 않으면 템플릿 핸들러에서 처리되지 않은 Promise 거부가 남는다)
|
||||
function swallow(p: Promise<unknown>): void {
|
||||
void p.catch(() => undefined)
|
||||
}
|
||||
async function onToggleItem(item: ApiTodoItem): Promise<void> {
|
||||
await store.toggleItem(item.id, !item.done)
|
||||
|
||||
// 추가만 실패를 호출측(카드)에 알려야 한다 — 입력을 지울지 남길지 판단하기 위해
|
||||
function onAddItem(labelId: string, title: string): Promise<void> {
|
||||
return store.addItem(labelId, title)
|
||||
}
|
||||
async function onRenameItem(item: ApiTodoItem, title: string): Promise<void> {
|
||||
await store.renameItem(item.id, title)
|
||||
function onToggleItem(item: ApiTodoItem): void {
|
||||
swallow(store.toggleItem(item.id, !item.done))
|
||||
}
|
||||
function onRenameItem(item: ApiTodoItem, title: string): void {
|
||||
swallow(store.renameItem(item.id, title))
|
||||
}
|
||||
async function onRemoveItem(item: ApiTodoItem): Promise<void> {
|
||||
await store.removeItem(item.id)
|
||||
// 되돌릴 수 없는 삭제라 라벨 삭제와 동일하게 확인을 받는다
|
||||
const ok = await dialog.confirm(`'${item.title}' 할 일을 삭제할까요?`, {
|
||||
title: '할 일 삭제',
|
||||
confirmText: '삭제',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
swallow(store.removeItem(item.id))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -131,7 +145,7 @@ async function onRemoveItem(item: ApiTodoItem): Promise<void> {
|
||||
v-for="label in store.displayLabels"
|
||||
:key="label.id"
|
||||
:label="label"
|
||||
@add="onAddItem(label.id, $event)"
|
||||
:add-item="(title: string) => onAddItem(label.id, title)"
|
||||
@toggle="onToggleItem"
|
||||
@rename="onRenameItem"
|
||||
@remove="onRemoveItem"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useTodo } from '@/composables/useTodo'
|
||||
import type { ApiTodoLabel, TodoLabelPayload } from '@/types/todo'
|
||||
import type { ApiTodoItem, ApiTodoLabel, TodoLabelPayload } from '@/types/todo'
|
||||
|
||||
// 할 일 스토어 — 라벨(항목 포함) 목록 + 검색어 + CRUD.
|
||||
// 항목 변경 API 는 변경된 라벨을 그대로 돌려주므로 해당 카드만 교체한다.
|
||||
@@ -35,11 +35,31 @@ export const useTodoStore = defineStore('todo', () => {
|
||||
labels.value.reduce((sum, l) => sum + l.totalCount, 0),
|
||||
)
|
||||
|
||||
// 진행 중인 항목 변경 — 같은 항목에 중복 요청이 나가지 않도록 막는다.
|
||||
// (응답이 올 때까지 화면 상태가 그대로라, 막지 않으면 같은 값을 두 번 보내고
|
||||
// 두 응답이 역순으로 도착하면 나중 응답이 이겨 버린다)
|
||||
const pendingItemIds = ref<Set<string>>(new Set())
|
||||
function markPending(id: string, on: boolean): void {
|
||||
const next = new Set(pendingItemIds.value)
|
||||
if (on) next.add(id)
|
||||
else next.delete(id)
|
||||
pendingItemIds.value = next
|
||||
}
|
||||
|
||||
// 변경된 라벨 한 건을 목록에 반영
|
||||
function replaceLabel(next: ApiTodoLabel): void {
|
||||
labels.value = labels.value.map((l) => (l.id === next.id ? next : l))
|
||||
}
|
||||
|
||||
// 항목 한 건을 화면에서 먼저 바꾼다(낙관적 갱신). 진행 집계도 함께 맞춘다.
|
||||
function patchItemLocally(id: string, patch: Partial<ApiTodoItem>): void {
|
||||
labels.value = labels.value.map((l) => {
|
||||
if (!l.items.some((i) => i.id === id)) return l
|
||||
const items = l.items.map((i) => (i.id === id ? { ...i, ...patch } : i))
|
||||
return { ...l, items, doneCount: items.filter((i) => i.done).length }
|
||||
})
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -69,14 +89,38 @@ export const useTodoStore = defineStore('todo', () => {
|
||||
async function addItem(labelId: string, title: string): Promise<void> {
|
||||
replaceLabel(await api.createItem(labelId, title))
|
||||
}
|
||||
// 체크 토글 — 응답을 기다리지 않고 먼저 반영하고, 실패하면 되돌린다
|
||||
async function toggleItem(id: string, done: boolean): Promise<void> {
|
||||
replaceLabel(await api.updateItem(id, { done }))
|
||||
if (pendingItemIds.value.has(id)) return
|
||||
const snapshot = labels.value
|
||||
markPending(id, true)
|
||||
patchItemLocally(id, { done })
|
||||
try {
|
||||
replaceLabel(await api.updateItem(id, { done }))
|
||||
} catch (e) {
|
||||
labels.value = snapshot
|
||||
throw e
|
||||
} finally {
|
||||
markPending(id, false)
|
||||
}
|
||||
}
|
||||
async function renameItem(id: string, title: string): Promise<void> {
|
||||
replaceLabel(await api.updateItem(id, { title }))
|
||||
if (pendingItemIds.value.has(id)) return
|
||||
markPending(id, true)
|
||||
try {
|
||||
replaceLabel(await api.updateItem(id, { title }))
|
||||
} finally {
|
||||
markPending(id, false)
|
||||
}
|
||||
}
|
||||
async function removeItem(id: string): Promise<void> {
|
||||
replaceLabel(await api.deleteItem(id))
|
||||
if (pendingItemIds.value.has(id)) return
|
||||
markPending(id, true)
|
||||
try {
|
||||
replaceLabel(await api.deleteItem(id))
|
||||
} finally {
|
||||
markPending(id, false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user