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
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<ValidatedPopover
|
||||
triggerStyle="outline-with-label"
|
||||
triggerIcon={<IconLink className="h-4 w-4" />}
|
||||
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..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
<MobileDropTargets
|
||||
steps={steps}
|
||||
@@ -161,6 +169,7 @@ function TabletLayout({
|
||||
archivingTaskId,
|
||||
showLoading,
|
||||
activeTask,
|
||||
externalLinkAvailability,
|
||||
}: ColumnGridProps & { showLoading: boolean; activeTask: Task | null }) {
|
||||
return (
|
||||
<>
|
||||
@@ -184,6 +193,7 @@ function TabletLayout({
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
deletingTaskId={deletingTaskId}
|
||||
archivingTaskId={archivingTaskId}
|
||||
externalLinkAvailability={externalLinkAvailability}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: <IconGitPullRequest className="mr-2 h-4 w-4" />,
|
||||
label: "GitHub Pull Request",
|
||||
disabled,
|
||||
onSelect: onLinkPullRequest,
|
||||
});
|
||||
}
|
||||
if (onLinkIssue) {
|
||||
children.push({
|
||||
kind: "item",
|
||||
key: "link-github-issue",
|
||||
testId: "task-context-link-github-issue",
|
||||
icon: <IconCircleDot className="mr-2 h-4 w-4" />,
|
||||
label: "GitHub Issue",
|
||||
disabled,
|
||||
onSelect: onLinkIssue,
|
||||
});
|
||||
}
|
||||
if (onLinkJiraTicket) {
|
||||
children.push({
|
||||
kind: "item",
|
||||
key: "link-jira-ticket",
|
||||
testId: "task-context-link-jira-ticket",
|
||||
icon: <IconTicket className="mr-2 h-4 w-4" />,
|
||||
label: "Jira Ticket",
|
||||
disabled,
|
||||
onSelect: onLinkJiraTicket,
|
||||
});
|
||||
}
|
||||
if (onLinkLinearIssue) {
|
||||
children.push({
|
||||
kind: "item",
|
||||
key: "link-linear-issue",
|
||||
testId: "task-context-link-linear-issue",
|
||||
icon: <IconCircleDot className="mr-2 h-4 w-4" />,
|
||||
label: "Linear Issue",
|
||||
disabled,
|
||||
onSelect: onLinkLinearIssue,
|
||||
});
|
||||
}
|
||||
if (onLinkSentryIssue) {
|
||||
children.push({
|
||||
kind: "item",
|
||||
key: "link-sentry-issue",
|
||||
testId: "task-context-link-sentry-issue",
|
||||
icon: <IconBrandSentry className="mr-2 h-4 w-4" />,
|
||||
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: <IconGitPullRequest className="mr-2 h-4 w-4" />,
|
||||
label: "GitHub Pull Request",
|
||||
disabled: disabled || !onLinkPullRequest,
|
||||
onSelect: onLinkPullRequest,
|
||||
},
|
||||
{
|
||||
kind: "item",
|
||||
key: "link-github-issue",
|
||||
testId: "task-context-link-github-issue",
|
||||
icon: <IconCircleDot className="mr-2 h-4 w-4" />,
|
||||
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);
|
||||
|
||||
|
||||
+185
-113
@@ -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<KanbanCardProps, "task" | "steps" | "isSelected" | "selectedIds" | "onMove">) {
|
||||
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<ExternalLinkProvider | null>(
|
||||
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<typeof useKanbanCardMenus>;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<TaskDeleteConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={setShowDeleteConfirm}
|
||||
open={menu.showDeleteConfirm}
|
||||
onOpenChange={menu.setShowDeleteConfirm}
|
||||
taskTitle={task.title}
|
||||
taskId={task.id}
|
||||
executorType={task.primaryExecutorType}
|
||||
@@ -242,8 +267,8 @@ function KanbanCardDialogs({
|
||||
onConfirm={({ cascade }) => onDelete?.(task, { cascade })}
|
||||
/>
|
||||
<TaskArchiveConfirmDialog
|
||||
open={showArchiveConfirm}
|
||||
onOpenChange={setShowArchiveConfirm}
|
||||
open={menu.showArchiveConfirm}
|
||||
onOpenChange={menu.setShowArchiveConfirm}
|
||||
taskTitle={task.title}
|
||||
taskId={task.id}
|
||||
executorType={task.primaryExecutorType}
|
||||
@@ -251,17 +276,28 @@ function KanbanCardDialogs({
|
||||
onConfirm={({ cascade }) => onArchive?.(task, { cascade })}
|
||||
/>
|
||||
<TaskGitHubPRDialog
|
||||
open={showPRDialog}
|
||||
onOpenChange={setShowPRDialog}
|
||||
open={menu.showPRDialog}
|
||||
onOpenChange={menu.setShowPRDialog}
|
||||
task={task}
|
||||
repositories={repositories}
|
||||
/>
|
||||
<TaskGitHubIssueDialog
|
||||
open={showIssueDialog}
|
||||
onOpenChange={setShowIssueDialog}
|
||||
open={menu.showIssueDialog}
|
||||
onOpenChange={menu.setShowIssueDialog}
|
||||
task={task}
|
||||
repositories={repositories}
|
||||
/>
|
||||
{menu.externalLinkProvider && workspaceId && (
|
||||
<TaskExternalLinkDialog
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
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<typeof useDraggable>;
|
||||
menu: KanbanCardMenuState;
|
||||
isPreviewed: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<KanbanCardContextMenu entries={menu.contextMenuEntries}>
|
||||
<KanbanCardShell
|
||||
task={task}
|
||||
repositoryChips={repositoryChips}
|
||||
attributes={draggable.attributes}
|
||||
listeners={draggable.listeners}
|
||||
setNodeRef={draggable.setNodeRef}
|
||||
transform={draggable.transform}
|
||||
isDragging={draggable.isDragging}
|
||||
isPreviewed={isPreviewed}
|
||||
isSelected={isSelected}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
isDeleting={isDeleting}
|
||||
isArchiving={isArchiving}
|
||||
menuEntries={menu.dropdownMenuEntries}
|
||||
onClick={onClick}
|
||||
onCheckboxClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect?.(task.id);
|
||||
}}
|
||||
onOpenFullPage={onOpenFullPage}
|
||||
/>
|
||||
</KanbanCardContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<KanbanCardContextMenu entries={contextMenuEntries}>
|
||||
<KanbanCardShell
|
||||
task={task}
|
||||
repositoryChips={repositoryChips}
|
||||
attributes={attributes}
|
||||
listeners={listeners}
|
||||
setNodeRef={setNodeRef}
|
||||
transform={transform}
|
||||
isDragging={isDragging}
|
||||
isPreviewed={isPreviewed}
|
||||
isSelected={isSelected}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
isDeleting={isDeleting}
|
||||
isArchiving={isArchiving}
|
||||
menuEntries={dropdownMenuEntries}
|
||||
onClick={handleClick}
|
||||
onCheckboxClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect?.(task.id);
|
||||
}}
|
||||
onOpenFullPage={onOpenFullPage}
|
||||
/>
|
||||
</KanbanCardContextMenu>
|
||||
<KanbanCardFrame
|
||||
task={task}
|
||||
repositoryChips={repositoryChips}
|
||||
draggable={draggable}
|
||||
menu={menu}
|
||||
isPreviewed={isPreviewed}
|
||||
isSelected={isSelected}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
isDeleting={isDeleting}
|
||||
isArchiving={isArchiving}
|
||||
onClick={handleClick}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onOpenFullPage={onOpenFullPage}
|
||||
/>
|
||||
<KanbanCardDialogs
|
||||
task={task}
|
||||
workspaceId={workspaceId}
|
||||
repositories={repositories}
|
||||
showDeleteConfirm={showDeleteConfirm}
|
||||
setShowDeleteConfirm={setShowDeleteConfirm}
|
||||
showArchiveConfirm={showArchiveConfirm}
|
||||
setShowArchiveConfirm={setShowArchiveConfirm}
|
||||
showPRDialog={showPRDialog}
|
||||
setShowPRDialog={setShowPRDialog}
|
||||
showIssueDialog={showIssueDialog}
|
||||
setShowIssueDialog={setShowIssueDialog}
|
||||
menu={menu}
|
||||
isDeleting={isDeleting}
|
||||
isArchiving={isArchiving}
|
||||
onDelete={onDelete}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { KanbanCard, resolveTaskRepositoryChips, Task } from "./kanban-card";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
|
||||
export interface WorkflowStep {
|
||||
@@ -37,6 +38,7 @@ interface KanbanColumnProps {
|
||||
/** Shift-click range select; receives the clicked id + this column's ordered ids. */
|
||||
onSelectRange?: (taskId: string, orderedIds: string[]) => 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({
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
workspaceId={activeWorkspaceId}
|
||||
externalLinkAvailability={externalLinkAvailability}
|
||||
repositoryChips={resolveTaskRepositoryChips(task, repositories)}
|
||||
onClick={onPreviewTask}
|
||||
onOpenFullPage={onOpenTask}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
|
||||
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
|
||||
import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability";
|
||||
|
||||
export type KanbanExternalLinkAvailability = {
|
||||
jira: boolean;
|
||||
linear: boolean;
|
||||
sentry: boolean;
|
||||
};
|
||||
|
||||
export function useKanbanExternalLinkAvailability(
|
||||
workspaceId: string | null,
|
||||
): KanbanExternalLinkAvailability {
|
||||
return {
|
||||
jira: useJiraAvailable(workspaceId),
|
||||
linear: useLinearAvailable(workspaceId),
|
||||
sentry: useSentryAvailable(workspaceId),
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { KanbanCardPreview } from "@/components/kanban-card-preview";
|
||||
import type { WorkflowStep } from "@/components/kanban-column";
|
||||
import type { MoveTaskError } from "@/hooks/use-drag-and-drop";
|
||||
import { useTaskActions } from "@/hooks/use-task-actions";
|
||||
import { useAppStoreApi } from "@/components/state-provider";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { MobileColumnTabs } from "./mobile-column-tabs";
|
||||
import { SwipeableColumns } from "./swipeable-columns";
|
||||
@@ -25,6 +25,10 @@ import { MobileDropTargets } from "./mobile-drop-targets";
|
||||
import { getKanbanColumnGridTemplate } from "./kanban-grid-template";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order";
|
||||
import {
|
||||
type KanbanExternalLinkAvailability,
|
||||
useKanbanExternalLinkAvailability,
|
||||
} from "@/components/kanban-external-link-availability";
|
||||
|
||||
export type SwimlaneKanbanContentProps = {
|
||||
workflowId: string;
|
||||
@@ -188,6 +192,7 @@ function MobileKanbanLayout({
|
||||
onToggleSelect,
|
||||
onSelectRange,
|
||||
isMultiSelectMode,
|
||||
externalLinkAvailability,
|
||||
}: {
|
||||
steps: WorkflowStep[];
|
||||
tasks: Task[];
|
||||
@@ -207,6 +212,7 @@ function MobileKanbanLayout({
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
externalLinkAvailability: KanbanExternalLinkAvailability;
|
||||
}) {
|
||||
const taskCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
@@ -244,6 +250,7 @@ function MobileKanbanLayout({
|
||||
onToggleSelect={onToggleSelect}
|
||||
onSelectRange={onSelectRange}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
externalLinkAvailability={externalLinkAvailability}
|
||||
/>
|
||||
<MobileDropTargets steps={steps} currentStepId={currentStepId} isDragging={!!activeTask} />
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<ValidatedPopover
|
||||
triggerStyle="outline-with-label"
|
||||
triggerIcon={<IconLink className="h-4 w-4" />}
|
||||
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..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ValidatedPopover
|
||||
triggerStyle="outline-with-label"
|
||||
triggerIcon={<IconLink className="h-4 w-4" />}
|
||||
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..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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> | 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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -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 })}
|
||||
/>
|
||||
<SidebarLinkDialogs
|
||||
actions={linking.actions}
|
||||
repositories={linking.repositories}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<StateProvider>
|
||||
<ToastProvider>
|
||||
<TaskExternalLinkDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
provider={provider}
|
||||
task={{ id: TASK_ID, title: TASK_TITLE }}
|
||||
workspaceId={WORKSPACE_ID}
|
||||
/>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
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<SentryIssue>).shortId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
const PROVIDERS: Record<ExternalLinkProvider, ProviderConfig> = {
|
||||
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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{config.title}</DialogTitle>
|
||||
<DialogDescription>{config.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-external-link-input">{config.inputLabel}</Label>
|
||||
<Input
|
||||
id="task-external-link-input"
|
||||
data-testid="task-external-link-input"
|
||||
value={input}
|
||||
onChange={(event) => {
|
||||
setInput(event.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder={config.placeholder}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive" data-testid="task-external-link-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="cursor-pointer"
|
||||
onClick={submit}
|
||||
disabled={submitting || !input.trim()}
|
||||
data-dialog-default-action
|
||||
data-testid="task-external-link-submit"
|
||||
>
|
||||
{submitting ? "Saving" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
import { TaskRenameDialog } from "./task-rename-dialog";
|
||||
import { TaskArchiveConfirmDialog } from "./task-archive-confirm-dialog";
|
||||
import { TaskDeleteConfirmDialog } from "./task-delete-confirm-dialog";
|
||||
import { TaskExternalLinkDialog } from "./task-external-link-dialog";
|
||||
import { TaskGitHubIssueDialog } from "./task-github-issue-dialog";
|
||||
import { TaskGitHubPRDialog } from "./task-github-pr-dialog";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import type { SidebarLinkTarget } from "./task-session-sidebar-link-actions";
|
||||
import type {
|
||||
SidebarExternalLinkTarget,
|
||||
SidebarLinkTarget,
|
||||
} from "./task-session-sidebar-link-actions";
|
||||
|
||||
type Target = { id: string; title: string; executorType?: string | null } | null;
|
||||
type LinkTarget = SidebarLinkTarget | null;
|
||||
@@ -28,14 +32,18 @@ export type SidebarDialogsActions = {
|
||||
setLinkingPullRequestTask: (next: LinkTarget) => void;
|
||||
linkingIssueTask: LinkTarget;
|
||||
setLinkingIssueTask: (next: LinkTarget) => void;
|
||||
linkingExternalIssueTask: SidebarExternalLinkTarget | null;
|
||||
setLinkingExternalIssueTask: (next: SidebarExternalLinkTarget | null) => void;
|
||||
};
|
||||
|
||||
export function SidebarDialogs({
|
||||
actions,
|
||||
repositories,
|
||||
workspaceId,
|
||||
}: {
|
||||
actions: SidebarDialogsActions;
|
||||
repositories: Repository[];
|
||||
workspaceId: string | null;
|
||||
}) {
|
||||
const {
|
||||
renamingTask,
|
||||
@@ -50,10 +58,6 @@ export function SidebarDialogs({
|
||||
setDeletingTask,
|
||||
isDeleting,
|
||||
handleDeleteConfirm,
|
||||
linkingPullRequestTask,
|
||||
setLinkingPullRequestTask,
|
||||
linkingIssueTask,
|
||||
setLinkingIssueTask,
|
||||
} = actions;
|
||||
return (
|
||||
<>
|
||||
@@ -87,6 +91,38 @@ export function SidebarDialogs({
|
||||
isDeleting={isDeleting}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
<SidebarLinkDialogs actions={actions} repositories={repositories} workspaceId={workspaceId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarLinkDialogs({
|
||||
actions,
|
||||
repositories,
|
||||
workspaceId,
|
||||
}: {
|
||||
actions: Pick<
|
||||
SidebarDialogsActions,
|
||||
| "linkingPullRequestTask"
|
||||
| "setLinkingPullRequestTask"
|
||||
| "linkingIssueTask"
|
||||
| "setLinkingIssueTask"
|
||||
| "linkingExternalIssueTask"
|
||||
| "setLinkingExternalIssueTask"
|
||||
>;
|
||||
repositories: Repository[];
|
||||
workspaceId: string | null;
|
||||
}) {
|
||||
const {
|
||||
linkingPullRequestTask,
|
||||
setLinkingPullRequestTask,
|
||||
linkingIssueTask,
|
||||
setLinkingIssueTask,
|
||||
linkingExternalIssueTask,
|
||||
setLinkingExternalIssueTask,
|
||||
} = actions;
|
||||
return (
|
||||
<>
|
||||
{linkingPullRequestTask && (
|
||||
<TaskGitHubPRDialog
|
||||
open={true}
|
||||
@@ -107,6 +143,17 @@ export function SidebarDialogs({
|
||||
repositories={repositories}
|
||||
/>
|
||||
)}
|
||||
{linkingExternalIssueTask && workspaceId && (
|
||||
<TaskExternalLinkDialog
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setLinkingExternalIssueTask(null);
|
||||
}}
|
||||
provider={linkingExternalIssueTask.provider}
|
||||
task={linkingExternalIssueTask.task}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
||||
import type { ExternalLinkProvider } from "./task-external-link-dialog";
|
||||
|
||||
type StoreApi = {
|
||||
getState: () => {
|
||||
@@ -20,19 +21,26 @@ export type SidebarLinkTarget = {
|
||||
repositories?: Array<{ id?: string; repository_id: string; position?: number }>;
|
||||
};
|
||||
|
||||
export type SidebarExternalLinkTarget = {
|
||||
provider: ExternalLinkProvider;
|
||||
task: SidebarLinkTarget;
|
||||
};
|
||||
|
||||
export function useSidebarLinkActions(store: StoreApi) {
|
||||
const [linkingPullRequestTask, setLinkingPullRequestTask] = useState<SidebarLinkTarget | null>(
|
||||
null,
|
||||
);
|
||||
const [linkingIssueTask, setLinkingIssueTask] = useState<SidebarLinkTarget | null>(null);
|
||||
const [linkingExternalIssueTask, setLinkingExternalIssueTask] =
|
||||
useState<SidebarExternalLinkTarget | null>(null);
|
||||
|
||||
const getLinkTarget = useCallback(
|
||||
(taskId: string): SidebarLinkTarget => {
|
||||
(taskId: string, fallbackTitle?: string): SidebarLinkTarget => {
|
||||
const state = store.getState();
|
||||
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
|
||||
return {
|
||||
id: taskId,
|
||||
title: task?.title ?? "this task",
|
||||
title: task?.title ?? fallbackTitle ?? "this task",
|
||||
repositoryId: task?.repositoryId,
|
||||
issueUrl: task?.issueUrl,
|
||||
issueNumber: task?.issueNumber,
|
||||
@@ -43,19 +51,44 @@ export function useSidebarLinkActions(store: StoreApi) {
|
||||
);
|
||||
|
||||
const handleLinkPullRequestTask = useCallback(
|
||||
(taskId: string) => {
|
||||
setLinkingPullRequestTask(getLinkTarget(taskId));
|
||||
(taskId: string, fallbackTitle?: string) => {
|
||||
setLinkingPullRequestTask(getLinkTarget(taskId, fallbackTitle));
|
||||
},
|
||||
[getLinkTarget],
|
||||
);
|
||||
|
||||
const handleLinkIssueTask = useCallback(
|
||||
(taskId: string) => {
|
||||
setLinkingIssueTask(getLinkTarget(taskId));
|
||||
(taskId: string, fallbackTitle?: string) => {
|
||||
setLinkingIssueTask(getLinkTarget(taskId, fallbackTitle));
|
||||
},
|
||||
[getLinkTarget],
|
||||
);
|
||||
|
||||
const handleLinkExternalIssueTask = useCallback(
|
||||
(provider: ExternalLinkProvider, taskId: string, fallbackTitle?: string) => {
|
||||
setLinkingExternalIssueTask({ provider, task: getLinkTarget(taskId, fallbackTitle) });
|
||||
},
|
||||
[getLinkTarget],
|
||||
);
|
||||
|
||||
const handleLinkJiraTicketTask = useCallback(
|
||||
(taskId: string, fallbackTitle?: string) =>
|
||||
handleLinkExternalIssueTask("jira", taskId, fallbackTitle),
|
||||
[handleLinkExternalIssueTask],
|
||||
);
|
||||
|
||||
const handleLinkLinearIssueTask = useCallback(
|
||||
(taskId: string, fallbackTitle?: string) =>
|
||||
handleLinkExternalIssueTask("linear", taskId, fallbackTitle),
|
||||
[handleLinkExternalIssueTask],
|
||||
);
|
||||
|
||||
const handleLinkSentryIssueTask = useCallback(
|
||||
(taskId: string, fallbackTitle?: string) =>
|
||||
handleLinkExternalIssueTask("sentry", taskId, fallbackTitle),
|
||||
[handleLinkExternalIssueTask],
|
||||
);
|
||||
|
||||
return {
|
||||
linkingPullRequestTask,
|
||||
setLinkingPullRequestTask,
|
||||
@@ -63,5 +96,10 @@ export function useSidebarLinkActions(store: StoreApi) {
|
||||
linkingIssueTask,
|
||||
setLinkingIssueTask,
|
||||
handleLinkIssueTask,
|
||||
linkingExternalIssueTask,
|
||||
setLinkingExternalIssueTask,
|
||||
handleLinkJiraTicketTask,
|
||||
handleLinkLinearIssueTask,
|
||||
handleLinkSentryIssueTask,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
|
||||
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
|
||||
import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability";
|
||||
import type { useSidebarActions } from "./task-session-sidebar";
|
||||
|
||||
type SidebarActions = Pick<
|
||||
ReturnType<typeof useSidebarActions>,
|
||||
| "handleLinkPullRequestTask"
|
||||
| "handleLinkIssueTask"
|
||||
| "handleLinkJiraTicketTask"
|
||||
| "handleLinkLinearIssueTask"
|
||||
| "handleLinkSentryIssueTask"
|
||||
>;
|
||||
|
||||
export function useSidebarTaskLinking(workspaceId: string | null, actions: SidebarActions) {
|
||||
const jiraAvailable = useJiraAvailable(workspaceId);
|
||||
const linearAvailable = useLinearAvailable(workspaceId);
|
||||
const sentryAvailable = useSentryAvailable(workspaceId);
|
||||
|
||||
return {
|
||||
onLinkPullRequest: actions.handleLinkPullRequestTask,
|
||||
onLinkIssue: actions.handleLinkIssueTask,
|
||||
onLinkJiraTicket: jiraAvailable ? actions.handleLinkJiraTicketTask : undefined,
|
||||
onLinkLinearIssue: linearAvailable ? actions.handleLinkLinearIssueTask : undefined,
|
||||
onLinkSentryIssue: sentryAvailable ? actions.handleLinkSentryIssueTask : undefined,
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { buildPendingFlags, readPendingFlags } from "./task-session-sidebar-aggr
|
||||
import { useGroupedSidebarView } from "./task-session-sidebar-grouped-view";
|
||||
import { useSidebarLinkActions } from "./task-session-sidebar-link-actions";
|
||||
import { buildArchivedSidebarItem } from "./task-session-sidebar-archived-item";
|
||||
import { useSidebarTaskLinking } from "./task-session-sidebar-task-linking";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { type AgentErrorOptions, agentErrorMessageForTask } from "@/lib/task-agent-error";
|
||||
import {
|
||||
@@ -97,7 +98,6 @@ function toIssueInfo(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Map a kanban task to a sidebar item with session info and repository metadata. */
|
||||
function toSidebarItem(
|
||||
task: KanbanState["tasks"][number] & { _workflowId: string },
|
||||
ctx: SidebarCtx,
|
||||
@@ -189,9 +189,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
isLoading: isLoadingWorkflow,
|
||||
} = useWorkspaceSidebarTasks(workspaceId);
|
||||
|
||||
// Stable list of primary session IDs for the bulk-subscribe effect and the
|
||||
// narrow pending-flag selector below. Derived from kanban tasks (always
|
||||
// available) rather than sessionsByTaskId (loaded on-demand).
|
||||
// Stable primary session IDs from kanban tasks feed subscriptions and pending-flag selectors.
|
||||
const primarySessionIds = useStablePrimarySessionIds(allTasks);
|
||||
const acknowledgementSessionIds = useMemo(
|
||||
() => agentErrorAcknowledgementSessionIds(allTasks, sessionsByTaskId),
|
||||
@@ -436,7 +434,7 @@ function useDeleteActions(
|
||||
};
|
||||
}
|
||||
|
||||
function useSidebarActions(store: StoreApi) {
|
||||
export function useSidebarActions(store: StoreApi) {
|
||||
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
||||
const setActiveSession = useAppStore((state) => state.setActiveSession);
|
||||
const [preparingTaskId, setPreparingTaskId] = useState<string | null>(null);
|
||||
@@ -528,7 +526,6 @@ function useBulkGitStatusSubscription(primarySessionIds: string[]) {
|
||||
if (connectionStatus !== "connected" || primarySessionIds.length === 0) return;
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
// Skip active session — it's already subscribed + focused by the task page hooks
|
||||
const backgroundIds = activeSessionId
|
||||
? primarySessionIds.filter((id) => id !== activeSessionId)
|
||||
: primarySessionIds;
|
||||
@@ -556,9 +553,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
primarySessionIds,
|
||||
} = useSidebarData(workspaceId);
|
||||
|
||||
// The sidebar is global, so `activeTaskId` lingers after navigating Home.
|
||||
// Only highlight a task while actually viewing a task route — otherwise the
|
||||
// last-opened task stays visually "selected" on Home and elsewhere.
|
||||
// Only highlight while viewing a task route; AppSidebar is global and activeTaskId lingers.
|
||||
const onTaskRoute =
|
||||
!!pathname && (pathname.startsWith("/t/") || pathname.startsWith("/office/tasks/"));
|
||||
const highlightedTaskId = onTaskRoute ? activeTaskId : null;
|
||||
@@ -575,9 +570,8 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
handleDeleteTask,
|
||||
handleMoveToStep,
|
||||
handleRenameTask,
|
||||
handleLinkPullRequestTask,
|
||||
handleLinkIssueTask,
|
||||
} = sidebarActions;
|
||||
const taskLinkHandlers = useSidebarTaskLinking(workspaceId, sidebarActions);
|
||||
const repositories =
|
||||
useAppStore((state) =>
|
||||
workspaceId ? state.repositories.itemsByWorkspaceId[workspaceId] : undefined,
|
||||
@@ -626,8 +620,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
onRenameTask={handleRenameTask}
|
||||
onArchiveTask={handleArchiveTask}
|
||||
onDeleteTask={handleDeleteTask}
|
||||
onLinkPullRequest={handleLinkPullRequestTask}
|
||||
onLinkIssue={handleLinkIssueTask}
|
||||
{...taskLinkHandlers}
|
||||
onMoveToStep={handleMoveToStep}
|
||||
onTogglePin={togglePinnedTask}
|
||||
onReorderGroup={handleReorderGroup}
|
||||
@@ -639,7 +632,11 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
{...selection.switcherProps}
|
||||
/>
|
||||
</PanelBody>
|
||||
<SidebarDialogs actions={sidebarActions} repositories={repositories} />
|
||||
<SidebarDialogs
|
||||
actions={sidebarActions}
|
||||
repositories={repositories}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
<SidebarBulkDialogs selection={selection} />
|
||||
</PanelRoot>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { cloneElement, isValidElement, useState } from "react";
|
||||
import {
|
||||
IconArchive,
|
||||
IconBrandSentry,
|
||||
IconCopy,
|
||||
IconCircleDot,
|
||||
IconGitPullRequest,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
IconPencil,
|
||||
IconPin,
|
||||
IconPinFilled,
|
||||
IconTicket,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -47,8 +49,11 @@ type ContextMenuProps = {
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onArchiveTask?: (taskId: string) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
onLinkPullRequest?: (taskId: string) => void;
|
||||
onLinkIssue?: (taskId: string) => 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;
|
||||
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
|
||||
onTogglePin?: (taskId: string) => void;
|
||||
isPinned?: boolean;
|
||||
@@ -76,6 +81,9 @@ export function TaskItemWithContextMenu({
|
||||
onDeleteTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
@@ -114,6 +122,9 @@ export function TaskItemWithContextMenu({
|
||||
onDeleteTask={onDeleteTask}
|
||||
onLinkPullRequest={onLinkPullRequest}
|
||||
onLinkIssue={onLinkIssue}
|
||||
onLinkJiraTicket={onLinkJiraTicket}
|
||||
onLinkLinearIssue={onLinkLinearIssue}
|
||||
onLinkSentryIssue={onLinkSentryIssue}
|
||||
onMoveToStep={onMoveToStep}
|
||||
onTogglePin={onTogglePin}
|
||||
isPinned={isPinned}
|
||||
@@ -139,31 +150,30 @@ type TaskContextMenuItemsProps = Omit<ContextMenuProps, "children"> & {
|
||||
moveTasks: ReturnType<typeof useTaskWorkflowMove>;
|
||||
};
|
||||
|
||||
function TaskContextMenuItems({
|
||||
task,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
onRenameTask,
|
||||
onArchiveTask,
|
||||
onDeleteTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
pinnedTaskIds,
|
||||
isDeleting,
|
||||
selectedTaskIds,
|
||||
onBulkArchive,
|
||||
onBulkDelete,
|
||||
onBulkPin,
|
||||
onBulkMove,
|
||||
onClearSelection,
|
||||
isMixedWorkflowSelection,
|
||||
closeMenu,
|
||||
moveTasks,
|
||||
}: TaskContextMenuItemsProps) {
|
||||
function TaskContextMenuItems(props: TaskContextMenuItemsProps) {
|
||||
const {
|
||||
task,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
onRenameTask,
|
||||
onArchiveTask,
|
||||
onDeleteTask,
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
pinnedTaskIds,
|
||||
isDeleting,
|
||||
selectedTaskIds,
|
||||
onBulkArchive,
|
||||
onBulkDelete,
|
||||
onBulkPin,
|
||||
onBulkMove,
|
||||
onClearSelection,
|
||||
isMixedWorkflowSelection,
|
||||
closeMenu,
|
||||
moveTasks,
|
||||
} = props;
|
||||
// Right-clicking any row that's part of the active selection acts on the whole
|
||||
// selection (even a one-row selection, so the action clears it); right-clicking
|
||||
// a non-selected row acts on just that task and leaves the selection intact.
|
||||
@@ -225,11 +235,7 @@ function TaskContextMenuItems({
|
||||
onBulkArchive={onBulkArchive}
|
||||
/>
|
||||
<TaskColorMenu taskId={task.id} disabled={isDeleting} />
|
||||
<TaskLinkMenu
|
||||
disabled={isDeleting}
|
||||
onLinkPullRequest={selectTaskAction(task.id, onLinkPullRequest, closeMenu)}
|
||||
onLinkIssue={selectTaskAction(task.id, onLinkIssue, closeMenu)}
|
||||
/>
|
||||
<TaskLinkMenu disabled={isDeleting} {...selectTaskLinkActions(task, closeMenu, props)} />
|
||||
<TaskMoveItems
|
||||
task={task}
|
||||
workflows={workflows}
|
||||
@@ -328,15 +334,36 @@ function BulkSelectionMenuItems({
|
||||
);
|
||||
}
|
||||
|
||||
function selectTaskAction(
|
||||
taskId: string,
|
||||
handler: ((taskId: string) => void) | undefined,
|
||||
export function createTaskLinkSelectAction(
|
||||
task: Pick<TaskSwitcherItem, "id" | "title">,
|
||||
handler: ((taskId: string, taskTitle?: string) => void) | undefined,
|
||||
closeMenu: () => void,
|
||||
) {
|
||||
if (!handler) return undefined;
|
||||
return () => {
|
||||
closeMenu();
|
||||
handler(taskId);
|
||||
handler(task.id, task.title);
|
||||
};
|
||||
}
|
||||
|
||||
function selectTaskLinkActions(
|
||||
task: Pick<TaskSwitcherItem, "id" | "title">,
|
||||
closeMenu: () => void,
|
||||
handlers: Pick<
|
||||
ContextMenuProps,
|
||||
| "onLinkPullRequest"
|
||||
| "onLinkIssue"
|
||||
| "onLinkJiraTicket"
|
||||
| "onLinkLinearIssue"
|
||||
| "onLinkSentryIssue"
|
||||
>,
|
||||
) {
|
||||
return {
|
||||
onLinkPullRequest: createTaskLinkSelectAction(task, handlers.onLinkPullRequest, closeMenu),
|
||||
onLinkIssue: createTaskLinkSelectAction(task, handlers.onLinkIssue, closeMenu),
|
||||
onLinkJiraTicket: createTaskLinkSelectAction(task, handlers.onLinkJiraTicket, closeMenu),
|
||||
onLinkLinearIssue: createTaskLinkSelectAction(task, handlers.onLinkLinearIssue, closeMenu),
|
||||
onLinkSentryIssue: createTaskLinkSelectAction(task, handlers.onLinkSentryIssue, closeMenu),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -537,12 +564,26 @@ function TaskLinkMenu({
|
||||
disabled,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onLinkPullRequest?: () => void;
|
||||
onLinkIssue?: () => void;
|
||||
onLinkJiraTicket?: () => void;
|
||||
onLinkLinearIssue?: () => void;
|
||||
onLinkSentryIssue?: () => void;
|
||||
}) {
|
||||
if (!onLinkPullRequest && !onLinkIssue) return null;
|
||||
if (
|
||||
!onLinkPullRequest &&
|
||||
!onLinkIssue &&
|
||||
!onLinkJiraTicket &&
|
||||
!onLinkLinearIssue &&
|
||||
!onLinkSentryIssue
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={disabled}>
|
||||
@@ -550,14 +591,36 @@ function TaskLinkMenu({
|
||||
Link
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-56">
|
||||
<ContextMenuItem disabled={disabled || !onLinkPullRequest} onSelect={onLinkPullRequest}>
|
||||
<IconGitPullRequest className="mr-2 h-4 w-4" />
|
||||
GitHub Pull Request
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={disabled || !onLinkIssue} onSelect={onLinkIssue}>
|
||||
<IconCircleDot className="mr-2 h-4 w-4" />
|
||||
GitHub Issue
|
||||
</ContextMenuItem>
|
||||
{onLinkPullRequest && (
|
||||
<ContextMenuItem disabled={disabled} onSelect={onLinkPullRequest}>
|
||||
<IconGitPullRequest className="mr-2 h-4 w-4" />
|
||||
GitHub Pull Request
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onLinkIssue && (
|
||||
<ContextMenuItem disabled={disabled} onSelect={onLinkIssue}>
|
||||
<IconCircleDot className="mr-2 h-4 w-4" />
|
||||
GitHub Issue
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onLinkJiraTicket && (
|
||||
<ContextMenuItem disabled={disabled} onSelect={onLinkJiraTicket}>
|
||||
<IconTicket className="mr-2 h-4 w-4" />
|
||||
Jira Ticket
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onLinkLinearIssue && (
|
||||
<ContextMenuItem disabled={disabled} onSelect={onLinkLinearIssue}>
|
||||
<IconCircleDot className="mr-2 h-4 w-4" />
|
||||
Linear Issue
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onLinkSentryIssue && (
|
||||
<ContextMenuItem disabled={disabled} onSelect={onLinkSentryIssue}>
|
||||
<IconBrandSentry className="mr-2 h-4 w-4" />
|
||||
Sentry Issue
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher";
|
||||
import { createTaskLinkSelectAction } from "./task-switcher-context-menu";
|
||||
import type { GroupedSidebarList } from "@/lib/sidebar/apply-view";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
@@ -163,3 +164,54 @@ describe("TaskSwitcher — bulk pin menu", () => {
|
||||
expect(screen.getByText("Unpin 2 tasks")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskSwitcher — external issue link menu", () => {
|
||||
it("offers configured external issue providers from the task context menu", async () => {
|
||||
render(
|
||||
<Providers>
|
||||
<TaskSwitcher
|
||||
grouped={{
|
||||
groups: [{ key: "__all__", label: "All", tasks: [TASK_A] }],
|
||||
subTasksByParentId: new Map(),
|
||||
}}
|
||||
activeTaskId={null}
|
||||
selectedTaskId={null}
|
||||
onSelectTask={vi.fn()}
|
||||
onLinkPullRequest={vi.fn()}
|
||||
onLinkIssue={vi.fn()}
|
||||
{...({
|
||||
onLinkJiraTicket: vi.fn(),
|
||||
onLinkLinearIssue: vi.fn(),
|
||||
onLinkSentryIssue: vi.fn(),
|
||||
} as Partial<Parameters<typeof TaskSwitcher>[0]>)}
|
||||
/>
|
||||
</Providers>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText(TASK_A.title));
|
||||
const linkTrigger = screen.getByText("Link");
|
||||
fireEvent.focus(linkTrigger);
|
||||
fireEvent.keyDown(linkTrigger, { key: "ArrowRight" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Jira Ticket")).toBeTruthy();
|
||||
expect(screen.getByText("Linear Issue")).toBeTruthy();
|
||||
expect(screen.getByText("Sentry Issue")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the visible task title to external issue link handlers", () => {
|
||||
const archivedTask: TaskSwitcherItem = {
|
||||
id: "archived-task",
|
||||
title: "Archived task title",
|
||||
isArchived: true,
|
||||
};
|
||||
const closeMenu = vi.fn();
|
||||
const onLinkJiraTicket = vi.fn();
|
||||
|
||||
createTaskLinkSelectAction(archivedTask, onLinkJiraTicket, closeMenu)?.();
|
||||
|
||||
expect(onLinkJiraTicket).toHaveBeenCalledWith(archivedTask.id, archivedTask.title);
|
||||
expect(closeMenu).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,8 +58,11 @@ type TaskSwitcherProps = {
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onArchiveTask?: (taskId: string) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
onLinkPullRequest?: (taskId: string) => void;
|
||||
onLinkIssue?: (taskId: string) => void;
|
||||
onLinkPullRequest?: TaskLinkHandler;
|
||||
onLinkIssue?: TaskLinkHandler;
|
||||
onLinkJiraTicket?: TaskLinkHandler;
|
||||
onLinkLinearIssue?: TaskLinkHandler;
|
||||
onLinkSentryIssue?: TaskLinkHandler;
|
||||
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
|
||||
onTogglePin?: (taskId: string) => void;
|
||||
onReorderGroup?: (groupTaskIds: string[]) => void;
|
||||
@@ -81,6 +84,8 @@ type TaskSwitcherProps = {
|
||||
isMixedWorkflowSelection?: boolean;
|
||||
};
|
||||
|
||||
type TaskLinkHandler = (taskId: string, taskTitle?: string) => void;
|
||||
|
||||
/**
|
||||
* Modifier-aware sidebar row click: cmd/ctrl toggles one task, shift extends a
|
||||
* range, a plain click toggles while a selection is active and otherwise
|
||||
@@ -136,8 +141,11 @@ type TaskRowProps = {
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onArchiveTask?: (taskId: string) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
onLinkPullRequest?: (taskId: string) => void;
|
||||
onLinkIssue?: (taskId: string) => void;
|
||||
onLinkPullRequest?: TaskLinkHandler;
|
||||
onLinkIssue?: TaskLinkHandler;
|
||||
onLinkJiraTicket?: TaskLinkHandler;
|
||||
onLinkLinearIssue?: TaskLinkHandler;
|
||||
onLinkSentryIssue?: TaskLinkHandler;
|
||||
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
|
||||
onTogglePin?: (taskId: string) => void;
|
||||
isPinned?: boolean;
|
||||
@@ -154,6 +162,24 @@ type TaskRowProps = {
|
||||
isMixedWorkflowSelection?: boolean;
|
||||
};
|
||||
|
||||
function taskLinkHandlerProps(props: Pick<TaskRowProps, keyof TaskLinkHandlerProps>) {
|
||||
return {
|
||||
onLinkPullRequest: props.onLinkPullRequest,
|
||||
onLinkIssue: props.onLinkIssue,
|
||||
onLinkJiraTicket: props.onLinkJiraTicket,
|
||||
onLinkLinearIssue: props.onLinkLinearIssue,
|
||||
onLinkSentryIssue: props.onLinkSentryIssue,
|
||||
};
|
||||
}
|
||||
|
||||
type TaskLinkHandlerProps = {
|
||||
onLinkPullRequest?: TaskLinkHandler;
|
||||
onLinkIssue?: TaskLinkHandler;
|
||||
onLinkJiraTicket?: TaskLinkHandler;
|
||||
onLinkLinearIssue?: TaskLinkHandler;
|
||||
onLinkSentryIssue?: TaskLinkHandler;
|
||||
};
|
||||
|
||||
function TaskRow({
|
||||
task,
|
||||
isSubTask,
|
||||
@@ -167,8 +193,6 @@ function TaskRow({
|
||||
onRenameTask,
|
||||
onArchiveTask,
|
||||
onDeleteTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
@@ -183,6 +207,7 @@ function TaskRow({
|
||||
onBulkMove,
|
||||
onClearSelection,
|
||||
isMixedWorkflowSelection,
|
||||
...props
|
||||
}: TaskRowProps) {
|
||||
const isSelected = task.id === selectedTaskId || task.id === activeTaskId;
|
||||
const isMultiSelected = selectedTaskIds?.has(task.id) ?? false;
|
||||
@@ -197,8 +222,7 @@ function TaskRow({
|
||||
onRenameTask={onRenameTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onLinkPullRequest={onLinkPullRequest}
|
||||
onLinkIssue={onLinkIssue}
|
||||
{...taskLinkHandlerProps(props)}
|
||||
onMoveToStep={onMoveToStep}
|
||||
onTogglePin={onTogglePin}
|
||||
isPinned={isPinned}
|
||||
@@ -372,8 +396,11 @@ type GroupSectionProps = {
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onArchiveTask?: (taskId: string) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
onLinkPullRequest?: (taskId: string) => void;
|
||||
onLinkIssue?: (taskId: string) => void;
|
||||
onLinkPullRequest?: TaskLinkHandler;
|
||||
onLinkIssue?: TaskLinkHandler;
|
||||
onLinkJiraTicket?: TaskLinkHandler;
|
||||
onLinkLinearIssue?: TaskLinkHandler;
|
||||
onLinkSentryIssue?: TaskLinkHandler;
|
||||
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
|
||||
onTogglePin?: (taskId: string) => void;
|
||||
onReorderGroup?: (groupTaskIds: string[]) => void;
|
||||
@@ -410,6 +437,9 @@ function GroupSection({
|
||||
onDeleteTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
onReorderGroup,
|
||||
@@ -444,6 +474,9 @@ function GroupSection({
|
||||
onDeleteTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
pinnedTaskIds,
|
||||
@@ -496,6 +529,9 @@ export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
onDeleteTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
onReorderGroup,
|
||||
@@ -548,6 +584,9 @@ export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
onDeleteTask={onDeleteTask}
|
||||
onLinkPullRequest={onLinkPullRequest}
|
||||
onLinkIssue={onLinkIssue}
|
||||
onLinkJiraTicket={onLinkJiraTicket}
|
||||
onLinkLinearIssue={onLinkLinearIssue}
|
||||
onLinkSentryIssue={onLinkSentryIssue}
|
||||
onMoveToStep={onMoveToStep}
|
||||
onTogglePin={onTogglePin}
|
||||
onReorderGroup={onReorderGroup}
|
||||
|
||||
@@ -44,19 +44,11 @@ vi.mock("@/components/jira/jira-ticket-button", () => ({
|
||||
extractJiraKey: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/jira/jira-link-button", () => ({
|
||||
JiraLinkButton: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/linear/linear-issue-button", () => ({
|
||||
LinearIssueButton: () => null,
|
||||
extractLinearKey: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/linear/linear-link-button", () => ({
|
||||
LinearLinkButton: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/jira/use-jira-availability", () => ({
|
||||
useJiraAvailable: () => false,
|
||||
}));
|
||||
|
||||
@@ -12,9 +12,7 @@ import { DocumentControls } from "@/components/task/document/document-controls";
|
||||
import { PRTopbarButton } from "@/components/github/pr-topbar-button";
|
||||
import { MRTopbarButton } from "@/components/gitlab/mr-topbar-button";
|
||||
import { JiraTicketButton, extractJiraKey } from "@/components/jira/jira-ticket-button";
|
||||
import { JiraLinkButton } from "@/components/jira/jira-link-button";
|
||||
import { LinearIssueButton, extractLinearKey } from "@/components/linear/linear-issue-button";
|
||||
import { LinearLinkButton } from "@/components/linear/linear-link-button";
|
||||
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
|
||||
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
|
||||
import { PortForwardButton } from "@/components/task/port-forward-dialog";
|
||||
@@ -96,7 +94,6 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
)}
|
||||
</div>
|
||||
<TopBarRight
|
||||
taskId={taskId}
|
||||
activeSessionId={activeSessionId}
|
||||
showDebugOverlay={showDebugOverlay}
|
||||
onToggleDebugOverlay={onToggleDebugOverlay}
|
||||
@@ -113,18 +110,16 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
);
|
||||
});
|
||||
|
||||
// IssueTrackerButtons picks the right ticket button for a task. Jira and
|
||||
// Linear use the same TEAM-NUMBER identifier shape, so both `extract` calls
|
||||
// would match "ENG-123" — we resolve ambiguity by preferring whichever
|
||||
// integration is currently available for the workspace, with Jira winning the
|
||||
// tie-break since it shipped first. When the title carries no identifier,
|
||||
// both link buttons are offered (each gated on its own availability).
|
||||
// IssueTrackerButtons picks the right ticket status button for a task whose
|
||||
// title already carries an external issue key. Jira and Linear use the same
|
||||
// TEAM-NUMBER identifier shape, so both `extract` calls would match
|
||||
// "ENG-123" — we resolve ambiguity by preferring whichever integration is
|
||||
// currently available for the workspace, with Jira winning the tie-break since
|
||||
// it shipped first. Creating new links lives in the task Link menu.
|
||||
function IssueTrackerButtons({
|
||||
taskId,
|
||||
workspaceId,
|
||||
taskTitle,
|
||||
}: {
|
||||
taskId: string | null | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
taskTitle: string | null | undefined;
|
||||
}) {
|
||||
@@ -139,12 +134,7 @@ function IssueTrackerButtons({
|
||||
if (linearKey && linearAvailable) {
|
||||
return <LinearIssueButton workspaceId={workspaceId} taskTitle={taskTitle} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<JiraLinkButton taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
|
||||
<LinearLinkButton taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
|
||||
</>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Left section: task name breadcrumb + executor info. Home + integrations
|
||||
@@ -227,7 +217,6 @@ function DebugOverlayToggle({
|
||||
}
|
||||
|
||||
function AttentionStatusGroup({
|
||||
taskId,
|
||||
activeSessionId,
|
||||
isArchived,
|
||||
workspaceId,
|
||||
@@ -237,7 +226,6 @@ function AttentionStatusGroup({
|
||||
issueUrl,
|
||||
issueNumber,
|
||||
}: {
|
||||
taskId?: string | null;
|
||||
activeSessionId?: string | null;
|
||||
isArchived?: boolean;
|
||||
workspaceId?: string | null;
|
||||
@@ -273,7 +261,7 @@ function AttentionStatusGroup({
|
||||
<GitHubIssueTopbarButton issueUrl={issueUrl} issueNumber={issueNumber} />
|
||||
<PRTopbarButton />
|
||||
<MRTopbarButton />
|
||||
<IssueTrackerButtons taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
|
||||
<IssueTrackerButtons workspaceId={workspaceId} taskTitle={taskTitle} />
|
||||
</>
|
||||
)}
|
||||
</TopbarCluster>
|
||||
@@ -347,7 +335,6 @@ function TopbarToolsGroup({
|
||||
* The former overflow popover was removed in the UI overhaul — every cluster
|
||||
* is always visible so users don't have to discover the dots menu. */
|
||||
function TopBarRight({
|
||||
taskId,
|
||||
activeSessionId,
|
||||
showDebugOverlay,
|
||||
onToggleDebugOverlay,
|
||||
@@ -360,7 +347,6 @@ function TopBarRight({
|
||||
issueNumber,
|
||||
officeTaskHref,
|
||||
}: {
|
||||
taskId?: string | null;
|
||||
activeSessionId?: string | null;
|
||||
showDebugOverlay?: boolean;
|
||||
onToggleDebugOverlay?: () => void;
|
||||
@@ -384,7 +370,6 @@ function TopBarRight({
|
||||
</TopbarCluster>
|
||||
)}
|
||||
<AttentionStatusGroup
|
||||
taskId={taskId}
|
||||
activeSessionId={activeSessionId}
|
||||
isArchived={isArchived}
|
||||
workspaceId={workspaceId}
|
||||
|
||||
@@ -1473,6 +1473,20 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async setSentryConfig(payload: {
|
||||
secret: string;
|
||||
url?: string;
|
||||
workspaceId?: string;
|
||||
}): Promise<unknown> {
|
||||
const { workspaceId, ...config } = payload;
|
||||
const path = await this.withActiveWorkspace("/api/v1/sentry/config", workspaceId);
|
||||
return this.request("PUT", path, {
|
||||
authMethod: "auth_token",
|
||||
url: "https://sentry.io",
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until the integration config reports `lastOk: true`. SetConfig kicks
|
||||
* off an async auth-health probe in a goroutine, so the row's `lastOk` flips
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
test.describe("Mobile sidebar — external link menu", () => {
|
||||
test("shows enabled integration link actions in the task switcher sheet", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.setJiraConfig({
|
||||
siteUrl: "https://acme.atlassian.net",
|
||||
email: "alice@example.com",
|
||||
secret: "api-token-value",
|
||||
});
|
||||
await apiClient.setLinearConfig({ secret: "lin_api_xxx" });
|
||||
await apiClient.setSentryConfig({ secret: "sntrys_xxx" });
|
||||
await Promise.all([
|
||||
apiClient.waitForIntegrationAuthHealthy("jira"),
|
||||
apiClient.waitForIntegrationAuthHealthy("linear"),
|
||||
apiClient.waitForIntegrationAuthHealthy("sentry"),
|
||||
]);
|
||||
|
||||
const task = await apiClient.seedTask(seedData.workspaceId, "Mobile external link task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.task_id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
await testPage.getByTestId("mobile-session-menu").click();
|
||||
const sheet = testPage.getByRole("dialog");
|
||||
const taskRow = sheet.locator('[role="button"]').filter({
|
||||
hasText: "Mobile external link task",
|
||||
});
|
||||
await expect(taskRow).toBeVisible({ timeout: 10_000 });
|
||||
await taskRow.getByRole("button", { name: "Task actions" }).click();
|
||||
|
||||
const linkTrigger = testPage.getByRole("menuitem", { name: /^Link$/ });
|
||||
await expect(linkTrigger).toBeVisible();
|
||||
await linkTrigger.focus();
|
||||
await testPage.keyboard.press("ArrowRight");
|
||||
|
||||
await expect(testPage.getByRole("menuitem", { name: "Jira Ticket" })).toBeVisible();
|
||||
await expect(testPage.getByRole("menuitem", { name: "Linear Issue" })).toBeVisible();
|
||||
await expect(testPage.getByRole("menuitem", { name: "Sentry Issue" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -4,16 +4,21 @@ created: 2026-06-23
|
||||
owner: product
|
||||
---
|
||||
|
||||
# Link Existing Task to GitHub References
|
||||
# Link Existing Task to External References
|
||||
|
||||
## Why
|
||||
|
||||
Users can create a task from GitHub, but they also need to attach GitHub references to a task that already exists. That breaks traceability when a task is started first and the GitHub pull request or issue is created later.
|
||||
Users can create tasks from external systems, but they also need to attach
|
||||
external references to a task that already exists. Traceability breaks when a
|
||||
task starts first and the GitHub pull request, GitHub issue, Jira ticket, Linear
|
||||
issue, or Sentry issue is identified later.
|
||||
|
||||
## What
|
||||
|
||||
- Any existing task can open a **Link** submenu from task menus.
|
||||
- The submenu contains **GitHub Pull Request** and **GitHub Issue** actions.
|
||||
- The submenu contains **GitHub Pull Request**, **GitHub Issue**, **Jira
|
||||
Ticket**, **Linear Issue**, and **Sentry Issue** actions when each action is
|
||||
available for the task workspace.
|
||||
- The actions are available from task card context/dropdown menus and sidebar task context menus.
|
||||
- The issue action accepts a GitHub issue URL. If the task has exactly one GitHub repository, an issue number such as `#1470` is also accepted.
|
||||
- The pull request action accepts a GitHub PR URL. If the task has exactly one GitHub repository, a PR number such as `#1471` is also accepted.
|
||||
@@ -21,12 +26,32 @@ Users can create a task from GitHub, but they also need to attach GitHub referen
|
||||
- The link is stored in task metadata using the existing `issue_url` and `issue_number` fields, so kanban cards and task detail surfaces render it through the existing issue indicator.
|
||||
- Pull request linking reuses the existing task PR association model and rendering.
|
||||
- A linked issue can be explicitly changed or unlinked from the same dialog.
|
||||
- Jira ticket linking is shown only when Jira is enabled and healthy for the
|
||||
current workspace. It accepts a Jira key or URL, validates it through the
|
||||
configured Jira integration, and links by rewriting the task title prefix to
|
||||
`KEY: existing title`.
|
||||
- Linear issue linking is shown only when Linear is enabled and healthy for the
|
||||
current workspace. It accepts a Linear identifier or URL, validates it through
|
||||
the configured Linear integration, and links by rewriting the task title
|
||||
prefix to `IDENTIFIER: existing title`.
|
||||
- Sentry issue linking is shown only when Sentry is enabled and healthy for the
|
||||
current workspace. It accepts a Sentry short ID or URL, validates it through
|
||||
the configured Sentry integration, and links by rewriting the task title
|
||||
prefix to `SHORT-ID: existing title`.
|
||||
- Jira, Linear, and Sentry title-prefix linking replaces an existing leading
|
||||
external issue prefix instead of stacking prefixes.
|
||||
- The dockview top bar does not show Jira or Linear create-link buttons for
|
||||
unlinked tasks. It still shows existing linked-reference affordances, such as
|
||||
Jira/Linear ticket buttons when a linked key is present in the title.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Creating GitHub issues from Kandev.
|
||||
- Creating GitHub pull requests from Kandev.
|
||||
- New issue synchronization beyond the existing metadata-backed reference.
|
||||
- Creating Jira tickets, Linear issues, or Sentry issues from Kandev.
|
||||
- A durable cross-provider `task_external_links` model.
|
||||
- A Sentry linked-issue top-bar affordance.
|
||||
|
||||
## Scenarios
|
||||
|
||||
@@ -60,8 +85,33 @@ GIVEN an existing task that already has GitHub issue metadata
|
||||
WHEN the user opens Link > GitHub Issue and chooses Unlink
|
||||
THEN Kandev removes only the issue metadata keys and preserves unrelated task metadata
|
||||
|
||||
### Link a Jira ticket to an existing task
|
||||
|
||||
GIVEN Jira is enabled and healthy for the task workspace
|
||||
WHEN the user opens Link > Jira Ticket and enters `PROJ-12`
|
||||
THEN Kandev validates the ticket through Jira and renames the task to `PROJ-12: <old title>` without changing task state, session history, repositories, or unrelated metadata
|
||||
|
||||
### Link a Linear issue to an existing task
|
||||
|
||||
GIVEN Linear is enabled and healthy for the task workspace
|
||||
WHEN the user opens Link > Linear Issue and enters `ENG-20`
|
||||
THEN Kandev validates the issue through Linear and renames the task to `ENG-20: <old title>` without changing task state, session history, repositories, or unrelated metadata
|
||||
|
||||
### Link a Sentry issue to an existing task
|
||||
|
||||
GIVEN Sentry is enabled and healthy for the task workspace
|
||||
WHEN the user opens Link > Sentry Issue and enters `API-99`
|
||||
THEN Kandev validates the issue through Sentry and renames the task to `API-99: <old title>` without changing task state, session history, repositories, or unrelated metadata
|
||||
|
||||
### Replace an existing external prefix
|
||||
|
||||
GIVEN an existing task titled `PROJ-12: Fix login`
|
||||
WHEN the user opens Link > Linear Issue and enters `ENG-20`
|
||||
THEN Kandev renames the task to `ENG-20: Fix login` instead of stacking prefixes
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Linking does not change task state, session history, repositories, or unrelated metadata.
|
||||
- Invalid issue references and repository mismatches return clear errors.
|
||||
- Right-click context menu, sidebar menu, and touch/dropdown menu users can reach the Link submenu.
|
||||
- Jira, Linear, and Sentry actions are hidden when the corresponding integration is disabled or unauthenticated.
|
||||
|
||||
Reference in New Issue
Block a user