From 635e7f53cb7240dbb899811130fa2be55aa74740 Mon Sep 17 00:00:00 2001 From: NaoyaTatetsu Date: Tue, 6 Oct 2026 14:23:57 +0900 Subject: [PATCH] feat(github-projects): render Board project views as a kanban with drag-and-drop (#19074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 Co-authored-by: Neil --- .../project-view/project-view-config.test.ts | 196 +++++++++++ .../project-view/project-view-config.ts | 55 ++- .../project-view/project-view-table.test.ts | 43 ++- .../github/project-view/project-view-table.ts | 18 +- .../github-project/ProjectBoard.test.tsx | 267 ++++++++++++++ .../github-project/ProjectBoard.tsx | 239 +++++++++++++ .../github-project/ProjectBoardCard.tsx | 108 ++++++ .../github-project/ProjectPickerPanels.tsx | 8 +- .../github-project/ProjectViewStates.tsx | 13 +- .../github-project/ProjectViewWrapper.tsx | 26 +- .../src/i18n/en-runtime-required.json | 7 +- src/renderer/src/i18n/locales/en.json | 20 ++ .../src/store/github/cache-identity.ts | 4 +- .../src/store/github/project-actions.ts | 138 +------- .../src/store/github/project-cache.ts | 7 +- .../github/project-field-mutations.test.ts | 326 ++++++++++++++++++ .../store/github/project-field-mutations.ts | 163 +++++++++ .../github/project-board-columns.test.ts | 186 ++++++++++ src/shared/github/project-board-columns.ts | 83 +++++ src/shared/github/project-group-sort.ts | 17 +- src/shared/github/project-types.ts | 11 + tests/e2e/github-project-board.spec.ts | 271 +++++++++++++++ 22 files changed, 2025 insertions(+), 181 deletions(-) create mode 100644 src/main/github/project-view/project-view-config.test.ts create mode 100644 src/renderer/src/components/github-project/ProjectBoard.test.tsx create mode 100644 src/renderer/src/components/github-project/ProjectBoard.tsx create mode 100644 src/renderer/src/components/github-project/ProjectBoardCard.tsx create mode 100644 src/renderer/src/store/github/project-field-mutations.test.ts create mode 100644 src/renderer/src/store/github/project-field-mutations.ts create mode 100644 src/shared/github/project-board-columns.test.ts create mode 100644 src/shared/github/project-board-columns.ts create mode 100644 tests/e2e/github-project-board.spec.ts diff --git a/src/main/github/project-view/project-view-config.test.ts b/src/main/github/project-view/project-view-config.test.ts new file mode 100644 index 00000000000..67022c23e3d --- /dev/null +++ b/src/main/github/project-view/project-view-config.test.ts @@ -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()), + 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) + } + }) +}) diff --git a/src/main/github/project-view/project-view-config.ts b/src/main/github/project-view/project-view-config.ts index 794e7861bf9..5b226a9e02b 100644 --- a/src/main/github/project-view/project-view-config.ts +++ b/src/main/github/project-view/project-view-config.ts @@ -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() + +/** 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>( - query, + let res = await runGraphql>( + buildQuery(), vars, projectGhExecOptions(args.host) ) + if (!res.ok && verticalGroupBySelection(args.host) && errorsIndicateVerticalGroupBy(res.raw)) { + hostsWithoutVerticalGroupBy.add(githubProjectHost(args.host)) + res = await runGraphql>( + 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 } : {}) } } } diff --git a/src/main/github/project-view/project-view-table.test.ts b/src/main/github/project-view/project-view-table.test.ts index b51a08d4bf0..b929b8d4fdc 100644 --- a/src/main/github/project-view/project-view-table.test.ts +++ b/src/main/github/project-view/project-view-table.test.ts @@ -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: '' }) + }) }) diff --git a/src/main/github/project-view/project-view-table.ts b/src/main/github/project-view/project-view-table.ts index bb580cb78e0..0a5f992d9fe 100644 --- a/src/main/github/project-view/project-view-table.ts +++ b/src/main/github/project-view/project-view-table.ts @@ -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 } : {}) } diff --git a/src/renderer/src/components/github-project/ProjectBoard.test.tsx b/src/renderer/src/components/github-project/ProjectBoard.test.tsx new file mode 100644 index 00000000000..6dd326a7db0 --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectBoard.test.tsx @@ -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 = {} + 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 } { + const store: Record = { '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( + list} + /> + ) + 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(list} />) + 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( + + ) + 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(list} />) + 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( + list} + /> + ) + 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( + list} + /> + ) + 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(list} />) + 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(list} />) + 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(list} />) + 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( + list} + /> + ) + 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( + + ) + expect(screen.getByLabelText('#7 — Read only').getAttribute('draggable')).toBe('false') + }) +}) diff --git a/src/renderer/src/components/github-project/ProjectBoard.tsx b/src/renderer/src/components/github-project/ProjectBoard.tsx new file mode 100644 index 00000000000..42ef72a7b81 --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectBoard.tsx @@ -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(null) + const containerRef = useRef(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 ( +
+
+ {translate( + 'projectBoard.noColumnField', + 'This board view has no single-select or iteration field to group by, so Orca is listing items instead.' + )} +
+ {fallback} +
+ ) + } + + if (table.rows.length === 0) { + return + } + + return ( +
+ {columns.map((column) => ( + { + if (column.dropValue !== undefined) { + setDropTarget(column.key) + } + }} + onDragLeaveOrEnd={() => + setDropTarget((current) => (current === column.key ? null : current)) + } + /> + ))} +
+ ) +} + +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 ( +
{ + 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() + } + }} + > +
+ {colors ? ( + + ) : null} + {column.label} + + {column.rows.length} + +
+
+ {column.rows.map((row) => ( + onOpenDialog?.(row)} + onDragStart={(event) => { + event.dataTransfer.setData(CARD_DRAG_MIME, row.id) + event.dataTransfer.effectAllowed = 'move' + }} + /> + ))} +
+
+ ) +} diff --git a/src/renderer/src/components/github-project/ProjectBoardCard.tsx b/src/renderer/src/components/github-project/ProjectBoardCard.tsx new file mode 100644 index 00000000000..d3855889b90 --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectBoardCard.tsx @@ -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) => 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 ( +
+
+ {Glyph ? : null} +
+ {clickable ? ( + + ) : ( + + {title} + + )} +
+ {row.content.number == null ? null : ( + #{row.content.number} + )} + {row.content.repository ? ( + {row.content.repository} + ) : null} +
+
+ {row.content.assignees.length > 0 ? ( +
+ {row.content.assignees.slice(0, 3).map((user) => + user.avatarUrl ? ( + {user.login} + ) : ( + + {user.login.charAt(0)} + + ) + )} +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/github-project/ProjectPickerPanels.tsx b/src/renderer/src/components/github-project/ProjectPickerPanels.tsx index a0183ee6968..72071b51669 100644 --- a/src/renderer/src/components/github-project/ProjectPickerPanels.tsx +++ b/src/renderer/src/components/github-project/ProjectPickerPanels.tsx @@ -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 }): 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( diff --git a/src/renderer/src/components/github-project/ProjectViewStates.tsx b/src/renderer/src/components/github-project/ProjectViewStates.tsx index d2cb13bbfb4..b9e79fb78db 100644 --- a/src/renderer/src/components/github-project/ProjectViewStates.tsx +++ b/src/renderer/src/components/github-project/ProjectViewStates.tsx @@ -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({

{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.' )}