feat: Implement drag-and-drop reordering for routine items and add corresponding E2E tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m41s

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-19 19:30:44 -04:00
parent ad8a8bf867
commit a6944ad59c
7 changed files with 324 additions and 83 deletions

View File

@@ -709,6 +709,50 @@ onUnmounted(() => {
<div v-if="!loading && !error" class="layout">
<div class="main">
<ChildDetailCard :child="child" />
<ScrollingList
v-if="routines.length > 0"
title="Routines"
ref="childRoutineListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
:ids="routines"
itemKey="routines"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="handleRoutineClick"
:getItemClass="
(item: ChildRoutine) => ({
good: true,
'routine-pending': item.pending_status === 'pending',
'routine-approved': item.pending_status === 'approved',
})
"
>
<template #item="{ item }: { item: ChildRoutine }">
<span v-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
>PENDING</span
>
<span v-else-if="item.pending_status === 'approved'" class="chore-stamp completed-stamp"
>APPROVED</span
>
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Routine Image"
class="item-image"
/>
<div class="item-points good-points">
{{
item.custom_value !== undefined && item.custom_value !== null
? item.custom_value
: item.points
}}
Points
</div>
</template>
</ScrollingList>
<ScrollingList
title="Chores"
ref="childChoreListRef"
@@ -783,6 +827,7 @@ onUnmounted(() => {
</template>
</ScrollingList>
<ScrollingList
v-show="childKindnessListRef?.items?.length > 0"
title="Kindness Acts"
ref="childKindnessListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
@@ -810,6 +855,7 @@ onUnmounted(() => {
</template>
</ScrollingList>
<ScrollingList
v-show="childPenaltyListRef?.items?.length > 0"
title="Penalties"
ref="childPenaltyListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
@@ -836,49 +882,6 @@ onUnmounted(() => {
</div>
</template>
</ScrollingList>
<ScrollingList
title="Routines"
ref="childRoutineListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
:ids="routines"
itemKey="routines"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="handleRoutineClick"
:getItemClass="
(item: ChildRoutine) => ({
good: true,
'routine-pending': item.pending_status === 'pending',
'routine-approved': item.pending_status === 'approved',
})
"
>
<template #item="{ item }: { item: ChildRoutine }">
<span v-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
>PENDING</span
>
<span v-else-if="item.pending_status === 'approved'" class="chore-stamp completed-stamp"
>APPROVED</span
>
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Routine Image"
class="item-image"
/>
<div class="item-points good-points">
{{
item.custom_value !== undefined && item.custom_value !== null
? item.custom_value
: item.points
}}
Points
</div>
</template>
</ScrollingList>
</div>
</div>
</div>

View File

@@ -1585,16 +1585,16 @@ function goToAssignRoutines() {
</div>
<div class="assign-buttons">
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Chores</button>
<button v-if="child" class="btn btn-primary" @click="goToAssignRoutines">
Assign Routines
</button>
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
<button v-if="child" class="btn btn-primary" @click="goToAssignKindness">
Assign Kindness Acts
</button>
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
Assign Penalties
</button>
<button v-if="child" class="btn btn-primary" @click="goToAssignRoutines">
Assign Routines
</button>
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
</div>
<!-- Pending Reward Dialog -->

View File

@@ -12,15 +12,16 @@
class="routine-item"
@click="toggleRoutine(routine.id)"
>
<input
type="checkbox"
:checked="selectedIds.includes(routine.id)"
@change="toggleRoutine(routine.id)"
:aria-label="`Select ${routine.name}`"
/>
<img v-if="routine.image_url" :src="routine.image_url" :alt="routine.name" />
<span class="name">{{ routine.name }}</span>
<span class="value">{{ routine.points }} pts</span>
<input
type="checkbox"
v-model="selectedIds"
:value="routine.id"
:aria-label="`Select ${routine.name}`"
@click.stop
/>
</div>
</div>
</div>
@@ -38,6 +39,7 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { setChildRoutines } from '@/common/api'
import MessageBlock from '../shared/MessageBlock.vue'
import { getCachedImageUrl } from '@/common/imageCache'
import '@/assets/styles.css'
import type { Routine } from '@/common/models'
@@ -61,11 +63,23 @@ async function fetchRoutines() {
const childData = await childResp.json()
selectedIds.value = childData.routines || []
// Fetch all routines
// Fetch all routines and resolve images
const routinesResp = await fetch('/api/routine/list')
if (!routinesResp.ok) throw new Error('Failed to fetch routines')
const routinesData = await routinesResp.json()
routines.value = routinesData.routines || []
const rawRoutines: Routine[] = routinesData.routines || []
await Promise.all(
rawRoutines.map(async (r: any) => {
if (r.image_id) {
try {
r.image_url = await getCachedImageUrl(r.image_id)
} catch {
r.image_url = null
}
}
}),
)
routines.value = rawRoutines
} catch (error) {
console.error('Failed to fetch routines:', error)
}
@@ -135,52 +149,65 @@ function onCancel() {
}
.routine-selection {
flex: 0 1 auto;
max-width: 480px;
width: 100%;
max-height: calc(100vh - 4.5rem);
overflow-y: auto;
margin: 0.2rem 0 0 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 500px;
gap: 0.7rem;
background: var(--list-bg);
padding: 0.2rem;
border-radius: 12px;
}
.routine-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 2px solid var(--list-item-border-good);
justify-content: space-between;
border: 2px outset var(--list-item-border-good);
border-radius: 8px;
padding: 0.2rem 1rem;
background: var(--list-item-bg-good);
font-size: 1.05rem;
font-weight: 500;
transition: border 0.18s;
margin-bottom: 0.2rem;
margin-left: 0.2rem;
margin-right: 0.2rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
box-sizing: border-box;
cursor: pointer;
transition:
background 0.2s,
border-color 0.2s;
}
.routine-item:hover {
background: var(--list-item-bg-good-hover, var(--list-item-bg-good));
border-color: var(--list-item-border-good-hover, var(--list-item-border-good));
}
.routine-item input[type='checkbox'] {
cursor: pointer;
width: 1.25rem;
height: 1.25rem;
width: 1.2em;
height: 1.2em;
margin-left: 1rem;
accent-color: var(--checkbox-accent);
flex-shrink: 0;
}
.routine-item img {
width: 2.5rem;
height: 2.5rem;
border-radius: 6px;
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 8px;
margin-right: 0.7rem;
background: var(--list-image-bg);
flex-shrink: 0;
}
.routine-item .name {
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.routine-item .value {
.value {
min-width: 60px;
text-align: right;
font-weight: 600;

View File

@@ -22,7 +22,18 @@
</div>
<div v-else class="items-list">
<div v-for="(item, idx) in items" :key="item.id || `new-${idx}`" class="item-row">
<div
v-for="(item, idx) in items"
:key="item.id || `new-${idx}`"
class="item-row"
:class="{ 'drag-over': dragOverIdx === idx, dragging: draggingIdx === idx }"
draggable="true"
@dragstart="(e) => onDragStart(e, idx)"
@dragover.prevent="(e) => onDragOver(e, idx)"
@drop.prevent="onDrop(idx)"
@dragend="onDragEnd"
>
<span class="drag-handle" title="Drag to reorder"></span>
<div class="item-left">
<img
v-if="item.image_url"
@@ -150,6 +161,8 @@ const loading = ref(false)
const error = ref<string | null>(null)
const localImageFile = ref<File | null>(null)
const removedItemIds = ref<string[]>([])
const draggingIdx = ref<number | null>(null)
const dragOverIdx = ref<number | null>(null)
onMounted(async () => {
if (isEdit.value && props.id) {
@@ -320,6 +333,36 @@ function cancelEditItem() {
newItemLocalFile.value = null
}
function onDragStart(e: DragEvent, idx: number) {
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
cancelEditItem()
draggingIdx.value = idx
}
function onDragOver(e: DragEvent, idx: number) {
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
if (draggingIdx.value === null || draggingIdx.value === idx) return
dragOverIdx.value = idx
}
function onDrop(idx: number) {
if (draggingIdx.value === null || draggingIdx.value === idx) {
draggingIdx.value = null
dragOverIdx.value = null
return
}
const moved = items.value.splice(draggingIdx.value, 1)[0]
items.value.splice(idx, 0, moved)
normalizeItemOrder()
draggingIdx.value = null
dragOverIdx.value = null
}
function onDragEnd() {
draggingIdx.value = null
dragOverIdx.value = null
}
async function uploadImage(file: File): Promise<string> {
const formData = new FormData()
formData.append('file', file)
@@ -473,6 +516,29 @@ function handleCancel() {
border: 1px solid var(--form-input-border, #e6e6e6);
border-radius: 10px;
padding: 0.55rem 0.7rem;
cursor: default;
}
.item-row.drag-over {
border-color: var(--btn-primary, #667eea);
background: var(--form-input-bg-hover, #f0f4ff);
}
.item-row.dragging {
opacity: 0.4;
}
.drag-handle {
cursor: grab;
color: var(--form-label, #aaa);
font-size: 1.15rem;
padding-right: 0.45rem;
user-select: none;
flex-shrink: 0;
}
.drag-handle:active {
cursor: grabbing;
}
.item-left {
@@ -480,6 +546,7 @@ function handleCancel() {
align-items: center;
min-width: 0;
gap: 0.6rem;
flex: 1;
}
.item-thumb {