fix(github): name an unfiltered empty project view instead of blaming a filter

The search-index workaround in this branch was a no-op. Live introspection of
ProjectV2.items shows `query` is declared `String = ""`, so omitting the
argument and sending `$q = ""` coerce to the identical resolver input; GitHub
applies declared defaults for omitted args (verified against its own endpoint).
There is no non-search item field on ProjectV2 and ProjectV2View has no `items`
at all, so no request shape can dodge the index. Revert the branching query
construction and the module it added.

What the user actually reported in #12648 is the copy: a view with no filter
rendered "No items match this view's filter", which reads as data loss when a
freshly populated board momentarily comes back empty. Word the empty state from
the view's own filter — the filter message only when there is a filter, and an
honest "no items yet" plus a transience hint when there is not — and share the
one implementation between the table and roadmap surfaces.

Refs #12648.
This commit is contained in:
Neil
2026-09-14 00:29:37 -07:00
parent 3e48f647b9
commit d866fa8758
18 changed files with 120 additions and 149 deletions
-12
View File
@@ -26,21 +26,9 @@ import {
isValidOwnerSlug,
isValidRepoSlug,
parseProjectPaste,
projectViewItemsUseSearchQuery,
resolveProjectRef
} from './project-view'
describe('projectViewItemsUseSearchQuery', () => {
it('skips search for empty / whitespace-only filters so unfiltered boards avoid index lag', () => {
expect(projectViewItemsUseSearchQuery('')).toBe(false)
expect(projectViewItemsUseSearchQuery(' ')).toBe(false)
})
it('uses search when the view has a real filter string', () => {
expect(projectViewItemsUseSearchQuery('status:Todo')).toBe(true)
})
})
describe('classifyProjectError', () => {
it('classifies HTTP 404 as not_found', () => {
expect(classifyProjectError('HTTP 404 Not Found', '').type).toBe('not_found')
-1
View File
@@ -35,7 +35,6 @@ export {
normalizeFieldValue
} from './project-view/project-view-field-normalization'
export { normalizeItem } from './project-view/project-view-item-normalization'
export { projectViewItemsUseSearchQuery } from './project-view/project-view-items-search-query'
export { getProjectViewTable } from './project-view/project-view-table'
export { listAccessibleProjects } from './project-view/project-view-discovery'
export { parseProjectPaste, resolveProjectRef } from './project-view/project-view-reference'
@@ -1,75 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { ghExecFileAsyncMock } = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn()
}))
vi.mock('./internals', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
extractExecError: vi.fn(),
ghExecFileAsync: ghExecFileAsyncMock,
noteRepositoryRateLimitSpend: vi.fn(),
projectGhExecOptions: () => ({}),
projectHostAuthenticationError: vi.fn().mockResolvedValue(null),
repositoryRateLimitGuard: vi.fn().mockReturnValue({ blocked: false })
}))
import { fetchItemsPageWithRaw } from './project-view-item-page'
function itemsStdout(): string {
return JSON.stringify({
data: {
organization: {
projectV2: {
items: {
totalCount: 1,
pageInfo: { hasNextPage: false, endCursor: null },
nodes: []
}
}
}
}
})
}
describe('fetchItemsPageWithRaw search-index query', () => {
beforeEach(() => {
ghExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: itemsStdout(), stderr: '' })
})
it('omits items(query:) for empty and whitespace filters', async () => {
for (const query of ['', ' ']) {
ghExecFileAsyncMock.mockClear()
await fetchItemsPageWithRaw({
owner: 'acme',
ownerType: 'organization',
projectNumber: 1,
query,
first: 100,
after: null,
includeParent: false
})
const args = ghExecFileAsyncMock.mock.calls[0]?.[0] as string[]
const graphql = args.find((part) => part.startsWith('query=')) ?? ''
expect(graphql).not.toContain('query:$q')
expect(args).not.toContainEqual(expect.stringMatching(/^q=/))
}
})
it('keeps items(query:) for a real view filter', async () => {
await fetchItemsPageWithRaw({
owner: 'acme',
ownerType: 'organization',
projectNumber: 1,
query: 'status:Todo',
first: 100,
after: null,
includeParent: false
})
const args = ghExecFileAsyncMock.mock.calls[0]?.[0] as string[]
const graphql = args.find((part) => part.startsWith('query=')) ?? ''
expect(graphql).toContain('query:$q')
expect(args).toContain('q=status:Todo')
})
})
@@ -17,7 +17,6 @@ import {
type GhGraphqlErrorShape
} from './project-error-classification'
import { ownerQueryRoot } from './project-view-config'
import { projectViewItemsUseSearchQuery } from './project-view-items-search-query'
import type { RawItem } from './project-view-item-normalization'
import {
FIELD_CONFIG_FRAGMENT,
@@ -59,21 +58,11 @@ export async function fetchItemsPageWithRaw(args: {
const root = ownerQueryRoot(args.ownerType)
const afterArg = args.after ? `, after: $after` : ''
const afterVar = args.after ? `$after:String!, ` : ''
// Why: empty query still hits Projects search index and can return 0 during
// index lag on unfiltered views; omit query: for no filter (#12648).
const filterQuery = args.query.trim()
const useSearchQuery = projectViewItemsUseSearchQuery(filterQuery)
const queryVars = useSearchQuery
? `${afterVar}$owner:String!, $num:Int!, $q:String!, $first:Int!`
: `${afterVar}$owner:String!, $num:Int!, $first:Int!`
const itemsArgs = useSearchQuery
? `first:$first${afterArg}, query:$q, orderBy:{ field: POSITION, direction: ASC }`
: `first:$first${afterArg}, orderBy:{ field: POSITION, direction: ASC }`
const query = `
query(${queryVars}) {
query(${afterVar}$owner:String!, $num:Int!, $q:String!, $first:Int!) {
${root}(login:$owner) {
projectV2(number:$num) {
items(${itemsArgs}) {
items(first:$first${afterArg}, query:$q, orderBy:{ field: POSITION, direction: ASC }) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
@@ -92,9 +81,7 @@ export async function fetchItemsPageWithRaw(args: {
const argsArr: string[] = ['api', 'graphql', '-f', `query=${query}`]
argsArr.push('-f', `owner=${args.owner}`)
argsArr.push('-F', `num=${args.projectNumber}`)
if (useSearchQuery) {
argsArr.push('-f', `q=${filterQuery}`)
}
argsArr.push('-f', `q=${args.query}`)
argsArr.push('-F', `first=${args.first}`)
if (args.after) {
argsArr.push('-f', `after=${args.after}`)
@@ -1,5 +0,0 @@
// Why: empty filter must not use Projects `items(query:)` — GitHub's search
// index can return totalCount 0 for minutes after bulk populate (#12648).
export function projectViewItemsUseSearchQuery(query: string): boolean {
return query.trim().length > 0
}
@@ -12,7 +12,6 @@ import {
} from './project-view-cache'
import { ownerQueryRoot } from './project-view-config'
import { fetchItemsPageWithRaw } from './project-view-item-page'
import { projectViewItemsUseSearchQuery } from './project-view-items-search-query'
import { normalizeItem, type RawItem } from './project-view-item-normalization'
const ITEM_PAGE_SIZE = 100
@@ -210,11 +209,7 @@ export async function fetchItemsCountOnly(args: {
host?: string
}): Promise<number | null> {
const root = ownerQueryRoot(args.ownerType)
const filterQuery = args.query.trim()
const useSearchQuery = projectViewItemsUseSearchQuery(filterQuery)
// Why: match item fetch — unfiltered count must not use the lagging search index (#12648).
const query = useSearchQuery
? `
const query = `
query($owner:String!, $num:Int!, $q:String!) {
${root}(login:$owner) {
projectV2(number:$num) {
@@ -222,23 +217,12 @@ export async function fetchItemsCountOnly(args: {
}
}
}
`
: `
query($owner:String!, $num:Int!) {
${root}(login:$owner) {
projectV2(number:$num) {
items(first:1) { totalCount }
}
}
}
`
const res = await runGraphql<
Record<string, { projectV2?: { items?: { totalCount?: number } | null } | null } | null>
>(
query,
useSearchQuery
? { owner: args.owner, num: args.projectNumber, q: filterQuery }
: { owner: args.owner, num: args.projectNumber },
{ owner: args.owner, num: args.projectNumber, q: args.query },
projectGhExecOptions(args.host)
)
if (!res.ok) {
@@ -64,7 +64,11 @@ function row(id: string, title: string, values: GitHubProjectFieldValue[]): GitH
}
}
function table(fields: GitHubProjectField[], rows: GitHubProjectRow[]): GitHubProjectTable {
function table(
fields: GitHubProjectField[],
rows: GitHubProjectRow[],
filter = ''
): GitHubProjectTable {
return {
project: {
id: 'PVT_1',
@@ -79,7 +83,7 @@ function table(fields: GitHubProjectField[], rows: GitHubProjectRow[]): GitHubPr
number: 2,
name: 'Roadmap',
layout: 'ROADMAP_LAYOUT',
filter: '',
filter,
fields,
groupByFields: [],
sortByFields: []
@@ -263,11 +267,22 @@ describe('ProjectRoadmap', () => {
it('reports an empty filter result instead of drawing an empty grid', () => {
render(
<ProjectRoadmap
table={table([TITLE_FIELD, START_FIELD, TARGET_FIELD], [])}
table={table([TITLE_FIELD, START_FIELD, TARGET_FIELD], [], 'status:Todo')}
fallback={<div>list</div>}
/>
)
expect(screen.getByText("No items match this view's filter.")).toBeTruthy()
expect(screen.queryByText('list')).toBeNull()
})
it('does not blame a filter an unfiltered roadmap does not have', () => {
render(
<ProjectRoadmap
table={table([TITLE_FIELD, START_FIELD, TARGET_FIELD], [])}
fallback={<div>list</div>}
/>
)
expect(screen.getByText('This view has no items yet.')).toBeTruthy()
expect(screen.queryByText("No items match this view's filter.")).toBeNull()
})
})
@@ -7,6 +7,7 @@ import { i18n, translate } from '@/i18n/i18n'
import ProjectGroupHeader from './ProjectGroupHeader'
import ProjectRoadmapBar from './ProjectRoadmapBar'
import { ProjectTitleCell } from './ProjectCellIdentity'
import { ProjectItemsEmptyState } from './ProjectViewStates'
import { formatRoadmapTick } from './roadmap-tick-format'
import { loadRoadmapZoom, saveRoadmapZoom } from './roadmap-zoom-preference'
import { groupRows, sortRows } from '../../../../shared/github/project-group-sort'
@@ -145,14 +146,7 @@ export default function ProjectRoadmap({
}
if (table.rows.length === 0) {
return (
<div className="flex min-h-[120px] items-center justify-center p-6 text-sm text-muted-foreground">
{translate(
'auto.components.github.project.ProjectViewList.4f57d2e0b1',
"No items match this view's filter."
)}
</div>
)
return <ProjectItemsEmptyState filter={view.filter} />
}
const undatedCount = table.rows.length - spans.size
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'
import ColumnResizeHandle from './ColumnResizeHandle'
import ProjectGroupHeader from './ProjectGroupHeader'
import ProjectRow from './ProjectRow'
import { ProjectItemsEmptyState } from './ProjectViewStates'
import { groupRows, sortRows } from '../../../../shared/github/project-group-sort'
import { getAvailableColumns, loadHiddenColumns, saveHiddenColumns } from './columns'
import {
@@ -181,14 +182,7 @@ export default function ProjectViewList({
}
if (table.rows.length === 0) {
return (
<div className="flex min-h-[120px] items-center justify-center p-6 text-sm text-muted-foreground">
{translate(
'auto.components.github.project.ProjectViewList.4f57d2e0b1',
"No items match this view's filter."
)}
</div>
)
return <ProjectItemsEmptyState filter={table.selectedView.filter} />
}
// Why: the visible sort indicator reflects either the local override or the
@@ -0,0 +1,28 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { ProjectItemsEmptyState } from './ProjectViewStates'
afterEach(cleanup)
const FILTERED_COPY = "No items match this view's filter."
const UNFILTERED_COPY = 'This view has no items yet.'
const TRANSIENCE_HINT = 'Recently added items can take a while to appear.'
describe('ProjectItemsEmptyState', () => {
it('blames the filter only when the view actually has one', () => {
render(<ProjectItemsEmptyState filter="status:Todo" />)
expect(screen.getByText(FILTERED_COPY)).toBeTruthy()
expect(screen.queryByText(UNFILTERED_COPY)).toBeNull()
})
// #12648: an unfiltered board that momentarily reads back empty must not be
// reported as a filter miss — that reads as data loss.
it.each(['', ' ', '\n\t'])('reports an unfiltered view as empty for filter %j', (filter) => {
render(<ProjectItemsEmptyState filter={filter} />)
expect(screen.getByText(UNFILTERED_COPY)).toBeTruthy()
expect(screen.getByText(TRANSIENCE_HINT)).toBeTruthy()
expect(screen.queryByText(FILTERED_COPY)).toBeNull()
})
})
@@ -225,3 +225,41 @@ export function ProjectTableSkeleton(): React.JSX.Element {
</div>
)
}
/**
* Empty result for a project view, worded from the view's own filter.
*
* Why: an unfiltered view has no filter to blame, so "no items match this
* view's filter" reads as data loss when a freshly populated board momentarily
* comes back empty (#12648). `ProjectV2.items(query:)` defaults to `""`, so
* there is no non-search request shape to fall back to — the honest remedy is
* to name the state correctly and say the emptiness may be transient.
*/
export function ProjectItemsEmptyState({ filter }: { filter: string }): React.JSX.Element {
if (filter.trim().length > 0) {
return (
<div className="flex min-h-[120px] items-center justify-center p-6 text-sm text-muted-foreground">
{translate(
'auto.components.github.project.ProjectViewList.4f57d2e0b1',
"No items match this view's filter."
)}
</div>
)
}
return (
<div className="flex min-h-[120px] flex-col items-center justify-center gap-1 p-6 text-center text-sm text-muted-foreground">
<span>
{translate(
'auto.components.github.project.ProjectViewStates.3b9c1d5e47',
'This view has no items yet.'
)}
</span>
<span className="text-xs">
{translate(
'auto.components.github.project.ProjectViewStates.7e4a2f80c6',
'Recently added items can take a while to appear.'
)}
</span>
</div>
)
}
+3 -1
View File
@@ -2584,7 +2584,9 @@
},
"ProjectViewStates": {
"ac83c45672": "Switch to a Table or Roadmap view to work with this project in Orca.",
"e4cc8b14f2": "Orca renders table and roadmap project views. This view uses a layout it cannot render yet."
"e4cc8b14f2": "Orca renders table and roadmap project views. This view uses a layout it cannot render yet.",
"3b9c1d5e47": "This view has no items yet.",
"7e4a2f80c6": "Recently added items can take a while to appear."
}
},
"GitHubMarkdownComposer": {
+4
View File
@@ -2218,6 +2218,10 @@
"7c302f8174": "Sin título"
}
}
},
"ProjectViewStates": {
"3b9c1d5e47": "Esta vista aún no tiene elementos.",
"7e4a2f80c6": "Los elementos añadidos recientemente pueden tardar un poco en aparecer."
}
},
"GitHubMarkdownComposer": {
+4
View File
@@ -2382,6 +2382,10 @@
"7c302f8174": "Sans titre"
}
}
},
"ProjectViewStates": {
"3b9c1d5e47": "Cette vue ne contient encore aucun élément.",
"7e4a2f80c6": "Les éléments ajoutés récemment peuvent mettre un moment à apparaître."
}
},
"GitHubMarkdownComposer": {
+4
View File
@@ -2218,6 +2218,10 @@
"7c302f8174": "無題"
}
}
},
"ProjectViewStates": {
"3b9c1d5e47": "このビューにはまだ項目がありません。",
"7e4a2f80c6": "最近追加した項目は、表示されるまで少し時間がかかることがあります。"
}
},
"GitHubMarkdownComposer": {
+4
View File
@@ -2223,6 +2223,10 @@
"7c302f8174": "제목 없음"
}
}
},
"ProjectViewStates": {
"3b9c1d5e47": "이 보기에는 아직 항목이 없습니다.",
"7e4a2f80c6": "최근에 추가한 항목은 표시되기까지 시간이 걸릴 수 있습니다."
}
},
"GitHubMarkdownComposer": {
+4
View File
@@ -2221,6 +2221,10 @@
"7c302f8174": "无标题"
}
}
},
"ProjectViewStates": {
"3b9c1d5e47": "此视图暂无任何项目。",
"7e4a2f80c6": "最近添加的项目可能需要一段时间才会显示。"
}
},
"GitHubMarkdownComposer": {
+4 -2
View File
@@ -81,8 +81,10 @@ export type GitHubProjectView = {
number: number
name: string
layout: GitHubProjectViewLayout
/** Normalized to '' when GitHub returns null. Empty/whitespace filters omit
* `items(query:)` so unfiltered boards skip GitHub's search-index lag. */
/** Normalized to '' when GitHub returns null. `ProjectV2.items(query:)` is
* declared `String = ""`, so sending '' and omitting the argument are the
* same request — there is no non-search item field to fall back to. '' is
* therefore only a UI signal: it means "this view is unfiltered". */
filter: string
fields: GitHubProjectField[]
groupByFields: GitHubProjectField[]