From 8f89fbdd83c2f3c473bd0f0fe24001da016e139b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 12:57:23 +0000 Subject: [PATCH] fix(bulk-edit): await bulkUpdate dispatch before closing flyout Return a fast accept/reject from the bulkUpdate handler so the edit flyout can surface queue-full and messaging failures without pinning Recent or dismissing the picker. --- .../__tests__/bulk-update-dispatch.test.ts | 77 +++ src/background/bulk-update.ts | 467 +++++++++--------- .../__tests__/bulk-edit-flyout.test.tsx | 85 +++- src/features/bulk-edit-flyout-helpers.ts | 58 +++ src/features/bulk-edit-flyout.tsx | 72 ++- src/lib/messages.ts | 7 +- src/lib/schemas-messages.ts | 5 +- 7 files changed, 520 insertions(+), 251 deletions(-) create mode 100644 src/background/__tests__/bulk-update-dispatch.test.ts diff --git a/src/background/__tests__/bulk-update-dispatch.test.ts b/src/background/__tests__/bulk-update-dispatch.test.ts new file mode 100644 index 0000000..d16368d --- /dev/null +++ b/src/background/__tests__/bulk-update-dispatch.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const hoisted = vi.hoisted(() => ({ + isBulkFull: vi.fn(() => false), + acquireBulk: vi.fn(), + releaseBulk: vi.fn(), + handlers: new Map< + string, + (msg: { data: unknown; sender: { tab?: { id?: number } } }) => Promise + >(), +})) + +vi.mock('@/lib/debug-logger', () => ({ + logger: { log: () => {}, warn: () => {}, error: () => {}, info: () => {} }, +})) + +vi.mock('@/background/concurrency', () => ({ + isBulkFull: hoisted.isBulkFull, + acquireBulk: hoisted.acquireBulk, + releaseBulk: hoisted.releaseBulk, +})) + +vi.mock('@/lib/messages', () => ({ + onMessage: (type: string, handler: (typeof hoisted.handlers) extends Map ? H : never) => { + hoisted.handlers.set(type, handler) + }, +})) + +vi.mock('@/background/cache', () => ({ takeCachedResolvedItems: vi.fn() })) +vi.mock('@/background/rest-helpers', () => ({ broadcastQueue: vi.fn(async () => {}) })) +vi.mock('@/background/relationship-helpers', () => ({ buildBulkRelationshipTasks: vi.fn(() => []) })) +vi.mock('@/background/project-helpers', () => ({ resolveProjectItemIds: vi.fn(async () => []) })) +vi.mock('@/lib/queue', () => ({ processQueue: vi.fn(async () => {}), sleep: vi.fn() })) +vi.mock('@/lib/graphql-client', () => ({ gql: vi.fn() })) + +import { registerBulkUpdateHandler } from '@/background/bulk-update' + +describe('bulkUpdate dispatch', () => { + beforeEach(() => { + hoisted.handlers.clear() + hoisted.isBulkFull.mockReset() + hoisted.acquireBulk.mockReset() + hoisted.releaseBulk.mockReset() + hoisted.isBulkFull.mockReturnValue(false) + registerBulkUpdateHandler() + }) + + it('returns concurrent rejection without acquiring when bulk is full', async () => { + hoisted.isBulkFull.mockReturnValue(true) + const handler = hoisted.handlers.get('bulkUpdate') + expect(handler).toBeDefined() + + const result = await handler!({ + data: { itemIds: ['a'], projectId: 'p', updates: [] }, + sender: { tab: { id: 1 } }, + }) + + expect(result).toEqual({ ok: false, reason: 'concurrent' }) + expect(hoisted.acquireBulk).not.toHaveBeenCalled() + expect(hoisted.releaseBulk).not.toHaveBeenCalled() + }) + + it('returns ok and releases bulk slot after background work', async () => { + const handler = hoisted.handlers.get('bulkUpdate')! + const result = await handler({ + data: { itemIds: ['a'], projectId: 'p', updates: [] }, + sender: { tab: { id: 2 } }, + }) + + expect(result).toEqual({ ok: true }) + expect(hoisted.acquireBulk).toHaveBeenCalledTimes(1) + + await vi.waitFor(() => { + expect(hoisted.releaseBulk).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/background/bulk-update.ts b/src/background/bulk-update.ts index 73d0019..1ab31a5 100644 --- a/src/background/bulk-update.ts +++ b/src/background/bulk-update.ts @@ -1,6 +1,7 @@ // bulkUpdate handler — applies field/title/body/comment/relationship updates. import { onMessage } from '@/lib/messages' +import type { BulkEditRelationshipsUpdate, BulkUpdateDispatchResult } from '@/lib/messages' import { gql } from '@/lib/graphql-client' import { ADD_ASSIGNEES, @@ -24,265 +25,291 @@ import { broadcastQueue } from '@/background/rest-helpers' import { buildBulkRelationshipTasks } from '@/background/relationship-helpers' import { resolveProjectItemIds } from '@/background/project-helpers' +export interface BulkUpdateMessageData { + itemIds: string[] + projectId: string + updates: { fieldId: string; value: unknown }[] + relationships?: BulkEditRelationshipsUpdate + fieldMeta?: Record< + string, + { + name: string + options?: { id: string; name: string }[] + iterations?: { id: string; title: string; startDate: string; duration: number }[] + } + > +} + function formatDetailDate(iso: string): string { const d = new Date(iso + 'T00:00:00') if (isNaN(d.getTime())) return iso return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } -export function registerBulkUpdateHandler(): void { - onMessage('bulkUpdate', async ({ data, sender }) => { - logger.log('[rgp:bg] bulkUpdate received', { - itemCount: data.itemIds.length, - updatesCount: data.updates.length, - projectId: data.projectId, - }) +export async function runBulkUpdate( + data: BulkUpdateMessageData, + tabId: number | undefined, +): Promise { + const processId = `bulk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` + const label = `Bulk update · ${data.itemIds.length} item${data.itemIds.length !== 1 ? 's' : ''}` - if (isBulkFull()) { - console.warn('[rgp:bg] max concurrent bulk updates reached, rejecting') + try { + await broadcastQueue( + { + total: data.itemIds.length, + completed: 0, + paused: false, + status: 'Resolving items...', + processId, + label, + }, + tabId, + ) + const cachedResolvedItems = data.relationships + ? takeCachedResolvedItems(data.projectId, data.itemIds) + : undefined + const resolvedItems = + cachedResolvedItems ?? (await resolveProjectItemIds(data.itemIds, data.projectId, tabId)) + logger.log('[rgp:bg] resolved item IDs', resolvedItems) + + if (resolvedItems.length === 0) { + console.error('[rgp:bg] no valid ProjectV2Item IDs resolved, aborting') return } - acquireBulk() - const processId = `bulk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` - const label = `Bulk update · ${data.itemIds.length} item${data.itemIds.length !== 1 ? 's' : ''}` - const tabId = sender.tab?.id - - try { - await broadcastQueue( - { - total: data.itemIds.length, - completed: 0, - paused: false, - status: 'Resolving items...', - processId, - label, - }, - tabId, - ) - const cachedResolvedItems = data.relationships - ? takeCachedResolvedItems(data.projectId, data.itemIds) - : undefined - const resolvedItems = - cachedResolvedItems ?? (await resolveProjectItemIds(data.itemIds, data.projectId, tabId)) - logger.log('[rgp:bg] resolved item IDs', resolvedItems) - - if (resolvedItems.length === 0) { - console.error('[rgp:bg] no valid ProjectV2Item IDs resolved, aborting') - return - } - - const tasks: QueueTask[] = [] + const tasks: QueueTask[] = [] - for (const item of resolvedItems) { - const { domId, projectItemId, issueNodeId, typename } = item - for (const update of data.updates) { - const { dataType, singleSelectOptionId, iterationId, array } = update.value as any + for (const item of resolvedItems) { + const { domId, projectItemId, issueNodeId, typename } = item + for (const update of data.updates) { + const { dataType, singleSelectOptionId, iterationId, array } = update.value as any - const meta = data.fieldMeta?.[update.fieldId] - const fieldLabel = meta?.name ?? 'Field' + const meta = data.fieldMeta?.[update.fieldId] + const fieldLabel = meta?.name ?? 'Field' - let detail: string - if (dataType === 'ASSIGNEES') { - const logins: string[] = (array ?? []).map((a: any) => a.login).filter(Boolean) - detail = - logins.length > 0 - ? `Adding assignees: ${logins.map((l: string) => '@' + l).join(', ')}` - : 'Adding assignees' - } else if (dataType === 'LABELS') { - const names: string[] = (array ?? []).map((l: any) => l.name).filter(Boolean) - detail = names.length > 0 ? `Adding labels: ${names.join(', ')}` : 'Adding labels' - } else if (dataType === 'MILESTONE') { - const milestoneName: string = - (array as any)?.[0]?.title ?? (array as any)?.[0]?.name ?? '' - detail = milestoneName ? `Setting milestone → ${milestoneName}` : 'Setting milestone' - } else if (dataType === 'ISSUE_TYPE') { - const issueTypeName: string = (array as any)?.[0]?.name ?? '' - detail = issueTypeName ? `Setting issue type → ${issueTypeName}` : 'Setting issue type' - } else if (dataType === 'TITLE') { - const { text } = update.value as any - const trimmed: string = text?.trim() ?? '' - detail = trimmed - ? `Changing title → "${trimmed.length > 40 ? trimmed.slice(0, 40) + '…' : trimmed}"` - : 'Updating title' - } else if (dataType === 'BODY') { - detail = 'Updating body' - } else if (dataType === 'COMMENT') { - detail = 'Adding comment' - } else if (dataType === 'SINGLE_SELECT') { - const optName = meta?.options?.find( - (o: { id: string; name: string }) => o.id === singleSelectOptionId, - )?.name - detail = optName ? `${fieldLabel} → ${optName}` : `${fieldLabel} → (option)` - } else if (dataType === 'ITERATION') { - const iterTitle = meta?.iterations?.find( - (i: { id: string; title: string }) => i.id === iterationId, - )?.title - detail = iterTitle ? `${fieldLabel} → ${iterTitle}` : `${fieldLabel} → (iteration)` + let detail: string + if (dataType === 'ASSIGNEES') { + const logins: string[] = (array ?? []).map((a: any) => a.login).filter(Boolean) + detail = + logins.length > 0 + ? `Adding assignees: ${logins.map((l: string) => '@' + l).join(', ')}` + : 'Adding assignees' + } else if (dataType === 'LABELS') { + const names: string[] = (array ?? []).map((l: any) => l.name).filter(Boolean) + detail = names.length > 0 ? `Adding labels: ${names.join(', ')}` : 'Adding labels' + } else if (dataType === 'MILESTONE') { + const milestoneName: string = (array as any)?.[0]?.title ?? (array as any)?.[0]?.name ?? '' + detail = milestoneName ? `Setting milestone → ${milestoneName}` : 'Setting milestone' + } else if (dataType === 'ISSUE_TYPE') { + const issueTypeName: string = (array as any)?.[0]?.name ?? '' + detail = issueTypeName ? `Setting issue type → ${issueTypeName}` : 'Setting issue type' + } else if (dataType === 'TITLE') { + const { text } = update.value as any + const trimmed: string = text?.trim() ?? '' + detail = trimmed + ? `Changing title → "${trimmed.length > 40 ? trimmed.slice(0, 40) + '…' : trimmed}"` + : 'Updating title' + } else if (dataType === 'BODY') { + detail = 'Updating body' + } else if (dataType === 'COMMENT') { + detail = 'Adding comment' + } else if (dataType === 'SINGLE_SELECT') { + const optName = meta?.options?.find( + (o: { id: string; name: string }) => o.id === singleSelectOptionId, + )?.name + detail = optName ? `${fieldLabel} → ${optName}` : `${fieldLabel} → (option)` + } else if (dataType === 'ITERATION') { + const iterTitle = meta?.iterations?.find( + (i: { id: string; title: string }) => i.id === iterationId, + )?.title + detail = iterTitle ? `${fieldLabel} → ${iterTitle}` : `${fieldLabel} → (iteration)` + } else { + const { text, date, number: num } = update.value as any + if (text !== undefined) { + const preview: string = + (text as string).length > 30 ? (text as string).slice(0, 30) + '…' : text + detail = `${fieldLabel} → "${preview}"` + } else if (num !== undefined && num !== null) { + detail = `${fieldLabel} → ${num}` + } else if (date !== undefined) { + detail = `${fieldLabel} → ${formatDetailDate(date as string)}` } else { - const { text, date, number: num } = update.value as any - if (text !== undefined) { - const preview: string = - (text as string).length > 30 ? (text as string).slice(0, 30) + '…' : text - detail = `${fieldLabel} → "${preview}"` - } else if (num !== undefined && num !== null) { - detail = `${fieldLabel} → ${num}` - } else if (date !== undefined) { - detail = `${fieldLabel} → ${formatDetailDate(date as string)}` - } else { - detail = `Updating ${fieldLabel}` - } + detail = `Updating ${fieldLabel}` } + } - tasks.push({ - id: `bulk-${domId}-${update.fieldId}`, - detail, - run: async () => { - if (dataType === 'ASSIGNEES') { - if (array?.length > 0) { - const assigneeIds = array.map((a: { id: string }) => a.id) - logger.log('[rgp:bg] Adding assignees:', assigneeIds, 'to issue:', issueNodeId) - await gql(ADD_ASSIGNEES, { - assignableId: issueNodeId, - assigneeIds, - }) - await sleep(1000) - } - return + tasks.push({ + id: `bulk-${domId}-${update.fieldId}`, + detail, + run: async () => { + if (dataType === 'ASSIGNEES') { + if (array?.length > 0) { + const assigneeIds = array.map((a: { id: string }) => a.id) + logger.log('[rgp:bg] Adding assignees:', assigneeIds, 'to issue:', issueNodeId) + await gql(ADD_ASSIGNEES, { + assignableId: issueNodeId, + assigneeIds, + }) + await sleep(1000) } + return + } - if (dataType === 'LABELS') { - if (array?.length > 0) { - const labelIds = array.map((l: { id: string }) => l.id) - logger.log('[rgp:bg] Adding labels:', labelIds, 'to issue:', issueNodeId) - await gql(ADD_LABELS, { - labelableId: issueNodeId, - labelIds, - }) - await sleep(1000) - } - return + if (dataType === 'LABELS') { + if (array?.length > 0) { + const labelIds = array.map((l: { id: string }) => l.id) + logger.log('[rgp:bg] Adding labels:', labelIds, 'to issue:', issueNodeId) + await gql(ADD_LABELS, { + labelableId: issueNodeId, + labelIds, + }) + await sleep(1000) } + return + } - if (dataType === 'MILESTONE') { - if (array?.length > 0) { - const milestoneId = array[0].id - logger.log('[rgp:bg] Setting milestone:', milestoneId, 'on issue:', issueNodeId) - await gql(UPDATE_ISSUE_MILESTONE, { - issueId: issueNodeId, - milestoneId, - }) - await sleep(1000) - } - return + if (dataType === 'MILESTONE') { + if (array?.length > 0) { + const milestoneId = array[0].id + logger.log('[rgp:bg] Setting milestone:', milestoneId, 'on issue:', issueNodeId) + await gql(UPDATE_ISSUE_MILESTONE, { + issueId: issueNodeId, + milestoneId, + }) + await sleep(1000) } + return + } - if (dataType === 'ISSUE_TYPE') { - if (array?.length > 0) { - const issueTypeId = array[0].id - logger.log('[rgp:bg] Setting issue type:', issueTypeId, 'on issue:', issueNodeId) - await gql(UPDATE_ISSUE_TYPE, { - issueId: issueNodeId, - issueTypeId, - }) - await sleep(1000) - } - return + if (dataType === 'ISSUE_TYPE') { + if (array?.length > 0) { + const issueTypeId = array[0].id + logger.log('[rgp:bg] Setting issue type:', issueTypeId, 'on issue:', issueNodeId) + await gql(UPDATE_ISSUE_TYPE, { + issueId: issueNodeId, + issueTypeId, + }) + await sleep(1000) } + return + } - if (dataType === 'TITLE') { - const { text } = update.value as any - if (text?.trim()) { - if (typename === 'PullRequest') { - await gql(UPDATE_PR_TITLE, { prId: issueNodeId, title: text.trim() }) - } else { - await gql(UPDATE_ISSUE_TITLE, { issueId: issueNodeId, title: text.trim() }) - } - await sleep(1000) + if (dataType === 'TITLE') { + const { text } = update.value as any + if (text?.trim()) { + if (typename === 'PullRequest') { + await gql(UPDATE_PR_TITLE, { prId: issueNodeId, title: text.trim() }) + } else { + await gql(UPDATE_ISSUE_TITLE, { issueId: issueNodeId, title: text.trim() }) } - return + await sleep(1000) } + return + } - if (dataType === 'BODY') { - const { text } = update.value as any - if (text !== undefined) { - if (typename === 'PullRequest') { - await gql(UPDATE_PR_BODY, { prId: issueNodeId, body: text }) - } else { - await gql(UPDATE_ISSUE_BODY, { issueId: issueNodeId, body: text }) - } - await sleep(1000) + if (dataType === 'BODY') { + const { text } = update.value as any + if (text !== undefined) { + if (typename === 'PullRequest') { + await gql(UPDATE_PR_BODY, { prId: issueNodeId, body: text }) + } else { + await gql(UPDATE_ISSUE_BODY, { issueId: issueNodeId, body: text }) } - return + await sleep(1000) } + return + } - if (dataType === 'COMMENT') { - const { text } = update.value as any - if (text?.trim()) { - await gql(ADD_COMMENT, { subjectId: issueNodeId, body: text.trim() }) - await sleep(1000) - } - return + if (dataType === 'COMMENT') { + const { text } = update.value as any + if (text?.trim()) { + await gql(ADD_COMMENT, { subjectId: issueNodeId, body: text.trim() }) + await sleep(1000) } + return + } - // default project custom fields - let valueOpt: any = {} - if (singleSelectOptionId) valueOpt = { singleSelectOptionId } - else if (iterationId) valueOpt = { iterationId } - else { - const { text, date, number: num } = update.value as any - if (date !== undefined) valueOpt = { date } - else if (num !== undefined && num !== null) valueOpt = { number: num } - else if (text !== undefined) valueOpt = { text } - } + // default project custom fields + let valueOpt: any = {} + if (singleSelectOptionId) valueOpt = { singleSelectOptionId } + else if (iterationId) valueOpt = { iterationId } + else { + const { text, date, number: num } = update.value as any + if (date !== undefined) valueOpt = { date } + else if (num !== undefined && num !== null) valueOpt = { number: num } + else if (text !== undefined) valueOpt = { text } + } - await gql(UPDATE_PROJECT_FIELD, { - projectId: data.projectId, - itemId: projectItemId, - fieldId: update.fieldId, - value: valueOpt, - }) - }, - }) - } + await gql(UPDATE_PROJECT_FIELD, { + projectId: data.projectId, + itemId: projectItemId, + fieldId: update.fieldId, + value: valueOpt, + }) + }, + }) + } - if (data.relationships) { - tasks.push(...buildBulkRelationshipTasks(item, data.relationships, tabId)) - } + if (data.relationships) { + tasks.push(...buildBulkRelationshipTasks(item, data.relationships, tabId)) } + } - await processQueue( - tasks, - async (state) => { - logger.log('[rgp:bg] queue state broadcast', { - completed: state.completed, + await processQueue( + tasks, + async (state) => { + logger.log('[rgp:bg] queue state broadcast', { + completed: state.completed, + total: state.total, + processId, + }) + await broadcastQueue( + { total: state.total, + completed: state.completed, + paused: state.paused, + retryAfter: state.retryAfter, + status: `Updating ${resolvedItems.length} item${resolvedItems.length !== 1 ? 's' : ''}...`, + detail: state.detail, processId, - }) - await broadcastQueue( - { - total: state.total, - completed: state.completed, - paused: state.paused, - retryAfter: state.retryAfter, - status: `Updating ${resolvedItems.length} item${resolvedItems.length !== 1 ? 's' : ''}...`, - detail: state.detail, - processId, - label, - failedItems: state.failedItems, - }, - tabId, - ) - }, - processId, - ) + label, + failedItems: state.failedItems, + }, + tabId, + ) + }, + processId, + ) - await broadcastQueue( - { total: 0, completed: 0, paused: false, status: 'Done!', processId, label }, - tabId, - ) - } finally { - releaseBulk() + await broadcastQueue( + { total: 0, completed: 0, paused: false, status: 'Done!', processId, label }, + tabId, + ) + } catch (error) { + console.error('[rgp:bg] bulkUpdate failed', error) + await broadcastQueue( + { total: 0, completed: 0, paused: false, status: 'Done!', processId, label }, + tabId, + ) + } +} + +export function registerBulkUpdateHandler(): void { + onMessage('bulkUpdate', async ({ data, sender }) => { + logger.log('[rgp:bg] bulkUpdate received', { + itemCount: data.itemIds.length, + updatesCount: data.updates.length, + projectId: data.projectId, + }) + + if (isBulkFull()) { + console.warn('[rgp:bg] max concurrent bulk updates reached, rejecting') + return { ok: false, reason: 'concurrent' } satisfies BulkUpdateDispatchResult } + + acquireBulk() + const tabId = sender.tab?.id + void runBulkUpdate(data, tabId).finally(() => releaseBulk()) + return { ok: true } }) } diff --git a/src/features/__tests__/bulk-edit-flyout.test.tsx b/src/features/__tests__/bulk-edit-flyout.test.tsx index 4a8c1f7..98bd2be 100644 --- a/src/features/__tests__/bulk-edit-flyout.test.tsx +++ b/src/features/__tests__/bulk-edit-flyout.test.tsx @@ -1,6 +1,28 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' -import { canApply, defaultValueFor, serializeValue } from '@/features/bulk-edit-flyout-helpers' +const hoisted = vi.hoisted(() => ({ + sendMessage: vi.fn(), + getActiveCount: vi.fn(() => 0), +})) + +vi.mock('@/lib/messages', () => ({ + sendMessage: hoisted.sendMessage, +})) + +vi.mock('@/lib/queue-store', () => ({ + queueStore: { + getActiveCount: hoisted.getActiveCount, + }, +})) + +import { + BULK_EDIT_CONCURRENT_MESSAGE, + BULK_EDIT_DISPATCH_FAILED_MESSAGE, + canApply, + defaultValueFor, + serializeValue, + submitBulkFieldUpdate, +} from '@/features/bulk-edit-flyout-helpers' import type { ProjectField } from '@/features/bulk-edit-utils' function field(over: Partial = {}): ProjectField { @@ -78,3 +100,62 @@ describe('bulk-edit-flyout — serializeValue shapes', () => { expect(serializeValue({ kind: 'array', array: arr })).toEqual({ array: arr }) }) }) + +describe('bulk-edit-flyout — submitBulkFieldUpdate dispatch', () => { + const textField = field({ id: 'f-text', name: 'Note', dataType: 'TEXT' }) + + beforeEach(() => { + hoisted.sendMessage.mockReset() + hoisted.getActiveCount.mockReset() + hoisted.getActiveCount.mockReturnValue(0) + hoisted.sendMessage.mockResolvedValue({ ok: true }) + }) + + it('returns ok when bulkUpdate accepts dispatch', async () => { + const result = await submitBulkFieldUpdate({ + activeField: textField, + value: { kind: 'text', text: 'hello' }, + itemIds: ['item-1'], + projectId: 'proj-1', + }) + expect(result).toEqual({ ok: true }) + expect(hoisted.sendMessage).toHaveBeenCalledWith( + 'bulkUpdate', + expect.objectContaining({ projectId: 'proj-1', itemIds: ['item-1'] }), + ) + }) + + it('returns concurrent message when queue is full', async () => { + hoisted.getActiveCount.mockReturnValue(3) + const result = await submitBulkFieldUpdate({ + activeField: textField, + value: { kind: 'text', text: 'hello' }, + itemIds: ['item-1'], + projectId: 'proj-1', + }) + expect(result).toEqual({ ok: false, message: BULK_EDIT_CONCURRENT_MESSAGE }) + expect(hoisted.sendMessage).not.toHaveBeenCalled() + }) + + it('returns concurrent message when bulkUpdate rejects', async () => { + hoisted.sendMessage.mockResolvedValue({ ok: false, reason: 'concurrent' }) + const result = await submitBulkFieldUpdate({ + activeField: textField, + value: { kind: 'text', text: 'hello' }, + itemIds: ['item-1'], + projectId: 'proj-1', + }) + expect(result).toEqual({ ok: false, message: BULK_EDIT_CONCURRENT_MESSAGE }) + }) + + it('returns dispatch failed message when sendMessage throws', async () => { + hoisted.sendMessage.mockRejectedValue(new Error('Receiving end does not exist')) + const result = await submitBulkFieldUpdate({ + activeField: textField, + value: { kind: 'text', text: 'hello' }, + itemIds: ['item-1'], + projectId: 'proj-1', + }) + expect(result).toEqual({ ok: false, message: BULK_EDIT_DISPATCH_FAILED_MESSAGE }) + }) +}) diff --git a/src/features/bulk-edit-flyout-helpers.ts b/src/features/bulk-edit-flyout-helpers.ts index c78ab30..8da9062 100644 --- a/src/features/bulk-edit-flyout-helpers.ts +++ b/src/features/bulk-edit-flyout-helpers.ts @@ -3,6 +3,14 @@ // `bulk-actions-flyouts` change. import type { ProjectField } from '@/features/bulk-edit-utils' +import { sendMessage } from '@/lib/messages' +import { queueStore } from '@/lib/queue-store' + +export const BULK_EDIT_CONCURRENT_MESSAGE = + '3 processes are already running. Wait for one to finish before starting another.' +export const BULK_EDIT_DISPATCH_FAILED_MESSAGE = 'Could not start the bulk update. Try again.' + +export type SubmitBulkFieldUpdateResult = { ok: true } | { ok: false; message: string } export type FieldValue = | { kind: 'cleared' } @@ -76,3 +84,53 @@ export function serializeValue(value: FieldValue): Record | nul return { array: value.array } } } + +export async function submitBulkFieldUpdate(args: { + activeField: ProjectField + value: FieldValue + itemIds: readonly string[] + projectId: string +}): Promise { + const payload = serializeValue(args.value) + if (payload === null) { + return { ok: false, message: BULK_EDIT_DISPATCH_FAILED_MESSAGE } + } + + if (queueStore.getActiveCount() >= 3) { + return { ok: false, message: BULK_EDIT_CONCURRENT_MESSAGE } + } + + try { + const result = await sendMessage('bulkUpdate', { + itemIds: [...args.itemIds], + projectId: args.projectId, + updates: [ + { + fieldId: args.activeField.id, + value: { ...payload, dataType: args.activeField.dataType }, + }, + ], + fieldMeta: { + [args.activeField.id]: { + name: args.activeField.name, + options: args.activeField.options, + iterations: args.activeField.configuration?.iterations, + }, + }, + }) + + if (!result.ok) { + return { + ok: false, + message: + result.reason === 'concurrent' + ? BULK_EDIT_CONCURRENT_MESSAGE + : BULK_EDIT_DISPATCH_FAILED_MESSAGE, + } + } + + return { ok: true } + } catch { + return { ok: false, message: BULK_EDIT_DISPATCH_FAILED_MESSAGE } + } +} diff --git a/src/features/bulk-edit-flyout.tsx b/src/features/bulk-edit-flyout.tsx index cd8724a..6eea53b 100644 --- a/src/features/bulk-edit-flyout.tsx +++ b/src/features/bulk-edit-flyout.tsx @@ -14,6 +14,7 @@ import { Avatar, Box, Checkbox, + Flash, Radio, RadioGroup, Spinner, @@ -28,7 +29,7 @@ import { getFieldIcon, type ProjectField } from '@/features/bulk-edit-utils' import { canApply, defaultValueFor, - serializeValue, + submitBulkFieldUpdate, type FieldValue, } from '@/features/bulk-edit-flyout-helpers' @@ -81,6 +82,8 @@ export function BulkEditFlyout({ Array<{ id: string; name: string; avatarUrl?: string }> >([]) const [metaLoading, setMetaLoading] = useState(false) + const [applyError, setApplyError] = useState(null) + const [applying, setApplying] = useState(false) useEffect(() => { if (!open) { @@ -89,6 +92,8 @@ export function BulkEditFlyout({ setQuery('') setMetaQuery('') setMetaResults([]) + setApplyError(null) + setApplying(false) } }, [open]) @@ -100,25 +105,31 @@ export function BulkEditFlyout({ function pickField(field: ProjectField) { setActiveFieldId(field.id) setValue(defaultValueFor(field)) + setApplyError(null) setCurrentPaneId('value') } - function handleApply() { - if (!activeField || !value) return - const payload = serializeValue(value) - if (payload === null) return - sendMessage('bulkUpdate', { - itemIds: [...itemIds], - projectId, - updates: [{ fieldId: activeField.id, value: { ...payload, dataType: activeField.dataType } }], - fieldMeta: { - [activeField.id]: { - name: activeField.name, - options: activeField.options, - iterations: activeField.configuration?.iterations, - }, - }, - }) + async function handleApply() { + if (!activeField || !value || applying) return + + setApplying(true) + setApplyError(null) + + try { + const result = await submitBulkFieldUpdate({ + activeField, + value, + itemIds, + projectId, + }) + if (!result.ok) { + setApplyError(result.message) + return + } + } finally { + setApplying(false) + } + onAppliedField(activeField.id) onClose() } @@ -224,15 +235,22 @@ export function BulkEditFlyout({ id: 'value', title: activeField?.name ?? 'Edit value', content: activeField ? ( - + + {applyError && ( + + {applyError} + + )} + + ) : ( No field picked. ), @@ -253,7 +271,7 @@ export function BulkEditFlyout({ onPaneChange={setCurrentPaneId} rootPaneId="list" footer={currentPaneId === 'value' ? 'apply-cancel' : null} - applyDisabled={!canApply(value)} + applyDisabled={!canApply(value) || applying} onApply={handleApply} applyLabel="Apply" /> diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 4f56c98..26f6c92 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -77,6 +77,11 @@ export interface BulkRelationshipValidationResult { errors: string[] } +/** Immediate accept/reject from the bulkUpdate message handler (work continues in background when ok). */ +export type BulkUpdateDispatchResult = + | { ok: true } + | { ok: false; reason: 'concurrent' } + export interface ItemPreviewData { resolvedItemId: string issueNumber: number @@ -244,7 +249,7 @@ interface ProtocolMap { iterations?: { id: string; title: string; startDate: string; duration: number }[] } > - }): void + }): BulkUpdateDispatchResult bulkClose(data: { itemIds: string[] projectId: string diff --git a/src/lib/schemas-messages.ts b/src/lib/schemas-messages.ts index 07f06c8..88988e7 100644 --- a/src/lib/schemas-messages.ts +++ b/src/lib/schemas-messages.ts @@ -310,7 +310,10 @@ export const Messages = { }), ), }), - output: Schema.Void, + output: Schema.Union( + Schema.Struct({ ok: Schema.Literal(true) }), + Schema.Struct({ ok: Schema.Literal(false), reason: Schema.Literal('concurrent') }), + ), }, bulkClose: { input: Schema.Struct({