feat(github-projects): render Board project views as a kanban with drag-and-drop (#19074)

* Add board layout support for GitHub project views

Board-layout views render as a kanban. Columns come from the view's
verticalGroupByFields (the host retries without the selection on older
GHES schemas and the renderer falls back to the Status field), with one
column per single-select option in option order — empty ones included —
plus a trailing no-value column whose drop clears the field. Card drops
reuse the table's field mutation path, committed from a document-level
capture listener because the preload's native-drop bridge stops drop
events before React's root ever sees them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(github-projects): harden board capability probe and drop lifecycle

Review follow-up. The verticalGroupByFields capability probe now matches
parsed GraphQL error messages instead of substring-scanning the whole
response body — partial-error responses echo the field name as a data key
on healthy schemas, so one SAML/FORBIDDEN partial error could permanently
degrade github.com boards for the session. Covered by new
project-view-config tests per the capability-cache testing contract.

Also: only single-select/iteration vertical fields shape columns (a
drifted field kind no longer yields a clear-on-drop no-value column),
optimistic patches resolve the column field through the board/group
config, drag cleanup moved to a document-level dragend listener, the
column dot follows dark mode via the chip CSS variables, the supported-
layout allowlist is a single shared predicate, and the board's edit
handler is stable so its drop listener stops re-registering per render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(github-projects): serialize board edits and verify rendered drops

* fix(github-projects): preserve refresh baselines and order edits across views

* fix(github-projects): accept source settings projection in cache scope

* fix(github-projects): distinguish view switches from refreshed field baselines

* test(github-projects): type board IPC recordings and verify clear request

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
NaoyaTatetsu
2026-10-05 22:23:57 -07:00
committed by GitHub
co-authored by Claude Fable 5 Neil
parent ca4e239861
commit 635e7f53cb
22 changed files with 2025 additions and 181 deletions
@@ -0,0 +1,196 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
fetchProjectViewsPage,
finalizeView,
resetVerticalGroupByCapabilityForTests,
type RawProjectView
} from './project-view-config'
import { runGraphql } from './internals'
import type * as Internals from './internals'
vi.mock('./internals', async (importOriginal) => ({
...(await importOriginal<typeof Internals>()),
runGraphql: vi.fn()
}))
const args = {
owner: 'acme',
ownerType: 'organization',
projectNumber: 1,
host: 'ghes.acme.test',
after: null
} as const
function okPage() {
return {
ok: true as const,
data: {
organization: {
projectV2: {
id: 'PVT_1',
title: 'Plan',
url: 'https://ghes.acme.test/orgs/acme/projects/1',
views: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }
}
}
}
}
}
function unknownFieldFailure() {
return {
ok: false as const,
error: { type: 'schema_drift' as const, message: 'Could not read this project view.' },
raw: {
stderr: '',
stdout: JSON.stringify({
errors: [
{
message: "Field 'verticalGroupByFields' doesn't exist on type 'ProjectV2View'"
}
]
})
}
}
}
beforeEach(() => {
vi.resetAllMocks()
resetVerticalGroupByCapabilityForTests()
})
describe('verticalGroupByFields capability fallback', () => {
it('drops the selection and retries once on an unknown-field error', async () => {
vi.mocked(runGraphql)
.mockResolvedValueOnce(unknownFieldFailure())
.mockResolvedValueOnce(okPage())
const result = await fetchProjectViewsPage(args)
expect(result.ok).toBe(true)
expect(vi.mocked(runGraphql).mock.calls[0]?.[0]).toContain('verticalGroupByFields')
expect(vi.mocked(runGraphql).mock.calls[1]?.[0]).not.toContain('verticalGroupByFields')
})
it('memoizes the incapability per host and keeps other hosts unaffected', async () => {
vi.mocked(runGraphql).mockResolvedValueOnce(unknownFieldFailure()).mockResolvedValue(okPage())
await fetchProjectViewsPage(args)
await fetchProjectViewsPage(args)
// Third call overall = first call of the second fetch: no failed probe repeated.
expect(vi.mocked(runGraphql).mock.calls[2]?.[0]).not.toContain('verticalGroupByFields')
await fetchProjectViewsPage({ ...args, host: 'github.com' })
expect(vi.mocked(runGraphql).mock.calls[3]?.[0]).toContain('verticalGroupByFields')
})
it('passes unrelated failures through without retrying', async () => {
vi.mocked(runGraphql).mockResolvedValueOnce({
ok: false,
error: { type: 'network_error', message: 'Network error — check your connection.' },
raw: { stderr: 'connect ETIMEDOUT', stdout: '' }
})
const result = await fetchProjectViewsPage(args)
expect(result.ok).toBe(false)
expect(vi.mocked(runGraphql)).toHaveBeenCalledTimes(1)
})
it('does not cache authorization or transient errors that mention the field', async () => {
vi.mocked(runGraphql)
.mockResolvedValueOnce({
ok: false,
error: { type: 'scope_missing', message: 'Not authorized' },
raw: {
stderr: '',
stdout: JSON.stringify({
errors: [{ message: 'Not authorized to access verticalGroupByFields' }]
})
}
})
.mockResolvedValue(okPage())
expect((await fetchProjectViewsPage(args)).ok).toBe(false)
expect(runGraphql).toHaveBeenCalledTimes(1)
await fetchProjectViewsPage(args)
expect(vi.mocked(runGraphql).mock.calls[1]?.[0]).toContain('verticalGroupByFields')
})
it('propagates a failed fallback and does not keep probing that host', async () => {
vi.mocked(runGraphql).mockResolvedValue(unknownFieldFailure())
expect((await fetchProjectViewsPage(args)).ok).toBe(false)
expect(runGraphql).toHaveBeenCalledTimes(2)
await fetchProjectViewsPage(args)
expect(runGraphql).toHaveBeenCalledTimes(3)
})
it('does not treat the field name inside partial-error DATA as incapability', async () => {
// Why: partial errors echo the whole body, where the field name appears as
// a plain data key on healthy schemas — that must not degrade the host.
vi.mocked(runGraphql).mockResolvedValueOnce({
ok: false,
error: { type: 'schema_drift', message: 'Could not read this project view.' },
raw: {
stderr: '',
stdout: JSON.stringify({
data: {
organization: { projectV2: { views: { nodes: [{ verticalGroupByFields: {} }] } } }
},
errors: [{ message: 'SAML enforcement: resource protected by organization policy' }]
})
}
})
const result = await fetchProjectViewsPage(args)
expect(result.ok).toBe(false)
expect(vi.mocked(runGraphql)).toHaveBeenCalledTimes(1)
})
})
describe('finalizeView verticalGroupByFields normalization', () => {
const base: RawProjectView = {
id: 'PVTV_1',
number: 1,
name: 'Board',
layout: 'BOARD_LAYOUT',
filter: null,
fields: { nodes: [] },
groupByFields: { nodes: [] },
sortByFields: { nodes: [] }
}
it('normalizes present vertical fields and drops invalid nodes', () => {
const finalized = finalizeView(
{
...base,
verticalGroupByFields: {
nodes: [
{
__typename: 'ProjectV2SingleSelectField',
id: 'f_status',
name: 'Status',
dataType: 'SINGLE_SELECT',
options: []
},
null,
{ __typename: 'ProjectV2Field' }
]
}
},
[]
)
expect(finalized.ok).toBe(true)
if (finalized.ok) {
expect(finalized.view.verticalGroupByFields).toEqual([
{
kind: 'single-select',
id: 'f_status',
name: 'Status',
dataType: 'SINGLE_SELECT',
options: []
}
])
}
})
it('omits the key entirely when the host never sent it (wire-compat shape)', () => {
const finalized = finalizeView(base, [])
expect(finalized.ok).toBe(true)
if (finalized.ok) {
expect('verticalGroupByFields' in finalized.view).toBe(false)
}
})
})
@@ -6,7 +6,8 @@ import type {
GitHubProjectViewLayout
} from '../../../shared/github/project-types'
import type { GitHubProjectViewError } from '../../../shared/github/project-result-types'
import { driftError } from './project-error-classification'
import { githubProjectHost } from '../../../shared/github/project-identity'
import { driftError, extractGraphqlErrors } from './project-error-classification'
import { projectGhExecOptions, runGraphql, type GraphqlVars } from './internals'
import { normalizeField, type RawProjectV2Field } from './project-view-field-normalization'
import { FIELD_CONFIG_FRAGMENT } from './project-view-query-fragments'
@@ -37,11 +38,37 @@ export type RawProjectView = {
nodes?: (RawProjectV2Field | null)[]
}
groupByFields?: { nodes?: (RawProjectV2Field | null)[] }
verticalGroupByFields?: { nodes?: (RawProjectV2Field | null)[] }
sortByFields?: {
nodes?: ({ direction?: string; field?: RawProjectV2Field | null } | null)[]
}
}
// Older GHES hosts omit vertical grouping so one missing field cannot break all views.
const hostsWithoutVerticalGroupBy = new Set<string>()
/** Test-only: capability state is module-level so real runs memoize per host. */
export function resetVerticalGroupByCapabilityForTests(): void {
hostsWithoutVerticalGroupBy.clear()
}
function verticalGroupBySelection(host: string | undefined): string {
return hostsWithoutVerticalGroupBy.has(githubProjectHost(host))
? ''
: 'verticalGroupByFields(first:10) { nodes { ...FieldConfig } }'
}
function errorsIndicateVerticalGroupBy(raw: { stderr: string; stdout: string }): boolean {
// Partial-error data can echo the field name even when the schema supports it.
return extractGraphqlErrors(raw.stderr, raw.stdout).some(
(error) =>
/\bverticalGroupByFields\b/.test(error.message ?? '') &&
/(?:doesn't exist|does not exist|cannot query field|unknown field|undefined field)/i.test(
error.message ?? ''
)
)
}
export function ownerQueryRoot(ownerType: GitHubProjectOwnerType): string {
return ownerType === 'organization' ? 'organization' : 'user'
}
@@ -65,7 +92,7 @@ export async function fetchProjectViewsPage(args: {
const root = ownerQueryRoot(args.ownerType)
const afterArg = args.after ? `, after: $after` : ''
const afterVar = args.after ? `$after:String!, ` : ''
const query = `
const buildQuery = (): string => `
query(${afterVar}$owner:String!, $num:Int!) {
${root}(login:$owner) {
projectV2(number:$num) {
@@ -79,6 +106,7 @@ export async function fetchProjectViewsPage(args: {
nodes { ...FieldConfig }
}
groupByFields(first:10) { nodes { ...FieldConfig } }
${verticalGroupBySelection(args.host)}
sortByFields(first:10) {
nodes { direction field { ...FieldConfig } }
}
@@ -93,11 +121,19 @@ export async function fetchProjectViewsPage(args: {
if (args.after) {
vars.after = args.after
}
const res = await runGraphql<Record<string, { projectV2?: RawProjectConfig | null } | null>>(
query,
let res = await runGraphql<Record<string, { projectV2?: RawProjectConfig | null } | null>>(
buildQuery(),
vars,
projectGhExecOptions(args.host)
)
if (!res.ok && verticalGroupBySelection(args.host) && errorsIndicateVerticalGroupBy(res.raw)) {
hostsWithoutVerticalGroupBy.add(githubProjectHost(args.host))
res = await runGraphql<Record<string, { projectV2?: RawProjectConfig | null } | null>>(
buildQuery(),
vars,
projectGhExecOptions(args.host)
)
}
if (!res.ok) {
return res
}
@@ -189,6 +225,13 @@ export function finalizeView(
groupByFields.push(n)
}
}
const verticalGroupByFields: GitHubProjectField[] = []
for (const f of raw.verticalGroupByFields?.nodes ?? []) {
const n = normalizeField(f)
if (n) {
verticalGroupByFields.push(n)
}
}
const sortByFields: GitHubProjectSort[] = []
for (const s of raw.sortByFields?.nodes ?? []) {
if (!s || (s.direction !== 'ASC' && s.direction !== 'DESC')) {
@@ -210,7 +253,9 @@ export function finalizeView(
filter: typeof raw.filter === 'string' ? raw.filter : '',
fields,
groupByFields,
sortByFields
sortByFields,
// Preserve absence for older hosts and cached payloads.
...(raw.verticalGroupByFields ? { verticalGroupByFields } : {})
}
}
}
@@ -89,19 +89,32 @@ describe('project view layout selection', () => {
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: '' })
}
)
it('fetches board items like a table', async () => {
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('board', 'BOARD_LAYOUT')]))
const result = await getProjectViewTable({ ...args, viewId: 'board' })
expect(result).toMatchObject({ ok: true, data: { selectedView: { layout: 'BOARD_LAYOUT' } } })
expect(fetchAllItems).toHaveBeenCalledWith({ ...args, query: 'status:open' })
expect(fetchItemsCountOnly).not.toHaveBeenCalled()
})
it('defaults to a board when neither a table nor a roadmap exists', async () => {
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('board', 'BOARD_LAYOUT')]))
expect(await getProjectViewTable(args)).toMatchObject({
ok: true,
data: { selectedView: { id: 'board' } }
})
})
it('rejects an unknown future layout without fetching items', async () => {
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('unsupported', 'FUTURE_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: '' })
})
})
@@ -2,6 +2,7 @@ import type { GetProjectViewTableArgs } from '../../../shared/github/project-req
import type { GetProjectViewTableResult } from '../../../shared/github/project-result-types'
import type { GitHubProjectTable } from '../../../shared/github/project-types'
import { githubProjectHost } from '../../../shared/github/project-identity'
import { isRenderableProjectViewLayout } from '../../../shared/github/project-types'
import { assertPositiveInt, assertSlug } from './internals'
import {
fetchProjectViewsPage,
@@ -90,7 +91,10 @@ export async function getProjectViewTable(
// 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
selectedRaw =
viewsSeen.find((v) => v.layout === 'ROADMAP_LAYOUT') ??
viewsSeen.find((v) => v.layout === 'BOARD_LAYOUT') ??
null
}
if (!selectedRaw) {
return { ok: false, error: { type: 'not_found', message: 'Could not find the selected view.' } }
@@ -117,10 +121,10 @@ export async function getProjectViewTable(
const effectiveQuery =
typeof args.queryOverride === 'string' ? args.queryOverride : selectedView.filter
// 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') {
// Why: boards and roadmaps read the same item stream as a table — only the
// renderer differs. Unknown future layouts must reject cleanly, not render
// as a table.
if (!isRenderableProjectViewLayout(selectedView.layout)) {
const count = await fetchItemsCountOnly({
owner: args.owner,
ownerType: args.ownerType,
@@ -132,7 +136,9 @@ export async function getProjectViewTable(
ok: false,
error: {
type: 'unsupported_layout',
message: `Orca renders table and roadmap views. This is a ${selectedView.layout.replace('_LAYOUT', '').toLowerCase()} view.`
// Why: the branch is type-unreachable (closed union) but runtime-real —
// raw.layout is cast unchecked, so an unknown value lands here.
message: `Orca renders table, board, and roadmap views. This is a ${String(selectedView.layout).replace('_LAYOUT', '').toLowerCase()} view.`
},
...(typeof count === 'number' ? { totalCount: count } : {})
}
@@ -0,0 +1,267 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import ProjectBoard from './ProjectBoard'
import type {
GitHubProjectField,
GitHubProjectFieldValue,
GitHubProjectRow,
GitHubProjectTable
} from '../../../../shared/github/project-types'
const STATUS_FIELD: GitHubProjectField = {
kind: 'single-select',
id: 'f_status',
name: 'Status',
dataType: 'SINGLE_SELECT',
options: [
{ id: 'opt_todo', name: 'Todo', color: 'GREEN' },
{ id: 'opt_done', name: 'Done', color: 'PURPLE' }
]
}
const TITLE_FIELD: GitHubProjectField = {
kind: 'field',
id: 'f_title',
name: 'Title',
dataType: 'TITLE'
}
function row(
id: string,
title: string,
values: GitHubProjectFieldValue[],
itemType: GitHubProjectRow['itemType'] = 'ISSUE'
): GitHubProjectRow {
const fieldValuesByFieldId: Record<string, GitHubProjectFieldValue> = {}
for (const value of values) {
fieldValuesByFieldId[value.fieldId] = value
}
return {
id,
itemType,
content: {
number: itemType === 'DRAFT_ISSUE' || itemType === 'REDACTED' ? null : 7,
title,
body: null,
url: 'https://github.com/o/r/issues/7',
state: 'OPEN',
stateReason: null,
isDraft: null,
repository: 'o/r',
assignees: [],
labels: [],
parentIssue: null,
issueType: null
},
fieldValuesByFieldId,
updatedAt: '2026-09-01T00:00:00Z',
position: 0
}
}
function table(fields: GitHubProjectField[], rows: GitHubProjectRow[]): GitHubProjectTable {
return {
project: {
id: 'PVT_1',
owner: 'o',
ownerType: 'user',
number: 1,
title: 'Project',
url: 'https://github.com/users/o/projects/1'
},
selectedView: {
id: 'PVTV_1',
number: 1,
name: 'Board',
layout: 'BOARD_LAYOUT',
filter: '',
fields,
groupByFields: [],
sortByFields: [],
verticalGroupByFields: fields.filter((field) => field.kind === 'single-select')
},
rows,
totalCount: rows.length,
parentFieldDropped: false
}
}
const status = (optionId: string, name: string): GitHubProjectFieldValue => ({
kind: 'single-select',
fieldId: 'f_status',
optionId,
name,
color: ''
})
function dragData(rowId: string): { dataTransfer: Partial<DataTransfer> } {
const store: Record<string, string> = { 'application/x-orca-project-row': rowId }
return {
dataTransfer: {
types: Object.keys(store),
getData: (type: string) => store[type] ?? '',
setData: (type: string, value: string) => {
store[type] = value
},
dropEffect: 'move',
effectAllowed: 'move'
}
}
}
afterEach(cleanup)
describe('ProjectBoard', () => {
it('renders one column per option (empty included) plus the no-value column', () => {
render(
<ProjectBoard
table={table(
[TITLE_FIELD, STATUS_FIELD],
[row('r1', 'Ship it', [status('opt_todo', 'Todo')]), row('r2', 'Loose end', [])]
)}
fallback={<div>list</div>}
/>
)
expect(screen.getByTestId('board-column-opt_todo')).toBeTruthy()
expect(screen.getByTestId('board-column-opt_done')).toBeTruthy()
expect(screen.getByTestId('board-column-__empty__').textContent).toContain('Loose end')
expect(screen.queryByText('list')).toBeNull()
})
it('moves a card on drop via onEditField and skips no-op drops', () => {
const onEditField = vi.fn()
const boardTable = table(
[TITLE_FIELD, STATUS_FIELD],
[row('r1', 'Ship it', [status('opt_todo', 'Todo')])]
)
render(<ProjectBoard table={boardTable} onEditField={onEditField} fallback={<div>list</div>} />)
fireEvent.drop(screen.getByTestId('board-column-opt_done'), dragData('r1'))
expect(onEditField).toHaveBeenCalledWith(boardTable.rows[0], 'f_status', {
kind: 'single-select',
optionId: 'opt_done'
})
onEditField.mockClear()
fireEvent.drop(screen.getByTestId('board-column-opt_todo'), dragData('r1'))
expect(onEditField).not.toHaveBeenCalled()
})
it('commits a drop even when the preload stops propagation at document capture', () => {
const preloadDrop = (event: Event) => {
event.preventDefault()
event.stopPropagation()
}
document.addEventListener('drop', preloadDrop, true)
try {
const onEditField = vi.fn()
render(
<ProjectBoard
table={table([STATUS_FIELD], [row('r1', 'Ship it', [])])}
onEditField={onEditField}
fallback={null}
/>
)
fireEvent.drop(screen.getByTestId('board-column-opt_done'), dragData('r1'))
expect(onEditField).toHaveBeenCalledOnce()
} finally {
document.removeEventListener('drop', preloadDrop, true)
}
})
it('clears the field when dropped on the no-value column', () => {
const onEditField = vi.fn()
const boardTable = table(
[TITLE_FIELD, STATUS_FIELD],
[row('r1', 'Ship it', [status('opt_todo', 'Todo')])]
)
render(<ProjectBoard table={boardTable} onEditField={onEditField} fallback={<div>list</div>} />)
fireEvent.drop(screen.getByTestId('board-column-__empty__'), dragData('r1'))
expect(onEditField).toHaveBeenCalledWith(boardTable.rows[0], 'f_status', null)
})
it('opens the dialog from a card title and labels restricted cards', () => {
const onOpenDialog = vi.fn()
render(
<ProjectBoard
table={table(
[TITLE_FIELD, STATUS_FIELD],
[
row('r1', 'Ship it', [status('opt_todo', 'Todo')]),
row('r2', '', [status('opt_todo', 'Todo')], 'REDACTED')
]
)}
onOpenDialog={onOpenDialog}
fallback={<div>list</div>}
/>
)
fireEvent.click(screen.getByRole('button', { name: /Ship it/ }))
expect(onOpenDialog).toHaveBeenCalledTimes(1)
expect(onOpenDialog.mock.calls[0]?.[0]).toMatchObject({ id: 'r1' })
expect(screen.getByText('Restricted item')).toBeTruthy()
})
it('clears the drop highlight on dragend and on payload-less drops', () => {
render(
<ProjectBoard
table={table(
[TITLE_FIELD, STATUS_FIELD],
[row('r1', 'Ship it', [status('opt_todo', 'Todo')])]
)}
fallback={<div>list</div>}
/>
)
const done = screen.getByTestId('board-column-opt_done')
fireEvent.dragOver(done, dragData('r1'))
expect(done.className).toContain('border-ring')
// Esc / drop outside any column fires only dragend on the card.
fireEvent.dragEnd(screen.getByLabelText('#7 — Ship it'))
expect(done.className).not.toContain('border-ring')
fireEvent.dragOver(done, dragData('r1'))
expect(done.className).toContain('border-ring')
fireEvent.drop(done, { dataTransfer: { getData: () => '', types: [] } })
expect(done.className).not.toContain('border-ring')
})
it('falls back to the caller-supplied list when no column field exists', () => {
const bare = table([TITLE_FIELD], [row('r1', 'Ship it', [])])
bare.selectedView.verticalGroupByFields = []
render(<ProjectBoard table={bare} fallback={<div>list</div>} />)
expect(screen.getByText('list')).toBeTruthy()
})
it('uses the shared empty-state copy for unfiltered and filtered boards', () => {
const empty = table([TITLE_FIELD, STATUS_FIELD], [])
const { rerender } = render(<ProjectBoard table={empty} 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()
empty.selectedView.filter = 'status:Done'
rerender(<ProjectBoard table={{ ...empty }} fallback={<div>list</div>} />)
expect(screen.getByText("No items match this view's filter.")).toBeTruthy()
})
it('ignores restricted and unknown payloads and deleted-option drops', () => {
const onEditField = vi.fn()
render(
<ProjectBoard
table={table(
[STATUS_FIELD],
[row('r1', '', [], 'REDACTED'), row('r2', 'Old', [status('deleted', 'Archived')])]
)}
onEditField={onEditField}
fallback={<div>list</div>}
/>
)
fireEvent.drop(screen.getByTestId('board-column-opt_done'), dragData('r1'))
fireEvent.drop(screen.getByTestId('board-column-opt_done'), dragData('unknown'))
fireEvent.drop(screen.getByTestId('board-column-deleted'), dragData('r2'))
expect(onEditField).not.toHaveBeenCalled()
expect(screen.getByLabelText('Restricted item').getAttribute('draggable')).toBe('false')
})
it('keeps a board without an edit handler read-only', () => {
render(
<ProjectBoard table={table([STATUS_FIELD], [row('r1', 'Read only', [])])} fallback={null} />
)
expect(screen.getByLabelText('#7 — Read only').getAttribute('draggable')).toBe('false')
})
})
@@ -0,0 +1,239 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import ProjectBoardCard from './ProjectBoardCard'
import { ProjectItemsEmptyState } from './ProjectViewStates'
import { chipStyle, singleSelectChipColors } from './project-cell-chip-colors'
import {
buildBoardColumns,
resolveBoardColumnField,
type ProjectBoardColumn
} from '../../../../shared/github/project-board-columns'
import { EMPTY_PROJECT_GROUP_KEY, sortRows } from '../../../../shared/github/project-group-sort'
import type {
GitHubProjectFieldMutationValue,
GitHubProjectRow,
GitHubProjectTable
} from '../../../../shared/github/project-types'
const COLUMN_WIDTH_PX = 272
const CARD_DRAG_MIME = 'application/x-orca-project-row'
type Props = {
table: GitHubProjectTable
onOpenDialog?: (row: GitHubProjectRow) => void
onEditField?: (
row: GitHubProjectRow,
fieldId: string,
value: GitHubProjectFieldMutationValue | null
) => void
/** Rendered instead of the board when no column field is resolvable — the
* caller supplies the table list so the items stay usable. */
fallback: React.ReactNode
}
export default function ProjectBoard({
table,
onOpenDialog,
onEditField,
fallback
}: Props): React.JSX.Element {
const field = useMemo(() => resolveBoardColumnField(table.selectedView), [table.selectedView])
const columns = useMemo(
() => (field ? buildBoardColumns(field, sortRows(table, table.rows)) : []),
[field, table]
)
const rowsById = useMemo(() => new Map(table.rows.map((row) => [row.id, row])), [table.rows])
const [dropTarget, setDropTarget] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const fieldId = field?.id ?? null
const moveRow = useCallback(
(rowId: string, column: ProjectBoardColumn): void => {
const row = rowsById.get(rowId)
// Deleted options and read-only buckets have no valid mutation target.
if (
!row ||
row.itemType === 'REDACTED' ||
fieldId === null ||
column.dropValue === undefined
) {
return
}
const current = row.fieldValuesByFieldId[fieldId]
const drop = column.dropValue
const alreadyThere =
drop === null
? current === undefined
: drop.kind === 'single-select'
? current?.kind === 'single-select' && current.optionId === drop.optionId
: drop.kind === 'iteration' &&
current?.kind === 'iteration' &&
current.iterationId === drop.iterationId
if (!alreadyThere) {
onEditField?.(row, fieldId, column.dropValue)
}
},
[rowsById, fieldId, onEditField]
)
// Preload stops bubbling drops; commit from capture on the same document.
useEffect(() => {
const columnsByKey = new Map(columns.map((column) => [column.key, column]))
const handleDocumentDrop = (event: DragEvent): void => {
setDropTarget(null)
const rowId = event.dataTransfer?.getData(CARD_DRAG_MIME)
if (!rowId) {
return
}
const target = event.target instanceof Element ? event.target : null
const columnEl = target?.closest('[data-board-column-key]')
if (!(columnEl instanceof HTMLElement) || !containerRef.current?.contains(columnEl)) {
return
}
const column = columnsByKey.get(columnEl.dataset.boardColumnKey ?? '')
if (!column) {
return
}
event.preventDefault()
moveRow(rowId, column)
}
// Esc and drops outside the board still clear the hover state.
const handleDocumentDragEnd = (): void => setDropTarget(null)
document.addEventListener('drop', handleDocumentDrop, true)
document.addEventListener('dragend', handleDocumentDragEnd, true)
return () => {
document.removeEventListener('drop', handleDocumentDrop, true)
document.removeEventListener('dragend', handleDocumentDragEnd, true)
}
}, [columns, moveRow])
if (!field) {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex-none border-b border-border/50 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{translate(
'projectBoard.noColumnField',
'This board view has no single-select or iteration field to group by, so Orca is listing items instead.'
)}
</div>
{fallback}
</div>
)
}
if (table.rows.length === 0) {
return <ProjectItemsEmptyState filter={table.selectedView.filter} />
}
return (
<div
ref={containerRef}
className="flex min-h-0 min-w-0 flex-1 gap-3 overflow-x-auto overflow-y-hidden p-3 scrollbar-sleek"
>
{columns.map((column) => (
<BoardColumn
key={column.key}
column={
column.key === EMPTY_PROJECT_GROUP_KEY
? {
...column,
label: translate('projectBoard.noField', 'No {{field}}', { field: field.name })
}
: column
}
highlighted={dropTarget === column.key}
onOpenDialog={onOpenDialog}
onEditField={onEditField}
onDragEnter={() => {
if (column.dropValue !== undefined) {
setDropTarget(column.key)
}
}}
onDragLeaveOrEnd={() =>
setDropTarget((current) => (current === column.key ? null : current))
}
/>
))}
</div>
)
}
function BoardColumn({
column,
highlighted,
onOpenDialog,
onEditField,
onDragEnter,
onDragLeaveOrEnd
}: {
column: ProjectBoardColumn
highlighted: boolean
onOpenDialog?: (row: GitHubProjectRow) => void
onEditField?: Props['onEditField']
onDragEnter: () => void
onDragLeaveOrEnd: () => void
}): React.JSX.Element {
const colors = column.color ? singleSelectChipColors(column.color) : null
return (
<div
role="list"
aria-label={column.label}
data-testid={`board-column-${column.key}`}
data-board-column-key={column.key}
className={cn(
'flex h-full min-h-0 shrink-0 flex-col rounded-lg border bg-muted/20',
highlighted && column.dropValue !== undefined
? 'border-ring/60 bg-accent/40'
: 'border-border/50'
)}
style={{ width: COLUMN_WIDTH_PX }}
onDragOver={(event) => {
if (column.dropValue !== undefined && event.dataTransfer.types.includes(CARD_DRAG_MIME)) {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
onDragEnter()
}
}}
onDragLeave={(event) => {
if (
!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget)
) {
onDragLeaveOrEnd()
}
}}
>
<div className="flex flex-none items-center gap-1.5 px-2.5 py-2 text-xs">
{colors ? (
<span
aria-hidden
className="size-2 shrink-0 rounded-full bg-[var(--github-project-chip-fg-light)] dark:bg-[var(--github-project-chip-fg-dark)]"
style={{
...chipStyle(colors),
boxShadow: `0 0 0 3px ${colors.bg}`
}}
/>
) : null}
<span className="truncate font-medium">{column.label}</span>
<span className="rounded-full border border-border/50 bg-background px-1.5 text-[11px] text-muted-foreground">
{column.rows.length}
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto px-2 pb-2 scrollbar-sleek">
{column.rows.map((row) => (
<ProjectBoardCard
key={row.id}
row={row}
draggable={Boolean(onEditField) && row.itemType !== 'REDACTED'}
onOpenDialog={() => onOpenDialog?.(row)}
onDragStart={(event) => {
event.dataTransfer.setData(CARD_DRAG_MIME, row.id)
event.dataTransfer.effectAllowed = 'move'
}}
/>
))}
</div>
</div>
)
}
@@ -0,0 +1,108 @@
import React from 'react'
import { FileText, GitPullRequest, Lock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { GitHubProjectRow } from '../../../../shared/github/project-types'
type Props = {
row: GitHubProjectRow
draggable: boolean
onOpenDialog?: () => void
onDragStart: (event: React.DragEvent<HTMLDivElement>) => void
}
export default function ProjectBoardCard({
row,
draggable,
onOpenDialog,
onDragStart
}: Props): React.JSX.Element {
const restricted = row.itemType === 'REDACTED'
const title = restricted
? translate('projectBoardCard.restrictedItem', 'Restricted item')
: row.content.title
const clickable = !restricted && row.itemType !== 'DRAFT_ISSUE'
const Glyph =
row.itemType === 'PULL_REQUEST'
? GitPullRequest
: row.itemType === 'DRAFT_ISSUE'
? FileText
: restricted
? Lock
: null
return (
<div
role="listitem"
draggable={draggable}
onDragStart={onDragStart}
aria-label={
row.content.number == null
? title
: translate('projectBoardCard.itemLabel', '{{number}} — {{title}}', {
number: `#${row.content.number}`,
title
})
}
className={cn(
'rounded-md border border-border/60 bg-card text-card-foreground p-2 shadow-xs',
draggable && 'cursor-grab active:cursor-grabbing',
restricted && 'opacity-60'
)}
>
<div className="flex items-start gap-1.5">
{Glyph ? <Glyph className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" /> : null}
<div className="min-w-0 flex-1">
{clickable ? (
<button
type="button"
onClick={onOpenDialog}
className="block w-full cursor-pointer text-left text-xs font-medium leading-snug hover:underline"
>
<span className="line-clamp-2">{title}</span>
</button>
) : (
<span
className={cn(
'line-clamp-2 text-xs font-medium leading-snug',
restricted && 'italic'
)}
>
{title}
</span>
)}
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
{row.content.number == null ? null : (
<span className="shrink-0">#{row.content.number}</span>
)}
{row.content.repository ? (
<span className="truncate">{row.content.repository}</span>
) : null}
</div>
</div>
{row.content.assignees.length > 0 ? (
<div className="flex shrink-0 -space-x-1.5">
{row.content.assignees.slice(0, 3).map((user) =>
user.avatarUrl ? (
<img
key={user.login}
src={user.avatarUrl}
alt={user.login}
title={user.login}
className="size-4 rounded-full border border-background"
/>
) : (
<span
key={user.login}
title={user.login}
className="flex size-4 items-center justify-center rounded-full border border-background bg-muted text-[11px] uppercase"
>
{user.login.charAt(0)}
</span>
)
)}
</div>
) : null}
</div>
</div>
)
}
@@ -3,6 +3,7 @@ import { AlertTriangle, Loader, Pin } from 'lucide-react'
import { GhAuthErrorHelp } from './GhAuthErrorHelp'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { isRenderableProjectViewLayout } from '../../../../shared/github/project-types'
import type { GitHubProjectViewSummary } from '../../../../shared/github/project-types'
import type { GitHubProjectViewError } from '../../../../shared/github/project-result-types'
@@ -126,17 +127,14 @@ function ProjectViewPickerRow({
view: GitHubProjectViewSummary
onPick: (view: GitHubProjectViewSummary) => void | Promise<void>
}): React.JSX.Element {
const supported = view.layout === 'TABLE_LAYOUT' || view.layout === 'ROADMAP_LAYOUT'
const supported = isRenderableProjectViewLayout(view.layout)
const layoutLabel =
view.layout === 'TABLE_LAYOUT'
? translate('auto.components.github.project.ProjectPicker.1a2b8e512e', 'Table')
: view.layout === 'ROADMAP_LAYOUT'
? translate('auto.components.github.project.ProjectPickerPanels.04ec212ccb', 'Roadmap')
: view.layout === 'BOARD_LAYOUT'
? translate(
'auto.components.github.project.ProjectPicker.d34ef9b554',
'Board (unsupported)'
)
? translate('projectViews.layout.board', 'Board')
: // Why: raw.layout is cast unchecked, so a future GitHub layout value
// lands here — keep it disabled instead of mislabeling it.
translate(
@@ -5,6 +5,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/h
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { GhAuthErrorHelp } from './GhAuthErrorHelp'
import { isRenderableProjectViewLayout } from '../../../../shared/github/project-types'
import type { GitHubProjectViewSummary } from '../../../../shared/github/project-types'
import type { GitHubProjectViewError } from '../../../../shared/github/project-result-types'
@@ -42,9 +43,7 @@ function ProjectViewTab({
active: boolean
onPick: (viewId: string) => void
}): React.JSX.Element {
// Why: allowlist, not denylist — raw.layout is cast unchecked, so a future
// GitHub layout value must stay disabled instead of masquerading as a table.
const supported = view.layout === 'TABLE_LAYOUT' || view.layout === 'ROADMAP_LAYOUT'
const supported = isRenderableProjectViewLayout(view.layout)
const layoutLabel =
view.layout === 'BOARD_LAYOUT'
? 'Board'
@@ -110,8 +109,8 @@ function ProjectViewTab({
<p className="text-xs leading-5 text-muted-foreground">
{message}{' '}
{translate(
'auto.components.github.project.ProjectViewStates.ac83c45672',
'Switch to a Table or Roadmap view to work with this project in Orca.'
'projectViews.unsupported.switchLayout',
'Switch to a Table, Board, or Roadmap view to work with this project in Orca.'
)}
</p>
<Button
@@ -161,8 +160,8 @@ export function ProjectViewErrorState({
? // Why: an older paired host still reports roadmaps as unsupported, so this
// copy must not name the layout — the tab strip already does that.
translate(
'auto.components.github.project.ProjectViewStates.e4cc8b14f2',
'Orca renders table and roadmap project views. This view uses a layout it cannot render yet.'
'projectViews.unsupported.description',
'Orca renders table, board, and roadmap project views. This view uses a layout it cannot render yet.'
)
: error.type === 'not_found'
? 'Could not find this project or view.'
@@ -1,9 +1,10 @@
import React from 'react'
import React, { useCallback } from 'react'
import GitHubItemDialog from '@/components/GitHubItemDialog'
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import ProjectViewList from './ProjectViewList'
import ProjectBoard from './ProjectBoard'
import ProjectRoadmap from './ProjectRoadmap'
import ProjectItemSlugDialog from './ProjectItemSlugDialog'
import { ProjectMissingRepoDialog } from './ProjectMissingRepoDialog'
@@ -15,6 +16,10 @@ import {
} from './ProjectViewStates'
import { useProjectRowActions } from './useProjectRowActions'
import { useProjectViewTable } from './useProjectViewTable'
import type {
GitHubProjectFieldMutationValue,
GitHubProjectRow
} from '../../../../shared/github/project-types'
type Props = { selectedRepoIds: ReadonlySet<string> }
@@ -60,6 +65,13 @@ function ProjectViewBody({
rowActions: ReturnType<typeof useProjectRowActions>
}): React.JSX.Element | null {
const { activeProject, error, loading, table, visibleTable } = tableState
// Keep the board's document drop subscription stable across wrapper renders.
const { editField } = rowActions
const onEditField = useCallback(
(row: GitHubProjectRow, fieldId: string, value: GitHubProjectFieldMutationValue | null) =>
void editField(row, fieldId, value),
[editField]
)
if (!activeProject) {
return (
<div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
@@ -128,7 +140,7 @@ function ProjectViewBody({
<ProjectViewList
table={visibleTable}
onOpenDialog={rowActions.openDialog}
onEditField={(row, fieldId, value) => void rowActions.editField(row, fieldId, value)}
onEditField={onEditField}
onEditAssignees={(row, add, remove) => void rowActions.editAssignees(row, add, remove)}
onEditLabels={(row, add, remove) => void rowActions.editLabels(row, add, remove)}
onEditIssueType={(row, issueType) => void rowActions.editIssueType(row, issueType)}
@@ -146,5 +158,15 @@ function ProjectViewBody({
<ProjectRoadmap table={visibleTable} onOpenDialog={rowActions.openDialog} fallback={list} />
)
}
if (visibleTable.selectedView.layout === 'BOARD_LAYOUT') {
return (
<ProjectBoard
table={visibleTable}
onOpenDialog={rowActions.openDialog}
onEditField={onEditField}
fallback={list}
/>
)
}
return list
}
+6 -1
View File
@@ -593,11 +593,16 @@
"ab1a2c357d": "Roadmap (unsupported)",
"b787682111": "Browse all",
"ba0ab9a117": "Browse all (loading…)",
"cafb908f34": "TABLE_LAYOUT"
"cafb908f34": "TABLE_LAYOUT",
"d34ef9b554": "Board (unsupported)"
},
"ProjectRow": {
"c3b81ddea2": "DRAFT_ISSUE"
},
"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."
},
"ProjectViewWrapper": {
"1bf8c01c8b": "Switch to a Table view to work with this project in Orca."
}
+20
View File
@@ -19139,5 +19139,25 @@
"preservedBranches": "Some nested worktree branches were kept"
}
},
"projectBoard": {
"noColumnField": "This board view has no single-select or iteration field to group by, so Orca is listing items instead.",
"noField": "No {{field}}"
},
"projectBoardCard": {
"restrictedItem": "Restricted item",
"itemLabel": "{{number}} — {{title}}"
},
"projectViews": {
"layout": {
"board": "Board"
},
"unsupported": {
"switchLayout": "Switch to a Table, Board, or Roadmap view to work with this project in Orca.",
"description": "Orca renders table, board, and roadmap project views. This view uses a layout it cannot render yet."
}
},
"projectField": {
"updateFailed": "Failed to update project field"
},
"settings.appearance.chat.title": "Chat"
}
@@ -49,7 +49,9 @@ export function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope
return `${sourceScope}:${projectKey}:${selector}${queryOverrideKeyPart(args.queryOverride)}`
}
export function projectViewSourceScope(settings: AppState['settings']): string {
export function projectViewSourceScope(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
): string {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'
}
@@ -1,18 +1,12 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { GitHubSlice } from './slice-types'
import type { GitHubProjectRow } from '../../../../shared/github/project-types'
import { translate } from '@/i18n/i18n'
import type {
GetProjectViewTableResult,
GitHubProjectMutationResult
} from '../../../../shared/github/project-result-types'
import type { GetProjectViewTableResult } from '../../../../shared/github/project-result-types'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
import {
projectViewCacheKey,
projectViewRequestKey,
projectViewSourceScope,
settingsForProjectViewCacheKey
projectViewSourceScope
} from './cache-identity'
import { withBoundedCacheEntry, WORK_ITEMS_CACHE_TTL } from './cache-policy'
import {
@@ -22,11 +16,7 @@ import {
ownsInflightRequest,
releaseProviderRequestSlot as releaseWorkItemSlot
} from './request-coordination'
import {
applyRowPatch,
optimisticFieldValueFromMutation,
rollbackRowIfPresent
} from './project-cache'
import { createProjectFieldActions } from './project-field-mutations'
export const createProjectActions = (
set: Parameters<StateCreator<AppState>>[0],
@@ -151,125 +141,5 @@ export const createProjectActions = (
return request
},
updateProjectFieldValue: async (cacheKey, rowId, fieldId, value) => {
const state = get()
const entry = state.projectViewCache[cacheKey]
const table = entry?.data
if (!table) {
return {
ok: false,
error: {
type: 'unknown',
message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded')
}
}
}
const rowIndex = table.rows.findIndex((r) => r.id === rowId)
if (rowIndex === -1) {
return {
ok: false,
error: {
type: 'unknown',
message: translate('auto.store.slices.github.f963485d37', 'Row not found')
}
}
}
const previousRow = table.rows[rowIndex]
// Optimistic patch: build a field value matching the mutation shape.
const nextField = optimisticFieldValueFromMutation(table, fieldId, value)
const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId }
if (nextField) {
optimisticFieldValues[fieldId] = nextField
}
const optimisticRow: GitHubProjectRow = {
...previousRow,
fieldValuesByFieldId: optimisticFieldValues
}
applyRowPatch(set, cacheKey, rowId, optimisticRow)
const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey))
const result =
target.kind === 'environment'
? await callRuntimeRpc<GitHubProjectMutationResult>(
target,
'github.project.updateItemField',
{
projectId: table.project.id,
host: table.project.host,
itemId: rowId,
fieldId,
value
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.updateProjectItemField({
projectId: table.project.id,
host: table.project.host,
itemId: rowId,
fieldId,
value
})
if (!result.ok) {
rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow)
}
return result
},
clearProjectFieldValue: async (cacheKey, rowId, fieldId) => {
const state = get()
const entry = state.projectViewCache[cacheKey]
const table = entry?.data
if (!table) {
return {
ok: false,
error: {
type: 'unknown',
message: translate('auto.store.slices.github.a967f23983', 'Project view not loaded')
}
}
}
const rowIndex = table.rows.findIndex((r) => r.id === rowId)
if (rowIndex === -1) {
return {
ok: false,
error: {
type: 'unknown',
message: translate('auto.store.slices.github.f963485d37', 'Row not found')
}
}
}
const previousRow = table.rows[rowIndex]
const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId }
delete optimisticFieldValues[fieldId]
const optimisticRow: GitHubProjectRow = {
...previousRow,
fieldValuesByFieldId: optimisticFieldValues
}
applyRowPatch(set, cacheKey, rowId, optimisticRow)
const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey))
const result =
target.kind === 'environment'
? await callRuntimeRpc<GitHubProjectMutationResult>(
target,
'github.project.clearItemField',
{
projectId: table.project.id,
host: table.project.host,
itemId: rowId,
fieldId
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.clearProjectItemField({
projectId: table.project.id,
host: table.project.host,
itemId: rowId,
fieldId
})
if (!result.ok) {
rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow)
}
return result
}
...createProjectFieldActions(set, get)
})
@@ -11,7 +11,12 @@ export function optimisticFieldValueFromMutation(
fieldId: string,
value: GitHubProjectFieldMutationValue
): GitHubProjectTable['rows'][number]['fieldValuesByFieldId'][string] | null {
const field = table.selectedView.fields.find((f) => f.id === fieldId)
// Board column fields can be hidden from the view's visible fields.
const field = [
...table.selectedView.fields,
...(table.selectedView.verticalGroupByFields ?? []),
...table.selectedView.groupByFields
].find((f) => f.id === fieldId)
switch (value.kind) {
case 'single-select': {
if (field?.kind === 'single-select') {
@@ -0,0 +1,326 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createTestStore,
mockApi,
resetRemoteRuntimeMocks
} from '../slices/github-slice-test-harness'
import { projectViewCacheKey } from './cache-identity'
import type { GitHubProjectTable } from '../../../../shared/github/project-types'
import type { GitHubProjectMutationResult } from '../../../../shared/github/project-result-types'
const key = projectViewCacheKey('user', 'owner', 1, 'view')
const failed: GitHubProjectMutationResult = {
ok: false,
error: { type: 'network_error', message: 'Offline' }
}
function fixture(): GitHubProjectTable {
return {
project: {
id: 'project',
owner: 'owner',
ownerType: 'user',
number: 1,
title: 'Test',
url: ''
},
selectedView: {
id: 'view',
number: 1,
name: 'Board',
layout: 'BOARD_LAYOUT',
filter: '',
fields: [],
groupByFields: [],
sortByFields: [],
verticalGroupByFields: [
{
id: 'status',
name: 'Status',
kind: 'single-select',
dataType: 'SINGLE_SELECT',
options: [
{ id: 'todo', name: 'Todo', color: 'GREEN' },
{ id: 'done', name: 'Done', color: 'PURPLE' }
]
}
]
},
rows: [
{
id: 'row',
itemType: 'ISSUE',
updatedAt: '',
position: 0,
content: {
title: 'Original',
number: 1,
body: null,
url: '',
state: 'OPEN',
stateReason: null,
isDraft: null,
repository: 'owner/repo',
assignees: [],
labels: [],
parentIssue: null,
issueType: null
},
fieldValuesByFieldId: {
status: {
kind: 'single-select',
fieldId: 'status',
optionId: 'todo',
name: 'Todo',
color: 'GREEN'
}
}
}
],
totalCount: 1,
parentFieldDropped: false
}
}
function setup() {
const store = createTestStore()
store.setState({ projectViewCache: { [key]: { data: fixture(), fetchedAt: 1 } } })
return store
}
function deferred() {
let resolve: (result: GitHubProjectMutationResult) => void = () => {
throw new Error('Not initialized')
}
const promise = new Promise<GitHubProjectMutationResult>((done) => {
resolve = done
})
return { promise, resolve }
}
const field = (store: ReturnType<typeof setup>) =>
store.getState().projectViewCache[key]?.data?.rows[0]?.fieldValuesByFieldId.status
beforeEach(() => {
vi.resetAllMocks()
resetRemoteRuntimeMocks()
})
describe('project field writes', () => {
it('keeps rapid moves optimistic and sends update then clear in order', async () => {
const store = setup()
const first = deferred()
mockApi.gh.updateProjectItemField.mockReturnValueOnce(first.promise)
mockApi.gh.clearProjectItemField.mockResolvedValueOnce({ ok: true })
const update = store
.getState()
.updateProjectFieldValue(key, 'row', 'status', { kind: 'single-select', optionId: 'done' })
expect(field(store)).toMatchObject({ optionId: 'done', name: 'Done', color: 'PURPLE' })
const clear = store.getState().clearProjectFieldValue(key, 'row', 'status')
expect(field(store)).toBeUndefined()
await vi.waitFor(() => expect(mockApi.gh.updateProjectItemField).toHaveBeenCalledTimes(1))
expect(mockApi.gh.clearProjectItemField).not.toHaveBeenCalled()
first.resolve({ ok: true })
await Promise.all([update, clear])
expect(mockApi.gh.clearProjectItemField).toHaveBeenCalledTimes(1)
expect(field(store)).toBeUndefined()
})
it.each([true, false])(
'rolls a failed clear back to the last confirmed value (first succeeds: %s)',
async (succeeds) => {
const store = setup()
const first = deferred()
mockApi.gh.updateProjectItemField.mockReturnValueOnce(first.promise)
mockApi.gh.clearProjectItemField.mockResolvedValueOnce(failed)
const update = store
.getState()
.updateProjectFieldValue(key, 'row', 'status', { kind: 'single-select', optionId: 'done' })
const clear = store.getState().clearProjectFieldValue(key, 'row', 'status')
first.resolve(succeeds ? { ok: true } : failed)
await Promise.all([update, clear])
expect(field(store)).toMatchObject({ optionId: succeeds ? 'done' : 'todo' })
}
)
it('serializes the same item field across different views of a project', async () => {
const store = setup()
const otherKey = projectViewCacheKey('user', 'owner', 1, 'other-view')
const other = fixture()
other.selectedView.id = 'other-view'
store.setState({
projectViewCache: {
...store.getState().projectViewCache,
[otherKey]: { data: other, fetchedAt: 1 }
}
})
const first = deferred()
mockApi.gh.updateProjectItemField
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce({ ok: true })
const update1 = store
.getState()
.updateProjectFieldValue(key, 'row', 'status', { kind: 'single-select', optionId: 'done' })
const update2 = store.getState().updateProjectFieldValue(otherKey, 'row', 'status', {
kind: 'single-select',
optionId: 'todo'
})
await vi.waitFor(() => expect(mockApi.gh.updateProjectItemField).toHaveBeenCalledTimes(1))
first.resolve({ ok: true })
await Promise.all([update1, update2])
expect(mockApi.gh.updateProjectItemField).toHaveBeenCalledTimes(2)
})
it('keeps the confirmed baseline when failed writes switch between cached views', async () => {
const store = setup()
const otherKey = projectViewCacheKey('user', 'owner', 1, 'other-view')
const other = fixture()
other.selectedView.id = 'other-view'
store.setState({
projectViewCache: {
...store.getState().projectViewCache,
[otherKey]: { data: other, fetchedAt: 1 }
}
})
const first = deferred()
mockApi.gh.updateProjectItemField
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(failed)
mockApi.gh.clearProjectItemField.mockResolvedValueOnce(failed)
const update1 = store
.getState()
.updateProjectFieldValue(key, 'row', 'status', { kind: 'single-select', optionId: 'done' })
const update2 = store.getState().updateProjectFieldValue(otherKey, 'row', 'status', {
kind: 'single-select',
optionId: 'done'
})
const clear = store.getState().clearProjectFieldValue(key, 'row', 'status')
first.resolve(failed)
await Promise.all([update1, update2, clear])
expect(field(store)).toMatchObject({ optionId: 'todo' })
expect(
store.getState().projectViewCache[otherKey]?.data?.rows[0]?.fieldValuesByFieldId.status
).toMatchObject({ optionId: 'todo' })
})
it('keeps identical item IDs on different GitHub hosts independent', async () => {
const store = setup()
const otherKey = projectViewCacheKey(
'user',
'owner',
1,
'view',
undefined,
'local',
'ghe.example'
)
const other = fixture()
other.project.host = 'ghe.example'
store.setState({
projectViewCache: {
...store.getState().projectViewCache,
[otherKey]: { data: other, fetchedAt: 1 }
}
})
const first = deferred()
mockApi.gh.updateProjectItemField
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce({ ok: true })
const update1 = store
.getState()
.updateProjectFieldValue(key, 'row', 'status', { kind: 'single-select', optionId: 'done' })
const update2 = store.getState().updateProjectFieldValue(otherKey, 'row', 'status', {
kind: 'single-select',
optionId: 'done'
})
await vi.waitFor(() => expect(mockApi.gh.updateProjectItemField).toHaveBeenCalledTimes(2))
first.resolve({ ok: true })
await Promise.all([update1, update2])
})
it('preserves a successful edit to a different field when status rolls back', async () => {
const store = setup()
const first = deferred()
mockApi.gh.updateProjectItemField
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce({ ok: true })
const update1 = store
.getState()
.updateProjectFieldValue(key, 'row', 'status', { kind: 'single-select', optionId: 'done' })
const update2 = store
.getState()
.updateProjectFieldValue(key, 'row', 'notes', { kind: 'text', text: 'Keep this' })
await vi.waitFor(() => expect(mockApi.gh.updateProjectItemField).toHaveBeenCalledTimes(2))
first.resolve(failed)
await Promise.all([update1, update2])
expect(field(store)).toMatchObject({ optionId: 'todo' })
expect(
store.getState().projectViewCache[key]?.data?.rows[0]?.fieldValuesByFieldId.notes
).toMatchObject({ text: 'Keep this' })
})
it('does not roll back a newer clear when two clear requests overlap', async () => {
const store = setup()
const first = deferred()
const second = deferred()
mockApi.gh.clearProjectItemField
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const clear1 = store.getState().clearProjectFieldValue(key, 'row', 'status')
const clear2 = store.getState().clearProjectFieldValue(key, 'row', 'status')
first.resolve(failed)
await clear1
expect(field(store)).toBeUndefined()
second.resolve({ ok: true })
await clear2
expect(field(store)).toBeUndefined()
})
it('preserves a concurrent content edit when a field transport throws', async () => {
const store = setup()
mockApi.gh.updateProjectItemField.mockRejectedValueOnce(new Error('Transport closed'))
const request = store
.getState()
.updateProjectFieldValue(key, 'row', 'status', { kind: 'single-select', optionId: 'done' })
store.getState().patchProjectRowContent(key, 'row', { title: 'Edited' })
expect(await request).toMatchObject({ ok: false, error: { message: 'Transport closed' } })
expect(field(store)).toMatchObject({ optionId: 'todo' })
expect(store.getState().projectViewCache[key]?.data?.rows[0]?.content.title).toBe('Edited')
})
it('uses a refreshed value as the rollback baseline for a subsequently queued edit', async () => {
const store = setup()
const first = deferred()
mockApi.gh.updateProjectItemField.mockReturnValueOnce(first.promise)
mockApi.gh.clearProjectItemField.mockResolvedValueOnce(failed)
const update = store
.getState()
.updateProjectFieldValue(key, 'row', 'status', { kind: 'single-select', optionId: 'done' })
const refreshed = fixture()
refreshed.rows[0].fieldValuesByFieldId.status = {
kind: 'single-select',
fieldId: 'status',
optionId: 'doing',
name: 'Doing',
color: 'YELLOW'
}
store.setState({ projectViewCache: { [key]: { data: refreshed, fetchedAt: 2 } } })
const clear = store.getState().clearProjectFieldValue(key, 'row', 'status')
first.resolve(failed)
await Promise.all([update, clear])
expect(field(store)).toMatchObject({ optionId: 'doing' })
})
it('preserves an authoritative refresh instead of rolling back a failed clear', async () => {
const store = setup()
const response = deferred()
mockApi.gh.clearProjectItemField.mockReturnValueOnce(response.promise)
const request = store.getState().clearProjectFieldValue(key, 'row', 'status')
const refreshed = fixture()
delete refreshed.rows[0].fieldValuesByFieldId.status
store.setState({ projectViewCache: { [key]: { data: refreshed, fetchedAt: 2 } } })
response.resolve(failed)
await request
expect(field(store)).toBeUndefined()
})
})
@@ -0,0 +1,163 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { GitHubSlice } from './slice-types'
import type {
GitHubProjectFieldMutationValue,
GitHubProjectFieldValue,
GitHubProjectView
} from '../../../../shared/github/project-types'
import type { GitHubProjectMutationResult } from '../../../../shared/github/project-result-types'
import { translate } from '@/i18n/i18n'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
import { projectViewSourceScope, settingsForProjectViewCacheKey } from './cache-identity'
import { applyRowPatch, optimisticFieldValueFromMutation } from './project-cache'
import { githubProjectHost } from '../../../../shared/github/project-identity'
export function createProjectFieldActions(
set: Parameters<StateCreator<AppState>>[0],
get: Parameters<StateCreator<AppState>>[1]
): Pick<GitHubSlice, 'updateProjectFieldValue' | 'clearProjectFieldValue'> {
// Serialize each field so rapid board moves reach GitHub in user order.
type FieldWrite = {
tail: Promise<GitHubProjectMutationResult> | null
confirmed: GitHubProjectFieldValue | undefined
revisionsByCache: Map<string, number>
baselineViewsByCache: Map<string, GitHubProjectView | undefined>
}
const pending = new Map<string, FieldWrite>()
const mutate = async (
cacheKey: string,
rowId: string,
fieldId: string,
value: GitHubProjectFieldMutationValue | null,
write: FieldWrite
): Promise<GitHubProjectMutationResult> => {
const revision = (write.revisionsByCache.get(cacheKey) ?? 0) + 1
write.revisionsByCache.set(cacheKey, revision)
const table = get().projectViewCache[cacheKey]?.data
const row = table?.rows.find((candidate) => candidate.id === rowId)
if (!table || !row) {
await write.tail?.catch(() => undefined)
return {
ok: false,
error: {
type: 'unknown',
message: table
? translate('auto.store.slices.github.f963485d37', 'Row not found')
: translate('auto.store.slices.github.a967f23983', 'Project view not loaded')
}
}
}
const next = value ? optimisticFieldValueFromMutation(table, fieldId, value) : null
const fields = { ...row.fieldValuesByFieldId }
if (next) {
fields[fieldId] = next
} else {
delete fields[fieldId]
}
applyRowPatch(set, cacheKey, rowId, { ...row, fieldValuesByFieldId: fields })
const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey))
const args = { projectId: table.project.id, host: table.project.host, itemId: rowId, fieldId }
let result: GitHubProjectMutationResult
try {
await write.tail?.catch(() => undefined)
result = value
? target.kind === 'environment'
? await callRuntimeRpc<GitHubProjectMutationResult>(
target,
'github.project.updateItemField',
{ ...args, value },
{ timeoutMs: 30_000 }
)
: await window.api.gh.updateProjectItemField({ ...args, value })
: target.kind === 'environment'
? await callRuntimeRpc<GitHubProjectMutationResult>(
target,
'github.project.clearItemField',
args,
{ timeoutMs: 30_000 }
)
: await window.api.gh.clearProjectItemField(args)
} catch (error) {
result = {
ok: false,
error: {
type: 'unknown',
message:
error instanceof Error
? error.message
: translate('projectField.updateFailed', 'Failed to update project field')
}
}
}
if (result.ok) {
write.confirmed = next ?? undefined
} else {
const currentTable = get().projectViewCache[cacheKey]?.data
const current = currentTable?.rows.find((item) => item.id === rowId)
// A refresh owns its new value; rollback only our field and preserve concurrent content edits.
if (
current &&
write.revisionsByCache.get(cacheKey) === revision &&
currentTable?.selectedView === table.selectedView &&
current.fieldValuesByFieldId[fieldId] === (next ?? undefined)
) {
const restored = { ...current.fieldValuesByFieldId }
if (write.confirmed) {
restored[fieldId] = write.confirmed
} else {
delete restored[fieldId]
}
applyRowPatch(set, cacheKey, rowId, { ...current, fieldValuesByFieldId: restored })
}
}
return result
}
const enqueue = (
cacheKey: string,
rowId: string,
fieldId: string,
value: GitHubProjectFieldMutationValue | null
): Promise<GitHubProjectMutationResult> => {
const table = get().projectViewCache[cacheKey]?.data
const key = JSON.stringify([
projectViewSourceScope(settingsForProjectViewCacheKey(get().settings, cacheKey)),
githubProjectHost(table?.project.host).toLowerCase(),
table?.project.id ?? cacheKey,
rowId,
fieldId
])
const current = table?.rows.find((row) => row.id === rowId)?.fieldValuesByFieldId[fieldId]
const write = pending.get(key) ?? {
tail: null,
revisionsByCache: new Map<string, number>(),
confirmed: current,
baselineViewsByCache: new Map([[cacheKey, table?.selectedView]])
}
if (
table &&
write.baselineViewsByCache.has(cacheKey) &&
write.baselineViewsByCache.get(cacheKey) !== table.selectedView
) {
write.confirmed = current
}
write.baselineViewsByCache.set(cacheKey, table?.selectedView)
const request = mutate(cacheKey, rowId, fieldId, value, write)
write.tail = request
pending.set(key, write)
const cleanup = (): void => {
if (pending.get(key)?.tail === request) {
pending.delete(key)
}
}
void request.then(cleanup, cleanup)
return request
}
return {
updateProjectFieldValue: (cacheKey, rowId, fieldId, value) =>
enqueue(cacheKey, rowId, fieldId, value),
clearProjectFieldValue: (cacheKey, rowId, fieldId) => enqueue(cacheKey, rowId, fieldId, null)
}
}
@@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest'
import { buildBoardColumns, resolveBoardColumnField } from './project-board-columns'
import type {
GitHubProjectField,
GitHubProjectFieldValue,
GitHubProjectRow,
GitHubProjectView
} from './project-types'
const STATUS_FIELD: GitHubProjectField = {
kind: 'single-select',
id: 'f_status',
name: 'Status',
dataType: 'SINGLE_SELECT',
options: [
{ id: 'opt_todo', name: 'Todo', color: 'GREEN' },
{ id: 'opt_prog', name: 'In Progress', color: 'YELLOW' },
{ id: 'opt_done', name: 'Done', color: 'PURPLE' }
]
}
const SPRINT_FIELD: GitHubProjectField = {
kind: 'iteration',
id: 'f_sprint',
name: 'Sprint',
dataType: 'ITERATION',
iterations: [
{ id: 'it_1', title: 'Sprint 1', startDate: '2026-08-24', duration: 14, completed: true },
{ id: 'it_2', title: 'Sprint 2', startDate: '2026-09-07', duration: 14, completed: false }
]
}
function view(overrides: Partial<GitHubProjectView>): GitHubProjectView {
return {
id: 'PVTV_1',
number: 1,
name: 'Board',
layout: 'BOARD_LAYOUT',
filter: '',
fields: [],
groupByFields: [],
sortByFields: [],
...overrides
}
}
function row(id: string, values: GitHubProjectFieldValue[]): GitHubProjectRow {
const fieldValuesByFieldId: Record<string, GitHubProjectFieldValue> = {}
for (const value of values) {
fieldValuesByFieldId[value.fieldId] = value
}
return {
id,
itemType: 'ISSUE',
content: {
number: 1,
title: id,
body: null,
url: `https://github.com/o/r/issues/1`,
state: 'OPEN',
stateReason: null,
isDraft: null,
repository: 'o/r',
assignees: [],
labels: [],
parentIssue: null,
issueType: null
},
fieldValuesByFieldId,
updatedAt: '2026-09-01T00:00:00Z',
position: 0
}
}
const statusValue = (optionId: string, name: string): GitHubProjectFieldValue => ({
kind: 'single-select',
fieldId: 'f_status',
optionId,
name,
color: ''
})
describe('resolveBoardColumnField', () => {
it('prefers the view-configured vertical group field', () => {
const v = view({ fields: [STATUS_FIELD], verticalGroupByFields: [SPRINT_FIELD] })
expect(resolveBoardColumnField(v)).toBe(SPRINT_FIELD)
})
it('falls back to the Status field, then any single-select', () => {
const priority: GitHubProjectField = { ...STATUS_FIELD, id: 'f_prio', name: 'Priority' }
expect(resolveBoardColumnField(view({ fields: [priority, STATUS_FIELD] }))).toBe(STATUS_FIELD)
expect(resolveBoardColumnField(view({ fields: [priority] }))).toBe(priority)
})
it('ignores a non-select vertical field instead of building clear-only columns', () => {
const textField: GitHubProjectField = {
kind: 'field',
id: 'f_text',
name: 'Notes',
dataType: 'TEXT'
}
const v = view({ fields: [STATUS_FIELD], verticalGroupByFields: [textField] })
expect(resolveBoardColumnField(v)).toBe(STATUS_FIELD)
})
it('returns null when nothing can shape columns', () => {
expect(
resolveBoardColumnField(
view({ fields: [{ kind: 'field', id: 'f_t', name: 'Title', dataType: 'TITLE' }] })
)
).toBeNull()
})
})
describe('buildBoardColumns', () => {
it('emits every option as a column in option order, empty ones included', () => {
const columns = buildBoardColumns(STATUS_FIELD, [
row('a', [statusValue('opt_done', 'Done')]),
row('b', [statusValue('opt_todo', 'Todo')])
])
expect(columns.map((c) => c.label)).toEqual(['Todo', 'In Progress', 'Done', 'No Status'])
expect(columns.map((c) => c.rows.length)).toEqual([1, 0, 1, 0])
expect(columns[0]?.dropValue).toEqual({ kind: 'single-select', optionId: 'opt_todo' })
expect(columns[0]?.color).toBe('GREEN')
})
it('routes no-value rows to the trailing column whose drop clears the field', () => {
const columns = buildBoardColumns(STATUS_FIELD, [row('a', [])])
const trailing = columns.at(-1)!
expect(trailing.label).toBe('No Status')
expect(trailing.rows.map((r) => r.id)).toEqual(['a'])
expect(trailing.dropValue).toBeNull()
})
it('keeps rows pointing at a deleted option in their own non-droppable column', () => {
const columns = buildBoardColumns(STATUS_FIELD, [
row('a', [statusValue('opt_gone', 'Archived')])
])
const ghost = columns.find((c) => c.label === 'Archived')!
expect(ghost.rows.map((r) => r.id)).toEqual(['a'])
expect(ghost.dropValue).toBeUndefined()
// Ghost columns sit between the real options and the no-value column.
expect(columns.at(-1)?.label).toBe('No Status')
})
it('builds iteration columns with iteration drops', () => {
const columns = buildBoardColumns(SPRINT_FIELD, [
row('a', [
{
kind: 'iteration',
fieldId: 'f_sprint',
iterationId: 'it_2',
title: 'Sprint 2',
startDate: '2026-09-07',
duration: 14
}
])
])
expect(columns.map((c) => c.label)).toEqual(['Sprint 1', 'Sprint 2', 'No Sprint'])
expect(columns[1]?.dropValue).toEqual({ kind: 'iteration', iterationId: 'it_2' })
expect(columns[1]?.rows.map((r) => r.id)).toEqual(['a'])
})
it('buckets non-select fields read-only', () => {
const assignees: GitHubProjectField = {
kind: 'field',
id: 'f_assignees',
name: 'Assignees',
dataType: 'ASSIGNEES'
}
const columns = buildBoardColumns(assignees, [
row('a', [
{
kind: 'users',
fieldId: 'f_assignees',
users: [{ login: 'alice', name: null, avatarUrl: null }]
}
]),
row('b', [])
])
expect(columns.map((c) => c.label)).toEqual(['alice', 'No Assignees'])
expect(columns[0]?.dropValue).toBeUndefined()
expect(columns.at(-1)?.dropValue).toBeUndefined()
expect(columns.at(-1)?.rows.map((r) => r.id)).toEqual(['b'])
})
})
@@ -0,0 +1,83 @@
import { EMPTY_PROJECT_GROUP_KEY, groupRowsByField } from './project-group-sort'
import type {
GitHubProjectField,
GitHubProjectFieldMutationValue,
GitHubProjectRow,
GitHubProjectView
} from './project-types'
export type ProjectBoardColumn = {
/** Stable key used for React reconciliation. */
key: string
label: string
/** GitHub single-select color token ('GREEN', …) when the column is one. */
color: string | null
/** undefined is read-only; null clears the field. */
dropValue: GitHubProjectFieldMutationValue | null | undefined
rows: GitHubProjectRow[]
}
export function resolveBoardColumnField(view: GitHubProjectView): GitHubProjectField | null {
const vertical = view.verticalGroupByFields?.[0]
// Non-select fields must never expose clear-only drop targets.
if (vertical && (vertical.kind === 'single-select' || vertical.kind === 'iteration')) {
return vertical
}
// Older hosts omit vertical grouping; Status is GitHub's default.
const singleSelects = view.fields.filter((field) => field.kind === 'single-select')
return singleSelects.find((field) => /^status$/i.test(field.name)) ?? singleSelects[0] ?? null
}
/** Includes empty options, deleted-option buckets, and a final unset bucket. */
export function buildBoardColumns(
field: GitHubProjectField,
rowsInOrder: GitHubProjectRow[]
): ProjectBoardColumn[] {
const buckets = groupRowsByField(field, rowsInOrder)
const bucketsByKey = new Map(buckets.map((bucket) => [bucket.key, bucket]))
const columns: ProjectBoardColumn[] = []
if (field.kind === 'single-select') {
for (const option of field.options) {
columns.push({
key: option.id,
label: option.name,
color: option.color || null,
dropValue: { kind: 'single-select', optionId: option.id },
rows: bucketsByKey.get(option.id)?.rows ?? []
})
bucketsByKey.delete(option.id)
}
} else if (field.kind === 'iteration') {
for (const iteration of field.iterations) {
columns.push({
key: iteration.id,
label: iteration.title,
color: null,
dropValue: { kind: 'iteration', iterationId: iteration.id },
rows: bucketsByKey.get(iteration.id)?.rows ?? []
})
bucketsByKey.delete(iteration.id)
}
}
// Preserve deleted options as read-only columns.
for (const bucket of buckets) {
if (!bucketsByKey.has(bucket.key) || bucket.key === EMPTY_PROJECT_GROUP_KEY) {
continue
}
columns.push({
key: bucket.key,
label: bucket.label,
color: null,
dropValue: undefined,
rows: bucket.rows
})
}
columns.push({
key: EMPTY_PROJECT_GROUP_KEY,
label: `No ${field.name}`,
color: null,
dropValue: field.kind === 'single-select' || field.kind === 'iteration' ? null : undefined,
rows: bucketsByKey.get(EMPTY_PROJECT_GROUP_KEY)?.rows ?? []
})
return columns
}
+13 -4
View File
@@ -22,7 +22,7 @@ export type ProjectGroup = {
rows: GitHubProjectRow[]
}
const EMPTY_GROUP_KEY = '__empty__'
export const EMPTY_PROJECT_GROUP_KEY = '__empty__'
type ProjectFieldValue = GitHubProjectRow['fieldValuesByFieldId'][string]
@@ -78,7 +78,7 @@ function getFieldValueForGrouping(
const value = row.fieldValuesByFieldId[field.id]
if (!hasNonEmptyFieldValue(value)) {
return {
key: EMPTY_GROUP_KEY,
key: EMPTY_PROJECT_GROUP_KEY,
label: labelForEmpty(field),
orderHint: UNKNOWN_INDEX_SENTINEL,
iteration: null
@@ -141,6 +141,15 @@ export function groupRows(
if (!groupField) {
return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }]
}
return groupRowsByField(groupField, rowsInOrder)
}
/** Buckets rows by an explicit field — the table view's groupBy and the board
* view's column field share this so both order and label groups identically. */
export function groupRowsByField(
groupField: GitHubProjectField,
rowsInOrder: GitHubProjectRow[]
): ProjectGroup[] {
const groupOrderIndex = createFieldOrderIndex(groupField)
const buckets = new Map<
string,
@@ -167,10 +176,10 @@ export function groupRows(
const entries = Array.from(buckets.entries())
// Ordering rules per design doc §Grouping.
entries.sort((a, b) => {
if (a[0] === EMPTY_GROUP_KEY) {
if (a[0] === EMPTY_PROJECT_GROUP_KEY) {
return 1
}
if (b[0] === EMPTY_GROUP_KEY) {
if (b[0] === EMPTY_PROJECT_GROUP_KEY) {
return -1
}
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
+11
View File
@@ -5,6 +5,13 @@
// would obscure ownership of the Project surface.
export type GitHubProjectViewLayout = 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT'
/** Allowlist shared by the host gate, tab strip, and picker — raw.layout is
* cast unchecked, so an unknown future GitHub layout must fail this check
* everywhere at once rather than drifting per call site. */
export function isRenderableProjectViewLayout(layout: string): boolean {
return layout === 'TABLE_LAYOUT' || layout === 'BOARD_LAYOUT' || layout === 'ROADMAP_LAYOUT'
}
export type GitHubProjectOwnerType = 'organization' | 'user'
// Why: anything outside this union must render as an empty cell — the
@@ -89,6 +96,10 @@ export type GitHubProjectView = {
fields: GitHubProjectField[]
groupByFields: GitHubProjectField[]
sortByFields: GitHubProjectSort[]
/** Board-layout column field(s). Optional for wire compat — older hosts
* don't send it, and hosts fall back to omitting it when the GraphQL
* schema lacks `verticalGroupByFields` (older GHES). */
verticalGroupByFields?: GitHubProjectField[]
}
export type GitHubProjectUser = {
+271
View File
@@ -0,0 +1,271 @@
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import { githubProjectIdentityKey } from '../../src/shared/github/project-identity'
import type { GitHubProjectTable } from '../../src/shared/github/project-types'
import type {
ClearProjectItemFieldArgs,
UpdateProjectItemFieldArgs
} from '../../src/shared/github/project-request-types'
type BoardFieldCall = UpdateProjectItemFieldArgs | ClearProjectItemFieldArgs
declare global {
var __githubProjectBoardCalls: BoardFieldCall[] | undefined
}
const project = { owner: 'board-proof', ownerType: 'user', number: 1, host: 'github.com' } as const
const board: GitHubProjectTable = {
project: {
...project,
id: 'project',
title: 'Board verification',
url: 'https://github.com/users/board-proof/projects/1'
},
selectedView: {
id: 'view',
number: 1,
name: 'Board',
layout: 'BOARD_LAYOUT',
filter: '',
fields: [],
groupByFields: [],
sortByFields: [],
verticalGroupByFields: [
{
id: 'status',
name: 'Status',
kind: 'single-select',
dataType: 'SINGLE_SELECT',
options: [
{ id: 'todo', name: 'Todo', color: 'GREEN' },
{ id: 'doing', name: 'In progress', color: 'YELLOW' },
{ id: 'done', name: 'Done', color: 'PURPLE' }
]
}
]
},
rows: [
{
id: 'row',
itemType: 'ISSUE',
updatedAt: '',
position: 0,
content: {
title: 'Verify a real board drop through the preload bridge',
number: 7,
body: null,
url: '',
state: 'OPEN',
stateReason: null,
isDraft: null,
repository: 'board-proof/repo',
labels: [],
parentIssue: null,
issueType: null,
assignees: [{ login: 'tester', name: null, avatarUrl: null }]
},
fieldValuesByFieldId: {
status: {
kind: 'single-select',
fieldId: 'status',
optionId: 'todo',
name: 'Todo',
color: 'GREEN'
}
}
}
],
totalCount: 1,
parentFieldDropped: false
}
test('board drops cross preload, settle optimistically, and roll back failures', async ({
orcaPage,
electronApp,
seededRepoPath
}, testInfo) => {
await waitForSessionReady(orcaPage)
await electronApp.evaluate(({ ipcMain }, data) => {
const responses = {
'gh:repoSlug': () => ({ owner: 'board-proof', repo: 'repo' }),
'gh:listProjectViews': () => ({ ok: true, views: [data.selectedView] }),
'gh:getProjectViewTable': () => ({ ok: true, data })
}
for (const [channel, handler] of Object.entries(responses)) {
ipcMain.removeHandler(channel)
ipcMain.handle(channel, handler)
}
const calls: BoardFieldCall[] = []
globalThis.__githubProjectBoardCalls = calls
ipcMain.removeHandler('gh:updateProjectItemField')
ipcMain.handle('gh:updateProjectItemField', async (_event, args) => {
calls.push(args)
await new Promise((resolve) => setTimeout(resolve, 400))
return { ok: true }
})
ipcMain.removeHandler('gh:clearProjectItemField')
ipcMain.handle('gh:clearProjectItemField', async (_event, args) => {
calls.push(args)
await new Promise((resolve) => setTimeout(resolve, 400))
return { ok: false, error: { type: 'network_error', message: 'Board verification: offline' } }
})
}, board)
await orcaPage.evaluate(
({ project, identity, seededRepoPath }) => {
const store = window.__store!
const state = store.getState()
const repo = state.repos.find((candidate) => candidate.path === seededRepoPath)
if (!repo || !state.settings) {
throw new Error('Seeded repository/settings missing')
}
store.setState({
repos: state.repos.map((candidate) =>
candidate.id === repo.id
? {
...candidate,
gitRemoteIdentity: {
canonicalKey: 'github.com/board-proof/repo',
remoteName: 'origin',
remoteUrl: 'https://github.com/board-proof/repo.git'
}
}
: candidate
),
settings: {
...state.settings,
defaultTaskSource: 'github',
defaultRepoSelection: [repo.id],
githubProjects: {
pinned: [],
recent: [],
activeProject: project,
lastViewByProject: { [identity]: { viewId: 'view' } }
}
},
taskResumeState: { githubMode: 'project' }
})
},
{ project, identity: githubProjectIdentityKey(project), seededRepoPath }
)
await orcaPage.getByRole('button', { name: 'Tasks', exact: true }).click()
await orcaPage.getByRole('button', { name: 'Projects', exact: true }).click()
const todo = orcaPage.getByTestId('board-column-todo')
const done = orcaPage.getByTestId('board-column-done')
const empty = orcaPage.getByTestId('board-column-__empty__')
const title = board.rows[0].content.title
await expect(todo.getByRole('button', { name: title })).toBeVisible()
await expect(orcaPage.getByTestId('board-column-doing')).toBeVisible()
await orcaPage.evaluate(() => document.documentElement.classList.add('dark'))
await orcaPage.screenshot({ path: testInfo.outputPath('board-dark.png') })
await todo.getByRole('listitem').dragTo(done)
await expect(done.getByRole('button', { name: title })).toBeVisible()
await expect(todo.getByRole('button', { name: title })).toHaveCount(0)
await expect
.poll(() => electronApp.evaluate(() => globalThis.__githubProjectBoardCalls))
.toEqual([
{
projectId: 'project',
host: 'github.com',
itemId: 'row',
fieldId: 'status',
value: { kind: 'single-select', optionId: 'done' }
}
])
await done.getByRole('listitem').dragTo(done)
await done.getByRole('listitem').dragTo(empty)
await expect(empty.getByRole('button', { name: title })).toBeVisible()
await expect(orcaPage.getByText('Board verification: offline', { exact: true })).toBeVisible()
await expect(done.getByRole('button', { name: title })).toBeVisible()
await expect(empty.getByRole('button', { name: title })).toHaveCount(0)
await expect
.poll(() => electronApp.evaluate(() => globalThis.__githubProjectBoardCalls))
.toEqual([
{
projectId: 'project',
host: 'github.com',
itemId: 'row',
fieldId: 'status',
value: { kind: 'single-select', optionId: 'done' }
},
{ projectId: 'project', host: 'github.com', itemId: 'row', fieldId: 'status' }
])
await orcaPage.screenshot({ path: testInfo.outputPath('board-rollback.png') })
await orcaPage.evaluate(() => document.documentElement.classList.remove('dark'))
await orcaPage.screenshot({ path: testInfo.outputPath('board-light.png') })
await orcaPage.evaluate(() => {
const store = window.__store!
const entry = Object.entries(store.getState().projectViewCache).find(
([, entry]) => entry.data?.project.id === 'project'
)
if (!entry?.[1].data) {
throw new Error('Board cache missing')
}
const [key, cached] = entry
const table = cached.data!
const source = table.rows[0]
const field = table.selectedView.verticalGroupByFields?.[0]
if (field?.kind !== 'single-select') {
throw new Error('Status field missing')
}
store.setState({
projectViewCache: {
[key]: {
...cached,
data: {
...table,
selectedView: {
...table.selectedView,
verticalGroupByFields: [
{
...field,
options: [
...field.options,
...Array.from({ length: 6 }, (_, index) => ({
id: `extra-${index}`,
name: `Extra ${index}`,
color: 'BLUE'
}))
]
}
]
},
rows: [
source,
...Array.from({ length: 40 }, (_, index) => ({
...source,
id: `overflow-${index}`,
position: index + 1,
content: {
...source.content,
title: `Overflow card ${index}`,
number: index + 100
},
fieldValuesByFieldId: {
status: {
kind: 'single-select' as const,
fieldId: 'status',
optionId: 'todo',
name: 'Todo',
color: 'GREEN'
}
}
}))
],
totalCount: 41
}
}
}
})
})
const scroller = todo.locator('.overflow-y-auto')
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight > element.clientHeight))
.toBe(true)
await todo.getByRole('button', { name: 'Overflow card 39', exact: true }).scrollIntoViewIfNeeded()
await expect(todo.getByRole('button', { name: 'Overflow card 39', exact: true })).toBeVisible()
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await orcaPage.getByTestId('board-column-extra-5').scrollIntoViewIfNeeded()
await expect(orcaPage.getByTestId('board-column-extra-5')).toBeVisible()
await orcaPage.screenshot({ path: testInfo.outputPath('board-scroll.png') })
})