mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -2218,6 +2218,10 @@
|
||||
"7c302f8174": "無題"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProjectViewStates": {
|
||||
"3b9c1d5e47": "このビューにはまだ項目がありません。",
|
||||
"7e4a2f80c6": "最近追加した項目は、表示されるまで少し時間がかかることがあります。"
|
||||
}
|
||||
},
|
||||
"GitHubMarkdownComposer": {
|
||||
|
||||
@@ -2223,6 +2223,10 @@
|
||||
"7c302f8174": "제목 없음"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProjectViewStates": {
|
||||
"3b9c1d5e47": "이 보기에는 아직 항목이 없습니다.",
|
||||
"7e4a2f80c6": "최근에 추가한 항목은 표시되기까지 시간이 걸릴 수 있습니다."
|
||||
}
|
||||
},
|
||||
"GitHubMarkdownComposer": {
|
||||
|
||||
@@ -2221,6 +2221,10 @@
|
||||
"7c302f8174": "无标题"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProjectViewStates": {
|
||||
"3b9c1d5e47": "此视图暂无任何项目。",
|
||||
"7e4a2f80c6": "最近添加的项目可能需要一段时间才会显示。"
|
||||
}
|
||||
},
|
||||
"GitHubMarkdownComposer": {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user