mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
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:
co-authored by
Claude Fable 5
Neil
parent
ca4e239861
commit
635e7f53cb
@@ -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
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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') })
|
||||
})
|
||||
Reference in New Issue
Block a user