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:
@@ -2,20 +2,20 @@
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "QCuLIgmPV2vnDfzmgvptt-mZAW1jh8UML_wwAee4OTQ",
|
||||
"value": "GyPtMIGYCphOLBJzZ6jG87S9PKImCOhrVEvuVL0-Wu8",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1786912678.702645,
|
||||
"expires": 1786993807.67933,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJlM2VmNGE2OC0zZWI3LTQ2MjQtYTI0Mi0yNmY1OTgyZDIwOWEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzkxNDc0Nzh9.P7CDplAG3MBXgtEpuYFtp6gOV5pi-6QeAYRqmE-Dkys",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJjNWUzMTkwYi1hZWIzLTRlMTEtYmFiNS1hNjBkOTM5NmEyN2QiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzkyMjg2MDd9.f7dGIujexK9ffYWPM5ExUbL_UPRg0ULo778AR2J_yf0",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1779147478.701971,
|
||||
"expires": 1779228607.679281,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
@@ -27,11 +27,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1779136678495}"
|
||||
"value": "{\"type\":\"logout\",\"at\":1779217807539}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1779309478870}"
|
||||
"value": "{\"expiresAt\":1779390607821}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const ROUTINE_NAME = 'ReorderTestRoutine'
|
||||
const ROUTINE_POINTS = 10
|
||||
const ITEM_A = 'Alpha Task'
|
||||
const ITEM_B = 'Beta Task'
|
||||
const ITEM_C = 'Gamma Task'
|
||||
|
||||
async function createRoutine(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const pre = await request.get('/api/routine/list')
|
||||
for (const r of (await pre.json()).routines ?? []) {
|
||||
if (r.name === name) await request.delete(`/api/routine/${r.id}`)
|
||||
}
|
||||
const res = await request.put('/api/routine/add', { data: { name, points } })
|
||||
return (await res.json()).routine?.id ?? ''
|
||||
}
|
||||
|
||||
test.describe('Routine item drag-to-reorder', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let routineId = ''
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
routineId = await createRoutine(request, ROUTINE_NAME, ROUTINE_POINTS)
|
||||
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: ITEM_A, order: 0 } })
|
||||
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: ITEM_B, order: 1 } })
|
||||
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: ITEM_C, order: 2 } })
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (routineId) await request.delete(`/api/routine/${routineId}`)
|
||||
})
|
||||
|
||||
test('items load in correct initial order', async ({ page }) => {
|
||||
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
|
||||
await page.locator('.item-row').first().waitFor({ state: 'visible' })
|
||||
|
||||
const names = await page.locator('.item-name').allTextContents()
|
||||
expect(names).toEqual([ITEM_A, ITEM_B, ITEM_C])
|
||||
})
|
||||
|
||||
test('dragging first item to last position reorders the list in the UI', async ({ page }) => {
|
||||
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
|
||||
await page.locator('.item-row').first().waitFor({ state: 'visible' })
|
||||
|
||||
await page.locator('.item-row').nth(0).dragTo(page.locator('.item-row').nth(2))
|
||||
|
||||
const names = await page.locator('.item-name').allTextContents()
|
||||
expect(names).toEqual([ITEM_B, ITEM_C, ITEM_A])
|
||||
})
|
||||
|
||||
test('reordered item order persists after saving', async ({ page }) => {
|
||||
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
|
||||
await page.locator('.item-row').first().waitFor({ state: 'visible' })
|
||||
|
||||
// Drag first to last: [A, B, C] → [B, C, A]
|
||||
await page.locator('.item-row').nth(0).dragTo(page.locator('.item-row').nth(2))
|
||||
|
||||
// Confirm the drag updated the DOM before saving
|
||||
const afterDrag = await page.locator('.item-name').allTextContents()
|
||||
expect(afterDrag).toEqual([ITEM_B, ITEM_C, ITEM_A])
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await page.waitForURL(/\/parent\/tasks\/routines$/, { timeout: 5000 })
|
||||
|
||||
// Reload edit view — should now show the saved order
|
||||
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
|
||||
await page.locator('.item-row').first().waitFor({ state: 'visible' })
|
||||
|
||||
const names = await page.locator('.item-name').allTextContents()
|
||||
expect(names).toEqual([ITEM_B, ITEM_C, ITEM_A])
|
||||
})
|
||||
})
|
||||
@@ -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