Let sub-project groups be collapsed on the Projects page (issue #2991)

A project with sub-projects drew every one of them expanded, at every
    level, with nothing to shut. Over a three-level hierarchy and a couple of
    hundred archives that makes the page one long scroll.

    Each group's caption is now a chevron that folds that group, and a
    Collapse pill next to the status filter tabs sets the default for the
    page and is remembered across reloads. A shut group takes one grid cell
    rather than a full-width row, so folding a deep tree actually gets the
    page back.

    The count on the caption is of the cards nested there, not the parent
    card's badge: the API counts sub-projects across every status on purpose,
    so under the Active filter the badge can legitimately say 2 where one
    card unfolds. A count that disagrees with what unfolds is worse than no
    count at all.
This commit is contained in:
maziggy
2026-08-29 14:20:42 +02:00
20 changed files with 337 additions and 50 deletions
@@ -705,5 +705,194 @@ describe('ProjectsPage', () => {
// Flattening every descendant onto the root would lose the depth.
expect(wingGroup!.textContent).not.toContain('Airframe');
});
describe('folding sub-project groups away (#2991)', () => {
// localStorage is globally mocked in setup.ts, so each test programs the
// preference it wants explicitly.
const getItemMock = localStorage.getItem as ReturnType<typeof vi.fn>;
const setItemMock = localStorage.setItem as ReturnType<typeof vi.fn>;
beforeEach(() => {
getItemMock.mockReset();
setItemMock.mockReset();
});
// The mock is module-global: an implementation left behind here would
// silently collapse the groups of every later test.
afterEach(() => {
getItemMock.mockReset();
setItemMock.mockReset();
});
const serveAirframe = () =>
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
listItem({ id: 1, name: 'Airframe', child_count: 1 }),
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
]),
),
);
it('leaves groups open when no preference has been stored', async () => {
getItemMock.mockReturnValue(null);
serveAirframe();
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText('Sub-projects of Airframe')).toBeInTheDocument();
});
// The page behaved this way before the toggle existed, and an upgrade
// that hides half of somebody's projects is not a fix.
expect(screen.getByText('Wing')).toBeInTheDocument();
});
it('opens with the groups shut when that is what was stored', async () => {
getItemMock.mockImplementation((key: string) =>
key === 'projects-collapse-subprojects' ? 'true' : null,
);
serveAirframe();
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText('Sub-projects of Airframe')).toBeInTheDocument();
});
// The caption stays — it is the way back in, and it says what is behind
// it. Only the cards go.
expect(screen.queryByText('Wing')).not.toBeInTheDocument();
});
it('shuts one group from its own caption, leaving the others alone', async () => {
getItemMock.mockReturnValue(null);
server.use(
http.get('/api/v1/projects/', () =>
HttpResponse.json([
listItem({ id: 1, name: 'Airframe', child_count: 1 }),
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
listItem({ id: 3, name: 'Fuselage', child_count: 1 }),
listItem({ id: 4, name: 'Bulkhead', parent_id: 3 }),
]),
),
);
const user = userEvent.setup();
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText('Wing')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /Sub-projects of Airframe/ }));
await waitFor(() => {
expect(screen.queryByText('Wing')).not.toBeInTheDocument();
});
expect(screen.getByText('Bulkhead')).toBeInTheDocument();
// A per-group chevron is a passing choice, not a new default for the
// whole page.
expect(setItemMock).not.toHaveBeenCalledWith('projects-collapse-subprojects', 'true');
});
it('remembers the default the pill sets', async () => {
getItemMock.mockReturnValue(null);
const user = userEvent.setup();
serveAirframe();
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText('Wing')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'Collapse' }));
await waitFor(() => {
expect(screen.queryByText('Wing')).not.toBeInTheDocument();
});
expect(setItemMock).toHaveBeenCalledWith('projects-collapse-subprojects', 'true');
});
it('drops per-group choices when the default is flipped against them', async () => {
getItemMock.mockReturnValue(null);
const user = userEvent.setup();
serveAirframe();
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText('Wing')).toBeInTheDocument();
});
// Shut this one by hand, then ask for everything shut, then everything
// open again. The hand-made choice was made against the old default,
// and honouring it now would leave a group defying the pill the user
// just pressed.
await user.click(screen.getByRole('button', { name: /Sub-projects of Airframe/ }));
await waitFor(() => {
expect(screen.queryByText('Wing')).not.toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'Collapse' }));
await user.click(screen.getByRole('button', { name: 'Collapse' }));
await waitFor(() => {
expect(screen.getByText('Wing')).toBeInTheDocument();
});
});
it('counts what the chevron actually unfolds, not the card badge', async () => {
// The API counts sub-projects across every status on purpose, so the
// card badge says 2 while the Active filter leaves one child to show.
// A caption repeating the badge would promise a card that is not there.
getItemMock.mockImplementation((key: string) =>
key === 'projects-collapse-subprojects' ? 'true' : null,
);
server.use(
http.get('/api/v1/projects/', ({ request }) => {
const status = new URL(request.url).searchParams.get('status');
const all = [
listItem({ id: 1, name: 'Airframe', child_count: 2 }),
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
listItem({ id: 3, name: 'Tailplane', parent_id: 1, status: 'archived' }),
];
return HttpResponse.json(status ? all.filter((p) => p.status === status) : all);
}),
);
render(<ProjectsPage />);
const caption = await screen.findByRole('button', { name: /Sub-projects of Airframe/ });
expect(caption.textContent).toContain('1');
expect(caption.textContent).not.toContain('2');
});
it('gives a shut group one grid cell instead of a whole row', async () => {
// Folding a tree that still spends a full-width row per parent halves
// the scrolling at best; the point is to get the page back.
getItemMock.mockImplementation((key: string) =>
key === 'projects-collapse-subprojects' ? 'true' : null,
);
serveAirframe();
render(<ProjectsPage />);
const caption = await screen.findByRole('button', { name: /Sub-projects of Airframe/ });
expect(caption.closest('.col-span-full')).toBeNull();
});
it('offers no pill on a page where nothing is nested', async () => {
getItemMock.mockReturnValue(null);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
});
// A control that cannot change anything is just one more thing to read.
expect(screen.queryByRole('button', { name: 'Collapse' })).not.toBeInTheDocument();
});
});
});
});
+2
View File
@@ -4039,6 +4039,8 @@ export default {
partOf: 'Teil von {{name}}',
subProjectCount: '{{count}} Unterprojekte',
subProjectsOf: 'Unterprojekte von {{name}}',
collapseSubProjects: 'Unterprojekte einklappen',
expandSubProjects: 'Unterprojekte ausklappen',
title: 'Projekte',
subtitle: 'Organisieren und verfolgen Sie Ihre 3D-Druckprojekte',
newProject: 'Neues Projekt',
+2
View File
@@ -4069,6 +4069,8 @@ export default {
partOf: 'Part of {{name}}',
subProjectCount: '{{count}} sub-projects',
subProjectsOf: 'Sub-projects of {{name}}',
collapseSubProjects: 'Collapse sub-projects',
expandSubProjects: 'Expand sub-projects',
title: 'Projects',
subtitle: 'Organize and track your 3D printing projects',
newProject: 'New Project',
+2
View File
@@ -4041,6 +4041,8 @@ export default {
partOf: 'Parte de {{name}}',
subProjectCount: '{{count}} subproyectos',
subProjectsOf: 'Subproyectos de {{name}}',
collapseSubProjects: 'Contraer subproyectos',
expandSubProjects: 'Expandir subproyectos',
title: 'Proyectos',
subtitle: 'Organice y haga el seguimiento de sus proyectos de impresión 3D',
newProject: 'Nuevo proyecto',
+2
View File
@@ -4028,6 +4028,8 @@ export default {
partOf: 'Fait partie de {{name}}',
subProjectCount: '{{count}} sous-projets',
subProjectsOf: 'Sous-projets de {{name}}',
collapseSubProjects: 'Réduire les sous-projets',
expandSubProjects: 'Développer les sous-projets',
title: 'Projets',
subtitle: 'Suivez vos projets d\'impression 3D',
newProject: 'Nouveau Projet',
+2
View File
@@ -4027,6 +4027,8 @@ export default {
partOf: 'Parte di {{name}}',
subProjectCount: '{{count}} sotto-progetti',
subProjectsOf: 'Sotto-progetti di {{name}}',
collapseSubProjects: 'Comprimi sotto-progetti',
expandSubProjects: 'Espandi sotto-progetti',
title: 'Progetti',
subtitle: 'Organizza e traccia i tuoi progetti di stampa 3D',
newProject: 'Nuovo progetto',
+2
View File
@@ -4039,6 +4039,8 @@ export default {
partOf: '{{name}} の一部',
subProjectCount: 'サブプロジェクト {{count}} 件',
subProjectsOf: '{{name}} のサブプロジェクト',
collapseSubProjects: 'サブプロジェクトを折りたたむ',
expandSubProjects: 'サブプロジェクトを展開',
title: 'プロジェクト',
subtitle: '印刷プロジェクトを管理',
newProject: '新規プロジェクト',
+2
View File
@@ -3846,6 +3846,8 @@ export default {
partOf: '{{name}}의 일부',
subProjectCount: '하위 프로젝트 {{count}}개',
subProjectsOf: '{{name}}의 하위 프로젝트',
collapseSubProjects: '하위 프로젝트 접기',
expandSubProjects: '하위 프로젝트 펼치기',
title: '프로젝트',
subtitle: '3D 인쇄 프로젝트 정리 및 추적',
newProject: '새 프로젝트',
+2
View File
@@ -4069,6 +4069,8 @@ export default {
partOf: 'Onderdeel van {{name}}',
subProjectCount: '{{count}} subprojecten',
subProjectsOf: 'Subprojecten van {{name}}',
collapseSubProjects: 'Subprojecten inklappen',
expandSubProjects: 'Subprojecten uitklappen',
title: 'Projecten',
subtitle: 'Organiseer en volg je 3D-printprojecten',
newProject: 'Nieuw project',
+2
View File
@@ -4027,6 +4027,8 @@ export default {
partOf: 'Parte de {{name}}',
subProjectCount: '{{count}} sub-projetos',
subProjectsOf: 'Sub-projetos de {{name}}',
collapseSubProjects: 'Recolher sub-projetos',
expandSubProjects: 'Expandir sub-projetos',
title: 'Projetos',
subtitle: 'Organize e acompanhe seus projetos de impressão 3D',
newProject: 'Novo Projeto',
+2
View File
@@ -3838,6 +3838,8 @@ export default {
partOf: "Часть проекта {{name}}",
subProjectCount: "Подпроектов: {{count}}",
subProjectsOf: "Подпроекты проекта {{name}}",
collapseSubProjects: "Свернуть подпроекты",
expandSubProjects: "Развернуть подпроекты",
title: "Проекты",
subtitle: "Организация и отслеживание проектов 3D-печати",
newProject: "Новый проект",
+2
View File
@@ -4034,6 +4034,8 @@ export default {
partOf: '{{name}} projesinin parçası',
subProjectCount: '{{count}} alt proje',
subProjectsOf: '{{name}} alt projeleri',
collapseSubProjects: 'Alt projeleri daralt',
expandSubProjects: 'Alt projeleri genişlet',
title: 'Projeler',
subtitle: '3D baskı projelerinizi organize edin ve takip edin',
newProject: 'Yeni Proje',
+2
View File
@@ -4067,6 +4067,8 @@ export default {
partOf: "Частина проєкту {{name}}",
subProjectCount: "Підпроєктів: {{count}}",
subProjectsOf: "Підпроєкти проєкту {{name}}",
collapseSubProjects: "Згорнути підпроєкти",
expandSubProjects: "Розгорнути підпроєкти",
title: "Проєкти",
subtitle: "Організовуйте та відстежуйте свої проєкти 3D-друку",
newProject: "Новий проєкт",
+2
View File
@@ -4027,6 +4027,8 @@ export default {
partOf: '属于 {{name}}',
subProjectCount: '{{count}} 个子项目',
subProjectsOf: '{{name}} 的子项目',
collapseSubProjects: '折叠子项目',
expandSubProjects: '展开子项目',
title: '项目',
subtitle: '组织和跟踪您的 3D 打印项目',
newProject: '新建项目',
+2
View File
@@ -4027,6 +4027,8 @@ export default {
partOf: '屬於 {{name}}',
subProjectCount: '{{count}} 個子專案',
subProjectsOf: '{{name}} 的子專案',
collapseSubProjects: '摺疊子專案',
expandSubProjects: '展開子專案',
title: '專案',
subtitle: '組織和追蹤您的 3D 列印專案',
newProject: '新增專案',
+101 -31
View File
@@ -17,6 +17,7 @@ import {
Clock,
CheckCircle2,
AlertTriangle,
ChevronDown,
ChevronRight,
MoreVertical,
Download,
@@ -930,6 +931,26 @@ export function ProjectsPage() {
const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
const [statusFilter, setStatusFilter] = useState<string>('active');
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
// Sub-project groups fold away (#2991). Every sub-project at every depth was
// drawn expanded with no way to shut one, so a three-level hierarchy over a
// couple of hundred archives turned this page into a very long scroll. The
// pill next to the filter tabs sets the default and is remembered; the
// chevron on a group deviates from it for that one parent.
const [collapseSubProjects, setCollapseSubProjects] = useState(
() => localStorage.getItem('projects-collapse-subprojects') === 'true',
);
const [subProjectOverrides, setSubProjectOverrides] = useState<Record<number, boolean>>({});
const toggleCollapseDefault = () => {
const next = !collapseSubProjects;
setCollapseSubProjects(next);
// Every deviation was made against the old default, so keeping them would
// leave groups sitting open right after the user asked for everything
// shut. Same bargain the file manager's folder tree makes, where flipping
// its Collapse toggle remounts the tree and drops each folder's own state.
setSubProjectOverrides({});
localStorage.setItem('projects-collapse-subprojects', String(next));
};
const { data: settings } = useQuery({
queryKey: ['settings'],
@@ -1014,20 +1035,49 @@ export function ProjectsPage() {
const children = (childrenByParent.get(project.id) || []).filter((c) => !descended.has(c.id));
if (children.length === 0) return <div key={project.id}>{card}</div>;
const expanded = subProjectOverrides[project.id] ?? !collapseSubProjects;
return (
<div key={project.id} className="col-span-full space-y-4">
// A shut group is a card like any other, so it takes one grid cell
// instead of a whole row of its own — folding a deep tree that still
// spent a full-width row per parent would only halve the scrolling.
<div key={project.id} className={expanded ? 'col-span-full space-y-4' : 'space-y-4'}>
{card}
<div
className="ml-4 md:ml-8 pl-4 md:pl-6 border-l-2 rounded-l space-y-4"
style={{ borderColor: project.color || '#6b7280' }}
>
<p className="text-xs uppercase tracking-wide text-bambu-gray flex items-center gap-1.5">
<FolderTree className="w-3.5 h-3.5" />
{t('projects.subProjectsOf', { name: project.name })}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{children.map((child) => renderProjectTree(child, depth + 1, descended))}
</div>
{/* The caption is the toggle rather than the card above it: clicking
a card opens the project, and taking that over would surprise
every user who has no sub-projects to fold. */}
<button
type="button"
onClick={() => setSubProjectOverrides((prev) => ({ ...prev, [project.id]: !expanded }))}
aria-expanded={expanded}
title={expanded ? t('projects.collapseSubProjects') : t('projects.expandSubProjects')}
className="text-xs uppercase tracking-wide text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5 max-w-full min-w-0"
>
{expanded
? <ChevronDown className="w-3.5 h-3.5 flex-shrink-0" />
: <ChevronRight className="w-3.5 h-3.5 flex-shrink-0" />}
<FolderTree className="w-3.5 h-3.5 flex-shrink-0" />
{/* Truncated because a shut group lives in one grid column, where
a long project name would otherwise run out of the card. */}
<span className="truncate">{t('projects.subProjectsOf', { name: project.name })}</span>
{/* Counted from what is actually nested here, not from the card's
own badge: the API counts sub-projects across every status on
purpose, so that badge can say 2 where only one card is behind
this chevron. A count that disagrees with what unfolds is
worse than no count. */}
<span className="px-1.5 py-0.5 rounded-full bg-bambu-dark normal-case tracking-normal flex-shrink-0">
{children.length}
</span>
</button>
{expanded && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{children.map((child) => renderProjectTree(child, depth + 1, descended))}
</div>
)}
</div>
</div>
);
@@ -1242,33 +1292,53 @@ export function ProjectsPage() {
</div>
{/* Filter tabs */}
<div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
{[
{ key: 'active', label: t('projects.statusActive'), icon: Clock },
{ key: 'completed', label: t('projects.statusCompleted'), icon: CheckCircle2 },
{ key: 'archived', label: t('projects.statusArchived'), icon: Archive },
{ key: 'all', label: t('common.all'), icon: FolderKanban },
].map(({ key, label, icon: Icon }) => (
<div className="flex flex-wrap items-center gap-3">
<div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
{[
{ key: 'active', label: t('projects.statusActive'), icon: Clock },
{ key: 'completed', label: t('projects.statusCompleted'), icon: CheckCircle2 },
{ key: 'archived', label: t('projects.statusArchived'), icon: Archive },
{ key: 'all', label: t('common.all'), icon: FolderKanban },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setStatusFilter(key)}
className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${
statusFilter === key
? 'bg-bambu-card text-white shadow-sm'
: 'text-bambu-gray hover:text-white'
}`}
>
<Icon className="w-4 h-4" />
<span>{label}</span>
{projectCounts[key] > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'
}`}>
{projectCounts[key]}
</span>
)}
</button>
))}
</div>
{/* Only where something can actually be folded -- with no nesting under
the current filter this would be a control that does nothing. */}
{childrenByParent.size > 0 && (
<button
key={key}
onClick={() => setStatusFilter(key)}
className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${
statusFilter === key
? 'bg-bambu-card text-white shadow-sm'
: 'text-bambu-gray hover:text-white'
type="button"
onClick={toggleCollapseDefault}
aria-pressed={collapseSubProjects}
title={collapseSubProjects ? t('projects.expandSubProjects') : t('projects.collapseSubProjects')}
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg transition-colors ${
collapseSubProjects
? 'bg-bambu-green/20 text-bambu-green'
: 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
}`}
>
<Icon className="w-4 h-4" />
<span>{label}</span>
{projectCounts[key] > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'
}`}>
{projectCounts[key]}
</span>
)}
<FolderTree className="w-4 h-4" />
{t('common.collapse')}
</button>
))}
)}
</div>
{/* Content */}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-YOSBC2S2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CexGkv-b.css">
<script type="module" crossorigin src="/assets/index-Cm_Xn_Pf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DR-aOvsI.css">
</head>
<body>
<div id="root"></div>