diff --git a/src/features/__tests__/bulk-edit-flyout.test.tsx b/src/features/__tests__/bulk-edit-flyout.test.tsx index 98bd2be..ae561d8 100644 --- a/src/features/__tests__/bulk-edit-flyout.test.tsx +++ b/src/features/__tests__/bulk-edit-flyout.test.tsx @@ -23,17 +23,24 @@ import { serializeValue, submitBulkFieldUpdate, } from '@/features/bulk-edit-flyout-helpers' -import type { ProjectField } from '@/features/bulk-edit-utils' +import { + buildRelationshipFieldRows, + partitionFieldList, + relationshipFieldId, + type ProjectField, +} from '@/features/bulk-edit-utils' +import { buildRelationshipsPayload } from '@/features/bulk-actions-utils' function field(over: Partial = {}): ProjectField { return { id: 'f1', name: 'Status', dataType: 'SINGLE_SELECT', ...over } } describe('bulk-edit-flyout — defaultValueFor', () => { - it('returns empty text for TEXT/TITLE/BODY', () => { + it('returns empty text for TEXT/TITLE/BODY/COMMENT', () => { expect(defaultValueFor(field({ dataType: 'TEXT' }))).toEqual({ kind: 'text', text: '' }) expect(defaultValueFor(field({ dataType: 'TITLE' }))).toEqual({ kind: 'text', text: '' }) expect(defaultValueFor(field({ dataType: 'BODY' }))).toEqual({ kind: 'text', text: '' }) + expect(defaultValueFor(field({ dataType: 'COMMENT' }))).toEqual({ kind: 'text', text: '' }) }) it('returns null number for NUMBER', () => { @@ -59,6 +66,12 @@ describe('bulk-edit-flyout — canApply gating', () => { expect(canApply({ kind: 'text', text: '' })).toBe(false) expect(canApply({ kind: 'text', text: 'x' })).toBe(true) }) + it('comment requires non-empty body', () => { + expect( + canApply(defaultValueFor(field({ id: '__comment__', name: 'Comment', dataType: 'COMMENT' }))), + ).toBe(false) + expect(canApply({ kind: 'text', text: 'Ship it' })).toBe(true) + }) it('number requires a non-null finite value', () => { expect(canApply({ kind: 'number', number: null })).toBe(false) expect(canApply({ kind: 'number', number: 7 })).toBe(true) @@ -159,3 +172,74 @@ describe('bulk-edit-flyout — submitBulkFieldUpdate dispatch', () => { expect(result).toEqual({ ok: false, message: BULK_EDIT_DISPATCH_FAILED_MESSAGE }) }) }) + +describe('bulk-edit — partitionFieldList', () => { + const baseFields: ProjectField[] = [ + { id: 'f-status', name: 'Status', dataType: 'SINGLE_SELECT' }, + { id: 'f-zeta', name: 'Zeta', dataType: 'TEXT' }, + { id: 'f-alpha', name: 'Alpha', dataType: 'NUMBER' }, + { id: '__comment__', name: 'Comment', dataType: 'COMMENT' }, + { id: '__body__', name: 'Description', dataType: 'BODY' }, + ] + + it('browse mode sorts project fields A→Z and excludes recent from lower sections', () => { + const partition = partitionFieldList({ + fields: baseFields, + recentIds: ['f-status', relationshipFieldId('parent')], + query: '', + }) + expect(partition.mode).toBe('browse') + if (partition.mode !== 'browse') return + expect(partition.recent.map((f) => f.id)).toEqual(['f-status', relationshipFieldId('parent')]) + expect(partition.projectFields.map((f) => f.name)).toEqual(['Alpha', 'Zeta']) + expect(partition.issueProperties.map((f) => f.name)).toEqual(['Comment', 'Description']) + expect(partition.relationships.map((f) => f.name)).toEqual(['Blocked by', 'Blocking']) + }) + + it('search mode flattens matches without Recent section', () => { + const partition = partitionFieldList({ + fields: baseFields, + recentIds: ['f-status'], + query: 'com', + }) + expect(partition.mode).toBe('search') + if (partition.mode !== 'search') return + expect(partition.matches.map((f) => f.id)).toEqual(['__comment__']) + }) + + it('dedupes ids in search results', () => { + const withRel = [...baseFields, ...buildRelationshipFieldRows()] + const partition = partitionFieldList({ + fields: withRel, + recentIds: [], + query: 'parent', + }) + expect(partition.mode).toBe('search') + if (partition.mode !== 'search') return + expect(partition.matches.filter((f) => f.id === relationshipFieldId('parent'))).toHaveLength(1) + }) +}) + +describe('bulk-edit — buildRelationshipsPayload', () => { + it('builds parent clear payload', () => { + expect(buildRelationshipsPayload('parent', 'clear', null, 'add', [])).toEqual({ + parent: { set: undefined, clear: true }, + blockedBy: { add: [], remove: [], clear: false }, + blocking: { add: [], remove: [], clear: false }, + }) + }) + + it('builds blockedBy add payload', () => { + const issue = { + databaseId: 1, + number: 42, + title: 'Blocker', + repoOwner: 'octo', + repoName: 'repo', + state: 'OPEN' as const, + } + const payload = buildRelationshipsPayload('blockedBy', 'set', null, 'add', [issue]) + expect(payload.blockedBy.add).toHaveLength(1) + expect(payload.blockedBy.add[0]?.number).toBe(42) + }) +}) diff --git a/src/features/bulk-actions-bar.tsx b/src/features/bulk-actions-bar.tsx index 15b209c..e1de38e 100644 --- a/src/features/bulk-actions-bar.tsx +++ b/src/features/bulk-actions-bar.tsx @@ -387,6 +387,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P for (const link of links) { const match = link.href.match(/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/\d+/) if (match && match[1] === owner) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- derive repo from DOM when selection changes setFirstRepoName(match[2]) break } @@ -405,7 +406,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P } defaultFields.unshift( { id: '__body__', name: 'Description', dataType: 'BODY' }, - { id: '__comment__', name: 'Add Comment', dataType: 'COMMENT' }, + { id: '__comment__', name: 'Comment', dataType: 'COMMENT' }, ) if (!existingTypes.includes('ASSIGNEES')) { defaultFields.push({ id: '__assignees__', name: 'Assignees', dataType: 'ASSIGNEES' }) @@ -542,7 +543,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P sendMessage('bulkPin', { itemIds, projectId: resolvedProjectId }) } - async function handleUnpin() { + async function _handleUnpin() { setMenuOpen(false) if (!(await checkToken())) return setShowUnpinModal(true) @@ -890,6 +891,7 @@ export function BulkActionsBar({ projectId, owner, isOrg, number, getFields }: P projectId={resolvedProjectId} itemIds={selectionStore.getAll()} fields={projectData?.fields ?? []} + repoName={firstRepoName || undefined} recentFieldIds={recentFieldIdsRef.current} onAppliedField={pushRecentField} /> diff --git a/src/features/bulk-actions-utils.tsx b/src/features/bulk-actions-utils.tsx index bfb8a90..258e6db 100644 --- a/src/features/bulk-actions-utils.tsx +++ b/src/features/bulk-actions-utils.tsx @@ -3,8 +3,8 @@ import React from 'react' import { Box, Spinner } from '@primer/react' -import type { BulkEditRelationshipsUpdate } from '@/lib/messages' -import type { RelationshipSelectionState } from '@/features/bulk-edit-utils' +import type { BulkEditRelationshipsUpdate, IssueSearchResultData } from '@/lib/messages' +import type { RelationshipKey, RelationshipSelectionState } from '@/features/bulk-edit-utils' import { isMac } from '@/lib/keyboard' import { Z_MODAL } from '@/lib/z-index' @@ -48,6 +48,49 @@ export function hasRelationshipOperations(relationships: BulkEditRelationshipsUp ) } +export type ParentOperation = 'set' | 'clear' +export type ListOperation = 'add' | 'remove' | 'clear' + +function mapSearchResult(item: IssueSearchResultData) { + return { + databaseId: item.databaseId, + number: item.number, + title: item.title, + repoOwner: item.repoOwner, + repoName: item.repoName, + state: item.state, + } +} + +export function buildRelationshipsPayload( + key: RelationshipKey, + parentOp: ParentOperation, + parentTarget: IssueSearchResultData | null, + listOp: ListOperation, + listTargets: IssueSearchResultData[], +): BulkEditRelationshipsUpdate { + const base = createEmptyRelationshipUpdates() + + if (key === 'parent') { + if (parentOp === 'clear') { + base.parent.clear = true + } else if (parentTarget) { + base.parent.set = mapSearchResult(parentTarget) + } + return base + } + + const list = key === 'blockedBy' ? base.blockedBy : base.blocking + if (listOp === 'clear') { + list.clear = true + } else if (listOp === 'add') { + list.add = listTargets.map(mapSearchResult) + } else if (listOp === 'remove') { + list.remove = listTargets.map(mapSearchResult) + } + return base +} + /** display string for a ctrl/cmd+shift+key shortcut */ export function shortcut(key: string) { return isMac ? `⌘⇧${key}` : `⌃⇧${key}` diff --git a/src/features/bulk-edit-flyout-helpers.ts b/src/features/bulk-edit-flyout-helpers.ts index 1a697b6..308e70d 100644 --- a/src/features/bulk-edit-flyout-helpers.ts +++ b/src/features/bulk-edit-flyout-helpers.ts @@ -26,6 +26,7 @@ export function defaultValueFor(field: ProjectField): FieldValue { case 'TEXT': case 'TITLE': case 'BODY': + case 'COMMENT': return { kind: 'text', text: '' } case 'NUMBER': return { kind: 'number', number: null } diff --git a/src/features/bulk-edit-flyout.tsx b/src/features/bulk-edit-flyout.tsx index 91c85ff..fa2772d 100644 --- a/src/features/bulk-edit-flyout.tsx +++ b/src/features/bulk-edit-flyout.tsx @@ -1,12 +1,6 @@ // Drilldown flyout for editing a single project field across the current -// selection (§5 of bulk-actions-flyouts). Pane 1: field list with Recent / -// All. Pane 2: per-`dataType` value picker. Apply dispatches the existing -// `bulkUpdate` message with a single field update. -// -// Multi-field editing and the relationships sub-form (parent / blockedBy / -// blocking) live in the legacy modal; both are out of scope for this pass -// per the proposal. The flyout sticks to the GitHub-native "edit one -// property at a time" idiom. +// selection (§5 of bulk-actions-flyouts). Pane 1: four-section field list. +// Pane 2: per-`dataType` value picker or operation-first relationship editor. import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { @@ -25,13 +19,24 @@ import { import { SearchIcon } from '@/ui/icons' import { BulkFlyout, type BulkFlyoutPane, useDrilldownPane } from '@/ui/bulk-flyout' import { sendMessage } from '@/lib/messages' -import { getFieldIcon, type ProjectField } from '@/features/bulk-edit-utils' +import { + buildFieldCatalog, + getFieldIcon, + isRelationshipFieldId, + partitionFieldList, + relationshipKeyFromFieldId, + type ProjectField, +} from '@/features/bulk-edit-utils' import { canApply, defaultValueFor, submitBulkFieldUpdate, type FieldValue, } from '@/features/bulk-edit-flyout-helpers' +import { + BulkEditRelationshipPane, + type BulkEditRelationshipPaneHandle, +} from '@/features/bulk-edit-relationship-pane' export interface BulkEditFlyoutProps { anchorRef: React.RefObject @@ -42,33 +47,22 @@ export interface BulkEditFlyoutProps { projectId: string itemIds: readonly string[] fields: readonly ProjectField[] + repoName?: string /** Pinned field IDs (last three edited). Read-only; the bar manages persistence. */ recentFieldIds: readonly string[] onAppliedField: (fieldId: string) => void } -const FALLBACK_DATATYPES = new Set([ - 'TEXT', - 'NUMBER', - 'DATE', - 'SINGLE_SELECT', - 'ITERATION', - 'ASSIGNEES', - 'LABELS', - 'ISSUE_TYPE', - 'TITLE', - 'BODY', -]) - export function BulkEditFlyout({ anchorRef, open, onClose, owner, - isOrg, + isOrg: _isOrg, projectId, itemIds, fields, + repoName, recentFieldIds, onAppliedField, }: BulkEditFlyoutProps) { @@ -76,7 +70,6 @@ export function BulkEditFlyout({ const [value, setValue] = useState(null) const [query, setQuery] = useState('') const { currentPaneId, setCurrentPaneId } = useDrilldownPane('list', open) - // Track metadata search state when the picked field is ASSIGNEES/LABELS/ISSUE_TYPE. const [metaQuery, setMetaQuery] = useState('') const [metaResults, setMetaResults] = useState< Array<{ id: string; name: string; avatarUrl?: string }> @@ -84,34 +77,87 @@ export function BulkEditFlyout({ const [metaLoading, setMetaLoading] = useState(false) const [applyError, setApplyError] = useState(null) const [applying, setApplying] = useState(false) + const [relationshipCanApply, setRelationshipCanApply] = useState(false) const latestMetaReq = useRef(0) + const relationshipPaneRef = useRef(null) + + const resetFlyoutState = useCallback(() => { + setActiveFieldId(null) + setValue(null) + setQuery('') + setMetaQuery('') + setMetaResults([]) + setApplyError(null) + setApplying(false) + setRelationshipCanApply(false) + }, []) + + const handleFlyoutClose = useCallback(() => { + resetFlyoutState() + onClose() + }, [onClose, resetFlyoutState]) useEffect(() => { if (!open) { - setActiveFieldId(null) - setValue(null) - setQuery('') - setMetaQuery('') - setMetaResults([]) - setApplyError(null) - setApplying(false) + // eslint-disable-next-line react-hooks/set-state-in-effect -- parent may close via Escape/selection without onClose + resetFlyoutState() } - }, [open]) + }, [open, resetFlyoutState]) + + const catalog = useMemo(() => buildFieldCatalog(fields), [fields]) const activeField = useMemo( - () => (activeFieldId ? (fields.find((f) => f.id === activeFieldId) ?? null) : null), - [activeFieldId, fields], + () => (activeFieldId ? (catalog.find((f) => f.id === activeFieldId) ?? null) : null), + [activeFieldId, catalog], ) - function pickField(field: ProjectField) { - setActiveFieldId(field.id) - setValue(defaultValueFor(field)) - setApplyError(null) - setCurrentPaneId('value') - } + const activeRelationshipKey = useMemo( + () => (activeFieldId ? relationshipKeyFromFieldId(activeFieldId) : null), + [activeFieldId], + ) - async function handleApply() { - if (!activeField || !value || applying) return + const partition = useMemo( + () => partitionFieldList({ fields, recentIds: recentFieldIds, query }), + [fields, recentFieldIds, query], + ) + + const pickField = useCallback( + (field: ProjectField) => { + setActiveFieldId(field.id) + setApplyError(null) + if (isRelationshipFieldId(field.id)) { + setCurrentPaneId('relationship') + return + } + setValue(defaultValueFor(field)) + setCurrentPaneId('value') + }, + [setCurrentPaneId], + ) + + const handleApply = useCallback(async () => { + if (applying) return + + if (currentPaneId === 'relationship' && activeFieldId && activeRelationshipKey) { + setApplying(true) + setApplyError(null) + try { + const result = await relationshipPaneRef.current?.apply() + if (!result?.ok) { + setApplyError(result?.message ?? 'Could not start the bulk update. Try again.') + return + } + onAppliedField(activeFieldId) + handleFlyoutClose() + } catch { + setApplyError('Could not start the bulk update. Try again.') + } finally { + setApplying(false) + } + return + } + + if (!activeField || !value) return setApplying(true) setApplyError(null) @@ -132,10 +178,22 @@ export function BulkEditFlyout({ } onAppliedField(activeField.id) - onClose() - } + handleFlyoutClose() + }, [ + applying, + currentPaneId, + activeFieldId, + activeRelationshipKey, + activeField, + value, + itemIds, + projectId, + onAppliedField, + handleFlyoutClose, + ]) + + const resolvedRepoName = repoName ?? firstRepoNameFromDom(owner) - // Metadata search (ASSIGNEES / LABELS / ISSUE_TYPE) useEffect(() => { if (!activeField) return const requiresMeta = @@ -143,20 +201,19 @@ export function BulkEditFlyout({ activeField.dataType === 'LABELS' || activeField.dataType === 'ISSUE_TYPE' if (!requiresMeta) return - const repoName = firstRepoNameFromDom(owner) - if (!repoName) return + if (!resolvedRepoName) return const requestId = latestMetaReq.current + 1 latestMetaReq.current = requestId - setMetaLoading(true) const protocolType: 'ASSIGNEES' | 'LABELS' | 'ISSUE_TYPES' = activeField.dataType === 'ISSUE_TYPE' ? 'ISSUE_TYPES' : (activeField.dataType as 'ASSIGNEES' | 'LABELS') const timer = setTimeout( () => { + setMetaLoading(true) sendMessage('searchRepoMetadata', { owner, - name: repoName, + name: resolvedRepoName, q: metaQuery, type: protocolType, }) @@ -175,22 +232,7 @@ export function BulkEditFlyout({ metaQuery ? 250 : 0, ) return () => clearTimeout(timer) - }, [activeField, owner, metaQuery]) - - const recentFields = useMemo(() => { - const idxed = new Map(fields.map((f) => [f.id, f])) - return recentFieldIds - .map((id) => idxed.get(id)) - .filter((f): f is ProjectField => f !== undefined) - }, [fields, recentFieldIds]) - - const filteredFields = useMemo(() => { - const q = query.trim().toLowerCase() - if (!q) return fields.filter((f) => FALLBACK_DATATYPES.has(f.dataType)) - return fields - .filter((f) => FALLBACK_DATATYPES.has(f.dataType)) - .filter((f) => f.name.toLowerCase().includes(q)) - }, [fields, query]) + }, [activeField, owner, metaQuery, resolvedRepoName]) const listPane: BulkFlyoutPane = { id: 'list', @@ -215,26 +257,60 @@ export function BulkEditFlyout({ }} data-testid="rgp-edit-field-list" > - {!query && recentFields.length > 0 && ( - - Recent - - {recentFields.map((field) => ( - - ))} - - All fields - + {partition.mode === 'search' ? ( + + {partition.matches.length === 0 ? ( + No fields match. + ) : ( + partition.matches.map((field) => ( + + )) + )} + + ) : ( + <> + {partition.recent.length > 0 && ( + + Recent + + {partition.recent.map((field) => ( + + ))} + + + )} + {partition.issueProperties.length > 0 && ( + + Issue properties + + {partition.issueProperties.map((field) => ( + + ))} + + + )} + {partition.projectFields.length > 0 && ( + + Project fields + + {partition.projectFields.map((field) => ( + + ))} + + + )} + {partition.relationships.length > 0 && ( + + Relationships + + {partition.relationships.map((field) => ( + + ))} + + + )} + )} - - {filteredFields.length === 0 ? ( - No fields match. - ) : ( - filteredFields.map((field) => ( - - )) - )} - ), @@ -265,22 +341,54 @@ export function BulkEditFlyout({ ), } + const relationshipPane: BulkFlyoutPane = { + id: 'relationship', + title: activeField?.name ?? 'Relationships', + content: + activeRelationshipKey && activeField ? ( + + {applyError && ( + + {applyError} + + )} + + + ) : ( + No relationship picked. + ), + } + + const footerPane = currentPaneId === 'relationship' ? 'relationship' : currentPaneId + return ( } open={open} - onClose={onClose} + onClose={handleFlyoutClose} title="Edit fields" ariaLabel="Edit fields" width={400} maxHeight={560} - panes={[listPane, valuePane]} + panes={[listPane, valuePane, relationshipPane]} currentPaneId={currentPaneId} onPaneChange={setCurrentPaneId} rootPaneId="list" - footer={currentPaneId === 'value' ? 'apply-cancel' : null} - applyDisabled={!canApply(value) || applying} + footer={footerPane === 'value' || footerPane === 'relationship' ? 'apply-cancel' : null} + applyDisabled={ + footerPane === 'relationship' + ? !relationshipCanApply || applying + : !canApply(value) || applying + } onApply={handleApply} applyLabel="Apply" /> @@ -365,7 +473,7 @@ function ValuePicker({ ) } - if (dataType === 'BODY') { + if (dataType === 'BODY' || dataType === 'COMMENT') { return ( @@ -378,8 +486,8 @@ function ValuePicker({ } rows={6} aria-label={field.name} - sx={{ width: '100%', fontFamily: 'mono' }} - data-testid="rgp-edit-value-body" + sx={{ width: '100%', fontFamily: dataType === 'BODY' ? 'mono' : 'inherit' }} + data-testid={dataType === 'COMMENT' ? 'rgp-edit-value-comment' : 'rgp-edit-value-body'} /> ) diff --git a/src/features/bulk-edit-relationship-pane.tsx b/src/features/bulk-edit-relationship-pane.tsx new file mode 100644 index 0000000..6f5a820 --- /dev/null +++ b/src/features/bulk-edit-relationship-pane.tsx @@ -0,0 +1,282 @@ +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { Box, Flash, Radio, RadioGroup, SegmentedControl, Text } from '@primer/react' +import { IssueRelationshipSelectPanel } from '@/ui/issue-relationship-select-panel' +import type { IssueRelationshipItem } from '@/ui/issue-relationship-select-panel' +import { + buildRelationshipsPayload, + type ListOperation, + type ParentOperation, +} from '@/features/bulk-actions-utils' +import type { RelationshipKey } from '@/features/bulk-edit-utils' +import { sendMessage } from '@/lib/messages' +import { queueStore } from '@/lib/queue-store' +import { + BULK_EDIT_CONCURRENT_MESSAGE, + BULK_EDIT_DISPATCH_FAILED_MESSAGE, +} from '@/features/bulk-edit-flyout-helpers' + +export type { ParentOperation, ListOperation } from '@/features/bulk-actions-utils' +export { buildRelationshipsPayload } from '@/features/bulk-actions-utils' + +export interface BulkEditRelationshipPaneProps { + relationshipKey: RelationshipKey + itemIds: readonly string[] + projectId: string + owner: string + repoName?: string + onCanApplyChange: (canApply: boolean) => void +} + +export interface BulkEditRelationshipPaneHandle { + apply: () => Promise<{ ok: true } | { ok: false; message: string }> +} + +function operationReady( + key: RelationshipKey, + parentOp: ParentOperation, + parentTarget: IssueRelationshipItem | null, + listOp: ListOperation, + listTargets: IssueRelationshipItem[], +): boolean { + if (key === 'parent') { + return parentOp === 'clear' || parentTarget !== null + } + if (listOp === 'clear') return true + return listTargets.length > 0 +} + +export const BulkEditRelationshipPane = forwardRef< + BulkEditRelationshipPaneHandle, + BulkEditRelationshipPaneProps +>(function BulkEditRelationshipPane( + { relationshipKey, itemIds, projectId, owner, repoName, onCanApplyChange }, + ref, +) { + const [parentOp, setParentOp] = useState('set') + const [parentTarget, setParentTarget] = useState([]) + const [listOp, setListOp] = useState('add') + const [listTargets, setListTargets] = useState([]) + const [validationErrors, setValidationErrors] = useState([]) + const [prSkipCount, setPrSkipCount] = useState(0) + const [validating, setValidating] = useState(false) + const latestPreflightReq = useRef(0) + + const relationships = useMemo( + () => + buildRelationshipsPayload( + relationshipKey, + parentOp, + parentTarget[0] ?? null, + listOp, + listTargets, + ), + [relationshipKey, parentOp, parentTarget, listOp, listTargets], + ) + + const ready = operationReady( + relationshipKey, + parentOp, + parentTarget[0] ?? null, + listOp, + listTargets, + ) + + useEffect(() => { + onCanApplyChange(ready && validationErrors.length === 0 && !validating) + }, [ready, validationErrors.length, validating, onCanApplyChange]) + + const runPreflight = useCallback(async () => { + const reqId = ++latestPreflightReq.current + + if (!ready) { + if (reqId !== latestPreflightReq.current) return + setValidationErrors([]) + setPrSkipCount(0) + return + } + + setValidating(true) + try { + const [validation, titles] = await Promise.all([ + sendMessage('validateBulkRelationshipUpdates', { + itemIds: [...itemIds], + projectId, + relationships, + }), + sendMessage('getItemTitles', { itemIds: [...itemIds], projectId }), + ]) + if (reqId !== latestPreflightReq.current) return + setValidationErrors(validation.errors) + setPrSkipCount(titles.filter((t) => t.typename === 'PullRequest').length) + } catch { + if (reqId !== latestPreflightReq.current) return + setValidationErrors(['Could not validate relationship changes. Try again.']) + setPrSkipCount(0) + } finally { + if (reqId === latestPreflightReq.current) setValidating(false) + } + }, [ready, itemIds, projectId, relationships]) + + useEffect(() => { + const timer = setTimeout(() => { + void runPreflight() + }, 300) + return () => clearTimeout(timer) + }, [runPreflight]) + + useImperativeHandle(ref, () => ({ + async apply() { + if (!ready) { + return { ok: false as const, message: BULK_EDIT_DISPATCH_FAILED_MESSAGE } + } + + if (queueStore.getActiveCount() >= 3) { + return { ok: false as const, message: BULK_EDIT_CONCURRENT_MESSAGE } + } + + let validation: { errors: string[] } + try { + validation = await sendMessage('validateBulkRelationshipUpdates', { + itemIds: [...itemIds], + projectId, + relationships, + }) + } catch { + return { ok: false as const, message: BULK_EDIT_DISPATCH_FAILED_MESSAGE } + } + if (validation.errors.length > 0) { + setValidationErrors(validation.errors) + return { + ok: false as const, + message: validation.errors[0] ?? BULK_EDIT_DISPATCH_FAILED_MESSAGE, + } + } + + try { + const result = await sendMessage('bulkUpdate', { + itemIds: [...itemIds], + projectId, + updates: [], + relationships, + }) + if (!result.ok) { + return { + ok: false as const, + message: + result.reason === 'concurrent' + ? BULK_EDIT_CONCURRENT_MESSAGE + : BULK_EDIT_DISPATCH_FAILED_MESSAGE, + } + } + return { ok: true as const } + } catch { + return { ok: false as const, message: BULK_EDIT_DISPATCH_FAILED_MESSAGE } + } + }, + })) + + const label = + relationshipKey === 'parent' + ? 'Parent' + : relationshipKey === 'blockedBy' + ? 'Blocked by' + : 'Blocking' + + return ( + + {validationErrors.length > 0 && ( + + {validationErrors.map((err) => ( + + {err} + + ))} + + )} + {prSkipCount > 0 && validationErrors.length === 0 && ( + + {prSkipCount} pull request{prSkipCount === 1 ? '' : 's'} will be skipped — relationships + apply to issues only. + + )} + + {relationshipKey === 'parent' ? ( + <> + + + Operation + + + + setParentOp('set')} + /> + Set to… + + + setParentOp('clear')} + /> + Clear parent + + + + {parentOp === 'set' && ( + + )} + + ) : ( + <> + Operation + { + const ops: ListOperation[] = ['add', 'remove', 'clear'] + setListOp(ops[index] ?? 'add') + }} + > + Add + Remove + + Clear all + + + {(listOp === 'add' || listOp === 'remove') && ( + + )} + + )} + + ) +}) diff --git a/src/features/bulk-edit-utils.tsx b/src/features/bulk-edit-utils.tsx index 8d5ffbb..22283c0 100644 --- a/src/features/bulk-edit-utils.tsx +++ b/src/features/bulk-edit-utils.tsx @@ -4,6 +4,7 @@ import React from 'react' import { CalendarIcon, HashIcon, + ArrowRightIcon, ListCheckIcon, OptionsSelectIcon, PencilIcon, @@ -47,6 +48,31 @@ export type RelationshipSummaryRow = { value: string } +/** Project V2 field types editable via the field picker (not issue properties). */ +export const EDITABLE_PROJECT_FIELD_DATATYPES = new Set([ + 'TEXT', + 'NUMBER', + 'DATE', + 'SINGLE_SELECT', + 'ITERATION', +]) + +/** Issue-level attributes injected as synthetic rows in the field catalog. */ +export const ISSUE_PROPERTY_DATATYPES = new Set([ + 'TITLE', + 'BODY', + 'COMMENT', + 'ASSIGNEES', + 'LABELS', + 'ISSUE_TYPE', +]) + +/** All datatypes shown in the Edit fields picker (excludes RELATIONSHIP). */ +export const BULK_EDIT_FALLBACK_DATATYPES = new Set([ + ...EDITABLE_PROJECT_FIELD_DATATYPES, + ...ISSUE_PROPERTY_DATATYPES, +]) + export const RELATIONSHIP_OPTIONS: Array<{ key: RelationshipKey label: string @@ -69,6 +95,102 @@ export const RELATIONSHIP_OPTIONS: Array<{ }, ] +export function relationshipFieldId(key: RelationshipKey): string { + return `__rel_${key}__` +} + +export function isRelationshipFieldId(id: string): boolean { + return /^__rel_\w+__$/.test(id) +} + +export function relationshipKeyFromFieldId(id: string): RelationshipKey | null { + const match = id.match(/^__rel_(\w+)__$/) + if (!match) return null + const key = match[1] as RelationshipKey + return RELATIONSHIP_OPTIONS.some((opt) => opt.key === key) ? key : null +} + +export function isSyntheticIssuePropertyId(id: string): boolean { + return id.startsWith('__') && !isRelationshipFieldId(id) +} + +export function buildRelationshipFieldRows(): ProjectField[] { + return RELATIONSHIP_OPTIONS.map((opt) => ({ + id: relationshipFieldId(opt.key), + name: opt.label, + dataType: 'RELATIONSHIP', + })) +} + +function sortFieldsByName(fields: ProjectField[]): ProjectField[] { + return [...fields].sort((a, b) => a.name.localeCompare(b.name)) +} + +/** Merge API/synthetic fields with relationship verb rows (deduped by id). */ +export function buildFieldCatalog(fields: readonly ProjectField[]): ProjectField[] { + const byId = new Map() + for (const field of fields) byId.set(field.id, field) + for (const row of buildRelationshipFieldRows()) byId.set(row.id, row) + return [...byId.values()] +} + +export type FieldListBrowsePartition = { + mode: 'browse' + recent: ProjectField[] + issueProperties: ProjectField[] + projectFields: ProjectField[] + relationships: ProjectField[] +} + +export type FieldListSearchPartition = { + mode: 'search' + matches: ProjectField[] +} + +export type FieldListPartition = FieldListBrowsePartition | FieldListSearchPartition + +export function partitionFieldList(args: { + fields: readonly ProjectField[] + recentIds: readonly string[] + query: string +}): FieldListPartition { + const catalog = buildFieldCatalog(args.fields) + const indexed = new Map(catalog.map((f) => [f.id, f])) + const recentSet = new Set(args.recentIds) + const q = args.query.trim().toLowerCase() + + const issueProperties = catalog.filter( + (f) => + !isRelationshipFieldId(f.id) && + (ISSUE_PROPERTY_DATATYPES.has(f.dataType) || isSyntheticIssuePropertyId(f.id)), + ) + const projectFields = catalog.filter( + (f) => !isSyntheticIssuePropertyId(f.id) && EDITABLE_PROJECT_FIELD_DATATYPES.has(f.dataType), + ) + const relationships = catalog.filter((f) => isRelationshipFieldId(f.id)) + + if (q) { + const matches = catalog + .filter((f) => BULK_EDIT_FALLBACK_DATATYPES.has(f.dataType) || f.dataType === 'RELATIONSHIP') + .filter((f) => f.name.toLowerCase().includes(q)) + return { mode: 'search', matches: sortFieldsByName(matches) } + } + + const recent = args.recentIds + .map((id) => indexed.get(id)) + .filter((f): f is ProjectField => f !== undefined) + + const excludeRecent = (list: ProjectField[]) => list.filter((f) => !recentSet.has(f.id)) + + return { + mode: 'browse', + recent, + issueProperties: sortFieldsByName(excludeRecent(issueProperties)), + projectFields: sortFieldsByName(excludeRecent(projectFields)), + relationships: sortFieldsByName(excludeRecent(relationships)), + } +} + export function getFieldIcon(dataType: string): React.ReactNode { switch (dataType) { case 'ASSIGNEES': @@ -93,6 +215,8 @@ export function getFieldIcon(dataType: string): React.ReactNode { return case 'COMMENT': return + case 'RELATIONSHIP': + return default: return null }