Explorar o código

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.
maziggy hai 1 semana
pai
achega
2b3fe10a64

+ 189 - 0
frontend/src/__tests__/pages/ProjectsPage.test.tsx

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 - 0
frontend/src/i18n/locales/es.ts

@@ -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 - 0
frontend/src/i18n/locales/fr.ts

@@ -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 - 0
frontend/src/i18n/locales/it.ts

@@ -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 - 0
frontend/src/i18n/locales/ja.ts

@@ -4039,6 +4039,8 @@ export default {
     partOf: '{{name}} の一部',
     subProjectCount: 'サブプロジェクト {{count}} 件',
     subProjectsOf: '{{name}} のサブプロジェクト',
+    collapseSubProjects: 'サブプロジェクトを折りたたむ',
+    expandSubProjects: 'サブプロジェクトを展開',
     title: 'プロジェクト',
     subtitle: '印刷プロジェクトを管理',
     newProject: '新規プロジェクト',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -3846,6 +3846,8 @@ export default {
     partOf: '{{name}}의 일부',
     subProjectCount: '하위 프로젝트 {{count}}개',
     subProjectsOf: '{{name}}의 하위 프로젝트',
+    collapseSubProjects: '하위 프로젝트 접기',
+    expandSubProjects: '하위 프로젝트 펼치기',
     title: '프로젝트',
     subtitle: '3D 인쇄 프로젝트 정리 및 추적',
     newProject: '새 프로젝트',

+ 2 - 0
frontend/src/i18n/locales/nl.ts

@@ -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 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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 - 0
frontend/src/i18n/locales/ru.ts

@@ -3838,6 +3838,8 @@ export default {
     partOf: "Часть проекта {{name}}",
     subProjectCount: "Подпроектов: {{count}}",
     subProjectsOf: "Подпроекты проекта {{name}}",
+    collapseSubProjects: "Свернуть подпроекты",
+    expandSubProjects: "Развернуть подпроекты",
     title: "Проекты",
     subtitle: "Организация и отслеживание проектов 3D-печати",
     newProject: "Новый проект",

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -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 - 0
frontend/src/i18n/locales/uk.ts

@@ -4067,6 +4067,8 @@ export default {
     partOf: "Частина проєкту {{name}}",
     subProjectCount: "Підпроєктів: {{count}}",
     subProjectsOf: "Підпроєкти проєкту {{name}}",
+    collapseSubProjects: "Згорнути підпроєкти",
+    expandSubProjects: "Розгорнути підпроєкти",
     title: "Проєкти",
     subtitle: "Організовуйте та відстежуйте свої проєкти 3D-друку",
     newProject: "Новий проєкт",

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4027,6 +4027,8 @@ export default {
     partOf: '属于 {{name}}',
     subProjectCount: '{{count}} 个子项目',
     subProjectsOf: '{{name}} 的子项目',
+    collapseSubProjects: '折叠子项目',
+    expandSubProjects: '展开子项目',
     title: '项目',
     subtitle: '组织和跟踪您的 3D 打印项目',
     newProject: '新建项目',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4027,6 +4027,8 @@ export default {
     partOf: '屬於 {{name}}',
     subProjectCount: '{{count}} 個子專案',
     subProjectsOf: '{{name}} 的子專案',
+    collapseSubProjects: '摺疊子專案',
+    expandSubProjects: '展開子專案',
     title: '專案',
     subtitle: '組織和追蹤您的 3D 列印專案',
     newProject: '新增專案',

+ 101 - 31
frontend/src/pages/ProjectsPage.tsx

@@ -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 */}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/index-CexGkv-b.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-Cm_Xn_Pf.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 0
static/assets/index-DR-aOvsI.css


+ 2 - 2
static/index.html

@@ -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>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio