diff --git a/app/workbench/src/index.ts b/app/workbench/src/index.ts index 9ea351c73..d5f1a1866 100644 --- a/app/workbench/src/index.ts +++ b/app/workbench/src/index.ts @@ -166,7 +166,6 @@ export type { ProjectArtifact, ProjectFeedItem, ProjectRunStatus, - ProjectFilter, ProjectTab, SettingsPageProps, SettingsPaneId, diff --git a/app/workbench/src/pages/ProjectsPage.test.tsx b/app/workbench/src/pages/ProjectsPage.test.tsx index 30130cb93..23e41bd28 100644 --- a/app/workbench/src/pages/ProjectsPage.test.tsx +++ b/app/workbench/src/pages/ProjectsPage.test.tsx @@ -19,8 +19,6 @@ function baseProps(overrides: Partial = {}): ProjectsPageProp projects: [], activeProjectId: null, onProjectSelect: () => undefined, - activeFilter: 'all', - onFilterChange: () => undefined, activeTab: 'overview', onTabChange: () => undefined, ...overrides, diff --git a/app/workbench/src/pages/ProjectsPage.tsx b/app/workbench/src/pages/ProjectsPage.tsx index dc69ec47c..c2a1e9595 100644 --- a/app/workbench/src/pages/ProjectsPage.tsx +++ b/app/workbench/src/pages/ProjectsPage.tsx @@ -26,7 +26,6 @@ export type { ProjectFeedItem, ProjectInfo, ProjectDraft, - ProjectFilter, ProjectTab, ProjectsPageProps, } from './projects'; @@ -43,9 +42,6 @@ export function ProjectsPage({ onProjectSelect, searchQuery = '', onSearchChange, - activeFilter, - onFilterChange, - availableFilters, activeTab, onTabChange, onNewProject, @@ -102,9 +98,6 @@ export function ProjectsPage({ onProjectSelect={onProjectSelect} searchQuery={searchQuery} onSearchChange={onSearchChange} - activeFilter={activeFilter} - onFilterChange={onFilterChange} - {...(availableFilters ? { availableFilters } : {})} canCreateProject={canCreateProject} onStartCreate={startProjectCreate} /> diff --git a/app/workbench/src/pages/index.ts b/app/workbench/src/pages/index.ts index c8f6a28bf..bcb1619cc 100644 --- a/app/workbench/src/pages/index.ts +++ b/app/workbench/src/pages/index.ts @@ -40,7 +40,6 @@ export type { ProjectArtifact, ProjectFeedItem, ProjectRunStatus, - ProjectFilter, ProjectTab, } from './ProjectsPage'; diff --git a/app/workbench/src/pages/projects/ProjectChromeViews.tsx b/app/workbench/src/pages/projects/ProjectChromeViews.tsx index a84acf1db..c077a7a47 100644 --- a/app/workbench/src/pages/projects/ProjectChromeViews.tsx +++ b/app/workbench/src/pages/projects/ProjectChromeViews.tsx @@ -16,11 +16,10 @@ import { import styles from '../ProjectsPage.module.css'; import type { ProjectDraft, - ProjectFilter, ProjectInfo, ProjectTab, } from './types'; -import { FILTER_ITEMS, TAB_ITEMS } from './types'; +import { TAB_ITEMS } from './types'; export function ProjectNavRow({ project, @@ -51,52 +50,6 @@ export function ProjectNavRow({ ); } -export function FilterList({ - activeFilter, - onFilterChange, - availableFilters, -}: { - activeFilter: ProjectFilter; - onFilterChange: (filter: ProjectFilter) => void; - /** Filters the current data source can satisfy; absent = all available. */ - availableFilters?: readonly ProjectFilter[] | undefined; -}) { - const { t } = useTranslation(SHARED_WORKBENCH_I18N_NAMESPACE); - return ( -
- {FILTER_ITEMS.map((item) => { - // #2154 P2-3: `all` never filters anything out, so it stays clickable. - // A lifecycle chip is clickable only when at least one loaded project - // classifies into that bucket — an enabled chip whose click can only - // yield an empty list would replace the old decorative chip with a - // fresh false fact about the user's data. Hub exposes no project - // lifecycle field today (the web projection labels every project - // 'Hub'/'Hub group'), so on real web data all three render disabled - // with the reason in the title. - const available = item.id === 'all' - || availableFilters === undefined - || availableFilters.includes(item.id); - return ( - - ); - })} -
- ); -} - export function ProjectTabs({ activeTab, onTabChange, diff --git a/app/workbench/src/pages/projects/ProjectFilterAvailability.test.tsx b/app/workbench/src/pages/projects/ProjectFilterAvailability.test.tsx deleted file mode 100644 index 3a164e6c4..000000000 --- a/app/workbench/src/pages/projects/ProjectFilterAvailability.test.tsx +++ /dev/null @@ -1,187 +0,0 @@ -// real_tested=true -import React from 'react'; -import { act, renderHook } from '@testing-library/react'; -import { beforeAll, describe, expect, it } from 'vitest'; -import { render, screen } from '../../__tests__/setup'; -import { useTestI18nLanguage } from '@shared/testing/i18n'; -import { ProjectsPage } from '../ProjectsPage'; -import { useWorkbenchProjectsRoute } from '../../useWorkbenchProjectsRoute'; -import { buildProjectsPageProps } from '../../workbenchRoutesHelpers'; -import { filterProjectsByStatus } from './shared'; -import type { ProjectFilter, ProjectInfo } from './types'; - -/* ═══════════════════════════════════════════════════════════════════════ - Project filter chip availability (#2154 P2-3). - - Invariant under test — **no filter chip may be clickable when the current - data source can only ever produce an empty list for it.** Before this gate - the chips were decorative; making them filter without the availability gate - would have been worse, because an enabled chip that returns nothing reads as - a fact about the user's data ("I have no archived projects"). - - Rendered through the real path (route hook → buildProjectsPageProps → - ProjectsPage → ProjectNav → FilterList) so the invariant is pinned on the - wiring, not on one component in isolation. - ═══════════════════════════════════════════════════════════════════════ */ - -beforeAll(async () => { - await useTestI18nLanguage('zh'); -}); - -const UNAVAILABLE_TITLE = '当前数据源没有可归入该状态的项目(Hub 尚未提供项目生命周期字段)'; -const LIFECYCLE_FILTERS: ProjectFilter[] = ['running', 'completed', 'archived']; - -function project(id: string, status: string): ProjectInfo { - return { - id, - name: `Project ${id}`, - description: `${id} description`, - status, - meta: 'Hub project', - members: [], - announcement: '', - runs: [], - artifacts: [], - feed: [], - }; -} - -/** The three status shapes the production mappers actually emit. */ -const DATA_SHAPES: Record = { - // web/src/platform/webWorkbenchProjects.ts — status is a projection label, - // not a lifecycle state, so nothing can be classified. - 'web projection (Hub / Hub group)': [project('w1', 'Hub'), project('w2', 'Hub group')], - // workbench hubDataMapping / projects port — every project is 'Active'. - 'port + desktop projection (Active)': [project('a1', 'Active'), project('a2', 'Active')], - // demo data + any future Hub lifecycle field. - 'mixed lifecycle labels': [ - project('m1', 'Active'), - project('m2', '已完成'), - project('m3', '已归档'), - project('m4', 'Hub group'), - ], -}; - -function renderProjectsPage(projects: ProjectInfo[]) { - const { result } = renderHook(() => useWorkbenchProjectsRoute({ projects, realDataMode: true })); - const props = buildProjectsPageProps(result.current, []); - const rendered = render(); - return { ...rendered, route: result.current }; -} - -function chipsOf(container: HTMLElement): Array<{ filter: ProjectFilter; el: HTMLButtonElement }> { - return Array.from(container.querySelectorAll('[data-filter-id]')).map((el) => ({ - filter: el.getAttribute('data-filter-id') as ProjectFilter, - el, - })); -} - -describe('project filter chip availability (#2154 P2-3)', () => { - it('disables all three lifecycle chips for the web projection shape', () => { - const { container, route } = renderProjectsPage(DATA_SHAPES['web projection (Hub / Hub group)']); - - expect(route.availableProjectFilters).toEqual(['all']); - const chips = chipsOf(container); - expect(chips).toHaveLength(4); - - const all = chips.find((chip) => chip.filter === 'all'); - expect(all?.el).toBeEnabled(); - expect(all?.el).not.toHaveAttribute('title'); - - for (const filter of LIFECYCLE_FILTERS) { - const chip = chips.find((c) => c.filter === filter); - expect(chip?.el, `${filter} chip`).toBeDisabled(); - expect(chip?.el).toHaveAttribute('title', UNAVAILABLE_TITLE); - } - }); - - it('enables only 运行中 for the Active-only projection shape', () => { - const { container, route } = renderProjectsPage(DATA_SHAPES['port + desktop projection (Active)']); - - expect(route.availableProjectFilters).toEqual(['all', 'running']); - const chips = chipsOf(container); - expect(chips.find((c) => c.filter === 'all')?.el).toBeEnabled(); - expect(chips.find((c) => c.filter === 'running')?.el).toBeEnabled(); - for (const filter of ['completed', 'archived'] as ProjectFilter[]) { - expect(chips.find((c) => c.filter === filter)?.el).toBeDisabled(); - expect(chips.find((c) => c.filter === filter)?.el).toHaveAttribute('title', UNAVAILABLE_TITLE); - } - }); - - it('enables every chip when the data really spans all three buckets', () => { - const { container, route } = renderProjectsPage(DATA_SHAPES['mixed lifecycle labels']); - - expect(route.availableProjectFilters).toEqual(['all', 'running', 'completed', 'archived']); - for (const chip of chipsOf(container)) { - expect(chip.el, `${chip.filter} chip`).toBeEnabled(); - expect(chip.el).not.toHaveAttribute('title'); - } - }); - - it('still filters when an available chip is clicked', () => { - const projects = DATA_SHAPES['mixed lifecycle labels']; - const { container, route } = renderProjectsPage(projects); - - const archived = chipsOf(container).find((c) => c.filter === 'archived'); - expect(archived?.el).toBeEnabled(); - act(() => { - archived?.el.click(); - }); - - expect(route.sourceProjects).toHaveLength(4); - expect(filterProjectsByStatus(route.sourceProjects, 'archived').map((p) => p.id)).toEqual(['m3']); - // Selection/paging keep reading the unfiltered source list. - expect(route.projectId).toBe('m1'); - }); -}); - -/* The invariant, asserted mechanically for every data shape: walk the rendered - chips and require that each ENABLED one can actually match something. */ -describe.each(Object.entries(DATA_SHAPES))( - 'enabled-but-always-empty invariant — %s', - (shape, projects) => { - it('offers no clickable chip whose result must be empty', () => { - const { container } = renderProjectsPage(projects); - const chips = chipsOf(container); - expect(chips.length).toBe(4); - - const enabled = chips.filter((chip) => !chip.el.disabled); - expect(enabled.length, 'at least 全部 stays clickable').toBeGreaterThan(0); - - for (const { filter, el } of enabled) { - if (filter === 'all') { - // `all` never filters, so it is empty only when there is no data at all. - expect(projects.length, 'all chip on an empty data source').toBeGreaterThan(0); - continue; - } - const matches = filterProjectsByStatus(projects, filter); - expect( - matches.length, - `chip "${filter}" is enabled but matches nothing in shape "${shape}"`, - ).toBeGreaterThan(0); - // Disabled chips must carry the reason, enabled ones must not. - expect(el).not.toHaveAttribute('title'); - } - - for (const { filter, el } of chips.filter((chip) => chip.el.disabled)) { - expect(filter).not.toBe('all'); - expect(filterProjectsByStatus(projects, filter)).toEqual([]); - expect(el).toHaveAttribute('title', UNAVAILABLE_TITLE); - } - }); - }, -); - -describe('filter availability copy', () => { - it('keeps the 全部 chip label and the disabled reason localized', () => { - const { container } = renderProjectsPage(DATA_SHAPES['web projection (Hub / Hub group)']); - - expect(screen.getByRole('button', { name: '全部项目' })).toBeEnabled(); - // The reason is user copy, not an engineering id. - const disabled = chipsOf(container).filter((chip) => chip.el.disabled); - expect(disabled).toHaveLength(3); - for (const { el } of disabled) { - expect(el.getAttribute('title')).not.toMatch(/Hub'|'Hub group|bucket|null/); - } - }); -}); diff --git a/app/workbench/src/pages/projects/ProjectNav.test.tsx b/app/workbench/src/pages/projects/ProjectNav.test.tsx new file mode 100644 index 000000000..13a34bc22 --- /dev/null +++ b/app/workbench/src/pages/projects/ProjectNav.test.tsx @@ -0,0 +1,45 @@ +// #2274 B-6 (Option A): the project lifecycle filter (Running/Completed/Archived +// chips) is removed because Hub exposes no authoritative project lifecycle. Pin +// the behavior: Hub-style status labels are never classified/filtered, and the +// Projects nav renders no lifecycle filter controls. +import { render, screen } from "../../__tests__/setup"; +import { describe, expect, it } from "vitest"; +import type { ProjectInfo } from "./types"; +import { ProjectNav } from "./ProjectNav"; + +function project(id: string, status: string): ProjectInfo { + return { + id, + name: `Proj ${id}`, + description: `${id} description`, + status, + meta: "Hub", + members: [], + announcement: "", + runs: [], + artifacts: [], + feed: [], + }; +} + +describe("ProjectNav lifecycle filter removal (#2274 B-6)", () => { + it("shows Hub-style projects as rows and renders no Running/Completed/Archived filter controls", () => { + render( + undefined} + canCreateProject={false} + onStartCreate={() => undefined} + />, + ); + + expect(screen.getByText("Proj a")).toBeInTheDocument(); + expect(screen.getByText("Hub")).toBeInTheDocument(); + expect(screen.getByText("Proj b")).toBeInTheDocument(); + + // No lifecycle filter chips remain in the nav. + expect(document.querySelectorAll("[data-filter-id]")).toHaveLength(0); + expect(screen.queryByRole("button", { name: /Running|Completed|Archived/ })).toBeNull(); + }); +}); diff --git a/app/workbench/src/pages/projects/ProjectNav.tsx b/app/workbench/src/pages/projects/ProjectNav.tsx index c54159107..d1f763d62 100644 --- a/app/workbench/src/pages/projects/ProjectNav.tsx +++ b/app/workbench/src/pages/projects/ProjectNav.tsx @@ -11,8 +11,8 @@ import { SHARED_WORKBENCH_I18N_NAMESPACE } from '@shared/i18n'; import { StatusNotice } from '@shared/ui'; import { DesignNavIcon } from '../../designIcons'; import styles from '../ProjectsPage.module.css'; -import { FilterList, ProjectNavRow } from './ProjectChromeViews'; -import type { ProjectFilter, ProjectInfo } from './types'; +import { ProjectNavRow } from './ProjectChromeViews'; +import type { ProjectInfo } from './types'; export type ProjectNavProps = { projects: ProjectInfo[]; @@ -22,10 +22,6 @@ export type ProjectNavProps = { onProjectSelect: (projectId: string) => void; searchQuery?: string | undefined; onSearchChange?: ((query: string) => void) | undefined; - activeFilter: ProjectFilter; - onFilterChange: (filter: ProjectFilter) => void; - /** Filters the loaded projects can satisfy; others render disabled (#2154 P2-3). */ - availableFilters?: readonly ProjectFilter[] | undefined; canCreateProject: boolean; onStartCreate: () => void; }; @@ -38,9 +34,6 @@ export function ProjectNav({ onProjectSelect, searchQuery = '', onSearchChange, - activeFilter, - onFilterChange, - availableFilters, canCreateProject, onStartCreate, }: ProjectNavProps): React.ReactElement { @@ -97,12 +90,6 @@ export function ProjectNav({ onSelect={onProjectSelect} /> ))} -
{t('projects.view')}
- ); } diff --git a/app/workbench/src/pages/projects/index.ts b/app/workbench/src/pages/projects/index.ts index 4dbf1b3c1..4a323d796 100644 --- a/app/workbench/src/pages/projects/index.ts +++ b/app/workbench/src/pages/projects/index.ts @@ -2,7 +2,6 @@ export { ProjectNavRow, - FilterList, ProjectTabs, ProjectEditor, } from './ProjectChromeViews'; @@ -23,13 +22,8 @@ export { artifactTypeLabel, runCount, projectSubmitErrorMessage, - filterProjectsByStatus, - projectStatusBucket, - resolveAvailableProjectFilters, } from './shared'; -export type { ProjectStatusBucket } from './shared'; - -export { DEFAULT_PROJECTS, FILTER_ITEMS, TAB_ITEMS } from './types'; +export { DEFAULT_PROJECTS, TAB_ITEMS } from './types'; export type { ProjectRunStatus, @@ -38,7 +32,6 @@ export type { ProjectFeedItem, ProjectInfo, ProjectDraft, - ProjectFilter, ProjectTab, ProjectsPageProps, } from './types'; diff --git a/app/workbench/src/pages/projects/projectStatusFilters.test.ts b/app/workbench/src/pages/projects/projectStatusFilters.test.ts deleted file mode 100644 index 46e7b73ee..000000000 --- a/app/workbench/src/pages/projects/projectStatusFilters.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// real_tested=true -import { describe, expect, it } from 'vitest'; -import type { ProjectInfo } from './types'; -import { - filterProjectsByStatus, - projectStatusBucket, - resolveAvailableProjectFilters, -} from './shared'; - -/* ═══════════════════════════════════════════════════════════════════════ - Project status filtering (#2154 P2-3) — the nav filter chips used to be - decorative: clicking archived only moved the highlight, so users read an - unfiltered list as a fact about their data. - ═══════════════════════════════════════════════════════════════════════ */ - -function project(id: string, status?: string): ProjectInfo { - return { - id, - name: `Project ${id}`, - description: '', - ...(status !== undefined ? { status } : {}), - meta: '', - members: [], - announcement: '', - runs: [], - artifacts: [], - feed: [], - } as ProjectInfo; -} - -describe('projectStatusBucket', () => { - it('classifies the labels the Hub/demo mappers actually emit', () => { - expect(projectStatusBucket('Active')).toBe('running'); - expect(projectStatusBucket('running')).toBe('running'); - expect(projectStatusBucket('进行中')).toBe('running'); - expect(projectStatusBucket('研究中')).toBe('running'); - expect(projectStatusBucket('已完成')).toBe('completed'); - expect(projectStatusBucket('done')).toBe('completed'); - expect(projectStatusBucket('已归档')).toBe('archived'); - expect(projectStatusBucket('Archived')).toBe('archived'); - }); - - it('returns null for labels that are not lifecycle states', () => { - // The web projection emits Hub / Hub group, and demo data carries a - // pending-archive label — neither may be guessed into a bucket. - expect(projectStatusBucket('Hub')).toBeNull(); - expect(projectStatusBucket('Hub group')).toBeNull(); - expect(projectStatusBucket('待归档确认')).toBeNull(); - expect(projectStatusBucket('')).toBeNull(); - expect(projectStatusBucket(' ')).toBeNull(); - expect(projectStatusBucket(undefined)).toBeNull(); - }); -}); - -describe('filterProjectsByStatus', () => { - const projects = [ - project('run', 'Active'), - project('done', '已完成'), - project('old', '已归档'), - project('hub', 'Hub group'), - project('nostatus'), - ]; - - it('passes every project through for "all", including unclassifiable ones', () => { - const all = filterProjectsByStatus(projects, 'all'); - expect(all).toBe(projects); - expect(all.map((p) => p.id)).toEqual(['run', 'done', 'old', 'hub', 'nostatus']); - }); - - it('keeps only the matching bucket for a concrete filter', () => { - expect(filterProjectsByStatus(projects, 'running').map((p) => p.id)).toEqual(['run']); - expect(filterProjectsByStatus(projects, 'completed').map((p) => p.id)).toEqual(['done']); - expect(filterProjectsByStatus(projects, 'archived').map((p) => p.id)).toEqual(['old']); - }); - - it('never drops status-less projects from "all"', () => { - const statusless = [project('a'), project('b', '')]; - expect(filterProjectsByStatus(statusless, 'all')).toHaveLength(2); - // …and never invents a bucket for them either. - expect(filterProjectsByStatus(statusless, 'running')).toEqual([]); - }); - - it('returns an empty list when nothing matches', () => { - expect(filterProjectsByStatus([project('run', 'Active')], 'archived')).toEqual([]); - }); -}); - -/* #2154 P2-3 second half: an enabled chip that can only produce an empty list - is a worse lie than the decorative chip it replaced, so the route publishes - which buckets the loaded projects can actually be classified into. */ -describe('resolveAvailableProjectFilters', () => { - it('always keeps "all" available, even with no projects at all', () => { - expect(resolveAvailableProjectFilters([])).toEqual(['all']); - }); - - it('offers nothing but "all" for the web projection labels', () => { - const projects = [project('w1', 'Hub'), project('w2', 'Hub group')]; - expect(resolveAvailableProjectFilters(projects)).toEqual(['all']); - }); - - it('offers only running for the Active-only port/desktop projection', () => { - const projects = [project('a1', 'Active'), project('a2', 'Active')]; - expect(resolveAvailableProjectFilters(projects)).toEqual(['all', 'running']); - }); - - it('offers every lifecycle chip when the data really spans all buckets', () => { - const projects = [ - project('m1', '研究中'), - project('m2', '已完成'), - project('m3', '已归档'), - project('m4', 'Hub group'), - ]; - expect(resolveAvailableProjectFilters(projects)).toEqual([ - 'all', - 'running', - 'completed', - 'archived', - ]); - }); - - it('keeps FILTER_ITEMS order and never lists a bucket without a match', () => { - const projects = [project('x1', '已归档'), project('x2', 'Hub')]; - const available = resolveAvailableProjectFilters(projects); - expect(available).toEqual(['all', 'archived']); - for (const filter of available) { - if (filter === 'all') continue; - expect(filterProjectsByStatus(projects, filter).length).toBeGreaterThan(0); - } - }); -}); diff --git a/app/workbench/src/pages/projects/shared.tsx b/app/workbench/src/pages/projects/shared.tsx index fc6f7da56..4ccffbaa9 100644 --- a/app/workbench/src/pages/projects/shared.tsx +++ b/app/workbench/src/pages/projects/shared.tsx @@ -4,8 +4,7 @@ ═══════════════════════════════════════════════════════════════════════ */ import styles from '../ProjectsPage.module.css'; -import { FILTER_ITEMS } from './types'; -import type { ProjectFilter, ProjectInfo, ProjectRun, ProjectRunStatus } from './types'; +import type { ProjectRun, ProjectRunStatus } from './types'; export function stateDotClass(status: ProjectRunStatus): string { switch (status) { @@ -76,84 +75,3 @@ export function projectSubmitErrorMessage(error: unknown): string { if (typeof error === 'string' && error.trim()) return error; return 'Hub Projects 保存失败'; } - -/* ═══════════════════════════════════════════════════════════════════════ - Project status filtering (#2154 P2-3). - - The projects nav rendered four filter chips (FILTER_ITEMS) that only moved - the highlight — the list never changed, so an unfiltered list read as "you - have no archived projects". - - `ProjectInfo.status` is a display label rather than an enum: Hub workspace - projects arrive as 'Active' (workbench hubDataMapping) or 'Hub'/'Hub group' - (web projection) and demo data uses the Chinese labels above. Only labels we - can classify honestly join a bucket; everything else stays visible under - `all` and is never guessed into a lifecycle state we do not know. - ═══════════════════════════════════════════════════════════════════════ */ - -export type ProjectStatusBucket = Exclude; - -/** Exact (trimmed, case-insensitive) label → bucket map. Deliberately exact: - * a substring rule would file a pending-archive label under archived, which - * is a different fact. */ -const PROJECT_STATUS_BUCKETS: Record = { - active: 'running', - running: 'running', - 'in progress': 'running', - '进行中': 'running', - '运行中': 'running', - '研究中': 'running', - completed: 'completed', - complete: 'completed', - done: 'completed', - finished: 'completed', - '已完成': 'completed', - '完成': 'completed', - archived: 'archived', - '已归档': 'archived', -}; - -/** - * Resolve a project's lifecycle bucket from its status label. Null when the - * status is missing or is not a lifecycle state we know — such projects belong - * to `all` only. - */ -export function projectStatusBucket(status: string | undefined): ProjectStatusBucket | null { - const normalized = status?.trim().toLowerCase(); - if (!normalized) return null; - return PROJECT_STATUS_BUCKETS[normalized] ?? null; -} - -/** - * Apply the nav filter to a project list. `all` passes the input array through - * unchanged (referentially stable, nothing dropped); other buckets keep only - * the projects whose status label resolves to that bucket. - */ -export function filterProjectsByStatus( - projects: ProjectInfo[], - filter: ProjectFilter, -): ProjectInfo[] { - if (filter === 'all') return projects; - return projects.filter((project) => projectStatusBucket(project.status) === filter); -} - -/** - * Which filter chips the current data source can actually satisfy (#2154 P2-3). - * - * A lifecycle chip that is clickable but can only ever produce an empty list is - * a worse lie than the decorative chip it replaced: the user reads the empty - * result as a fact about their data ("I have no completed projects"). So the - * route publishes the buckets the loaded projects really classify into, and the - * nav disables every lifecycle chip outside that set. `all` is always - * available — it never filters anything out. - */ -export function resolveAvailableProjectFilters(projects: ProjectInfo[]): ProjectFilter[] { - const buckets = new Set(); - for (const project of projects) { - const bucket = projectStatusBucket(project.status); - if (bucket !== null) buckets.add(bucket); - } - return FILTER_ITEMS - .map((item) => item.id) - .filter((id) => id === 'all' || buckets.has(id as ProjectStatusBucket)); -} diff --git a/app/workbench/src/pages/projects/types.ts b/app/workbench/src/pages/projects/types.ts index b37d645c8..be1935399 100644 --- a/app/workbench/src/pages/projects/types.ts +++ b/app/workbench/src/pages/projects/types.ts @@ -70,8 +70,6 @@ export interface ProjectDraft { themeColor?: FolderThemeColor; } -export type ProjectFilter = 'all' | 'running' | 'completed' | 'archived'; - export type ProjectTab = 'overview' | 'runs' | 'artifacts' | 'archive' | 'settings'; export interface ProjectsPageProps { @@ -95,18 +93,6 @@ export interface ProjectsPageProps { /** Called when search input changes */ onSearchChange?: ((query: string) => void) | undefined; - /** Active filter in the left nav */ - activeFilter: ProjectFilter; - /** Called when user clicks a filter button */ - onFilterChange: (filter: ProjectFilter) => void; - /** - * Filters the current data source can actually satisfy (#2154 P2-3). Chips - * outside this list render disabled with an explanation instead of offering a - * click whose only possible outcome is an empty list. Absent = every filter - * is treated as available (presentational callers / fixtures). - */ - availableFilters?: readonly ProjectFilter[] | undefined; - /** Active tab in the detail view */ activeTab: ProjectTab; /** Called when user clicks a tab */ @@ -192,13 +178,6 @@ export const DEFAULT_PROJECTS: ProjectInfo[] = [ }, ]; -export const FILTER_ITEMS: { id: ProjectFilter; labelKey: string; icon: DesignNavIconName }[] = [ - { id: 'all', labelKey: 'projects.nav.all', icon: 'grid' }, - { id: 'running', labelKey: 'projects.nav.running', icon: 'running' }, - { id: 'completed', labelKey: 'projects.nav.completed', icon: 'done' }, - { id: 'archived', labelKey: 'projects.nav.archived', icon: 'archive' }, -]; - export const TAB_ITEMS: { id: ProjectTab; labelKey: string; icon: DesignNavIconName }[] = [ { id: 'overview', labelKey: 'projects.tab.overview', icon: 'home' }, { id: 'runs', labelKey: 'projects.projectRuns', icon: 'running' }, diff --git a/app/workbench/src/useWorkbenchProjectsRoute.test.ts b/app/workbench/src/useWorkbenchProjectsRoute.test.ts index ac9e71b18..ee3c1d604 100644 --- a/app/workbench/src/useWorkbenchProjectsRoute.test.ts +++ b/app/workbench/src/useWorkbenchProjectsRoute.test.ts @@ -7,7 +7,7 @@ import { } from './useWorkbenchProjectsRoute'; /* ═══════════════════════════════════════════════════════════════════════ - useWorkbenchProjectsRoute — 数据源解析与状态过滤。 + useWorkbenchProjectsRoute — 数据源解析。 项目数据只有两个来源:父级(两个 shell 各自用 react-query 拉 Hub workspace projects 后传 `projects`/`projectsStatus`/`projectsActions`),或 fixture @@ -84,105 +84,3 @@ describe('useWorkbenchProjectsRoute — 数据源解析', () => { expect(result.current.canMutateProject).toBe(true); }); }); - -describe('useWorkbenchProjectsRoute — status filter (#2154 P2-3)', () => { - it('filters the rendered list while leaving sourceProjects untouched', () => { - const { result } = renderHook(() => useWorkbenchProjectsRoute({ - projects: [project('p1'), project('p2')], - realDataMode: true, - })); - expect(result.current.sourceProjects).toHaveLength(2); - - // 'all' is a pass-through, including projects whose status we cannot - // classify (they must never be dropped by filtering). - expect(result.current.visibleProjects).toEqual(result.current.sourceProjects); - // The fixtures carry status 'Active' → only running is satisfiable. - expect(result.current.availableProjectFilters).toEqual(['all', 'running']); - - act(() => { - result.current.setProjectFilter('running'); - }); - expect(result.current.projectFilter).toBe('running'); - expect(result.current.visibleProjects.map((p) => p.id)).toEqual(['p1', 'p2']); - // Selection keeps reading the unfiltered source list. - expect(result.current.sourceProjects).toHaveLength(2); - expect(result.current.projectId).toBe('p1'); - - // A bucket this data source cannot satisfy is not selectable: the route - // falls straight back to 'all' instead of parking on a list that is empty - // by construction (#2154 P2-3 — no enabled-but-always-empty chip). - act(() => { - result.current.setProjectFilter('archived'); - }); - expect(result.current.projectFilter).toBe('all'); - expect(result.current.visibleProjects).toEqual(result.current.sourceProjects); - }); - - it('publishes which filters the loaded projects can satisfy (#2154 P2-3)', () => { - const { result, rerender } = renderHook( - ({ projects }: { projects: ProjectInfo[] }) => - useWorkbenchProjectsRoute({ projects, realDataMode: true }), - { - initialProps: { - projects: [{ ...project('m1'), status: 'Active' }, { ...project('m2'), status: '已归档' }], - }, - }, - ); - - expect(result.current.availableProjectFilters).toEqual(['all', 'running', 'archived']); - - // Data refresh drops every archived project → the bucket disappears. - rerender({ projects: [{ ...project('m1'), status: 'Active' }] }); - expect(result.current.availableProjectFilters).toEqual(['all', 'running']); - }); - - it('falls back to "all" when the selected bucket stops being available', () => { - const { result, rerender } = renderHook( - ({ projects }: { projects: ProjectInfo[] }) => - useWorkbenchProjectsRoute({ projects, realDataMode: true }), - { - initialProps: { - projects: [{ ...project('m1'), status: 'Active' }, { ...project('m2'), status: '已归档' }], - }, - }, - ); - - act(() => { - result.current.setProjectFilter('archived'); - }); - expect(result.current.projectFilter).toBe('archived'); - expect(result.current.visibleProjects.map((p) => p.id)).toEqual(['m2']); - - // The archived project disappears (delete / reload / paging): parking the - // user on an empty list is exactly the false fact this gate removes. - rerender({ projects: [{ ...project('m1'), status: 'Active' }] }); - - expect(result.current.projectFilter).toBe('all'); - expect(result.current.visibleProjects.map((p) => p.id)).toEqual(['m1']); - // Selection still reads the unfiltered source list. - expect(result.current.sourceProjects.map((p) => p.id)).toEqual(['m1']); - }); - - it('keeps parent-managed projects with an unknown status under "all" only', () => { - const managed = [ - { ...project('m1'), status: 'Hub group' }, - { ...project('m2'), status: '已归档' }, - ]; - const { result } = renderHook(() => useWorkbenchProjectsRoute({ - projects: managed, - realDataMode: true, - })); - - expect(result.current.visibleProjects).toHaveLength(2); - - act(() => { - result.current.setProjectFilter('archived'); - }); - expect(result.current.visibleProjects.map((p) => p.id)).toEqual(['m2']); - - act(() => { - result.current.setProjectFilter('all'); - }); - expect(result.current.visibleProjects.map((p) => p.id)).toEqual(['m1', 'm2']); - }); -}); diff --git a/app/workbench/src/useWorkbenchProjectsRoute.ts b/app/workbench/src/useWorkbenchProjectsRoute.ts index 9fa301b3c..423277df0 100644 --- a/app/workbench/src/useWorkbenchProjectsRoute.ts +++ b/app/workbench/src/useWorkbenchProjectsRoute.ts @@ -1,13 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { ProjectArtifact, ProjectDraft, - ProjectFilter, ProjectInfo, ProjectTab, } from './pages'; import { WORKBENCH_MOCK_PROJECTS } from './mockData'; -import { filterProjectsByStatus, resolveAvailableProjectFilters } from './pages/projects/shared'; import type { WorkbenchDocumentPreview } from './documentPreview'; import { createProjectArtifactPreview } from './workbenchProjectPreview'; @@ -33,24 +31,9 @@ export interface UseWorkbenchProjectsRouteOptions { export interface WorkbenchProjectsRoute { sourceProjects: ProjectInfo[]; - /** - * `sourceProjects` after the nav status filter (#2154 P2-3) — this is what - * the projects page renders. Selection keeps reading `sourceProjects` so - * filtering the list cannot drop the active project. - */ - visibleProjects: ProjectInfo[]; - /** - * Filter chips the loaded projects can actually satisfy (#2154 P2-3). Always - * contains 'all'; a lifecycle bucket appears only when at least one loaded - * project classifies into it, so the nav never offers a click whose only - * possible outcome is an empty list. - */ - availableProjectFilters: ProjectFilter[]; effectiveProjectsStatus: WorkbenchProjectsStatus | undefined; canMutateProject: boolean; projectId: string | null; - projectFilter: ProjectFilter; - setProjectFilter: (filter: ProjectFilter) => void; projectTab: ProjectTab; setProjectTab: (tab: ProjectTab) => void; projectPreview: WorkbenchDocumentPreview | null; @@ -89,27 +72,6 @@ export function useWorkbenchProjectsRoute({ ? activeProjectId : null; const projectId = controlledProjectId ?? localProjectId; - const [projectFilter, setProjectFilter] = useState('all'); - // #2154 P2-3: the filter chips used to only move the highlight. `all` is a - // pass-through (including projects whose status we cannot classify); the - // other buckets match on the status label via workbenchProjectFilters. - const visibleProjects = useMemo( - () => filterProjectsByStatus(sourceProjects, projectFilter), - [sourceProjects, projectFilter], - ); - const availableProjectFilters = useMemo( - () => resolveAvailableProjectFilters(sourceProjects), - [sourceProjects], - ); - - // The selected bucket can disappear when the data refreshes (page reload, the - // last archived project deleted). Fall back to `all` instead of parking the - // user on a list that is empty by construction (#2154 P2-3). - useEffect(() => { - if (projectFilter !== 'all' && !availableProjectFilters.includes(projectFilter)) { - setProjectFilter('all'); - } - }, [availableProjectFilters, projectFilter]); const [projectTab, setProjectTab] = useState('overview'); const [projectPreview, setProjectPreview] = useState(null); @@ -135,13 +97,9 @@ export function useWorkbenchProjectsRoute({ return { sourceProjects, - visibleProjects, - availableProjectFilters, effectiveProjectsStatus, canMutateProject, projectId, - projectFilter, - setProjectFilter, projectTab, setProjectTab, projectPreview, diff --git a/app/workbench/src/workbenchRoutesHelpers.test.ts b/app/workbench/src/workbenchRoutesHelpers.test.ts index b31a6fac4..c2ef58818 100644 --- a/app/workbench/src/workbenchRoutesHelpers.test.ts +++ b/app/workbench/src/workbenchRoutesHelpers.test.ts @@ -149,8 +149,6 @@ describe('buildProjectsPageProps', () => { effectiveProjectsStatus: { loading: true, error: 'boom' }, canMutateProject: false, projectId: null, - projectFilter: 'all', - setProjectFilter: vi.fn(), projectTab: 'overview', setProjectTab: vi.fn(), projectPreview: null, @@ -168,86 +166,6 @@ describe('buildProjectsPageProps', () => { expect(Object.prototype.hasOwnProperty.call(props, 'onProjectUpdate')).toBe(false); }); - it('renders the status-filtered list instead of the raw source list (#2154 P2-3)', () => { - const source = [{ id: 'p1', status: 'Active' }, { id: 'p2', status: '已归档' }]; - const route = { - sourceProjects: source, - visibleProjects: [source[1]], - effectiveProjectsStatus: undefined, - canMutateProject: false, - projectId: 'p2', - projectFilter: 'archived', - setProjectFilter: vi.fn(), - projectTab: 'overview', - setProjectTab: vi.fn(), - projectPreview: null, - setProjectPreview: vi.fn(), - selectProject: vi.fn(), - handleProjectCreate: vi.fn(), - handleProjectUpdate: vi.fn(), - openArtifactPreview: vi.fn(), - loadMore: undefined, - hasMore: false, - loadingMore: false, - } as unknown as WorkbenchProjectsRoute; - - const props = buildProjectsPageProps(route, []); - expect(props.activeFilter).toBe('archived'); - expect(props.projects).toEqual([source[1]]); - }); - - it('forwards the available filters so unsatisfiable chips render disabled (#2154 P2-3)', () => { - const route = { - sourceProjects: [{ id: 'p1', status: 'Active' }], - visibleProjects: [{ id: 'p1', status: 'Active' }], - availableProjectFilters: ['all', 'running'], - effectiveProjectsStatus: undefined, - canMutateProject: false, - projectId: 'p1', - projectFilter: 'all', - setProjectFilter: vi.fn(), - projectTab: 'overview', - setProjectTab: vi.fn(), - projectPreview: null, - setProjectPreview: vi.fn(), - selectProject: vi.fn(), - handleProjectCreate: vi.fn(), - handleProjectUpdate: vi.fn(), - openArtifactPreview: vi.fn(), - loadMore: undefined, - hasMore: false, - loadingMore: false, - } as unknown as WorkbenchProjectsRoute; - - const props = buildProjectsPageProps(route, []); - expect(props.availableFilters).toEqual(['all', 'running']); - }); - - it('omits availableFilters for partial route fixtures that predate it', () => { - const route = { - sourceProjects: [{ id: 'p1' }], - effectiveProjectsStatus: undefined, - canMutateProject: false, - projectId: 'p1', - projectFilter: 'all', - setProjectFilter: vi.fn(), - projectTab: 'overview', - setProjectTab: vi.fn(), - projectPreview: null, - setProjectPreview: vi.fn(), - selectProject: vi.fn(), - handleProjectCreate: vi.fn(), - handleProjectUpdate: vi.fn(), - openArtifactPreview: vi.fn(), - loadMore: undefined, - hasMore: false, - loadingMore: false, - } as unknown as WorkbenchProjectsRoute; - - const props = buildProjectsPageProps(route, []); - expect(Object.prototype.hasOwnProperty.call(props, 'availableFilters')).toBe(false); - }); - it('includes mutate handlers and clears preview via onClosePreview', () => { const setProjectPreview = vi.fn(); const handleProjectCreate = vi.fn(); @@ -257,8 +175,6 @@ describe('buildProjectsPageProps', () => { effectiveProjectsStatus: { saving: true, actionError: 'nope' }, canMutateProject: true, projectId: 'p1', - projectFilter: 'running', - setProjectFilter: vi.fn(), projectTab: 'runs', setProjectTab: vi.fn(), projectPreview: { id: 'prev' }, diff --git a/app/workbench/src/workbenchRoutesHelpers.ts b/app/workbench/src/workbenchRoutesHelpers.ts index d91225803..ae82e4509 100644 --- a/app/workbench/src/workbenchRoutesHelpers.ts +++ b/app/workbench/src/workbenchRoutesHelpers.ts @@ -106,24 +106,17 @@ export function buildProjectsPageProps( ): ProjectsPageProps { const status = projectsRoute.effectiveProjectsStatus; const props: ProjectsPageProps = { - activeFilter: projectsRoute.projectFilter, activeProjectId: projectsRoute.projectId, activeTab: projectsRoute.projectTab, activePreview: projectsRoute.projectPreview, - onFilterChange: projectsRoute.setProjectFilter, profiles, onArtifactClick: projectsRoute.openArtifactPreview, onClosePreview: () => projectsRoute.setProjectPreview(null), onProjectSelect: projectsRoute.selectProject, onTabChange: projectsRoute.setProjectTab, - // #2154 P2-3: render the status-filtered list; fall back to the source - // list only for partial route fixtures that predate `visibleProjects`. - projects: projectsRoute.visibleProjects ?? projectsRoute.sourceProjects, + projects: projectsRoute.sourceProjects, }; - // #2154 P2-3: chips the data source cannot satisfy render disabled. - assignDefined(props, 'availableFilters', projectsRoute.availableProjectFilters); - if (projectsRoute.canMutateProject) { props.onProjectCreate = projectsRoute.handleProjectCreate; props.onProjectUpdate = projectsRoute.handleProjectUpdate;