diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b142aae..504b5de84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file. - **The K value is on the AMS slot itself, not only in the popover (#2532, requested and contributed by @gyrene2083)** — Reading back a slot's pressure-advance value meant hovering it: the K factor lived in the filament popover alone, so checking whether a calibration had actually taken across four slots was four hovers, and comparing two of them side by side was not possible at all. Every slot card now carries the value under the material name, the way Bambu Studio shows it per slot — on regular AMS units, on AMS-HT, and on the external spool of a dual-nozzle machine. Only a value the printer actually reported is shown: a loaded but never-calibrated slot stays blank rather than inheriting the 0.020 that fills the popover's own field, and a slot the firmware reports as exactly 0 counts as uncalibrated the same way the stored K-profiles do. The label is shortened to **K** with the full localized name on hover, because "K Factor", "K-Faktor" and "Facteur K" ate the value itself — the whole point of the line — on cards under about 350px, and the figure is set in tabular numerals so it measures the same in Safari as in Chromium. Where one slot of a unit is calibrated and its neighbours are not, the neighbours hold the same row open so the fill bars stay level across the card. ### Fixed +- **Archived projects crowded out the live ones in every project picker (#2888, reported by @e77)** — The reporter files each job under its own project and archives it when the job is done, so five active projects sat behind thirty-odd finished ones in the Project dropdown of the Edit Archive dialog — an unscrolled list of everything ever created, with no way to tell which entries were still live. That dropdown, the one on the pending-uploads panel, the bulk "Add to Project" dialog and the File Manager's folder link now leave archived projects out. Completed projects stay: a project marked completed says the work is done, not that it should be hidden, and filing a reprint under one is ordinary. The Archives right-click submenu had gone the other way and offered active projects only, so a completed project was reachable from the Edit dialog and not from the menu beside it; all five surfaces now apply the same rule. Whatever an archive is already filed under stays on its own list whatever its status — a ` holding a value no option matches is reset by the + // browser to its first option -- "No project" -- so an archive filed in an + // archived project would claim to be filed nowhere. + const projects = [ + project({ id: 1, status: 'active' }), + project({ id: 2, status: 'archived' }), + ]; + + expect(ids(assignableProjects(projects, 2))).toEqual([1, 2]); + }); + + it('keeps only the named archived project, not archived ones generally', () => { + const projects = [ + project({ id: 2, status: 'archived' }), + project({ id: 3, status: 'archived' }), + ]; + + expect(ids(assignableProjects(projects, 3))).toEqual([3]); + }); + + it('treats no current project as naming nothing', () => { + // The pickers pass `archive.project_id` straight through, and an + // unassigned archive carries null there. Reading null as an id would be + // harmless today only because no project has that id -- but a picker with + // no argument at all must behave the same way. + const projects = [project({ id: 1, status: 'archived' })]; + + expect(assignableProjects(projects, null)).toEqual([]); + expect(assignableProjects(projects, undefined)).toEqual([]); + expect(assignableProjects(projects)).toEqual([]); + }); + + it('leaves the order it was given', () => { + // Every caller sorts by name either side of this, so reordering here + // would silently fight them. + const projects = [ + project({ id: 3, name: 'Wing' }), + project({ id: 1, name: 'Airframe' }), + project({ id: 2, name: 'Spar' }), + ]; + + expect(ids(assignableProjects(projects))).toEqual([3, 1, 2]); + }); + + it('does not modify the array it is given', () => { + const projects = [ + project({ id: 1, status: 'active' }), + project({ id: 2, status: 'archived' }), + ]; + + assignableProjects(projects); + + expect(ids(projects)).toEqual([1, 2]); + }); +}); diff --git a/frontend/src/components/BatchProjectModal.tsx b/frontend/src/components/BatchProjectModal.tsx index fbdc95a7f..1a1c4c1e7 100644 --- a/frontend/src/components/BatchProjectModal.tsx +++ b/frontend/src/components/BatchProjectModal.tsx @@ -7,6 +7,7 @@ import { Card, CardContent } from './Card'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; import { invalidateArchiveAndProjectViews } from '../utils/projectQueries'; +import { assignableProjects } from '../utils/projectTree'; interface BatchProjectModalProps { selectedIds: number[]; @@ -24,8 +25,10 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro queryFn: () => api.getProjects(), }); + // Assigning in bulk, so nothing here has a project to preserve: archived + // ones drop off outright (#2888). const sortedProjects = useMemo( - () => (projects ? [...projects].sort((a, b) => a.name.localeCompare(b.name)) : undefined), + () => (projects ? assignableProjects([...projects].sort((a, b) => a.name.localeCompare(b.name))) : undefined), [projects], ); diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx index 60237a326..de687553e 100644 --- a/frontend/src/components/EditArchiveModal.tsx +++ b/frontend/src/components/EditArchiveModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link, Weight } from 'lucide-react'; @@ -7,6 +7,7 @@ import type { Archive } from '../api/client'; import { Button } from './Button'; import { PrintLogTable } from './PrintLogTable'; import { invalidateArchiveAndProjectViews } from '../utils/projectQueries'; +import { assignableProjects } from '../utils/projectTree'; // Keys for failure reasons - translated at render time. // Exported so the Print Log per-row classification editor (#1687 part 4) @@ -98,6 +99,15 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)), }); + // Archived projects drop off the list, except the one this archive is + // already filed under. That one has to stay: a select holding a value with + // no matching option resets to the first one, so the field would read + // "No project" for an archive that is in one (#2888). + const projectOptions = useMemo( + () => assignableProjects(projects ?? [], archive.project_id), + [projects, archive.project_id], + ); + // Fetch all tags using the dedicated API const { data: tagsData } = useQuery({ queryKey: ['tags'], @@ -317,7 +327,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" > - {projects?.map((p) => ( + {projectOptions.map((p) => ( diff --git a/frontend/src/components/PendingUploadsPanel.tsx b/frontend/src/components/PendingUploadsPanel.tsx index 409fd7b26..1e994ad7e 100644 --- a/frontend/src/components/PendingUploadsPanel.tsx +++ b/frontend/src/components/PendingUploadsPanel.tsx @@ -9,6 +9,7 @@ import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; import { ConfirmModal } from './ConfirmModal'; import { formatFileSize } from '../utils/file'; +import { assignableProjects } from '../utils/projectTree'; function formatTimeAgo(dateStr: string): string { const date = new Date(dateStr); @@ -183,11 +184,12 @@ export function PendingUploadsPanel() { refetchInterval: 10000, // Refresh every 10 seconds }); - // Fetch projects for dropdown + // Fetch projects for dropdown. Nothing pending is filed anywhere yet, so + // there is no current project to hold on to -- archived ones just go (#2888). const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: () => api.getProjects(), - select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)), + select: (rows) => assignableProjects([...rows].sort((a, b) => a.name.localeCompare(b.name))), }); // Archive mutation diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index 72fb0580a..3b5b59fcc 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -69,6 +69,7 @@ import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDu import { getCurrencySymbol } from '../utils/currency'; import { getBedTypeInfo } from '../utils/bedType'; import { invalidateArchiveAndProjectViews } from '../utils/projectQueries'; +import { assignableProjects } from '../utils/projectTree'; import { usePageFileDrop } from '../hooks/usePageFileDrop'; import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client'; import { Card, CardContent } from '../components/Card'; @@ -767,7 +768,7 @@ function ArchiveCard({ onClick: () => {}, disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, - submenuSearchPlaceholder: (projects?.filter(p => p.status === 'active').length ?? 0) > 5 + submenuSearchPlaceholder: assignableProjects(projects ?? [], archive.project_id).length > 5 ? t('archives.menu.searchProjects') : undefined, submenu: (() => { @@ -792,10 +793,14 @@ function ArchiveCard({ disabled: true, }); } else { - const activeProjects = projects - .filter(p => p.status === 'active') + // Archived projects are put away on purpose and are left out; + // completed ones stay, since a reprint filed against a finished + // project is ordinary (#2888). The archive's own project is kept + // whatever its status -- it is disabled below, and dropping it + // would leave the menu unable to say where the archive already is. + const assignable = assignableProjects(projects, archive.project_id) .sort((a, b) => a.name.localeCompare(b.name)); - if (activeProjects.length === 0) { + if (assignable.length === 0) { items.push({ label: t('archives.menu.noProjectsAvailable'), icon: , @@ -803,7 +808,7 @@ function ArchiveCard({ disabled: true, }); } else { - activeProjects.forEach(p => { + assignable.forEach(p => { items.push({ label: p.name, icon:
, @@ -2156,7 +2161,7 @@ function ArchiveListRow({ label: t('archives.menu.addToProject'), icon: , onClick: () => {}, - submenuSearchPlaceholder: (projects?.filter(p => p.status === 'active').length ?? 0) > 5 + submenuSearchPlaceholder: assignableProjects(projects ?? [], archive.project_id).length > 5 ? t('archives.menu.searchProjects') : undefined, submenu: (() => { @@ -2176,10 +2181,14 @@ function ArchiveListRow({ disabled: true, }); } else { - const activeProjects = projects - .filter(p => p.status === 'active') + // Archived projects are put away on purpose and are left out; + // completed ones stay, since a reprint filed against a finished + // project is ordinary (#2888). The archive's own project is kept + // whatever its status -- it is disabled below, and dropping it + // would leave the menu unable to say where the archive already is. + const assignable = assignableProjects(projects, archive.project_id) .sort((a, b) => a.name.localeCompare(b.name)); - if (activeProjects.length === 0) { + if (assignable.length === 0) { items.push({ label: t('archives.menu.noProjectsAvailable'), icon: , @@ -2187,7 +2196,7 @@ function ArchiveListRow({ disabled: true, }); } else { - activeProjects.forEach(p => { + assignable.forEach(p => { items.push({ label: p.name, icon:
, diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index 9d4f39ada..825e7d1b0 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -74,6 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop'; import { useAuth } from '../contexts/AuthContext'; import { formatDuration, parseUTCDate, formatDate } from '../utils/date'; import { formatFileSize } from '../utils/file'; +import { assignableProjects } from '../utils/projectTree'; import { isApiSliceableFilename, isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer'; type SortField = 'name' | 'date' | 'size' | 'type' | 'prints'; @@ -404,10 +405,14 @@ function LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderMo if (folder.archive_id) setLinkType('archive'); }); + // Archived projects are left out, bar the one this folder is already linked + // to -- dropping that one would leave the folder looking unlinked while the + // link is still there (#2888). const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: () => api.getProjects(), - select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)), + select: (rows) => + assignableProjects([...rows].sort((a, b) => a.name.localeCompare(b.name)), folder.project_id), }); const { data: archives } = useQuery({ diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx index ce42503a2..e6132ba50 100644 --- a/frontend/src/pages/ProjectsPage.tsx +++ b/frontend/src/pages/ProjectsPage.tsx @@ -1177,8 +1177,12 @@ export function ProjectsPage() { } }; - // Count projects by status for filter badges - const projectCounts = projects?.reduce((acc, p) => { + // Count projects by status for filter badges. Counted from the unfiltered + // list, not the one on screen: `projects` holds only the selected status, so + // every other tab counted zero and lost its badge entirely -- a fleet of + // thirty finished projects showed "Completed" bare while Active read 5 + // (#2888). + const projectCounts = allProjects?.reduce((acc, p) => { acc[p.status] = (acc[p.status] || 0) + 1; acc.all = (acc.all || 0) + 1; return acc; diff --git a/frontend/src/utils/projectTree.ts b/frontend/src/utils/projectTree.ts index 381ffdaf9..91fd1933f 100644 --- a/frontend/src/utils/projectTree.ts +++ b/frontend/src/utils/projectTree.ts @@ -28,3 +28,26 @@ export function eligibleParents( } return projects.filter((p) => !blocked.has(p.id)); } + +/** + * Projects a picker should offer when filing something away (#2888). + * + * An archived project is one its owner has explicitly put out of the way, so + * leaving it in a picker only lengthens a list they then have to search -- + * the reporter had five active projects behind thirty-odd finished ones. + * Completed projects stay: filing a reprint against a finished project is + * ordinary, and "completed" says the work is done, not that it should be + * hidden. + * + * `keepId` names one project that survives whatever its status -- the one the + * thing being edited already belongs to. Without it a controlled ` without a
`);let s=e.getAttribute(`formaction`)||o.getAttribute(`action`);if(r=s?_n(s,t):null,n=e.getAttribute(`formmethod`)||o.getAttribute(`method`)||Vr,i=ei(e.getAttribute(`formenctype`))||ei(o.getAttribute(`enctype`))||Hr,a=new FormData(o,e),!Qr()){let{name:t,type:n,value:r}=e;if(n===`image`){let e=t?`${t}.`:``;a.append(`${e}x`,`0`),a.append(`${e}y`,`0`)}else t&&a.append(t,r)}}else if(Ur(e))throw Error(`Cannot submit element that is not ,