From e54268f08341983531d494c6abebcd95bff8b5de Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Fri, 3 Jul 2026 02:55:04 +0700 Subject: [PATCH 1/9] refactor: drop dead code and satisfy react-hooks/set-state-in-effect - delete unused modal-factory + use-subscription-ref hooks and the unused progress-state / empty-state / section-header UI primitives (~560 LOC) - add targeted eslint-disable + reason on legit external-sync effects (fetch / timer / reset-on-prop) flagged by react-hooks/set-state-in-effect - dedupe the 3x iteration+endDate mapping in sprint-handlers via a local withEndDate helper --- .../__tests__/bulk-update-dispatch.test.ts | 9 +- src/background/bulk-position.ts | 5 +- src/background/project-service.ts | 12 +- src/background/relationship-helpers.ts | 11 +- src/background/sprint-handlers.ts | 20 ++- src/background/transfer-eligibility.ts | 4 +- src/features/bulk-random-assign-flyout.tsx | 4 + src/features/bulk-rename-flyout.tsx | 2 + src/features/bulk-reorder-flyout.tsx | 2 + src/features/bulk-transfer-modal.tsx | 1 + src/features/project-context-card.tsx | 1 + src/features/queue-tracker.tsx | 1 + src/lib/__tests__/modal-factory.test.tsx | 75 --------- src/lib/modal-factory.tsx | 152 ------------------ src/lib/queue-store.test.ts | 11 +- src/lib/use-subscription-ref.ts | 74 --------- src/ui/empty-state.tsx | 54 ------- src/ui/progress-state.tsx | 75 --------- src/ui/search-select-panel.tsx | 1 + src/ui/section-header.tsx | 54 ------- 20 files changed, 57 insertions(+), 511 deletions(-) delete mode 100644 src/lib/__tests__/modal-factory.test.tsx delete mode 100644 src/lib/modal-factory.tsx delete mode 100644 src/lib/use-subscription-ref.ts delete mode 100644 src/ui/empty-state.tsx delete mode 100644 src/ui/progress-state.tsx delete mode 100644 src/ui/section-header.tsx diff --git a/src/background/__tests__/bulk-update-dispatch.test.ts b/src/background/__tests__/bulk-update-dispatch.test.ts index d16368d..60eb5df 100644 --- a/src/background/__tests__/bulk-update-dispatch.test.ts +++ b/src/background/__tests__/bulk-update-dispatch.test.ts @@ -21,14 +21,19 @@ vi.mock('@/background/concurrency', () => ({ })) vi.mock('@/lib/messages', () => ({ - onMessage: (type: string, handler: (typeof hoisted.handlers) extends Map ? H : never) => { + 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/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() })) diff --git a/src/background/bulk-position.ts b/src/background/bulk-position.ts index c3a47a8..5c0e8b3 100644 --- a/src/background/bulk-position.ts +++ b/src/background/bulk-position.ts @@ -2,10 +2,7 @@ import { onMessage } from '@/lib/messages' import { gql } from '@/lib/graphql-client' -import { - GET_PROJECT_ITEMS_FOR_REORDER, - UPDATE_PROJECT_ITEM_POSITION, -} from '@/lib/graphql-queries' +import { GET_PROJECT_ITEMS_FOR_REORDER, UPDATE_PROJECT_ITEM_POSITION } from '@/lib/graphql-queries' import { processQueue, sleep } from '@/lib/queue' import type { QueueTask } from '@/lib/queue' import { logger } from '@/lib/debug-logger' diff --git a/src/background/project-service.ts b/src/background/project-service.ts index 783fae8..59f33db 100644 --- a/src/background/project-service.ts +++ b/src/background/project-service.ts @@ -1,7 +1,15 @@ import { Context, Effect, Layer } from 'effect' -import { listIssueRelationshipsSafe as listIssueRelationshipsSafeAsync, listSubIssuesSafe as listSubIssuesSafeAsync } from '@/background/relationship-helpers' -import { getProjectFieldsData as getProjectFieldsDataAsync, getRepositoryId as getRepositoryIdAsync, resolveProjectItemIds as resolveProjectItemIdsAsync, resolveProjectItemIdsWithTitles as resolveProjectItemIdsWithTitlesAsync } from '@/background/project-helpers' +import { + listIssueRelationshipsSafe as listIssueRelationshipsSafeAsync, + listSubIssuesSafe as listSubIssuesSafeAsync, +} from '@/background/relationship-helpers' +import { + getProjectFieldsData as getProjectFieldsDataAsync, + getRepositoryId as getRepositoryIdAsync, + resolveProjectItemIds as resolveProjectItemIdsAsync, + resolveProjectItemIdsWithTitles as resolveProjectItemIdsWithTitlesAsync, +} from '@/background/project-helpers' import type { FieldsResultProject, ResolvedItem, ResolvedItemWithTitle } from '@/background/types' import type { IssueRelationshipData, SubIssueData } from '@/lib/messages' diff --git a/src/background/relationship-helpers.ts b/src/background/relationship-helpers.ts index c1701a8..b81430a 100644 --- a/src/background/relationship-helpers.ts +++ b/src/background/relationship-helpers.ts @@ -20,15 +20,8 @@ import type { RelationshipSearchIssueNode, } from '@/background/types' -import { - githubRest, - parseRepoFromUrl, - withRateLimitRetry, -} from '@/background/rest-helpers' -import { - formatIssueReference, - relationshipKey, -} from '@/lib/relationship-utils' +import { githubRest, parseRepoFromUrl, withRateLimitRetry } from '@/background/rest-helpers' +import { formatIssueReference, relationshipKey } from '@/lib/relationship-utils' export { relationshipKey } from '@/lib/relationship-utils' diff --git a/src/background/sprint-handlers.ts b/src/background/sprint-handlers.ts index 3524c92..9e36677 100644 --- a/src/background/sprint-handlers.ts +++ b/src/background/sprint-handlers.ts @@ -9,7 +9,11 @@ import { allSprintSettingsStorage } from '@/lib/storage' import { todayUtc, isActive, nearestUpcoming, iterationEndDate } from '@/lib/sprint-utils' import { logger } from '@/lib/debug-logger' -import { sprintProgressCache, SPRINT_PROGRESS_CACHE_TTL_MS, pruneExpiredCache } from '@/background/cache' +import { + sprintProgressCache, + SPRINT_PROGRESS_CACHE_TTL_MS, + pruneExpiredCache, +} from '@/background/cache' import { isSprintEndFull, acquireSprintEnd, releaseSprintEnd } from '@/background/concurrency' @@ -46,15 +50,15 @@ export function registerSprintHandlers(): void { ] const today = todayUtc() + const withEndDate = [0]>( + iter: T, + ): T & { endDate: string } => ({ ...iter, endDate: iterationEndDate(iter) }) + const active = allIters.find((i) => isActive(i, today)) ?? null - const activeSprint: SprintInfo | null = active - ? { ...active, endDate: iterationEndDate(active) } - : null + const activeSprint: SprintInfo | null = active ? withEndDate(active) : null const upcoming = nearestUpcoming(iterField.configuration.iterations ?? [], today) - const nearestUpcomingSprint: SprintInfo | null = upcoming - ? { ...upcoming, endDate: iterationEndDate(upcoming) } - : null + const nearestUpcomingSprint: SprintInfo | null = upcoming ? withEndDate(upcoming) : null // check acknowledged sprint (if any) — clear stale IDs let acknowledgedSprint: SprintInfo | null = null @@ -63,7 +67,7 @@ export function registerSprintHandlers(): void { (i) => i.id === settings.acknowledgedSprintId, ) if (ackIter) { - acknowledgedSprint = { ...ackIter, endDate: iterationEndDate(ackIter) } + acknowledgedSprint = withEndDate(ackIter) } else { // stale — clear it const updated = { ...settings, acknowledgedSprintId: undefined } diff --git a/src/background/transfer-eligibility.ts b/src/background/transfer-eligibility.ts index b02597d..4fb30e0 100644 --- a/src/background/transfer-eligibility.ts +++ b/src/background/transfer-eligibility.ts @@ -9,7 +9,9 @@ export interface TransferEligibilityRow { title?: string } -export function unresolvedTransferEligibilityRows(itemIds: readonly string[]): TransferEligibilityRow[] { +export function unresolvedTransferEligibilityRows( + itemIds: readonly string[], +): TransferEligibilityRow[] { return itemIds.map((domId) => ({ domId, eligible: false, diff --git a/src/features/bulk-random-assign-flyout.tsx b/src/features/bulk-random-assign-flyout.tsx index e17f0b4..da067da 100644 --- a/src/features/bulk-random-assign-flyout.tsx +++ b/src/features/bulk-random-assign-flyout.tsx @@ -106,6 +106,7 @@ export function BulkRandomAssignFlyout({ // Reset state on open useEffect(() => { if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset local draft state when the flyout closes setQuery('') setPicked([]) setStrategy('balanced') @@ -120,6 +121,7 @@ export function BulkRandomAssignFlyout({ // Load current assignees per item when preserve is enabled. useEffect(() => { if (!open || !preserveExisting || itemIds.length === 0) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- loading state for the assignee-fetch effect setLoadingExisting(false) if (!preserveExisting) setExistingByItemId(new Map()) return @@ -172,6 +174,7 @@ export function BulkRandomAssignFlyout({ if (!open || !repoName) return const requestId = Date.now() latestReq.current = requestId + // eslint-disable-next-line react-hooks/set-state-in-effect -- loading state for the debounced search effect setLoading(true) const timer = setTimeout( () => { @@ -203,6 +206,7 @@ export function BulkRandomAssignFlyout({ // Seed cache with recents the first time the flyout opens. useEffect(() => { if (!recentAssignees || recentAssignees.length === 0) return + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time seed of recents into local cache setCache((prev) => { const next = new Map(prev) for (const r of recentAssignees) { diff --git a/src/features/bulk-rename-flyout.tsx b/src/features/bulk-rename-flyout.tsx index 1dd97a3..f424afa 100644 --- a/src/features/bulk-rename-flyout.tsx +++ b/src/features/bulk-rename-flyout.tsx @@ -72,6 +72,7 @@ export function BulkRenameFlyout({ // Reset on open useEffect(() => { if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset local draft state when the flyout closes setRule(DEFAULT_RULE_STATE) setShowAll(false) setItems([]) @@ -85,6 +86,7 @@ export function BulkRenameFlyout({ const requestId = latestFetch.current + 1 latestFetch.current = requestId + // eslint-disable-next-line react-hooks/set-state-in-effect -- loading state for the rename-preview fetch effect setLoading(true) setFetchError(null) sendMessage('getItemTitles', { itemIds: [...itemIds], projectId }) diff --git a/src/features/bulk-reorder-flyout.tsx b/src/features/bulk-reorder-flyout.tsx index a9835c0..20af5e6 100644 --- a/src/features/bulk-reorder-flyout.tsx +++ b/src/features/bulk-reorder-flyout.tsx @@ -75,6 +75,7 @@ export function BulkReorderFlyout({ if (!open || itemIds.length === 0) return const requestId = latestReq.current + 1 latestReq.current = requestId + // eslint-disable-next-line react-hooks/set-state-in-effect -- loading state for the reorder-context fetch effect setLoading(true) setFetchError(null) const allDomIds = Array.from(document.querySelectorAll('[data-rgp-cb]')) @@ -108,6 +109,7 @@ export function BulkReorderFlyout({ useEffect(() => { if (!open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset local draft state when the flyout closes setQuery('') setDirection('before') } diff --git a/src/features/bulk-transfer-modal.tsx b/src/features/bulk-transfer-modal.tsx index 69e1424..b4ef7ae 100644 --- a/src/features/bulk-transfer-modal.tsx +++ b/src/features/bulk-transfer-modal.tsx @@ -204,6 +204,7 @@ export function BulkTransferModal({ ? `${selectedTarget.targetRepoOwner}/${selectedTarget.targetRepoName}`.toLowerCase() : null useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset expander before eligibility recheck setShowIneligibleExpander(false) if (!selectedTarget || !targetKey || !projectId || !itemIds || itemIds.length === 0) { setEligibilityLoading(false) diff --git a/src/features/project-context-card.tsx b/src/features/project-context-card.tsx index 75e262c..6b2fb3d 100644 --- a/src/features/project-context-card.tsx +++ b/src/features/project-context-card.tsx @@ -19,6 +19,7 @@ export function ProjectContextCard({ itemId, projectContext }: ProjectContextCar useEffect(() => { let cancelled = false + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset to loading before refetch on input change setState({ status: 'loading' }) Promise.all([ diff --git a/src/features/queue-tracker.tsx b/src/features/queue-tracker.tsx index f4b895a..41e2ca4 100644 --- a/src/features/queue-tracker.tsx +++ b/src/features/queue-tracker.tsx @@ -49,6 +49,7 @@ export function ProcessCard({ useEffect(() => { if (!entry.paused || !entry.retryAfter) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- countdown reset synced to queue retry state setCountdown(0) return } diff --git a/src/lib/__tests__/modal-factory.test.tsx b/src/lib/__tests__/modal-factory.test.tsx deleted file mode 100644 index 2fdaa17..0000000 --- a/src/lib/__tests__/modal-factory.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { BaseStyles, ThemeProvider } from '@primer/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/debug-logger', () => ({ - logger: { log: () => {}, warn: () => {}, error: () => {}, info: () => {} }, - initDebugLogger: async () => {}, -})) -vi.mock('@/lib/tippy-utils', () => ({ ensureTippyCss: () => {} })) -vi.mock('@/lib/toast-store', () => ({ - toastStore: { show: () => {} }, -})) - -import { createModal } from '@/lib/modal-factory' -;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true - -const TestModal = createModal<{ onConfirm: () => void }>({ - name: 'Test', - renderContent: (_props, _helpers) => , - onSubmit: async () => {}, -}) - -let mounted: Array<{ container: HTMLDivElement; root: Root }> = [] - -function render(node: React.ReactElement) { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - act(() => { - root.render(node) - }) - mounted.push({ container, root }) - return container -} - -beforeEach(() => { - mounted = [] -}) - -afterEach(() => { - for (const { container, root } of mounted) { - act(() => root.unmount()) - container.remove() - } -}) - -describe('createModal keyboard propagation', () => { - it('stops keydown/keyup propagation to document when typing inside the modal panel', () => { - const container = render( - - - {}} onClose={() => {}} /> - - , - ) - - const input = container.querySelector('[data-testid="modal-input"]') as HTMLInputElement - expect(input).not.toBeNull() - - const spy = vi.fn() - document.addEventListener('keydown', spy) - document.addEventListener('keyup', spy) - - act(() => { - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })) - input.dispatchEvent(new KeyboardEvent('keyup', { key: 'a', bubbles: true })) - }) - - document.removeEventListener('keydown', spy) - document.removeEventListener('keyup', spy) - - expect(spy).not.toHaveBeenCalled() - }) -}) diff --git a/src/lib/modal-factory.tsx b/src/lib/modal-factory.tsx deleted file mode 100644 index 7ef77e5..0000000 --- a/src/lib/modal-factory.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { Button, Flash, Spinner } from '@primer/react' -import { ModalStepHeader } from '@/ui/modal-step-header' -import { ModalShell } from '@/ui/modal-shell' -import { primerCss } from '@/lib/primer-css-helper' -import { toastStore } from '@/lib/toast-store' -import { ensureTippyCss } from '@/lib/tippy-utils' - -export type ModalStage = 'LOADING' | 'ERROR' | 'INPUT' | 'CONFIRM' - -export interface RenderHelpers { - error: string | null - stage: ModalStage -} - -export interface FooterOpts { - onClose: () => void - onSubmit: () => void - loading: boolean -} - -export interface CreateModalOptions { - /** Modal name used as aria-label and ModalStepHeader title */ - name: string - /** Icon rendered in ModalStepHeader (optional) */ - icon?: React.ReactNode - /** Renders the modal content area. Receives data props T, onClose, and render helpers. */ - renderContent: (props: T & { onClose: () => void }, helpers: RenderHelpers) => React.ReactNode - /** Async handler called when user clicks the confirm button. Receives data props T. */ - onSubmit: (props: T) => Promise - /** Optional validation before onSubmit. Returns null to allow, or error string to block. */ - validate?: () => string | null - /** Text for the primary action button (default: "Confirm") */ - confirmLabel?: string - /** Optional custom footer renderer. Replaces the default cancel/confirm buttons. */ - footer?: (opts: FooterOpts) => React.ReactNode -} - -/** The modal component receives all T props plus onClose */ -export type ModalComponentProps = T & { onClose: () => void } - -export function createModal(opts: CreateModalOptions): React.FC> { - const { name, icon, renderContent, onSubmit, validate, confirmLabel = 'Confirm', footer } = opts - - const ModalComponent: React.FC> = (props) => { - const { onClose, ...rest } = props - const dataProps = rest as T - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const submitInFlightRef = useRef(false) - const mountedRef = useRef(true) - - const stage: ModalStage = loading ? 'LOADING' : error ? 'ERROR' : 'INPUT' - - useEffect(() => { - ensureTippyCss() - }, []) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } - }, []) - - const handleRequestClose = useCallback(() => { - if (loading) return - onClose() - }, [loading, onClose]) - - const handleSubmit = async () => { - if (submitInFlightRef.current) return - submitInFlightRef.current = true - - setError(null) - try { - if (validate) { - const validationError = validate() - if (validationError) { - setError(validationError) - return - } - } - - setLoading(true) - await onSubmit(dataProps) - toastStore.show({ message: `${name} completed`, type: 'success' }) - handleRequestClose() - } catch (err) { - if (mountedRef.current) { - setError(err instanceof Error ? err.message : String(err)) - setLoading(false) - } - } finally { - submitInFlightRef.current = false - } - } - - const defaultFooter = ( - <> - - - - ) - - return ( - } - footer={ - footer - ? footer({ onClose: handleRequestClose, onSubmit: handleSubmit, loading }) - : defaultFooter - } - > - {error && ( - - {error} - - )} - {renderContent( - { ...dataProps, onClose: handleRequestClose } as T & { onClose: () => void }, - { - error, - stage, - }, - )} - - ) - } - - ModalComponent.displayName = `Modal(${name})` - return ModalComponent -} diff --git a/src/lib/queue-store.test.ts b/src/lib/queue-store.test.ts index aa8d988..e8ccef3 100644 --- a/src/lib/queue-store.test.ts +++ b/src/lib/queue-store.test.ts @@ -51,7 +51,16 @@ beforeEach(() => { vi.useFakeTimers() toastShow.mockReset() // clear any leftover processes between tests - for (const pid of ['p1', 'p2', 'bulk', 'hints-auto-1', 'hints-1', 'hints-2', 'hints-bg-1', 'hints-bg-2']) + for (const pid of [ + 'p1', + 'p2', + 'bulk', + 'hints-auto-1', + 'hints-1', + 'hints-2', + 'hints-bg-1', + 'hints-bg-2', + ]) queueStore.dismiss(pid) }) diff --git a/src/lib/use-subscription-ref.ts b/src/lib/use-subscription-ref.ts deleted file mode 100644 index 42d2f54..0000000 --- a/src/lib/use-subscription-ref.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Effect, Fiber, Stream, type SubscriptionRef } from 'effect' -import { useSyncExternalStore } from 'react' - -import { runFork } from '@/lib/effect-runtime' - -/** - * React hook that subscribes to an Effect `SubscriptionRef` and returns the - * current value, kept up-to-date across changes. Uses `useSyncExternalStore` - * for tearing-free concurrent rendering compatibility. - * - * @param ref The SubscriptionRef to observe. - * @param snapshot Synchronous accessor returning the current value. Required - * because `useSyncExternalStore` needs a sync getter; the - * caller is expected to maintain a mirror in sync with the - * ref (this is what every store in `src/lib/*-store.ts` does). - */ -export function useSubscriptionRef( - ref: SubscriptionRef.SubscriptionRef, - snapshot: () => A, -): A { - return useSyncExternalStore( - (onStoreChange) => { - // skip the very first emission (it equals the current snapshot) so - // we only react to actual changes — this avoids a redundant render - // on mount. - let first = true - const fiber = runFork( - Stream.runForEach(ref.changes, () => - Effect.sync(() => { - if (first) { - first = false - return - } - onStoreChange() - }), - ), - ) - return () => { - runFork(Fiber.interrupt(fiber)) - } - }, - snapshot, - snapshot, - ) -} - -/** - * Convenience hook for stores that don't expose their `SubscriptionRef` - * directly but provide a Stream of changes plus a snapshot accessor (this is - * the shape used by `selection-store.ts`, `toast-store.ts`, etc). - */ -export function useStoreChanges(changes: Stream.Stream, snapshot: () => A): A { - return useSyncExternalStore( - (onStoreChange) => { - let first = true - const fiber = runFork( - Stream.runForEach(changes, () => - Effect.sync(() => { - if (first) { - first = false - return - } - onStoreChange() - }), - ), - ) - return () => { - runFork(Fiber.interrupt(fiber)) - } - }, - snapshot, - snapshot, - ) -} diff --git a/src/ui/empty-state.tsx b/src/ui/empty-state.tsx deleted file mode 100644 index 7f0bb7d..0000000 --- a/src/ui/empty-state.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react' -import { Box, Heading, Text } from '@primer/react' - -interface EmptyStateProps { - icon?: React.ReactNode - title: string - description?: string - action?: React.ReactNode -} - -export function EmptyState({ icon, title, description, action }: EmptyStateProps) { - return ( - - {icon && ( - - {icon} - - )} - - - {title} - - {description && ( - - {description} - - )} - - {action && {action}} - - ) -} diff --git a/src/ui/progress-state.tsx b/src/ui/progress-state.tsx deleted file mode 100644 index aabccd5..0000000 --- a/src/ui/progress-state.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react' -import { Box, Text } from '@primer/react' - -import { PanelCard } from '@/ui/panel-card' - -interface ProgressStateProps { - progress: number - status?: 'idle' | 'running' | 'paused' | 'complete' | 'error' - label?: string - sublabel?: string -} - -export function ProgressState({ progress, status = 'idle', label, sublabel }: ProgressStateProps) { - const isComplete = status === 'complete' - const isError = status === 'error' - const isPaused = status === 'paused' - - const progressColor = isError - ? 'danger.emphasis' - : isComplete - ? 'success.emphasis' - : isPaused - ? 'attention.emphasis' - : 'accent.emphasis' - - const title = - label || (isComplete ? 'Complete' : isError ? 'Error' : isPaused ? 'Paused' : 'In progress') - - return ( - - - - - - {title} - - - {Math.round(progress)}% - - - - - - - - {sublabel && {sublabel}} - - - ) -} diff --git a/src/ui/search-select-panel.tsx b/src/ui/search-select-panel.tsx index 4c670eb..a6eec6c 100644 --- a/src/ui/search-select-panel.tsx +++ b/src/ui/search-select-panel.tsx @@ -281,6 +281,7 @@ export function SearchSelectPanel(props: MultiProps | SingleProps) { log('panel:disabled-reset') requestGen.current += 1 + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset panel state when the control is disabled setPanelOpen(false) setFilterQuery('') setFetchError(null) diff --git a/src/ui/section-header.tsx b/src/ui/section-header.tsx deleted file mode 100644 index 361339a..0000000 --- a/src/ui/section-header.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react' -import { Box, Heading, Text } from '@primer/react' - -interface SectionHeaderProps { - title: string - subtitle?: string - action?: React.ReactNode - icon?: React.ReactNode -} - -export function SectionHeader({ title, subtitle, action, icon }: SectionHeaderProps) { - return ( - - - {icon && ( - - {icon} - - )} - - - {title} - - {subtitle && ( - - {subtitle} - - )} - - - {action && {action}} - - ) -} From 2345a9742b335be1f8d5d3c5066fc4d54078eaa0 Mon Sep 17 00:00:00 2001 From: Fathiraz Arthuro Date: Fri, 3 Jul 2026 03:04:41 +0700 Subject: [PATCH 2/9] refactor(bulk-edit): extract ValuePicker, FieldRow, SectionHeader to siblings Split bulk-edit-flyout.tsx (717 -> 440 LOC) by moving the presentational sub-components into bulk-edit-value-picker.tsx and bulk-edit-field-row.tsx, and the DOM helper firstRepoNameFromDom into bulk-edit-flyout-helpers.ts. No behavior change; public component + props unchanged. --- src/features/bulk-edit-field-row.tsx | 41 ++++ src/features/bulk-edit-flyout-helpers.ts | 12 + src/features/bulk-edit-flyout.tsx | 295 +---------------------- src/features/bulk-edit-value-picker.tsx | 257 ++++++++++++++++++++ 4 files changed, 313 insertions(+), 292 deletions(-) create mode 100644 src/features/bulk-edit-field-row.tsx create mode 100644 src/features/bulk-edit-value-picker.tsx diff --git a/src/features/bulk-edit-field-row.tsx b/src/features/bulk-edit-field-row.tsx new file mode 100644 index 0000000..ce2725c --- /dev/null +++ b/src/features/bulk-edit-field-row.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { ActionList, Box } from '@primer/react' +import { getFieldIcon, type ProjectField } from '@/features/bulk-edit-utils' + +export function SectionHeader({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +export interface FieldRowProps { + field: ProjectField + onPick: (field: ProjectField) => void +} + +export function FieldRow({ field, onPick }: FieldRowProps) { + return ( + onPick(field)} data-testid={`rgp-edit-field-${field.id}`}> + + {getFieldIcon(field.dataType)} + + {field.name} + + {field.dataType.toLowerCase().replace(/_/g, ' ')} + + + ) +} diff --git a/src/features/bulk-edit-flyout-helpers.ts b/src/features/bulk-edit-flyout-helpers.ts index 308e70d..e208312 100644 --- a/src/features/bulk-edit-flyout-helpers.ts +++ b/src/features/bulk-edit-flyout-helpers.ts @@ -135,3 +135,15 @@ export async function submitBulkFieldUpdate(args: { return { ok: false, message: BULK_EDIT_DISPATCH_FAILED_MESSAGE } } } + +export function firstRepoNameFromDom(owner: string): string | null { + if (typeof document === 'undefined') return null + const links = document.querySelectorAll( + 'a[href*="/issues/"], a[href*="/pull/"]', + ) + for (const link of Array.from(links)) { + const match = link.href.match(/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/\d+/) + if (match && match[1] === owner) return match[2] + } + return null +} diff --git a/src/features/bulk-edit-flyout.tsx b/src/features/bulk-edit-flyout.tsx index e783512..edff027 100644 --- a/src/features/bulk-edit-flyout.tsx +++ b/src/features/bulk-edit-flyout.tsx @@ -16,6 +16,8 @@ import { TextInput, Textarea, } from '@primer/react' +import { ValuePicker } from '@/features/bulk-edit-value-picker' +import { FieldRow, SectionHeader } from '@/features/bulk-edit-field-row' import { SearchIcon } from '@/ui/icons' import { BulkFlyout, type BulkFlyoutPane, useDrilldownPane } from '@/ui/bulk-flyout' import { sendMessage } from '@/lib/messages' @@ -32,6 +34,7 @@ import { defaultValueFor, submitBulkFieldUpdate, type FieldValue, + firstRepoNameFromDom, } from '@/features/bulk-edit-flyout-helpers' import { BulkEditRelationshipPane, @@ -423,295 +426,3 @@ export function BulkEditFlyout({ /> ) } - -function SectionHeader({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -interface FieldRowProps { - field: ProjectField - onPick: (field: ProjectField) => void -} - -function FieldRow({ field, onPick }: FieldRowProps) { - return ( - onPick(field)} data-testid={`rgp-edit-field-${field.id}`}> - - {getFieldIcon(field.dataType)} - - {field.name} - - {field.dataType.toLowerCase().replace(/_/g, ' ')} - - - ) -} - -interface ValuePickerProps { - field: ProjectField - value: FieldValue | null - onChange: (next: FieldValue) => void - metaQuery: string - setMetaQuery: (q: string) => void - metaResults: Array<{ id: string; name: string; avatarUrl?: string }> - metaLoading: boolean -} - -function ValuePicker({ - field, - value, - onChange, - metaQuery, - setMetaQuery, - metaResults, - metaLoading, -}: ValuePickerProps) { - const dataType = field.dataType - - if (dataType === 'TEXT' || dataType === 'TITLE') { - return ( - - - {field.name} - - ) => - onChange({ kind: 'text', text: e.target.value }) - } - aria-label={field.name} - sx={{ width: '100%' }} - data-testid="rgp-edit-value-text" - /> - - ) - } - - if (dataType === 'BODY' || dataType === 'COMMENT') { - return ( - - - {field.name} - -