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
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:
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user