mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
fix(projects): carry tags, due date and priority in the list payload (#2536)
The edit dialog is shared between the projects list and the project detail page and seeds itself from whichever project object it is handed. The list payload never carried tags, due_date or priority, so editing from the list showed a blank tags field -- and, unreported, submitted the dialog's default priority over a stored high/urgent one. The component read those fields through a cast, so the compiler never flagged that they were always absent. Put them on ProjectListResponse and ProjectListItem, drop the casts, and let an explicit null clear tags and due date the way it already clears budget and url -- an emptied field was previously sent as undefined and silently reverted. The template list was missing target_parts_count, which the same dialog edits.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* Tests for the ProjectsPage component.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
@@ -379,4 +379,91 @@ describe('ProjectsPage', () => {
|
||||
expect(card).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit dialog seeds itself from the list payload (#2536)', () => {
|
||||
/**
|
||||
* The same ProjectModal is opened from the projects list and from the
|
||||
* project detail page. The detail page hands it a full project; the list
|
||||
* hands it a list item. Reporter saw an empty tags field when editing from
|
||||
* the list, because the list payload didn't carry tags — and, unreported,
|
||||
* the dialog then submitted its default priority over the stored one.
|
||||
*/
|
||||
const listItem = {
|
||||
id: 7,
|
||||
name: 'Spool holder',
|
||||
description: null,
|
||||
color: '#00ae42',
|
||||
status: 'active',
|
||||
target_count: null,
|
||||
target_parts_count: null,
|
||||
budget: null,
|
||||
tags: 'prototype,client-work',
|
||||
due_date: '2026-08-01T12:00:00Z',
|
||||
priority: 'high',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
archive_count: 0,
|
||||
total_items: 0,
|
||||
completed_count: 0,
|
||||
failed_count: 0,
|
||||
queue_count: 0,
|
||||
progress_percent: null,
|
||||
archives: [],
|
||||
url: null,
|
||||
cover_image_filename: null,
|
||||
};
|
||||
|
||||
const renderModal = (onSave: (data: unknown) => void) =>
|
||||
render(
|
||||
<ProjectModal
|
||||
project={listItem}
|
||||
onClose={() => {}}
|
||||
onSave={onSave as never}
|
||||
isLoading={false}
|
||||
currencySymbol="€"
|
||||
t={((k: string) => k) as never}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tagsInput = () => screen.getByPlaceholderText('projects.tagsPlaceholder') as HTMLInputElement;
|
||||
const dueDateInput = () => document.querySelector('input[type="date"]') as HTMLInputElement;
|
||||
const prioritySelect = () =>
|
||||
Array.from(document.querySelectorAll('select')).find((s) =>
|
||||
s.querySelector('option[value="urgent"]'),
|
||||
) as HTMLSelectElement;
|
||||
|
||||
it('prefills tags, due date and priority from a list item', () => {
|
||||
renderModal(() => {});
|
||||
|
||||
expect(tagsInput().value).toBe('prototype,client-work');
|
||||
expect(dueDateInput().value).toBe('2026-08-01');
|
||||
expect(prioritySelect().value).toBe('high');
|
||||
});
|
||||
|
||||
it('does not downgrade a stored priority when saving an untouched field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn();
|
||||
renderModal(onSave);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.save' }));
|
||||
|
||||
// Before the fix the dialog fell back to its 'normal' default and sent
|
||||
// that, silently demoting a high/urgent project edited from the list.
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ priority: 'high', tags: 'prototype,client-work' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends null when an existing tag list is cleared', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn();
|
||||
renderModal(onSave);
|
||||
|
||||
await user.clear(tagsInput());
|
||||
await user.click(screen.getByRole('button', { name: 'common.save' }));
|
||||
|
||||
// undefined would drop the key from the PATCH body and the backend would
|
||||
// keep the old tags — the field has to be cleared explicitly.
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tags: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -923,6 +923,9 @@ export interface ProjectListItem {
|
||||
target_count: number | null; // Target number of plates/print jobs
|
||||
target_parts_count: number | null; // Target number of parts/objects
|
||||
budget: number | null;
|
||||
tags: string | null; // #2536 — the shared edit dialog seeds itself from this
|
||||
due_date: string | null; // #2536
|
||||
priority: string; // #2536
|
||||
created_at: string;
|
||||
archive_count: number; // Number of print jobs (plates)
|
||||
total_items: number; // Sum of quantities (total items printed, including failed)
|
||||
@@ -958,8 +961,8 @@ export interface ProjectUpdate {
|
||||
target_count?: number;
|
||||
target_parts_count?: number;
|
||||
notes?: string;
|
||||
tags?: string;
|
||||
due_date?: string;
|
||||
tags?: string | null; // #2536 — explicit null clears the tags
|
||||
due_date?: string | null; // #2536 — explicit null clears the due date
|
||||
priority?: string;
|
||||
budget?: number | null;
|
||||
parent_id?: number;
|
||||
|
||||
@@ -62,9 +62,9 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
|
||||
const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
|
||||
const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || '');
|
||||
const [status, setStatus] = useState(project?.status || 'active');
|
||||
const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || '');
|
||||
const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
|
||||
const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal');
|
||||
const [tags, setTags] = useState(project?.tags || '');
|
||||
const [dueDate, setDueDate] = useState(project?.due_date?.split('T')[0] || '');
|
||||
const [priority, setPriority] = useState(project?.priority || 'normal');
|
||||
const [budget, setBudget] = useState(project?.budget?.toString() || '');
|
||||
const [url, setUrl] = useState(project?.url || '');
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
@@ -120,13 +120,14 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
|
||||
color,
|
||||
target_count: targetCount ? parseInt(targetCount, 10) : undefined,
|
||||
target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined,
|
||||
tags: tags.trim() || undefined,
|
||||
due_date: dueDate || undefined,
|
||||
// Null clears the stored value on edit; undefined omits the key on create.
|
||||
// Sending undefined on edit would make an emptied field un-clearable.
|
||||
tags: project ? (tags.trim() || null) : (tags.trim() || undefined),
|
||||
due_date: project ? (dueDate || null) : (dueDate || undefined),
|
||||
priority,
|
||||
budget: budget.trim() ? parseFloat(budget) : null,
|
||||
// Pydantic accepts null to clear the URL; an empty string would fail the
|
||||
// http(s) prefix validator. Use undefined for create (omit) and null for
|
||||
// edit-with-cleared-value.
|
||||
// http(s) prefix validator.
|
||||
url: project ? (trimmedUrl || null) : (trimmedUrl || undefined),
|
||||
...(project && { status }),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user