diff --git a/src/router/worker-env.ts b/src/router/worker-env.ts index 64c3c932..134699d0 100644 --- a/src/router/worker-env.ts +++ b/src/router/worker-env.ts @@ -170,6 +170,7 @@ export function extractWorkItemId(data: CascadeJob): string | undefined { if (jobData.type === 'trello' && jobData.workItemId) return jobData.workItemId; if (jobData.type === 'jira' && jobData.issueKey) return jobData.issueKey; if (jobData.type === 'github') return jobData.triggerResult?.workItemId; + if (jobData.type === 'linear') return jobData.triggerResult?.workItemId ?? jobData.workItemId; // Dashboard jobs (manual-run, retry-run, debug-analysis) if (jobData.workItemId) return jobData.workItemId; return undefined; diff --git a/tests/unit/router/worker-env.test.ts b/tests/unit/router/worker-env.test.ts index 7c98b0e8..e4d2964e 100644 --- a/tests/unit/router/worker-env.test.ts +++ b/tests/unit/router/worker-env.test.ts @@ -225,6 +225,20 @@ describe('extractWorkItemId', () => { expect(extractWorkItemId(job)).toBe('gh-wi-1'); }); + it('returns triggerResult.workItemId for linear jobs when present', () => { + const job = { + type: 'linear', + workItemId: 'linear-issue-uuid', + triggerResult: { workItemId: 'TEAM-123' }, + } as unknown as CascadeJob; + expect(extractWorkItemId(job)).toBe('TEAM-123'); + }); + + it('falls back to top-level workItemId for linear jobs without triggerResult work item', () => { + const job = { type: 'linear', workItemId: 'linear-issue-uuid' } as unknown as CascadeJob; + expect(extractWorkItemId(job)).toBe('linear-issue-uuid'); + }); + it('returns workItemId from dashboard jobs', () => { const job = { type: 'manual-run', workItemId: 'wi-dash' } as unknown as CascadeJob; expect(extractWorkItemId(job)).toBe('wi-dash'); diff --git a/tests/unit/web/combobox.test.ts b/tests/unit/web/combobox.test.ts new file mode 100644 index 00000000..c9b35130 --- /dev/null +++ b/tests/unit/web/combobox.test.ts @@ -0,0 +1,52 @@ +/** + * Structural regression guard for the shared Combobox component. + * + * cmdk v1+ always sets `data-disabled="false"` on every non-disabled + * CommandItem. Tailwind's bare `data-[disabled]:` variant generates the + * CSS selector `[data-disabled]`, which matches any element that *has* the + * attribute — including `data-disabled="false"`. This caused every option + * in every cascade dashboard combobox to receive `pointer-events-none` and + * `opacity-50`, making them visually greyed-out and impossible to click. + * + * The fix is `data-[disabled=true]:` which generates `[data-disabled="true"]` + * and only activates when the item is explicitly disabled. + * + * This test reads the source directly (Combobox uses React hooks so it + * cannot be called as a plain function outside a React rendering context, + * and the unit environment has no jsdom). The source-read pattern follows + * the precedent in `pm-wizard-styling-guard.test.ts`. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const REPO_ROOT = resolve(__dirname, '..', '..', '..'); +const COMBOBOX_PATH = resolve(REPO_ROOT, 'web/src/components/ui/combobox.tsx'); + +describe('Combobox — disabled-item CSS regression guard', () => { + it('uses data-[disabled=true] not bare data-[disabled] for pointer-events', () => { + const source = readFileSync(COMBOBOX_PATH, 'utf8'); + // Must NOT have the bare attribute selector — it matches data-disabled="false". + expect(source, 'bare data-[disabled]: matches data-disabled="false" set by cmdk').not.toMatch( + /data-\[disabled\]:pointer-events-none/, + ); + // Must use the value-qualified selector — only fires when actually disabled. + expect(source, 'data-[disabled=true]: must be present').toContain( + 'data-[disabled=true]:pointer-events-none', + ); + }); + + it('uses data-[disabled=true] not bare data-[disabled] for opacity', () => { + const source = readFileSync(COMBOBOX_PATH, 'utf8'); + expect(source, 'bare data-[disabled]: matches data-disabled="false" set by cmdk').not.toMatch( + /data-\[disabled\]:opacity-50/, + ); + expect(source, 'data-[disabled=true]: must be present').toContain( + 'data-[disabled=true]:opacity-50', + ); + }); +}); diff --git a/web/src/components/projects/pm-providers/steps/label-mapping.tsx b/web/src/components/projects/pm-providers/steps/label-mapping.tsx index 5db6895d..2db04c46 100644 --- a/web/src/components/projects/pm-providers/steps/label-mapping.tsx +++ b/web/src/components/projects/pm-providers/steps/label-mapping.tsx @@ -243,9 +243,7 @@ export function LabelMappingStep({ */ function renderBulkBanner( missingSlots: ReadonlyArray<{ slot: string; name: string; color?: string }>, - onCreate: - | ((slots: ReadonlyArray<{ slot: string; name: string; color?: string }>) => void) - | undefined, + onCreate: (slots: ReadonlyArray<{ slot: string; name: string; color?: string }>) => void, busy: boolean, ): ReactNode { const count = missingSlots.length; @@ -279,7 +277,7 @@ function renderBulkBanner( size: 'sm', 'data-action': 'create-missing-labels', disabled: busy, - onClick: () => onCreate?.(missingSlots), + onClick: () => onCreate(missingSlots), } as React.ComponentProps & DataProps, busy ? 'Creating…' : 'Create all', ), @@ -299,6 +297,8 @@ interface CreateLabelFormProps { * directly and traverse the returned tree without a React renderer. */ function CreateLabelForm({ slotKey, defaultName, defaultColor, onCreate }: CreateLabelFormProps) { + // Intentionally seeded once from defaultName — the user may edit the field + // freely, and resetting it mid-edit when team details load would be jarring. const [newLabelName, setNewLabelName] = useState(defaultName ?? ''); return createElement( 'div', diff --git a/web/src/components/ui/combobox.tsx b/web/src/components/ui/combobox.tsx index 8883a9d2..3c682204 100644 --- a/web/src/components/ui/combobox.tsx +++ b/web/src/components/ui/combobox.tsx @@ -144,7 +144,7 @@ export function Combobox({ key={option.value} value={option.value} onSelect={() => handleSelect(option.value)} - className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground" + className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground" >