Add routine management features for child and parent views
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m8s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m8s
- Implemented routine child-mode flow tests to ensure proper functionality of routine assignment and task completion. - Created notification tests for parent view to verify routine completion notifications for children. - Developed ChildRoutineOverlay component for displaying routine tasks and handling user interactions. - Added RoutineApproveDialog component for approving or rejecting completed routines. - Created unit tests for ChildRoutineOverlay and RoutineEditView components to ensure correct behavior and rendering. - Enhanced RoutineEditView with proper handling of task addition and form submission. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -7,33 +7,149 @@
|
||||
:isEdit="isEdit"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:requireDirty="false"
|
||||
:submitDisabled="items.length === 0"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
@add-image="handleAddImage"
|
||||
/>
|
||||
@add-image="handleAddMainImage"
|
||||
>
|
||||
<template #before-actions>
|
||||
<section class="items-panel">
|
||||
<h3>Routine Tasks</h3>
|
||||
|
||||
<div v-if="items.length === 0" class="items-empty">
|
||||
Add at least one task to save this routine.
|
||||
</div>
|
||||
|
||||
<div v-else class="items-list">
|
||||
<div v-for="(item, idx) in items" :key="item.id || `new-${idx}`" class="item-row">
|
||||
<div class="item-left">
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
:alt="item.name"
|
||||
class="item-thumb"
|
||||
/>
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary small-btn"
|
||||
@click="startEditItem(idx)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary small-btn" @click="removeItem(idx)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button v-if="!addFormOpen" type="button" class="add-task-trigger" @click="openAddForm">
|
||||
<span class="add-task-plus">+</span> Add Task
|
||||
</button>
|
||||
|
||||
<div v-else class="add-item-form">
|
||||
<div class="add-form-header">
|
||||
<h4>{{ editingItemIdx !== null ? 'Edit Task' : 'New Task' }}</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="close-form-btn"
|
||||
@click="cancelEditItem"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<input
|
||||
id="item-name"
|
||||
v-model="newItem.name"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
placeholder="Task name, e.g. Make bed"
|
||||
@input="error = null"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showImagePicker" class="group">
|
||||
<ImagePicker
|
||||
id="item-image"
|
||||
v-model="newItem.image_id"
|
||||
:image-type="2"
|
||||
@add-image="handleAddItemImage"
|
||||
/>
|
||||
</div>
|
||||
<button v-else type="button" class="toggle-image-btn" @click="showImagePicker = true">
|
||||
+ Add image (optional)
|
||||
</button>
|
||||
|
||||
<div class="item-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="addOrUpdateItem"
|
||||
:disabled="!newItem.name.trim()"
|
||||
>
|
||||
{{ editingItemIdx !== null ? 'Update' : 'Add' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" @click="cancelEditItem">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</EntityEditForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import EntityEditForm from '@/components/shared/EntityEditForm.vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
import type { RoutineItem } from '@/common/models'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
const router = useRouter()
|
||||
const isEdit = computed(() => !!props.id)
|
||||
|
||||
type EditableRoutineItem = RoutineItem & {
|
||||
image_url?: string | null
|
||||
local_file?: File | null
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{ name: 'name', label: 'Routine Name', type: 'text' as const, required: true, maxlength: 64 },
|
||||
{ name: 'points', label: 'Points', type: 'number' as const, required: true, min: 1, max: 1000 },
|
||||
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 4 },
|
||||
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 2 },
|
||||
]
|
||||
|
||||
const initialData = ref({ name: '', points: 1, image_id: null })
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const initialData = ref({
|
||||
name: '',
|
||||
points: 1,
|
||||
image_id: null as string | null,
|
||||
})
|
||||
const items = ref<EditableRoutineItem[]>([])
|
||||
const newItem = ref({
|
||||
name: '',
|
||||
image_id: null as string | null,
|
||||
})
|
||||
const newItemLocalFile = ref<File | null>(null)
|
||||
const editingItemIdx = ref<number | null>(null)
|
||||
const addFormOpen = ref(false)
|
||||
const showImagePicker = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const removedItemIds = ref<string[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value && props.id) {
|
||||
@@ -47,21 +163,182 @@ onMounted(async () => {
|
||||
points: Number(data.points) || 1,
|
||||
image_id: data.image_id ?? null,
|
||||
}
|
||||
|
||||
const itemsResp = await fetch(`/api/routine/${props.id}/items`)
|
||||
if (itemsResp.ok) {
|
||||
const itemsData = await itemsResp.json()
|
||||
const loadedItems = (itemsData.items || []) as RoutineItem[]
|
||||
items.value = await Promise.all(
|
||||
loadedItems.map(async (item, idx) => {
|
||||
let imageUrl: string | null = null
|
||||
if (item.image_id) {
|
||||
try {
|
||||
imageUrl = await getCachedImageUrl(item.image_id)
|
||||
} catch {
|
||||
imageUrl = null
|
||||
}
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
order: idx,
|
||||
image_url: imageUrl,
|
||||
local_file: null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
error.value = 'Could not load routine.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function handleAddImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') localImageFile.value = file
|
||||
function handleAddMainImage({ id, file }: { id: string; file: File }) {
|
||||
if (id === 'local-upload') {
|
||||
localImageFile.value = file
|
||||
} else {
|
||||
localImageFile.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddItemImage({ id, file }: { id: string; file: File }) {
|
||||
if (id !== 'local-upload') {
|
||||
newItemLocalFile.value = null
|
||||
return
|
||||
}
|
||||
|
||||
newItemLocalFile.value = file
|
||||
try {
|
||||
const imageUrl = await getCachedImageUrl(id)
|
||||
if (editingItemIdx.value !== null) {
|
||||
const editingItem = items.value[editingItemIdx.value]
|
||||
if (editingItem) {
|
||||
editingItem.image_url = imageUrl
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No-op: preview still works through ImagePicker.
|
||||
}
|
||||
}
|
||||
|
||||
function openAddForm() {
|
||||
editingItemIdx.value = null
|
||||
newItem.value = { name: '', image_id: null }
|
||||
newItemLocalFile.value = null
|
||||
showImagePicker.value = false
|
||||
addFormOpen.value = true
|
||||
}
|
||||
|
||||
function startEditItem(idx: number) {
|
||||
const item = items.value[idx]
|
||||
if (!item) return
|
||||
|
||||
editingItemIdx.value = idx
|
||||
newItem.value = {
|
||||
name: item.name,
|
||||
image_id: item.image_id,
|
||||
}
|
||||
newItemLocalFile.value = null
|
||||
showImagePicker.value = !!item.image_id
|
||||
addFormOpen.value = true
|
||||
}
|
||||
|
||||
function normalizeItemOrder() {
|
||||
items.value = items.value.map((item, idx) => ({
|
||||
...item,
|
||||
order: idx,
|
||||
}))
|
||||
}
|
||||
|
||||
function addOrUpdateItem() {
|
||||
if (!newItem.value.name.trim()) {
|
||||
error.value = 'Item name is required.'
|
||||
return
|
||||
}
|
||||
|
||||
if (editingItemIdx.value !== null) {
|
||||
const current = items.value[editingItemIdx.value]
|
||||
if (!current) {
|
||||
cancelEditItem()
|
||||
return
|
||||
}
|
||||
|
||||
items.value[editingItemIdx.value] = {
|
||||
id: current.id || '',
|
||||
routine_id: props.id || '',
|
||||
name: newItem.value.name,
|
||||
image_id: newItem.value.image_id,
|
||||
image_url: current.image_url,
|
||||
local_file: newItemLocalFile.value,
|
||||
order: editingItemIdx.value,
|
||||
}
|
||||
} else {
|
||||
items.value.push({
|
||||
id: `temp-${Date.now()}`,
|
||||
routine_id: props.id || '',
|
||||
name: newItem.value.name,
|
||||
image_id: newItem.value.image_id,
|
||||
image_url: null,
|
||||
local_file: newItemLocalFile.value,
|
||||
order: items.value.length,
|
||||
})
|
||||
}
|
||||
|
||||
normalizeItemOrder()
|
||||
editingItemIdx.value = null
|
||||
addFormOpen.value = false
|
||||
showImagePicker.value = false
|
||||
newItem.value = { name: '', image_id: null }
|
||||
newItemLocalFile.value = null
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function removeItem(idx: number) {
|
||||
const item = items.value[idx]
|
||||
if (!item) return
|
||||
|
||||
if (item.id && !item.id.startsWith('temp-')) {
|
||||
removedItemIds.value.push(item.id)
|
||||
}
|
||||
items.value.splice(idx, 1)
|
||||
normalizeItemOrder()
|
||||
|
||||
if (editingItemIdx.value === idx) {
|
||||
cancelEditItem()
|
||||
} else if (editingItemIdx.value !== null && editingItemIdx.value > idx) {
|
||||
editingItemIdx.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditItem() {
|
||||
editingItemIdx.value = null
|
||||
addFormOpen.value = false
|
||||
showImagePicker.value = false
|
||||
newItem.value = { name: '', image_id: null }
|
||||
newItemLocalFile.value = null
|
||||
}
|
||||
|
||||
async function uploadImage(file: File): Promise<string> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', '4')
|
||||
formData.append('permanent', 'false')
|
||||
const resp = await fetch('/api/image/upload', { method: 'POST', body: formData })
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
return data.id
|
||||
}
|
||||
|
||||
async function resolveItemImageId(item: EditableRoutineItem): Promise<string | null> {
|
||||
if (item.image_id === 'local-upload' && item.local_file) {
|
||||
return uploadImage(item.local_file)
|
||||
}
|
||||
return item.image_id ?? null
|
||||
}
|
||||
|
||||
async function handleSubmit(form: { name: string; points: number; image_id: string | null }) {
|
||||
let imageId = form.image_id
|
||||
error.value = null
|
||||
if (!form.name.trim()) {
|
||||
error.value = 'Routine name is required.'
|
||||
@@ -71,26 +348,22 @@ async function handleSubmit(form: { name: string; points: number; image_id: stri
|
||||
error.value = 'Points must be at least 1.'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
|
||||
if (imageId === 'local-upload' && localImageFile.value) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', localImageFile.value)
|
||||
formData.append('type', '4')
|
||||
formData.append('permanent', 'false')
|
||||
try {
|
||||
const resp = await fetch('/api/image/upload', { method: 'POST', body: formData })
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
imageId = data.id
|
||||
} catch {
|
||||
error.value = 'Failed to upload image.'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
if (items.value.length === 0) {
|
||||
error.value = 'Routine must have at least one item.'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
let imageId = form.image_id
|
||||
if (imageId === null && localImageFile.value) {
|
||||
imageId = await uploadImage(localImageFile.value)
|
||||
}
|
||||
if (imageId === 'local-upload' && localImageFile.value) {
|
||||
imageId = await uploadImage(localImageFile.value)
|
||||
}
|
||||
|
||||
const url = isEdit.value && props.id ? `/api/routine/${props.id}/edit` : '/api/routine/add'
|
||||
const resp = await fetch(url, {
|
||||
method: 'PUT',
|
||||
@@ -98,11 +371,41 @@ async function handleSubmit(form: { name: string; points: number; image_id: stri
|
||||
body: JSON.stringify({ name: form.name, points: form.points, image_id: imageId }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to save routine')
|
||||
const routineData = await resp.json()
|
||||
const routineId = routineData.id || props.id
|
||||
|
||||
for (const itemId of removedItemIds.value) {
|
||||
await fetch(`/api/routine/${routineId}/item/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of items.value) {
|
||||
const resolvedImageId = await resolveItemImageId(item)
|
||||
const payload = { name: item.name, image_id: resolvedImageId, order: item.order }
|
||||
|
||||
if (item.id?.startsWith('temp-') || !item.id) {
|
||||
await fetch(`/api/routine/${routineId}/item/add`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
} else {
|
||||
await fetch(`/api/routine/${routineId}/item/${item.id}/edit`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await router.push({ name: 'RoutineView' })
|
||||
} catch {
|
||||
} catch (err) {
|
||||
error.value = 'Failed to save routine.'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
@@ -119,4 +422,188 @@ function handleCancel() {
|
||||
box-shadow: 0 4px 24px var(--form-shadow);
|
||||
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||
}
|
||||
|
||||
.items-panel {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--form-input-border, #e6e6e6);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.items-panel h3 {
|
||||
color: var(--form-label, #444);
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.items-empty {
|
||||
color: var(--loading-color, #888);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--form-input-bg, #f8fafc);
|
||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
|
||||
.item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.item-thumb {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: 600;
|
||||
color: var(--form-label, #333);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.add-task-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: none;
|
||||
border: 1.5px dashed var(--form-input-border, #c8d0db);
|
||||
border-radius: 10px;
|
||||
color: var(--btn-primary, #667eea);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.add-task-trigger:hover {
|
||||
background: var(--form-input-bg, #f0f4ff);
|
||||
border-color: var(--btn-primary, #667eea);
|
||||
}
|
||||
|
||||
.add-task-plus {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.add-item-form {
|
||||
margin-top: 0.25rem;
|
||||
background: var(--form-input-bg, #f8fafc);
|
||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||
border-radius: 10px;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.add-form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.add-form-header h4 {
|
||||
color: var(--form-label, #444);
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-form-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--form-label, #888);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.close-form-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.toggle-image-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--btn-primary, #667eea);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-bottom: 0.8rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toggle-image-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.group input[type='text'] {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||
font-size: 1rem;
|
||||
background: var(--form-input-bg, #fff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.item-form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.view {
|
||||
padding: 1.5rem 1.2rem;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user