mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
feat(github-projects): render Roadmap project views as a timeline (#17795)
* Add roadmap timeline view for GitHub Projects - Renders roadmap-layout project views as a scrollable timeline with date/iteration-based placement, zoom levels, and grouped lanes, instead of surfacing them as unsupported - Derives placement fields from view config or row-carried field values since GitHub's API never exposes a roadmap's date source directly - Falls back to the existing table list when no field can place items * Fix roadmap timeline edge cases: reject invalid calendar dates and refre - parseRoadmapDate previously let Date.UTC silently normalize overflowing dates (e.g. 2026-02-30 → Mar 2); now round-trips components to reject them - ProjectRoadmap's "today" marker was frozen at mount, so panes left open across midnight showed the wrong day; now re-derives and re-arms a timer * fix(github-projects): center roadmaps when dated rows arrive * fix(github-projects): keep pinned roadmap header opaque * fix: remove stale pnpm executable lockfile entries * fix(i18n): retain replaced project labels in runtime catalog --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
@@ -145,7 +145,8 @@ export function normalizeFieldValue(
|
||||
iterationId: raw.iterationId,
|
||||
title: raw.title ?? '',
|
||||
startDate: raw.startDate ?? '',
|
||||
duration: typeof raw.duration === 'number' ? raw.duration : 0
|
||||
duration: typeof raw.duration === 'number' ? raw.duration : 0,
|
||||
...(typeof raw.field.name === 'string' ? { fieldName: raw.field.name } : {})
|
||||
}
|
||||
case 'ProjectV2ItemFieldTextValue':
|
||||
return { kind: 'text', fieldId, text: raw.text ?? '' }
|
||||
@@ -155,7 +156,12 @@ export function normalizeFieldValue(
|
||||
}
|
||||
return { kind: 'number', fieldId, number: raw.number }
|
||||
case 'ProjectV2ItemFieldDateValue':
|
||||
return { kind: 'date', fieldId, date: raw.date ?? '' }
|
||||
return {
|
||||
kind: 'date',
|
||||
fieldId,
|
||||
date: raw.date ?? '',
|
||||
...(typeof raw.field.name === 'string' ? { fieldName: raw.field.name } : {})
|
||||
}
|
||||
case 'ProjectV2ItemFieldLabelValue': {
|
||||
const labels = (raw.labels?.nodes ?? [])
|
||||
.map(normalizeLabel)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fetchProjectViewsPage, type RawProjectView } from './project-view-config'
|
||||
import type * as ProjectViewConfig from './project-view-config'
|
||||
import { fetchAllItems, fetchItemsCountOnly } from './project-view-items'
|
||||
import { getProjectViewTable } from './project-view-table'
|
||||
|
||||
vi.mock('./project-view-config', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof ProjectViewConfig>()),
|
||||
fetchProjectViewsPage: vi.fn()
|
||||
}))
|
||||
vi.mock('./project-view-items', () => ({
|
||||
fetchAllItems: vi.fn(),
|
||||
fetchItemsCountOnly: vi.fn()
|
||||
}))
|
||||
|
||||
const args = {
|
||||
owner: 'acme',
|
||||
ownerType: 'organization',
|
||||
projectNumber: 1,
|
||||
host: 'github.acme.test'
|
||||
} as const
|
||||
const view = (id: string, layout: string): RawProjectView => ({
|
||||
id,
|
||||
number: 1,
|
||||
name: id,
|
||||
layout,
|
||||
filter: 'status:open',
|
||||
fields: { nodes: [] },
|
||||
groupByFields: { nodes: [] },
|
||||
sortByFields: { nodes: [] }
|
||||
})
|
||||
function page(views: RawProjectView[], hasNextPage = false) {
|
||||
return {
|
||||
ok: true as const,
|
||||
project: { id: 'project', title: 'Plan', url: 'https://github.acme.test/orgs/acme/projects/1' },
|
||||
views,
|
||||
hasNextPage,
|
||||
endCursor: hasNextPage ? 'next' : null
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(fetchAllItems).mockResolvedValue({
|
||||
ok: true,
|
||||
rows: [],
|
||||
totalCount: 0,
|
||||
parentFieldDropped: false
|
||||
})
|
||||
vi.mocked(fetchItemsCountOnly).mockResolvedValue(12)
|
||||
})
|
||||
|
||||
describe('project view layout selection', () => {
|
||||
it('fetches roadmap items with the selected host and filter', async () => {
|
||||
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('roadmap', 'ROADMAP_LAYOUT')]))
|
||||
const result = await getProjectViewTable({ ...args, viewId: 'roadmap' })
|
||||
expect(result).toMatchObject({ ok: true, data: { selectedView: { layout: 'ROADMAP_LAYOUT' } } })
|
||||
expect(fetchAllItems).toHaveBeenCalledWith({ ...args, query: 'status:open' })
|
||||
expect(fetchItemsCountOnly).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults to a roadmap when no table exists across all view pages', async () => {
|
||||
vi.mocked(fetchProjectViewsPage)
|
||||
.mockResolvedValueOnce(page([view('roadmap', 'ROADMAP_LAYOUT')], true))
|
||||
.mockResolvedValueOnce(page([view('board', 'BOARD_LAYOUT')]))
|
||||
expect(await getProjectViewTable(args)).toMatchObject({
|
||||
ok: true,
|
||||
data: { selectedView: { id: 'roadmap' } }
|
||||
})
|
||||
expect(fetchProjectViewsPage).toHaveBeenLastCalledWith({ ...args, after: 'next' })
|
||||
})
|
||||
|
||||
it('prefers a table on a later page over an earlier roadmap', async () => {
|
||||
vi.mocked(fetchProjectViewsPage)
|
||||
.mockResolvedValueOnce(page([view('roadmap', 'ROADMAP_LAYOUT')], true))
|
||||
.mockResolvedValueOnce(page([view('table', 'TABLE_LAYOUT')]))
|
||||
expect(await getProjectViewTable(args)).toMatchObject({
|
||||
ok: true,
|
||||
data: { selectedView: { id: 'table' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not substitute a roadmap for a missing explicit selection', async () => {
|
||||
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('roadmap', 'ROADMAP_LAYOUT')]))
|
||||
expect(await getProjectViewTable({ ...args, viewId: 'missing' })).toMatchObject({
|
||||
ok: false,
|
||||
error: { type: 'not_found' }
|
||||
})
|
||||
expect(fetchAllItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['BOARD_LAYOUT', 'FUTURE_LAYOUT'])(
|
||||
'rejects %s without fetching items',
|
||||
async (layout) => {
|
||||
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('unsupported', layout)]))
|
||||
expect(
|
||||
await getProjectViewTable({ ...args, viewId: 'unsupported', queryOverride: '' })
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { type: 'unsupported_layout' },
|
||||
totalCount: 12
|
||||
})
|
||||
expect(fetchAllItems).not.toHaveBeenCalled()
|
||||
expect(fetchItemsCountOnly).toHaveBeenCalledWith({ ...args, query: '' })
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -84,6 +84,14 @@ export async function getProjectViewTable(
|
||||
if (!project) {
|
||||
return { ok: false, error: { type: 'not_found', message: 'Project not found.' } }
|
||||
}
|
||||
const noSelector =
|
||||
args.viewId === undefined && args.viewNumber === undefined && args.viewName === undefined
|
||||
if (!selectedRaw && noSelector) {
|
||||
// Why: `matchesSelector` only defaults to a table view, so a project whose
|
||||
// views are all roadmaps resolved to nothing even though we can now render
|
||||
// one. Table stays the preferred default; this is the empty-handed case.
|
||||
selectedRaw = viewsSeen.find((v) => v.layout === 'ROADMAP_LAYOUT') ?? null
|
||||
}
|
||||
if (!selectedRaw) {
|
||||
return { ok: false, error: { type: 'not_found', message: 'Could not find the selected view.' } }
|
||||
}
|
||||
@@ -109,8 +117,10 @@ export async function getProjectViewTable(
|
||||
const effectiveQuery =
|
||||
typeof args.queryOverride === 'string' ? args.queryOverride : selectedView.filter
|
||||
|
||||
// Unsupported layout: skip item pagination; best-effort count-only query.
|
||||
if (selectedView.layout !== 'TABLE_LAYOUT') {
|
||||
// Why: roadmaps read the same item stream as a table — only the renderer
|
||||
// differs. Allowlist, not `=== 'BOARD_LAYOUT'`: raw.layout is cast unchecked,
|
||||
// so a future GitHub layout must reject cleanly, not render as a table.
|
||||
if (selectedView.layout !== 'TABLE_LAYOUT' && selectedView.layout !== 'ROADMAP_LAYOUT') {
|
||||
const count = await fetchItemsCountOnly({
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
@@ -122,7 +132,7 @@ export async function getProjectViewTable(
|
||||
ok: false,
|
||||
error: {
|
||||
type: 'unsupported_layout',
|
||||
message: `Orca only renders table views. This is a ${selectedView.layout.replace('_LAYOUT', '').toLowerCase()} view.`
|
||||
message: `Orca renders table and roadmap views. This is a ${selectedView.layout.replace('_LAYOUT', '').toLowerCase()} view.`
|
||||
},
|
||||
...(typeof count === 'number' ? { totalCount: count } : {})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user