From 4eae00964dc01334ddfc437b3424b6c324779c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:43:35 +0100 Subject: [PATCH] feat: move integration links into task menus (#1644) * feat: move integration links into task menus * fix: address integration link review feedback * fix: preserve archived task title when linking * fix: address integration link review issues * fix: remove ignored swimlane availability prop --- .../integrations/validated-popover.tsx | 5 +- apps/web/components/jira/jira-link-button.tsx | 63 ---- apps/web/components/kanban-board-grid.tsx | 17 +- .../kanban-card-menu-items.test.tsx | 51 ++- .../web/components/kanban-card-menu-items.tsx | 104 ++++-- apps/web/components/kanban-card.tsx | 298 +++++++++++------- apps/web/components/kanban-column.tsx | 6 + .../kanban-external-link-availability.ts | 21 ++ .../kanban/swimlane-kanban-content.tsx | 19 +- .../components/kanban/swipeable-columns.tsx | 4 + .../components/linear/linear-link-button.tsx | 58 ---- .../components/sentry/sentry-link-button.tsx | 63 ---- .../mobile/session-task-switcher-sheet.tsx | 41 ++- .../task/task-external-link-dialog.test.tsx | 126 ++++++++ .../task/task-external-link-dialog.tsx | 205 ++++++++++++ .../task/task-external-link-utils.test.ts | 19 ++ .../task/task-external-link-utils.ts | 6 + apps/web/components/task/task-item.tsx | 2 +- .../task/task-session-sidebar-dialogs.tsx | 57 +++- .../task/task-session-sidebar-link-actions.ts | 50 ++- .../task/task-session-sidebar-task-linking.ts | 29 ++ .../components/task/task-session-sidebar.tsx | 25 +- .../task/task-switcher-context-menu.tsx | 153 ++++++--- .../components/task/task-switcher.test.tsx | 54 +++- apps/web/components/task/task-switcher.tsx | 59 +++- .../web/components/task/task-top-bar.test.tsx | 8 - apps/web/components/task/task-top-bar.tsx | 31 +- apps/web/e2e/helpers/api-client.ts | 14 + .../task/mobile-external-link-menu.spec.ts | 49 +++ .../tasks/link-existing-task-github-issue.md | 56 +++- 30 files changed, 1252 insertions(+), 441 deletions(-) delete mode 100644 apps/web/components/jira/jira-link-button.tsx create mode 100644 apps/web/components/kanban-external-link-availability.ts delete mode 100644 apps/web/components/linear/linear-link-button.tsx delete mode 100644 apps/web/components/sentry/sentry-link-button.tsx create mode 100644 apps/web/components/task/task-external-link-dialog.test.tsx create mode 100644 apps/web/components/task/task-external-link-dialog.tsx create mode 100644 apps/web/components/task/task-external-link-utils.test.ts create mode 100644 apps/web/components/task/task-external-link-utils.ts create mode 100644 apps/web/components/task/task-session-sidebar-task-linking.ts create mode 100644 apps/web/e2e/tests/task/mobile-external-link-menu.spec.ts diff --git a/apps/web/components/integrations/validated-popover.tsx b/apps/web/components/integrations/validated-popover.tsx index 5ffc5c569b..15d8873db2 100644 --- a/apps/web/components/integrations/validated-popover.tsx +++ b/apps/web/components/integrations/validated-popover.tsx @@ -18,9 +18,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; // Each integration provides {icon, label, tooltip, placeholder, regex, // fetch, onSuccess, validationHint, headline, submitLabel, submittingLabel}. // -// Designing this as one shared skeleton both fixes the existing bug that -// JiraLinkButton was missing the clear-error-on-close behaviour LinearLinkButton -// had, and prevents future drift between the four near-identical popovers. +// Designing this as one shared skeleton prevents drift between the +// near-identical integration popovers. export type ValidatedPopoverTriggerStyle = "outline-with-label" | "ghost-icon"; diff --git a/apps/web/components/jira/jira-link-button.tsx b/apps/web/components/jira/jira-link-button.tsx deleted file mode 100644 index 999170c9ee..0000000000 --- a/apps/web/components/jira/jira-link-button.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { IconLink } from "@tabler/icons-react"; -import { toast } from "sonner"; -import { getJiraTicket } from "@/lib/api/domains/jira-api"; -import { updateTask } from "@/lib/api/domains/kanban-api"; -import { JIRA_KEY_RE } from "./jira-ticket-common"; -import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability"; -import { ValidatedPopover } from "@/components/integrations/validated-popover"; - -type JiraLinkButtonProps = { - taskId: string | null | undefined; - workspaceId: string | null | undefined; - taskTitle: string | undefined | null; -}; - -// JiraLinkButton lets the user attach a Jira ticket to an existing task by -// prepending the ticket key to its title ("PROJ-123: ..."). The existing -// JiraTicketButton picks up the key automatically once the title is updated. -export function JiraLinkButton({ taskId, workspaceId, taskTitle }: JiraLinkButtonProps) { - const available = useJiraAvailable(workspaceId); - - const buildLinkedTitle = useCallback( - (key: string) => { - // Strip an existing leading "PROJ-123: " so re-linking a task to a - // different ticket replaces the prefix instead of stacking - // ("PROJ-456: PROJ-123: ..."). - const stripped = (taskTitle ?? "").trim().replace(/^[A-Z][A-Z0-9]+-\d+:\s*/, ""); - return stripped ? `${key}: ${stripped}` : key; - }, - [taskTitle], - ); - - if (!available || !taskId || !workspaceId) return null; - - return ( - } - triggerLabel="Link Jira" - tooltip="Link this task to a Jira ticket" - headline="Link to Jira ticket" - placeholder="PROJ-123 or paste ticket URL" - extractKey={(raw) => raw.toUpperCase().match(JIRA_KEY_RE)?.[0] ?? null} - validationHint="Paste a Jira ticket URL or key (PROJ-123)" - // Run getJiraTicket *and* the title update inside the awaited fetch so - // a failure on either surfaces as the popover's inline error instead of - // an unhandled rejection that leaves the user thinking the link - // succeeded. - fetch={async (key) => { - const ticket = await getJiraTicket(key, { workspaceId }); - await updateTask(taskId, { title: buildLinkedTitle(key) }); - return ticket; - }} - onSuccess={(key) => { - toast.success(`Linked to ${key}`); - }} - submitLabel="Link" - submittingLabel="Linking..." - /> - ); -} diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx index 7307129e93..1af8723399 100644 --- a/apps/web/components/kanban-board-grid.tsx +++ b/apps/web/components/kanban-board-grid.tsx @@ -22,6 +22,10 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useAppStore } from "@/components/state-provider"; import { getKanbanColumnGridTemplate } from "./kanban/kanban-grid-template"; import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order"; +import { + type KanbanExternalLinkAvailability, + useKanbanExternalLinkAvailability, +} from "./kanban-external-link-availability"; export type KanbanBoardGridProps = { steps: WorkflowStep[]; @@ -56,7 +60,9 @@ type ColumnGridProps = Pick< | "showMaximizeButton" | "deletingTaskId" | "archivingTaskId" ->; +> & { + externalLinkAvailability: KanbanExternalLinkAvailability; +}; function getTasksForStep(tasks: Task[], stepId: string) { return tasks @@ -92,6 +98,7 @@ function MobileLayout({ activeColumnIndex, setActiveColumnIndex, currentStepId, + externalLinkAvailability, }: ColumnGridProps & { showLoading: boolean; activeTask: Task | null; @@ -128,6 +135,7 @@ function MobileLayout({ showMaximizeButton={showMaximizeButton} deletingTaskId={deletingTaskId} archivingTaskId={archivingTaskId} + externalLinkAvailability={externalLinkAvailability} /> @@ -184,6 +193,7 @@ function TabletLayout({ showMaximizeButton={showMaximizeButton} deletingTaskId={deletingTaskId} archivingTaskId={archivingTaskId} + externalLinkAvailability={externalLinkAvailability} /> ))} @@ -212,6 +222,7 @@ function DesktopLayout({ showLoading, activeTask, isCompactDesktop, + externalLinkAvailability, }: ColumnGridProps & { showLoading: boolean; activeTask: Task | null; @@ -245,6 +256,7 @@ function DesktopLayout({ showMaximizeButton={showMaximizeButton} deletingTaskId={deletingTaskId} archivingTaskId={archivingTaskId} + externalLinkAvailability={externalLinkAvailability} /> ))} @@ -306,6 +318,8 @@ export function KanbanBoardGrid({ const { isMobile, isTablet, isCompactDesktop } = useResponsiveBreakpoint(); const activeColumnIndex = useAppStore((state) => state.mobileKanban.activeColumnIndex); const setActiveColumnIndex = useAppStore((state) => state.setMobileKanbanColumnIndex); + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); + const externalLinkAvailability = useKanbanExternalLinkAvailability(activeWorkspaceId); const sensors = useKanbanBoardSensors(); // Calculate task counts per step for tabs @@ -347,6 +361,7 @@ export function KanbanBoardGrid({ showMaximizeButton, deletingTaskId, archivingTaskId, + externalLinkAvailability, }; let layoutContent: React.ReactNode; diff --git a/apps/web/components/kanban-card-menu-items.test.tsx b/apps/web/components/kanban-card-menu-items.test.tsx index 75d088b2a4..61084d9408 100644 --- a/apps/web/components/kanban-card-menu-items.test.tsx +++ b/apps/web/components/kanban-card-menu-items.test.tsx @@ -1,7 +1,11 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu"; -import { KanbanCardDropdownMenuItems, type KanbanCardMenuEntry } from "./kanban-card-menu-items"; +import { + buildKanbanCardMenuEntries, + KanbanCardDropdownMenuItems, + type KanbanCardMenuEntry, +} from "./kanban-card-menu-items"; // Regression: React synthetic events bubble through the fiber tree from a Radix portal; without stopPropagation the parent Card's onClick fires instead of the confirm dialog. describe("KanbanCardDropdownMenuItems — click propagation", () => { @@ -81,3 +85,48 @@ describe("KanbanCardDropdownMenuItems — click propagation", () => { expect(parentOnPointerDown).not.toHaveBeenCalled(); }); }); + +describe("buildKanbanCardMenuEntries — external issue links", () => { + function itemLabels(entry: KanbanCardMenuEntry | undefined) { + if (entry?.kind !== "submenu") return []; + return entry.children.filter((child) => child.kind === "item").map((child) => child.label); + } + + it("adds configured external issue providers to the Link submenu", () => { + const entries = buildKanbanCardMenuEntries({ + workflows: [], + stepsByWorkflowId: {}, + onLinkPullRequest: vi.fn(), + onLinkIssue: vi.fn(), + onLinkJiraTicket: vi.fn(), + onLinkLinearIssue: vi.fn(), + onLinkSentryIssue: vi.fn(), + }); + + const linkMenu = entries.find((entry) => entry.kind === "submenu" && entry.key === "link"); + expect(linkMenu?.kind).toBe("submenu"); + + expect(itemLabels(linkMenu)).toEqual([ + "GitHub Pull Request", + "GitHub Issue", + "Jira Ticket", + "Linear Issue", + "Sentry Issue", + ]); + }); + + it("omits external issue providers that are not configured", () => { + const entries = buildKanbanCardMenuEntries({ + workflows: [], + stepsByWorkflowId: {}, + onLinkPullRequest: vi.fn(), + onLinkIssue: vi.fn(), + onLinkJiraTicket: vi.fn(), + }); + + const linkMenu = entries.find((entry) => entry.kind === "submenu" && entry.key === "link"); + expect(linkMenu?.kind).toBe("submenu"); + + expect(itemLabels(linkMenu)).toEqual(["GitHub Pull Request", "GitHub Issue", "Jira Ticket"]); + }); +}); diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx index 7b130aa589..4afa98a50b 100644 --- a/apps/web/components/kanban-card-menu-items.tsx +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -4,12 +4,14 @@ import { useMemo, type ReactNode } from "react"; import { IconArchive, IconArrowRight, + IconBrandSentry, IconCircleDot, IconGitPullRequest, IconLink, IconLoader, IconLogicBuffer, IconPencil, + IconTicket, IconTrash, } from "@tabler/icons-react"; import { @@ -83,6 +85,9 @@ type BuildKanbanCardMenuEntriesArgs = { onDelete?: () => void; onLinkPullRequest?: () => void; onLinkIssue?: () => void; + onLinkJiraTicket?: () => void; + onLinkLinearIssue?: () => void; + onLinkSentryIssue?: () => void; onMoveToStep?: (stepId: string) => void; onSendToWorkflow?: (workflowId: string, stepId: string) => void; }; @@ -222,12 +227,82 @@ function buildLinkSubmenu({ disabled, onLinkPullRequest, onLinkIssue, + onLinkJiraTicket, + onLinkLinearIssue, + onLinkSentryIssue, }: { disabled?: boolean; onLinkPullRequest?: () => void; onLinkIssue?: () => void; + onLinkJiraTicket?: () => void; + onLinkLinearIssue?: () => void; + onLinkSentryIssue?: () => void; }): KanbanCardMenuEntry | null { - if (!onLinkPullRequest && !onLinkIssue) return null; + if ( + !onLinkPullRequest && + !onLinkIssue && + !onLinkJiraTicket && + !onLinkLinearIssue && + !onLinkSentryIssue + ) { + return null; + } + const children: KanbanCardMenuEntry[] = []; + if (onLinkPullRequest) { + children.push({ + kind: "item", + key: "link-github-pull-request", + testId: "task-context-link-github-pull-request", + icon: , + label: "GitHub Pull Request", + disabled, + onSelect: onLinkPullRequest, + }); + } + if (onLinkIssue) { + children.push({ + kind: "item", + key: "link-github-issue", + testId: "task-context-link-github-issue", + icon: , + label: "GitHub Issue", + disabled, + onSelect: onLinkIssue, + }); + } + if (onLinkJiraTicket) { + children.push({ + kind: "item", + key: "link-jira-ticket", + testId: "task-context-link-jira-ticket", + icon: , + label: "Jira Ticket", + disabled, + onSelect: onLinkJiraTicket, + }); + } + if (onLinkLinearIssue) { + children.push({ + kind: "item", + key: "link-linear-issue", + testId: "task-context-link-linear-issue", + icon: , + label: "Linear Issue", + disabled, + onSelect: onLinkLinearIssue, + }); + } + if (onLinkSentryIssue) { + children.push({ + kind: "item", + key: "link-sentry-issue", + testId: "task-context-link-sentry-issue", + icon: , + label: "Sentry Issue", + disabled, + onSelect: onLinkSentryIssue, + }); + } return { kind: "submenu", key: "link", @@ -236,26 +311,7 @@ function buildLinkSubmenu({ label: "Link", disabled, className: "w-56", - children: [ - { - kind: "item", - key: "link-github-pull-request", - testId: "task-context-link-github-pull-request", - icon: , - label: "GitHub Pull Request", - disabled: disabled || !onLinkPullRequest, - onSelect: onLinkPullRequest, - }, - { - kind: "item", - key: "link-github-issue", - testId: "task-context-link-github-issue", - icon: , - label: "GitHub Issue", - disabled: disabled || !onLinkIssue, - onSelect: onLinkIssue, - }, - ], + children, }; } @@ -272,6 +328,9 @@ export function buildKanbanCardMenuEntries({ onDelete, onLinkPullRequest, onLinkIssue, + onLinkJiraTicket, + onLinkLinearIssue, + onLinkSentryIssue, onMoveToStep, onSendToWorkflow, }: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] { @@ -310,6 +369,9 @@ export function buildKanbanCardMenuEntries({ disabled: isProcessing, onLinkPullRequest, onLinkIssue, + onLinkJiraTicket, + onLinkLinearIssue, + onLinkSentryIssue, }); if (linkEntry) entries.push(linkEntry); diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 69b2823388..aaff8b2d67 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -11,6 +11,11 @@ import { import { useAppStore } from "@/components/state-provider"; import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; +import { + TaskExternalLinkDialog, + type ExternalLinkProvider, +} from "@/components/task/task-external-link-dialog"; +import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability"; import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog"; import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog"; import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; @@ -68,6 +73,8 @@ export interface WorkflowStep { interface KanbanCardProps { task: Task; + workspaceId: string | null; + externalLinkAvailability: KanbanExternalLinkAvailability; /** Display labels and hover paths of every repository linked to the task, primary first. */ repositoryChips?: RepositoryChip[]; onClick?: (task: Task) => void; @@ -88,45 +95,22 @@ interface KanbanCardProps { isMultiSelectMode?: boolean; } -function useKanbanCardMenus({ +function useKanbanCardMoveMenuActions({ task, steps, - isDeleting, - isArchiving, isSelected, selectedIds, - onEdit, - onDelete, - onArchive, onMove, -}: Pick< - KanbanCardProps, - | "task" - | "steps" - | "isDeleting" - | "isArchiving" - | "isSelected" - | "selectedIds" - | "onEdit" - | "onDelete" - | "onArchive" - | "onMove" ->) { +}: Pick) { const moveTargets = useKanbanCardMoveTargets(task.id, steps); const moveTasks = useTaskWorkflowMove(); const { sortByDisplayOrder, getWorkflowIdForTask } = useTaskMultiSelectStore(); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); - const [showPRDialog, setShowPRDialog] = useState(false); - const [showIssueDialog, setShowIssueDialog] = useState(false); - const disabled = Boolean(isDeleting || isArchiving); const runMoveTasks = (taskIds: string[], workflowId: string, stepId: string) => { void moveTasks(taskIds, workflowId, stepId).catch(() => { // useTaskWorkflowMove already shows the failure toast. }); }; - const moveToStepFromDropdown = (stepId: string) => { if (onMove) { onMove(task, stepId); @@ -136,14 +120,8 @@ function useKanbanCardMenus({ runMoveTasks([task.id], moveTargets.currentWorkflowId, stepId); } }; - const selectedTaskIds = isSelected && selectedIds?.size ? [...selectedIds] : [task.id]; - // Sort into board order lazily (only when a move actually fires) so a backward - // range selection isn't scrambled — and we don't pay it on every card render. const orderedSelectedIds = () => sortByDisplayOrder(selectedTaskIds); - // A selection spanning workflows can't safely use same-workflow "Move to" (it - // would drag other-workflow cards into this card's workflow), so gate it — only - // computed for a genuine multi-selection to avoid per-render snapshot scans. const isMixedWorkflowSelection = selectedTaskIds.length > 1 && new Set(selectedTaskIds.map((id) => getWorkflowIdForTask(id))).size > 1; @@ -156,11 +134,71 @@ function useKanbanCardMenus({ runMoveTasks(orderedSelectedIds(), moveTargets.currentWorkflowId, stepId); }; + return { + moveTargets, + moveToStepFromDropdown, + moveSelectedToStep: isMixedWorkflowSelection ? undefined : moveSelectedToStep, + sendTaskToWorkflow: (workflowId: string, stepId: string) => { + runMoveTasks([task.id], workflowId, stepId); + }, + sendSelectionToWorkflow: (workflowId: string, stepId: string) => { + runMoveTasks(orderedSelectedIds(), workflowId, stepId); + }, + }; +} + +function externalLinkHandlers( + availability: KanbanCardProps["externalLinkAvailability"], + setExternalLinkProvider: (provider: ExternalLinkProvider) => void, +) { + return { + onLinkJiraTicket: availability.jira ? () => setExternalLinkProvider("jira") : undefined, + onLinkLinearIssue: availability.linear ? () => setExternalLinkProvider("linear") : undefined, + onLinkSentryIssue: availability.sentry ? () => setExternalLinkProvider("sentry") : undefined, + }; +} + +function useKanbanCardMenus({ + task, + steps, + isDeleting, + isArchiving, + isSelected, + selectedIds, + onEdit, + onDelete, + onArchive, + onMove, + externalLinkAvailability, +}: Pick< + KanbanCardProps, + | "task" + | "externalLinkAvailability" + | "steps" + | "isDeleting" + | "isArchiving" + | "isSelected" + | "selectedIds" + | "onEdit" + | "onDelete" + | "onArchive" + | "onMove" +>) { + const moveMenu = useKanbanCardMoveMenuActions({ task, steps, isSelected, selectedIds, onMove }); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); + const [showPRDialog, setShowPRDialog] = useState(false); + const [showIssueDialog, setShowIssueDialog] = useState(false); + const [externalLinkProvider, setExternalLinkProvider] = useState( + null, + ); + const disabled = Boolean(isDeleting || isArchiving); + const menuBase = { - currentWorkflowId: moveTargets.currentWorkflowId, + currentWorkflowId: moveMenu.moveTargets.currentWorkflowId, currentStepId: task.workflowStepId, - workflows: moveTargets.workflowItems, - stepsByWorkflowId: moveTargets.stepsByWorkflowId, + workflows: moveMenu.moveTargets.workflowItems, + stepsByWorkflowId: moveMenu.moveTargets.stepsByWorkflowId, disabled, isDeleting, isArchiving, @@ -169,24 +207,19 @@ function useKanbanCardMenus({ onDelete: onDelete ? () => setShowDeleteConfirm(true) : undefined, onLinkPullRequest: () => setShowPRDialog(true), onLinkIssue: () => setShowIssueDialog(true), + ...externalLinkHandlers(externalLinkAvailability, setExternalLinkProvider), }; return { dropdownMenuEntries: buildKanbanCardMenuEntries({ ...menuBase, - onMoveToStep: moveToStepFromDropdown, - onSendToWorkflow: (workflowId, stepId) => { - runMoveTasks([task.id], workflowId, stepId); - }, + onMoveToStep: moveMenu.moveToStepFromDropdown, + onSendToWorkflow: moveMenu.sendTaskToWorkflow, }), contextMenuEntries: buildKanbanCardMenuEntries({ ...menuBase, - // Hide same-workflow "Move to" for a mixed-workflow selection; only the - // explicit "Send to workflow" path remains (matches the toolbar guard). - onMoveToStep: isMixedWorkflowSelection ? undefined : moveSelectedToStep, - onSendToWorkflow: (workflowId, stepId) => { - runMoveTasks(orderedSelectedIds(), workflowId, stepId); - }, + onMoveToStep: moveMenu.moveSelectedToStep, + onSendToWorkflow: moveMenu.sendSelectionToWorkflow, }), showDeleteConfirm, setShowDeleteConfirm, @@ -196,35 +229,27 @@ function useKanbanCardMenus({ setShowPRDialog, showIssueDialog, setShowIssueDialog, + externalLinkProvider, + setExternalLinkProvider, }; } +type KanbanCardMenuState = ReturnType; + function KanbanCardDialogs({ task, + workspaceId, repositories, - showDeleteConfirm, - setShowDeleteConfirm, - showArchiveConfirm, - setShowArchiveConfirm, - showPRDialog, - setShowPRDialog, - showIssueDialog, - setShowIssueDialog, + menu, isDeleting, isArchiving, onDelete, onArchive, }: { task: Task; + workspaceId: string | null; repositories: Repository[]; - showDeleteConfirm: boolean; - setShowDeleteConfirm: (open: boolean) => void; - showArchiveConfirm: boolean; - setShowArchiveConfirm: (open: boolean) => void; - showPRDialog: boolean; - setShowPRDialog: (open: boolean) => void; - showIssueDialog: boolean; - setShowIssueDialog: (open: boolean) => void; + menu: KanbanCardMenuState; isDeleting?: boolean; isArchiving?: boolean; onDelete?: KanbanCardProps["onDelete"]; @@ -233,8 +258,8 @@ function KanbanCardDialogs({ return ( <> onDelete?.(task, { cascade })} /> onArchive?.(task, { cascade })} /> + {menu.externalLinkProvider && workspaceId && ( + { + if (!open) menu.setExternalLinkProvider(null); + }} + provider={menu.externalLinkProvider} + task={task} + workspaceId={workspaceId} + /> + )} ); } @@ -309,8 +345,69 @@ function useActiveWorkspaceRepositories() { ); } +function KanbanCardFrame({ + task, + repositoryChips, + draggable, + menu, + isPreviewed, + isSelected, + isMultiSelectMode, + showMaximizeButton, + isDeleting, + isArchiving, + onClick, + onToggleSelect, + onOpenFullPage, +}: Pick< + KanbanCardProps, + | "task" + | "repositoryChips" + | "isSelected" + | "isMultiSelectMode" + | "showMaximizeButton" + | "isDeleting" + | "isArchiving" + | "onToggleSelect" + | "onOpenFullPage" +> & { + draggable: ReturnType; + menu: KanbanCardMenuState; + isPreviewed: boolean; + onClick: (e: React.MouseEvent) => void; +}) { + return ( + + { + e.stopPropagation(); + onToggleSelect?.(task.id); + }} + onOpenFullPage={onOpenFullPage} + /> + + ); +} + export function KanbanCard({ task, + workspaceId, + externalLinkAvailability, repositoryChips, onClick, onEdit, @@ -328,25 +425,15 @@ export function KanbanCard({ onRangeSelect, isMultiSelectMode, }: KanbanCardProps) { - const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + const draggable = useDraggable({ id: task.id, disabled: isMultiSelectMode, }); const isPreviewed = useAppStore((state) => state.kanbanPreviewedTaskId === task.id); const repositories = useActiveWorkspaceRepositories(); - const { - dropdownMenuEntries, - contextMenuEntries, - showDeleteConfirm, - setShowDeleteConfirm, - showArchiveConfirm, - setShowArchiveConfirm, - showPRDialog, - setShowPRDialog, - showIssueDialog, - setShowIssueDialog, - } = useKanbanCardMenus({ + const menu = useKanbanCardMenus({ task, + externalLinkAvailability, steps, isDeleting, isArchiving, @@ -368,41 +455,26 @@ export function KanbanCard({ return ( <> - - { - e.stopPropagation(); - onToggleSelect?.(task.id); - }} - onOpenFullPage={onOpenFullPage} - /> - + void; isMultiSelectMode?: boolean; + externalLinkAvailability: KanbanExternalLinkAvailability; } export function KanbanColumn({ @@ -57,10 +59,12 @@ export function KanbanColumn({ onToggleSelect, onSelectRange, isMultiSelectMode, + externalLinkAvailability, }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id: step.id, }); + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); // Access repositories from store to pass repository names to cards const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); @@ -102,6 +106,8 @@ export function KanbanColumn({ void; onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; + externalLinkAvailability: KanbanExternalLinkAvailability; }) { const taskCounts = useMemo(() => { const counts: Record = {}; @@ -244,6 +250,7 @@ function MobileKanbanLayout({ onToggleSelect={onToggleSelect} onSelectRange={onSelectRange} isMultiSelectMode={isMultiSelectMode} + externalLinkAvailability={externalLinkAvailability} /> @@ -266,6 +273,7 @@ function TabletKanbanLayout({ onToggleSelect, onSelectRange, isMultiSelectMode, + externalLinkAvailability, }: { steps: WorkflowStep[]; tasks: Task[]; @@ -282,6 +290,7 @@ function TabletKanbanLayout({ onToggleSelect?: (taskId: string) => void; onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; + externalLinkAvailability: KanbanExternalLinkAvailability; }) { const getTasksForStep = useTasksByStep(tasks); @@ -309,6 +318,7 @@ function TabletKanbanLayout({ onToggleSelect={onToggleSelect} onSelectRange={onSelectRange} isMultiSelectMode={isMultiSelectMode} + externalLinkAvailability={externalLinkAvailability} /> ))} @@ -333,6 +343,7 @@ function DesktopKanbanLayout({ onSelectRange, isMultiSelectMode, isCompactDesktop, + externalLinkAvailability, }: { steps: WorkflowStep[]; tasks: Task[]; @@ -350,6 +361,7 @@ function DesktopKanbanLayout({ onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; isCompactDesktop: boolean; + externalLinkAvailability: KanbanExternalLinkAvailability; }) { const getTasksForStep = useTasksByStep(tasks); @@ -380,6 +392,7 @@ function DesktopKanbanLayout({ onToggleSelect={onToggleSelect} onSelectRange={onSelectRange} isMultiSelectMode={isMultiSelectMode} + externalLinkAvailability={externalLinkAvailability} /> ))} @@ -405,6 +418,8 @@ export function SwimlaneKanbanContent({ isMultiSelectMode, }: SwimlaneKanbanContentProps) { const { isMobile, isTablet, isCompactDesktop } = useResponsiveBreakpoint(); + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); + const externalLinkAvailability = useKanbanExternalLinkAvailability(activeWorkspaceId); const { activeIndex, setActiveIndex } = useMobileColumnIndex(steps, tasks); const { sensors, handleDragStart, handleDragEnd, handleDragCancel, moveTaskToStep, activeTask } = useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }); @@ -429,6 +444,7 @@ export function SwimlaneKanbanContent({ onToggleSelect, onSelectRange, isMultiSelectMode, + externalLinkAvailability, }), [ steps, @@ -446,6 +462,7 @@ export function SwimlaneKanbanContent({ onToggleSelect, onSelectRange, isMultiSelectMode, + externalLinkAvailability, ], ); diff --git a/apps/web/components/kanban/swipeable-columns.tsx b/apps/web/components/kanban/swipeable-columns.tsx index 25ed0c3bb0..31a078c9aa 100644 --- a/apps/web/components/kanban/swipeable-columns.tsx +++ b/apps/web/components/kanban/swipeable-columns.tsx @@ -5,6 +5,7 @@ import useEmblaCarousel from "embla-carousel-react"; import { KanbanColumn, WorkflowStep } from "../kanban-column"; import { Task } from "../kanban-card"; import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order"; +import type { KanbanExternalLinkAvailability } from "../kanban-external-link-availability"; type SwipeableColumnsProps = { steps: WorkflowStep[]; @@ -24,6 +25,7 @@ type SwipeableColumnsProps = { onToggleSelect?: (taskId: string) => void; onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; + externalLinkAvailability: KanbanExternalLinkAvailability; }; /** Two-way sync between Embla's carousel position and the external activeIndex. */ @@ -80,6 +82,7 @@ export function SwipeableColumns({ onToggleSelect, onSelectRange, isMultiSelectMode, + externalLinkAvailability, }: SwipeableColumnsProps) { // Stable options to avoid Embla reinitializing on every activeIndex change const [initialIndex] = useState(activeIndex); @@ -137,6 +140,7 @@ export function SwipeableColumns({ onToggleSelect={onToggleSelect} onSelectRange={onSelectRange} isMultiSelectMode={isMultiSelectMode} + externalLinkAvailability={externalLinkAvailability} hideHeader /> diff --git a/apps/web/components/linear/linear-link-button.tsx b/apps/web/components/linear/linear-link-button.tsx deleted file mode 100644 index 4d90bf2479..0000000000 --- a/apps/web/components/linear/linear-link-button.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { IconLink } from "@tabler/icons-react"; -import { toast } from "sonner"; -import { getLinearIssue } from "@/lib/api/domains/linear-api"; -import { updateTask } from "@/lib/api/domains/kanban-api"; -import { LINEAR_KEY_RE } from "./linear-issue-common"; -import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability"; -import { ValidatedPopover } from "@/components/integrations/validated-popover"; - -type LinearLinkButtonProps = { - taskId: string | null | undefined; - workspaceId: string | null | undefined; - taskTitle: string | undefined | null; -}; - -// LinearLinkButton attaches a Linear issue to an existing task by prepending -// the identifier to its title ("ENG-123: ..."). -export function LinearLinkButton({ taskId, workspaceId, taskTitle }: LinearLinkButtonProps) { - const available = useLinearAvailable(workspaceId); - - const buildLinkedTitle = useCallback( - (key: string) => { - const stripped = (taskTitle ?? "").trim().replace(/^[A-Z][A-Z0-9]*-\d+:\s*/, ""); - return stripped ? `${key}: ${stripped}` : key; - }, - [taskTitle], - ); - - if (!available || !taskId || !workspaceId) return null; - - return ( - } - triggerLabel="Link Linear" - tooltip="Link this task to a Linear issue" - headline="Link to Linear issue" - placeholder="ENG-123 or paste issue URL" - extractKey={(raw) => raw.toUpperCase().match(LINEAR_KEY_RE)?.[0] ?? null} - validationHint="Paste a Linear issue URL or identifier (ENG-123)" - // Run getLinearIssue *and* the title update inside the awaited fetch so - // a failure on either surfaces as the popover's inline error instead of - // an unhandled rejection. - fetch={async (key) => { - const issue = await getLinearIssue(key, { workspaceId }); - await updateTask(taskId, { title: buildLinkedTitle(key) }); - return issue; - }} - onSuccess={(key) => { - toast.success(`Linked to ${key}`); - }} - submitLabel="Link" - submittingLabel="Linking..." - /> - ); -} diff --git a/apps/web/components/sentry/sentry-link-button.tsx b/apps/web/components/sentry/sentry-link-button.tsx deleted file mode 100644 index a720b2af0b..0000000000 --- a/apps/web/components/sentry/sentry-link-button.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { useCallback } from "react"; -import { IconLink } from "@tabler/icons-react"; -import { toast } from "sonner"; -import { getSentryIssue } from "@/lib/api/domains/sentry-api"; -import { updateTask } from "@/lib/api/domains/kanban-api"; -import { SENTRY_SHORT_ID_RE } from "./sentry-issue-common"; -import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability"; -import { ValidatedPopover } from "@/components/integrations/validated-popover"; - -type SentryLinkButtonProps = { - taskId: string | null | undefined; - workspaceId: string | null | undefined; - taskTitle: string | undefined | null; -}; - -// SentryLinkButton attaches a Sentry issue to an existing task by prepending -// its short ID to the title ("PROJ-123: ..."). -export function SentryLinkButton({ taskId, workspaceId, taskTitle }: SentryLinkButtonProps) { - const available = useSentryAvailable(workspaceId); - - const buildLinkedTitle = useCallback( - (key: string) => { - const stripped = (taskTitle ?? "").trim().replace(/^[A-Z][A-Z0-9_-]*-\d+:\s*/, ""); - return stripped ? `${key}: ${stripped}` : key; - }, - [taskTitle], - ); - - // Require taskTitle to be loaded (== null catches null + undefined but allows - // an empty string): otherwise linking would overwrite the real title with - // just the Sentry key while the title is still in flight. - if (!available || !taskId || !workspaceId || taskTitle == null) return null; - - return ( - } - triggerLabel="Link Sentry" - tooltip="Link this task to a Sentry issue" - headline="Link to Sentry issue" - placeholder="PROJ-123 or paste issue URL" - extractKey={(raw) => { - const upper = raw.toUpperCase().trim(); - return SENTRY_SHORT_ID_RE.test(upper) - ? upper - : (upper.match(/[A-Z][A-Z0-9_-]*-\d+/)?.[0] ?? null); - }} - validationHint="Paste a Sentry issue URL or short ID (PROJ-123)" - fetch={async (key) => { - const issue = await getSentryIssue(key, { workspaceId }); - await updateTask(taskId, { title: buildLinkedTitle(key) }); - return issue; - }} - onSuccess={(key) => { - toast.success(`Linked to ${key}`); - }} - submitLabel="Link" - submittingLabel="Linking..." - /> - ); -} diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 2933469ad1..9f21f46b93 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -10,13 +10,17 @@ import { SidebarFilterBar } from "../sidebar-filter/sidebar-filter-bar"; import type { StepDef } from "../task-switcher-context-menu"; import type { TaskMoveWorkflow } from "../task-move-context-menu"; import { applyView } from "@/lib/sidebar/apply-view"; -import { useAppStore } from "@/components/state-provider"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view"; import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs"; +import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { WorkspaceSwitcher } from "../workspace-switcher"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { TaskArchiveConfirmDialog } from "../task-archive-confirm-dialog"; import { TaskDeleteConfirmDialog } from "../task-delete-confirm-dialog"; +import { SidebarLinkDialogs } from "../task-session-sidebar-dialogs"; +import { useSidebarLinkActions } from "../task-session-sidebar-link-actions"; +import { useSidebarTaskLinking } from "../task-session-sidebar-task-linking"; import { useSheetData, useSheetActions } from "./session-task-switcher-sheet-hooks"; type SessionTaskSwitcherSheetProps = { @@ -26,6 +30,19 @@ type SessionTaskSwitcherSheetProps = { workflowId: string | null; }; +function useMobileTaskLinking(workspaceId: string | null) { + const store = useAppStoreApi(); + const actions = useSidebarLinkActions(store); + const taskListHandlers = useSidebarTaskLinking(workspaceId, actions); + const { repositories } = useRepositories(workspaceId); + + return { + actions, + repositories, + taskListHandlers, + }; +} + export function MobileTaskList({ tasks, workflows, @@ -35,6 +52,11 @@ export function MobileTaskList({ onSelectTask, onArchiveTask, onDeleteTask, + onLinkPullRequest, + onLinkIssue, + onLinkJiraTicket, + onLinkLinearIssue, + onLinkSentryIssue, deletingTaskId, isLoading, }: { @@ -46,6 +68,11 @@ export function MobileTaskList({ onSelectTask: (taskId: string) => void; onArchiveTask: (taskId: string) => void; onDeleteTask: (taskId: string) => Promise | void; + onLinkPullRequest?: (taskId: string, taskTitle?: string) => void; + onLinkIssue?: (taskId: string, taskTitle?: string) => void; + onLinkJiraTicket?: (taskId: string, taskTitle?: string) => void; + onLinkLinearIssue?: (taskId: string, taskTitle?: string) => void; + onLinkSentryIssue?: (taskId: string, taskTitle?: string) => void; deletingTaskId: string | null; isLoading?: boolean; }) { @@ -88,6 +115,11 @@ export function MobileTaskList({ onSelectTask={onSelectTask} onArchiveTask={onArchiveTask} onDeleteTask={onDeleteTask} + onLinkPullRequest={onLinkPullRequest} + onLinkIssue={onLinkIssue} + onLinkJiraTicket={onLinkJiraTicket} + onLinkLinearIssue={onLinkLinearIssue} + onLinkSentryIssue={onLinkSentryIssue} onTogglePin={togglePinnedTask} onReorderGroup={handleReorderGroup} onReorderSubtasks={handleReorderSubtasks} @@ -108,6 +140,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ const [dialogOpen, setDialogOpen] = useState(false); const data = useSheetData(workspaceId); const actions = useSheetActions(workspaceId, onOpenChange); + const linking = useMobileTaskLinking(workspaceId); return ( @@ -150,6 +183,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ onSelectTask={actions.handleSelectTask} onArchiveTask={actions.handleArchiveTask} onDeleteTask={actions.handleDeleteTask} + {...linking.taskListHandlers} deletingTaskId={actions.deletingTaskId} isLoading={data.tasksLoading} /> @@ -189,6 +223,11 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ isDeleting={actions.isDeleting} onConfirm={({ cascade }) => actions.handleDeleteConfirm({ cascade })} /> + ); }); diff --git a/apps/web/components/task/task-external-link-dialog.test.tsx b/apps/web/components/task/task-external-link-dialog.test.tsx new file mode 100644 index 0000000000..e5e75bea89 --- /dev/null +++ b/apps/web/components/task/task-external-link-dialog.test.tsx @@ -0,0 +1,126 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { ToastProvider } from "@/components/toast-provider"; +import { getJiraTicket } from "@/lib/api/domains/jira-api"; +import { getLinearIssue } from "@/lib/api/domains/linear-api"; +import { getSentryIssue } from "@/lib/api/domains/sentry-api"; +import { updateTask } from "@/lib/api/domains/kanban-api"; +import { TaskExternalLinkDialog } from "./task-external-link-dialog"; + +vi.mock("@/lib/api/domains/jira-api", () => ({ + getJiraTicket: vi.fn(), +})); + +vi.mock("@/lib/api/domains/linear-api", () => ({ + getLinearIssue: vi.fn(), +})); + +vi.mock("@/lib/api/domains/sentry-api", () => ({ + getSentryIssue: vi.fn(), +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + updateTask: vi.fn(), +})); + +const INPUT_TEST_ID = "task-external-link-input"; +const SUBMIT_TEST_ID = "task-external-link-submit"; +const ERROR_TEST_ID = "task-external-link-error"; +const TASK_ID = "task-1"; +const WORKSPACE_ID = "workspace-1"; +const TASK_TITLE = "Fix login"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function renderDialog(provider: "jira" | "linear" | "sentry" = "jira") { + return render( + + + + + , + ); +} + +describe("TaskExternalLinkDialog", () => { + it("validates and links a Jira ticket by updating the task title", async () => { + vi.mocked(getJiraTicket).mockResolvedValue({ key: "PROJ-12" } as never); + vi.mocked(updateTask).mockResolvedValue({ id: TASK_ID, title: "PROJ-12: Fix login" } as never); + + renderDialog("jira"); + + fireEvent.change(screen.getByTestId(INPUT_TEST_ID), { + target: { value: "PROJ-12" }, + }); + fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID)); + + await waitFor(() => { + expect(getJiraTicket).toHaveBeenCalledWith("PROJ-12", { workspaceId: WORKSPACE_ID }); + }); + expect(updateTask).toHaveBeenCalledWith(TASK_ID, { title: "PROJ-12: Fix login" }); + }); + + it("validates and links a Linear issue by updating the task title", async () => { + vi.mocked(getLinearIssue).mockResolvedValue({ identifier: "ENG-20" } as never); + vi.mocked(updateTask).mockResolvedValue({ id: TASK_ID, title: "ENG-20: Fix login" } as never); + + renderDialog("linear"); + + fireEvent.change(screen.getByTestId(INPUT_TEST_ID), { + target: { value: "eng-20" }, + }); + fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID)); + + await waitFor(() => { + expect(getLinearIssue).toHaveBeenCalledWith("ENG-20", { workspaceId: WORKSPACE_ID }); + }); + expect(updateTask).toHaveBeenCalledWith(TASK_ID, { title: "ENG-20: Fix login" }); + }); + + it("extracts and links a Sentry issue key from a numeric issue URL", async () => { + vi.mocked(getSentryIssue).mockResolvedValue({ shortId: "API-42" } as never); + vi.mocked(updateTask).mockResolvedValue({ id: TASK_ID, title: "API-42: Fix login" } as never); + + renderDialog("sentry"); + + fireEvent.change(screen.getByTestId(INPUT_TEST_ID), { + target: { value: "https://sentry.io/organizations/acme/issues/123456/" }, + }); + fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID)); + + await waitFor(() => { + expect(getSentryIssue).toHaveBeenCalledWith("123456", { workspaceId: WORKSPACE_ID }); + }); + expect(updateTask).toHaveBeenCalledWith(TASK_ID, { title: "API-42: Fix login" }); + }); + + it("shows an inline validation error for invalid input", async () => { + renderDialog("jira"); + + fireEvent.change(screen.getByTestId(INPUT_TEST_ID), { + target: { value: "not a key" }, + }); + fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID)); + + expect((await screen.findByTestId(ERROR_TEST_ID)).textContent).toMatch( + /Paste a Jira ticket URL or key/i, + ); + expect(updateTask).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByTestId(INPUT_TEST_ID), { + target: { value: "PROJ-12" }, + }); + + expect(screen.queryByTestId(ERROR_TEST_ID)).toBeNull(); + }); +}); diff --git a/apps/web/components/task/task-external-link-dialog.tsx b/apps/web/components/task/task-external-link-dialog.tsx new file mode 100644 index 0000000000..fa9124d1a0 --- /dev/null +++ b/apps/web/components/task/task-external-link-dialog.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { useToast } from "@/components/toast-provider"; +import { useAppStoreApi } from "@/components/state-provider"; +import { getJiraTicket } from "@/lib/api/domains/jira-api"; +import { getLinearIssue } from "@/lib/api/domains/linear-api"; +import { getSentryIssue } from "@/lib/api/domains/sentry-api"; +import { updateTask } from "@/lib/api/domains/kanban-api"; +import { JIRA_KEY_RE } from "@/components/jira/jira-ticket-common"; +import { LINEAR_KEY_RE } from "@/components/linear/linear-issue-common"; +import { extractSentryShortId } from "@/components/sentry/sentry-issue-common"; +import { findTaskInSnapshots } from "@/lib/kanban/find-task"; +import type { SentryIssue } from "@/lib/types/sentry"; +import { buildLinkedIssueTitle } from "./task-external-link-utils"; + +export type ExternalLinkProvider = "jira" | "linear" | "sentry"; + +type TaskExternalLinkTarget = { + id: string; + title: string; +}; + +type TaskExternalLinkDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + provider: ExternalLinkProvider; + task: TaskExternalLinkTarget; + workspaceId: string; +}; + +type ProviderConfig = { + title: string; + description: string; + inputLabel: string; + placeholder: string; + validationHint: string; + successLabel: string; + extractKey: (raw: string) => string | null; + fetch: (key: string, workspaceId: string) => Promise; + resolveLinkedKey?: (requestedKey: string, result: unknown) => string; +}; + +const SENTRY_NUMERIC_ISSUE_URL_RE = /\/issues\/(\d+)(?:[/?#]|$)/i; + +function extractSentryIssueKey(raw: string): string | null { + return extractSentryShortId(raw) ?? raw.match(SENTRY_NUMERIC_ISSUE_URL_RE)?.[1] ?? null; +} + +function isSentryIssue(result: unknown): result is SentryIssue { + return ( + typeof result === "object" && + result !== null && + typeof (result as Partial).shortId === "string" + ); +} + +const PROVIDERS: Record = { + jira: { + title: "Link Jira ticket", + description: "Use a Jira ticket key or URL for this task.", + inputLabel: "Ticket", + placeholder: "PROJ-123 or paste ticket URL", + validationHint: "Paste a Jira ticket URL or key (PROJ-123).", + successLabel: "Jira ticket linked", + extractKey: (raw) => raw.toUpperCase().match(JIRA_KEY_RE)?.[0] ?? null, + fetch: (key, workspaceId) => getJiraTicket(key, { workspaceId }), + }, + linear: { + title: "Link Linear issue", + description: "Use a Linear issue identifier or URL for this task.", + inputLabel: "Issue", + placeholder: "ENG-123 or paste issue URL", + validationHint: "Paste a Linear issue URL or identifier (ENG-123).", + successLabel: "Linear issue linked", + extractKey: (raw) => raw.toUpperCase().match(LINEAR_KEY_RE)?.[0] ?? null, + fetch: (key, workspaceId) => getLinearIssue(key, { workspaceId }), + }, + sentry: { + title: "Link Sentry issue", + description: "Use a Sentry short ID or URL for this task.", + inputLabel: "Issue", + placeholder: "PROJ-123 or paste issue URL", + validationHint: "Paste a Sentry issue URL or short ID (PROJ-123).", + successLabel: "Sentry issue linked", + extractKey: extractSentryIssueKey, + fetch: (key, workspaceId) => getSentryIssue(key, { workspaceId }), + resolveLinkedKey: (requestedKey, result) => + isSentryIssue(result) && result.shortId ? result.shortId : requestedKey, + }, +}; + +export function TaskExternalLinkDialog({ + open, + onOpenChange, + provider, + task, + workspaceId, +}: TaskExternalLinkDialogProps) { + const { toast } = useToast(); + const store = useAppStoreApi(); + const config = PROVIDERS[provider]; + const [input, setInput] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) { + setInput(""); + setError(null); + } + }, [open]); + + const submit = async () => { + const key = config.extractKey(input); + if (!key) { + setError(config.validationHint); + return; + } + + setSubmitting(true); + setError(null); + try { + const result = await config.fetch(key, workspaceId); + const state = store.getState(); + const latestTask = findTaskInSnapshots( + task.id, + state.kanbanMulti.snapshots, + state.kanban.tasks, + ); + const linkedKey = config.resolveLinkedKey?.(key, result) ?? key; + await updateTask(task.id, { + title: buildLinkedIssueTitle(latestTask?.title ?? task.title, linkedKey), + }); + toast({ description: config.successLabel, variant: "success" }); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : `Failed to link ${config.inputLabel}.`); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + {config.title} + {config.description} + +
+ + { + setInput(event.target.value); + if (error) setError(null); + }} + placeholder={config.placeholder} + disabled={submitting} + /> + {error && ( +

+ {error} +

+ )} +
+ + + + +
+
+ ); +} diff --git a/apps/web/components/task/task-external-link-utils.test.ts b/apps/web/components/task/task-external-link-utils.test.ts new file mode 100644 index 0000000000..4f5aa81e6d --- /dev/null +++ b/apps/web/components/task/task-external-link-utils.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { buildLinkedIssueTitle } from "./task-external-link-utils"; + +describe("buildLinkedIssueTitle", () => { + it("prepends the external issue key to a normal title", () => { + expect(buildLinkedIssueTitle("Fix login", "PROJ-12")).toBe("PROJ-12: Fix login"); + }); + + it("uses the key alone when the task title is empty", () => { + expect(buildLinkedIssueTitle(" ", "ENG-20")).toBe("ENG-20"); + }); + + it("replaces an existing leading Jira/Linear/Sentry-style prefix", () => { + expect(buildLinkedIssueTitle("PROJ-12: Fix login", "ENG-20")).toBe("ENG-20: Fix login"); + expect(buildLinkedIssueTitle("API-99: Debug production crash", "SVC-7")).toBe( + "SVC-7: Debug production crash", + ); + }); +}); diff --git a/apps/web/components/task/task-external-link-utils.ts b/apps/web/components/task/task-external-link-utils.ts new file mode 100644 index 0000000000..bde3229ca8 --- /dev/null +++ b/apps/web/components/task/task-external-link-utils.ts @@ -0,0 +1,6 @@ +const LEADING_EXTERNAL_ISSUE_RE = /^[A-Z][A-Z0-9_-]*-\d+:\s*/; + +export function buildLinkedIssueTitle(taskTitle: string | null | undefined, key: string): string { + const stripped = (taskTitle ?? "").trim().replace(LEADING_EXTERNAL_ISSUE_RE, ""); + return stripped ? `${key}: ${stripped}` : key; +} diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 2307bba8dc..894e380ec2 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -560,7 +560,7 @@ function TaskMenuButton({ visible }: { visible: boolean }) { "self-center shrink-0 flex items-center transition-opacity duration-100", visible ? "opacity-100" - : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto", + : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto", )} >