fix: preserve client-side navigation for task links (#3844)
This commit is contained in:
+2
-2
@@ -111,8 +111,8 @@ surface.
|
||||
`next-themes` directly. The routing/image/dynamic adapters now provide
|
||||
browser-native behavior for the Vite SPA while legacy Next entrypoints are
|
||||
phased out.
|
||||
- Components: <200 lines, extract to domain components, composition over props.
|
||||
- Hooks: domain-organized in `hooks/domains/`, encapsulate subscription + selection.
|
||||
- Task links: `lib/links.ts::linkToTask` is the only `/t/:taskId` builder; pass raw IDs, use `TaskLink` or `AppLink`, and use `linkToTask` for router pushes. Keep compatibility `/tasks/:id`, Office/API paths, and route-recognition prefixes separate.
|
||||
- Components stay under 200 lines; extract domain components. Hooks belong in `hooks/domains/` and encapsulate subscription plus selection.
|
||||
- **Code-host dashboards:** GitHub, GitLab, and plugin code-host pages must use
|
||||
the provider-neutral primitives in `components/integrations/` for
|
||||
change-request lists, rows, toolbars, scope controls, task preset menus, and
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "@/lib/routing/client-dynamic";
|
||||
import Link from "@/components/routing/app-link";
|
||||
import TaskLink from "@/components/routing/task-link";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Switch } from "@kandev/ui/switch";
|
||||
@@ -47,13 +47,13 @@ export function TaskAdvancedMode({ task, onToggleSimple }: TaskAdvancedModeProps
|
||||
</Label>
|
||||
<Switch id="advanced-toggle" checked onCheckedChange={() => onToggleSimple()} />
|
||||
</div>
|
||||
<Link
|
||||
href={`/t/${task.id}`}
|
||||
<TaskLink
|
||||
taskId={task.id}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:underline cursor-pointer whitespace-nowrap"
|
||||
data-testid="task-cross-link"
|
||||
>
|
||||
{t("office:openInAdvancedView")}
|
||||
</Link>
|
||||
</TaskLink>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
|
||||
import Link from "@/components/routing/app-link";
|
||||
import TaskLink from "@/components/routing/task-link";
|
||||
import { TaskPageContent } from "@/components/task/task-page-content";
|
||||
import { TaskBody, resolveTaskBodyMode } from "@/components/task/TaskBody";
|
||||
import { TaskHeader } from "@/components/task/TaskHeader";
|
||||
@@ -153,11 +154,21 @@ function SimpleTaskHeaderRow({ task }: { task: Task | null }) {
|
||||
|
||||
function CrossLinkRow({ taskId, target }: { taskId: string; target: "office" | "kanban" }) {
|
||||
const { t } = useTranslation();
|
||||
const href = target === "office" ? `/office/tasks/${taskId}` : `/t/${taskId}`;
|
||||
const label = target === "office" ? t("tasks:openInOfficeView") : t("tasks:openInAdvancedView");
|
||||
if (target === "kanban") {
|
||||
return (
|
||||
<TaskLink
|
||||
taskId={taskId}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:underline cursor-pointer"
|
||||
data-testid="task-cross-link"
|
||||
>
|
||||
{label}
|
||||
</TaskLink>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
href={`/office/tasks/${taskId}`}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:underline cursor-pointer"
|
||||
data-testid="task-cross-link"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "@/lib/routing/server-navigation";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
|
||||
/**
|
||||
* Compatibility alias for `/tasks/:id`.
|
||||
@@ -25,6 +26,5 @@ export default async function TasksDetailAliasPage({
|
||||
query.set(key, value);
|
||||
}
|
||||
}
|
||||
const queryString = query.toString();
|
||||
redirect(queryString ? `/t/${id}?${queryString}` : `/t/${id}`);
|
||||
redirect(linkToTask(id, { searchParams: query }));
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("RunsSection run log", () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId("run-row-run-x"));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/tasks/task-hidden");
|
||||
expect(mockPush).toHaveBeenCalledWith("/t/task-hidden");
|
||||
});
|
||||
|
||||
it("does not link a run that never produced a task", () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { IconChevronDown, IconChevronUp, IconRefresh, IconTrash } from "@tabler/icons-react";
|
||||
import { useAutomationRuns } from "@/hooks/domains/settings/use-automation-runs";
|
||||
import { buildRunOutcomeReasonSuffix } from "@/lib/automation-run-reason";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import type { AutomationRun, RunStatus } from "@/lib/types/automation";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
|
||||
@@ -326,7 +327,7 @@ export function RunsSection({ automationId, workspaceId }: RunsSectionProps) {
|
||||
run={run}
|
||||
deleting={deleting}
|
||||
onDelete={deleteRun}
|
||||
onNavigate={(id) => router.push(`/tasks/${id}`)}
|
||||
onNavigate={(id) => router.push(linkToTask(id))}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import type { AutomationTrigger } from "@/lib/types/automation";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import TaskLink from "@/components/routing/task-link";
|
||||
|
||||
export function PluginWebhookControls({
|
||||
trigger,
|
||||
@@ -81,9 +82,9 @@ export function PluginWebhookControls({
|
||||
{new Date(receipt.created_at * 1000).toLocaleString()}:{" "}
|
||||
{t(`automations:pluginWebhookState_${receipt.state}`)}{" "}
|
||||
{receipt.task_id ? (
|
||||
<a className="underline" href={`/tasks/${receipt.task_id}`}>
|
||||
<TaskLink taskId={receipt.task_id} className="underline">
|
||||
{receipt.run_id}
|
||||
</a>
|
||||
</TaskLink>
|
||||
) : (
|
||||
receipt.run_id
|
||||
)}
|
||||
|
||||
@@ -147,7 +147,7 @@ it("updates the cache and store after associating the created task", async () =>
|
||||
|
||||
await waitFor(() => expect(mocks.cache).toHaveBeenCalledWith(workspaceId, "task-1", linked));
|
||||
expect(mocks.setTaskPullRequest).toHaveBeenCalledWith("task-1", linked);
|
||||
expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1");
|
||||
expect(mocks.push).toHaveBeenCalledWith("/t/task-1");
|
||||
});
|
||||
|
||||
it("reports a pull request association failure", async () => {
|
||||
@@ -158,7 +158,7 @@ it("reports a pull request association failure", async () => {
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith("Azure association failed"));
|
||||
expect(mocks.close).toHaveBeenCalled();
|
||||
expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1");
|
||||
expect(mocks.push).toHaveBeenCalledWith("/t/task-1");
|
||||
});
|
||||
|
||||
it("associates and caches a created task for a work item", async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAppStore } from "@/components/state-provider";
|
||||
import { cacheAzureDevOpsTaskPullRequest } from "@/hooks/domains/azure-devops/use-azure-devops-task-pull-requests";
|
||||
import { cacheAzureDevOpsTaskWorkItem } from "@/hooks/domains/azure-devops/use-azure-devops-task-work-items";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import {
|
||||
associateAzureDevOpsPullRequest,
|
||||
associateAzureDevOpsWorkItem,
|
||||
@@ -186,7 +187,7 @@ export function AzureDevOpsTaskLauncher({
|
||||
) => {
|
||||
await linkCreatedTask(task);
|
||||
onClose();
|
||||
if (meta?.autoFocus !== false) router.push(`/tasks/${task.id}`);
|
||||
if (meta?.autoFocus !== false) router.push(linkToTask(task.id));
|
||||
};
|
||||
|
||||
if (!workspaceId || !payload || !launch?.workflow || !launch.workflowSteps[0]) return null;
|
||||
|
||||
@@ -292,7 +292,7 @@ describe("QuickTaskLauncher issue linking", () => {
|
||||
expect(mocks.upsertTaskIssue).toHaveBeenCalledWith(WORKSPACE_ID, link);
|
||||
});
|
||||
expect(mocks.push).toHaveBeenCalledTimes(autoFocus ? 1 : 0);
|
||||
if (autoFocus) expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1");
|
||||
if (autoFocus) expect(mocks.push).toHaveBeenCalledWith("/t/task-1");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -302,7 +302,7 @@ describe("QuickTaskLauncher issue linking", () => {
|
||||
|
||||
mocks.dialogProps?.onSuccess?.({ id: "task-1" } as Task);
|
||||
|
||||
expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1");
|
||||
expect(mocks.push).toHaveBeenCalledWith("/t/task-1");
|
||||
await waitFor(() => expect(mocks.linkTaskIssue).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.upsertTaskIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import { TaskCreateDialog } from "@/components/task-create-dialog";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
@@ -236,7 +237,7 @@ export function QuickTaskLauncher({
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
if (meta?.autoFocus !== false) router.push(`/tasks/${task.id}`);
|
||||
if (meta?.autoFocus !== false) router.push(linkToTask(task.id));
|
||||
};
|
||||
|
||||
if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { toast } from "@/lib/toast/sonner";
|
||||
import { formatRelativeTime } from "@/lib/i18n/formats";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import {
|
||||
inboxHistoryClarificationQuestions,
|
||||
inboxHistoryPermissionContent,
|
||||
@@ -32,8 +33,7 @@ const TOUCH_ICON_BUTTON =
|
||||
"max-md:min-h-11 max-md:min-w-11 [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11";
|
||||
|
||||
function taskHrefForBundle(bundle: InboxHistoryBundle): string {
|
||||
if (!bundle.session_id) return `/t/${bundle.task_id}`;
|
||||
return `/t/${bundle.task_id}?sessionId=${encodeURIComponent(bundle.session_id)}`;
|
||||
return linkToTask(bundle.task_id, { sessionId: bundle.session_id ?? undefined });
|
||||
}
|
||||
|
||||
function InboxHistoryQuestionDetail({ bundle }: { bundle: InboxHistoryBundle }) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import { TaskCreateDialog } from "@/components/task-create-dialog";
|
||||
import type { Task, Workflow, WorkflowStep } from "@/lib/types/http";
|
||||
import type { JiraTicket } from "@/lib/types/jira";
|
||||
@@ -62,7 +63,7 @@ export function QuickTaskLauncher({
|
||||
};
|
||||
const handleSuccess = (task: Task, _mode?: "create" | "edit", meta?: { autoFocus?: boolean }) => {
|
||||
onClose();
|
||||
if (meta?.autoFocus !== false) router.push(`/tasks/${task.id}`);
|
||||
if (meta?.autoFocus !== false) router.push(linkToTask(task.id));
|
||||
};
|
||||
|
||||
if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import { TaskCreateDialog } from "@/components/task-create-dialog";
|
||||
import type { Task, Workflow, WorkflowStep } from "@/lib/types/http";
|
||||
import type { LinearIssue } from "@/lib/types/linear";
|
||||
@@ -84,7 +85,7 @@ export function LinearQuickTaskLauncher({
|
||||
};
|
||||
const handleSuccess = (task: Task, _mode?: "create" | "edit", meta?: { autoFocus?: boolean }) => {
|
||||
onClose();
|
||||
if (meta?.autoFocus !== false) router.push(`/tasks/${task.id}`);
|
||||
if (meta?.autoFocus !== false) router.push(linkToTask(task.id));
|
||||
};
|
||||
|
||||
if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatRelativeTime } from "@/lib/i18n/formats";
|
||||
import { getTaskStateIcon } from "@/lib/ui/state-icons";
|
||||
import { FAILED_TASK_STATUS } from "@/lib/threads/thread-session-status";
|
||||
import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import {
|
||||
failedInboxOriginMarkerKey,
|
||||
hasResolvableFailedInboxReason,
|
||||
@@ -16,7 +17,7 @@ import type { FailedInboxRow as FailedInboxRowData } from "@/lib/types/failed-in
|
||||
// The row addresses a task, never a session (design-01#Session-resolution):
|
||||
// the open-task control needs only the task id.
|
||||
function taskHrefForRow(row: FailedInboxRowData): string {
|
||||
return `/t/${row.task_id}`;
|
||||
return linkToTask(row.task_id);
|
||||
}
|
||||
|
||||
export function FailedInboxRow({ row }: { row: FailedInboxRowData }) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
rowSecondaryText,
|
||||
} from "@/lib/needs-you-inbox/row-presentation";
|
||||
import { resolveThreadSessionStatus } from "@/lib/threads/thread-session-status";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import type { TaskSessionState } from "@/lib/types/http";
|
||||
import type {
|
||||
LateClarificationSnapshot,
|
||||
@@ -167,8 +168,7 @@ function RowActionsMenu({
|
||||
}
|
||||
|
||||
function taskHrefForBundle(bundle: ClarificationInboxBundle): string {
|
||||
if (!bundle.session_id) return `/t/${bundle.task_id}`;
|
||||
return `/t/${bundle.task_id}?sessionId=${encodeURIComponent(bundle.session_id)}`;
|
||||
return linkToTask(bundle.task_id, { sessionId: bundle.session_id ?? undefined });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- the row keeps its responsive actions and inline panel together.
|
||||
|
||||
@@ -4,14 +4,18 @@ import {
|
||||
clearNavigationBlockerForTests,
|
||||
setNavigationBlocker,
|
||||
} from "@/lib/routing/navigation-guard";
|
||||
import { LOCATION_CHANGE_EVENT } from "@/lib/routing/navigation-event";
|
||||
import Link from "./app-link";
|
||||
|
||||
const BLOCKED_PATH = "/settings/general/appearance";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearNavigationBlockerForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- the suite keeps one AppLink contract together.
|
||||
describe("AppLink", () => {
|
||||
it("navigates same-origin links through browser history", () => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
@@ -37,13 +41,48 @@ describe("AppLink", () => {
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onNavigated after the location event for a committed navigation", () => {
|
||||
const events: string[] = [];
|
||||
const onNavigated = vi.fn(() => events.push("callback"));
|
||||
window.addEventListener(LOCATION_CHANGE_EVENT, () => events.push("location"), { once: true });
|
||||
render(
|
||||
<Link href="/tasks" onNavigated={onNavigated}>
|
||||
Tasks
|
||||
</Link>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Tasks"));
|
||||
|
||||
expect(events).toEqual(["location", "callback"]);
|
||||
expect(onNavigated).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not intercept modified clicks", () => {
|
||||
const pushState = vi.spyOn(window.history, "pushState");
|
||||
render(<Link href="/tasks">Tasks</Link>);
|
||||
const onNavigated = vi.fn();
|
||||
render(
|
||||
<Link href="/tasks" onNavigated={onNavigated}>
|
||||
Tasks
|
||||
</Link>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Tasks"), { metaKey: true });
|
||||
|
||||
expect(pushState).not.toHaveBeenCalled();
|
||||
expect(onNavigated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call onNavigated when a caller prevents the click", () => {
|
||||
const onNavigated = vi.fn();
|
||||
render(
|
||||
<Link href="/tasks" onNavigated={onNavigated} onClick={(event) => event.preventDefault()}>
|
||||
Tasks
|
||||
</Link>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Tasks"));
|
||||
|
||||
expect(onNavigated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not intercept hash-only links", () => {
|
||||
@@ -70,7 +109,7 @@ describe("AppLink", () => {
|
||||
});
|
||||
|
||||
it("waits for a navigation blocker before changing routes", () => {
|
||||
window.history.replaceState({}, "", "/settings/general/appearance");
|
||||
window.history.replaceState({}, "", BLOCKED_PATH);
|
||||
let proceed: () => void = () => undefined;
|
||||
const onLocationChange = vi.fn();
|
||||
window.addEventListener("kandev:navigation", onLocationChange, { once: true });
|
||||
@@ -81,9 +120,49 @@ describe("AppLink", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("Tasks"));
|
||||
|
||||
expect(window.location.pathname).toBe("/settings/general/appearance");
|
||||
expect(window.location.pathname).toBe(BLOCKED_PATH);
|
||||
proceed();
|
||||
expect(window.location.pathname).toBe("/tasks");
|
||||
expect(onLocationChange).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("runs onNavigated only when a navigation blocker proceeds", () => {
|
||||
window.history.replaceState({}, "", BLOCKED_PATH);
|
||||
let proceed: () => void = () => undefined;
|
||||
const onNavigated = vi.fn();
|
||||
setNavigationBlocker((intent) => {
|
||||
proceed = intent.proceed;
|
||||
});
|
||||
render(
|
||||
<Link href="/tasks" onNavigated={onNavigated}>
|
||||
Tasks
|
||||
</Link>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Tasks"));
|
||||
|
||||
expect(onNavigated).not.toHaveBeenCalled();
|
||||
proceed();
|
||||
expect(onNavigated).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not call onNavigated when a navigation blocker cancels", () => {
|
||||
window.history.replaceState({}, "", BLOCKED_PATH);
|
||||
let cancel: () => void = () => undefined;
|
||||
const onNavigated = vi.fn();
|
||||
setNavigationBlocker((intent) => {
|
||||
cancel = intent.cancel;
|
||||
});
|
||||
render(
|
||||
<Link href="/tasks" onNavigated={onNavigated}>
|
||||
Tasks
|
||||
</Link>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Tasks"));
|
||||
cancel();
|
||||
|
||||
expect(window.location.pathname).toBe(BLOCKED_PATH);
|
||||
expect(onNavigated).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,10 +11,11 @@ type AppLinkHref = string | URL;
|
||||
export type AppLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
|
||||
href: AppLinkHref;
|
||||
children?: ReactNode;
|
||||
onNavigated?: () => void;
|
||||
};
|
||||
|
||||
const Link = forwardRef<HTMLAnchorElement, AppLinkProps>(function Link(
|
||||
{ href, onClick, ...props },
|
||||
{ href, onClick, onNavigated, ...props },
|
||||
ref,
|
||||
) {
|
||||
const resolvedHref = href.toString();
|
||||
@@ -27,6 +28,7 @@ const Link = forwardRef<HTMLAnchorElement, AppLinkProps>(function Link(
|
||||
pushNavigationState({}, "", resolvedHref, () => {
|
||||
window.scrollTo({ top: 0, left: 0 });
|
||||
window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT));
|
||||
onNavigated?.();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import TaskLink from "./task-link";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("TaskLink", () => {
|
||||
it("builds a canonical task href from raw task and query context", () => {
|
||||
const searchParams = new URLSearchParams([["view", "focused"]]);
|
||||
render(
|
||||
<TaskLink taskId="task/123" layout="plan" sessionId="session 123" searchParams={searchParams}>
|
||||
Open task
|
||||
</TaskLink>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Open task" }).getAttribute("href")).toBe(
|
||||
"/t/task%2F123?view=focused&layout=plan&sessionId=session+123",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards navigation callbacks and anchor refs through AppLink", () => {
|
||||
const onNavigated = vi.fn();
|
||||
const ref = { current: null as HTMLAnchorElement | null };
|
||||
render(
|
||||
<TaskLink ref={ref} taskId="task-123" onNavigated={onNavigated}>
|
||||
Open task
|
||||
</TaskLink>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "Open task" }));
|
||||
|
||||
expect(ref.current).toBe(screen.getByRole("link", { name: "Open task" }));
|
||||
expect(onNavigated).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import Link, { type AppLinkProps } from "./app-link";
|
||||
import { linkToTask, type TaskLinkOptions } from "@/lib/links";
|
||||
|
||||
export type TaskLinkProps = Omit<AppLinkProps, "href"> &
|
||||
TaskLinkOptions & {
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
const TaskLink = forwardRef<HTMLAnchorElement, TaskLinkProps>(function TaskLink(
|
||||
{ taskId, layout, sessionId, searchParams, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Link {...props} ref={ref} href={linkToTask(taskId, { layout, sessionId, searchParams })} />
|
||||
);
|
||||
});
|
||||
|
||||
export default TaskLink;
|
||||
@@ -171,7 +171,7 @@ describe("RunsListPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId("run-entry-run-1"));
|
||||
|
||||
expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1");
|
||||
expect(mocks.push).toHaveBeenCalledWith("/t/task-1");
|
||||
});
|
||||
|
||||
it("states the scheduler constraint rather than leaving it to be discovered", async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import Link from "@/components/routing/app-link";
|
||||
import { PageTopbar } from "@/components/page-topbar";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorkspaceAutomationRun } from "@/lib/types/automation";
|
||||
import { buildAgenda, STATE_DOT_CLASS } from "./automation-rows";
|
||||
@@ -231,7 +232,7 @@ export function RunsListPage({ workspaceId }: { workspaceId?: string }) {
|
||||
<RecentRuns
|
||||
runs={runsState.runs}
|
||||
loading={loading}
|
||||
onOpen={(taskId) => router.push(`/tasks/${taskId}`)}
|
||||
onOpen={(taskId) => router.push(linkToTask(taskId))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("RunsPageClient feed", () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId("run-entry-run-x"));
|
||||
|
||||
expect(mocks.push).toHaveBeenCalledWith("/tasks/task-hidden");
|
||||
expect(mocks.push).toHaveBeenCalledWith("/t/task-hidden");
|
||||
});
|
||||
|
||||
it("does not navigate for a run that never produced a task", async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@kandev/ui/button";
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { PageTopbar } from "@/components/page-topbar";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import type { WorkspaceAutomationRun } from "@/lib/types/automation";
|
||||
import { RunFeedItem } from "./run-feed-item";
|
||||
import { ANY_AUTOMATION, RunFilters, isDefaultFilters } from "./run-filters";
|
||||
@@ -191,7 +192,7 @@ export function RunsPageClient({ workspaceId }: RunsPageClientProps) {
|
||||
loading={loading}
|
||||
error={error}
|
||||
onClearFilters={clearFilters}
|
||||
onOpen={(taskId) => router.push(`/tasks/${taskId}`)}
|
||||
onOpen={(taskId) => router.push(linkToTask(taskId))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import {
|
||||
canvasHref,
|
||||
getCanvas,
|
||||
@@ -372,10 +373,7 @@ async function editCanvasFromHost(options: CanvasHostEditOptions): Promise<void>
|
||||
try {
|
||||
const response = await startCanvasEdit(canvas.id);
|
||||
if (response.task_id) {
|
||||
const query = response.session_id
|
||||
? `?sessionId=${encodeURIComponent(response.session_id)}`
|
||||
: "";
|
||||
router.push(`/t/${encodeURIComponent(response.task_id)}${query}`);
|
||||
router.push(linkToTask(response.task_id, { sessionId: response.session_id ?? undefined }));
|
||||
}
|
||||
} catch (reason: unknown) {
|
||||
onError(reason);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { formatDateTime } from "@/lib/i18n/formats";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import type { KubernetesSession } from "@/lib/types/http-kubernetes";
|
||||
|
||||
const POD_STATUS_LABEL_KEYS: Record<string, string> = {
|
||||
@@ -86,7 +87,7 @@ export function translateTaskLink(session: KubernetesSession, t: TFunction): str
|
||||
}
|
||||
|
||||
export function taskHref(taskId: string): string {
|
||||
return `/t/${encodeURIComponent(taskId)}`;
|
||||
return linkToTask(taskId);
|
||||
}
|
||||
|
||||
export function shortId(value: string): string {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import TaskLink from "@/components/routing/task-link";
|
||||
import type {
|
||||
DiagnosticBundleCapabilities,
|
||||
DiagnosticBundleSource,
|
||||
@@ -477,8 +477,8 @@ function SessionRow({
|
||||
aria-label={session.session_id}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
href={linkToTask(session.task_id)}
|
||||
<TaskLink
|
||||
taskId={session.task_id}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex w-fit max-w-full items-center gap-1 truncate text-sm font-medium text-foreground underline-offset-4 hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@@ -487,7 +487,7 @@ function SessionRow({
|
||||
>
|
||||
<span className="truncate">{taskTitle}</span>
|
||||
<IconExternalLink className="size-3 shrink-0" />
|
||||
</a>
|
||||
</TaskLink>
|
||||
<label htmlFor={checkboxID} className="mt-1 block min-h-11 cursor-pointer">
|
||||
<span className="block truncate text-sm font-medium">{title}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{details}</span>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Button } from "@kandev/ui/button";
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import {
|
||||
archiveCanvas,
|
||||
canvasHref,
|
||||
@@ -101,10 +102,7 @@ function useWorkspaceCanvasState(workspaceId: string) {
|
||||
try {
|
||||
const response = await startCanvasEdit(canvas.id);
|
||||
if (response.task_id) {
|
||||
const query = response.session_id
|
||||
? `?sessionId=${encodeURIComponent(response.session_id)}`
|
||||
: "";
|
||||
router.push(`/t/${encodeURIComponent(response.task_id)}${query}`);
|
||||
router.push(linkToTask(response.task_id, { sessionId: response.session_id ?? undefined }));
|
||||
}
|
||||
} catch (reason: unknown) {
|
||||
setError(canvasErrorMessage(reason, t, "canvases:actionFailed"));
|
||||
|
||||
@@ -152,6 +152,7 @@ function MobileChatPanelContent({
|
||||
<TaskChatPanel
|
||||
sessionId={effectiveSessionId}
|
||||
taskId={effectiveSessionId ? activeTaskId : null}
|
||||
statusTaskId={activeTaskId}
|
||||
onOpenFile={onOpenFile}
|
||||
pendingScrollTarget={scrollTarget}
|
||||
isVisible={isVisible}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "@/components/routing/app-link";
|
||||
import TaskLink from "@/components/routing/task-link";
|
||||
import {
|
||||
IconCopy,
|
||||
IconPlayerPause,
|
||||
@@ -141,13 +142,13 @@ function useSimplePaneTopbar(task: Task, onToggleAdvanced?: () => void) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href={`/t/${task.id}`}
|
||||
<TaskLink
|
||||
taskId={task.id}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:underline cursor-pointer whitespace-nowrap"
|
||||
data-testid="task-cross-link"
|
||||
>
|
||||
{t("task:openInAdvancedView")}
|
||||
</Link>
|
||||
</TaskLink>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getTaskDependenciesMock = vi.hoisted(() => vi.fn());
|
||||
const touchDrawerMock = vi.hoisted(() => ({ value: false }));
|
||||
vi.mock("@/lib/api/domains/task-dependencies-api", () => ({
|
||||
getTaskDependencies: getTaskDependenciesMock,
|
||||
}));
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
vi.mock("@/hooks/use-compact-task-chrome", () => ({
|
||||
useTouchDrawer: () => touchDrawerMock.value,
|
||||
}));
|
||||
import { cleanup, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import {
|
||||
clearNavigationBlockerForTests,
|
||||
setNavigationBlocker,
|
||||
} from "@/lib/routing/navigation-guard";
|
||||
import { TaskDependencyChip } from "./task-dependency-chip";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
|
||||
beforeEach(() => {
|
||||
getTaskDependenciesMock.mockReset();
|
||||
touchDrawerMock.value = false;
|
||||
// Default: the server also reports no edges, so the fetch fallback cannot be
|
||||
// what makes a "renders nothing" assertion pass.
|
||||
getTaskDependenciesMock.mockResolvedValue({ id: "unknown", depends_on: [], blocks: [] });
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearNavigationBlockerForTests();
|
||||
});
|
||||
|
||||
type KanbanTask = KanbanState["tasks"][number];
|
||||
|
||||
@@ -38,6 +50,7 @@ function renderChip(tasks: KanbanTask[], taskId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- the suite covers both disclosure surfaces.
|
||||
describe("TaskDependencyChip", () => {
|
||||
it("renders nothing when the task has no edges in either direction", () => {
|
||||
renderChip([task({ id: "a" })], "a");
|
||||
@@ -73,6 +86,52 @@ describe("TaskDependencyChip", () => {
|
||||
expect(chip.textContent).toMatch(/1/);
|
||||
});
|
||||
|
||||
it("uses canonical guarded links for both dependency directions", () => {
|
||||
renderChip(
|
||||
[
|
||||
task({
|
||||
id: "b",
|
||||
dependsOn: [{ id: "a", title: "A", status: "pending" }],
|
||||
blocks: [{ id: "c", title: "C" }],
|
||||
}),
|
||||
],
|
||||
"b",
|
||||
);
|
||||
const chip = screen.getByTestId(CHIP_TESTID);
|
||||
fireEvent.click(chip);
|
||||
const entries = screen.getAllByTestId("task-dependency-entry");
|
||||
|
||||
expect(entries.map((entry) => entry.getAttribute("href"))).toEqual(["/t/a", "/t/c"]);
|
||||
|
||||
const click = createEvent.click(entries[0]);
|
||||
fireEvent(entries[0], click);
|
||||
expect(click.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the disclosure open until guarded navigation commits", async () => {
|
||||
let proceed: () => void = () => undefined;
|
||||
setNavigationBlocker((intent) => {
|
||||
proceed = intent.proceed;
|
||||
});
|
||||
renderChip([task({ id: "b", dependsOn: [{ id: "a", title: "A", status: "pending" }] })], "b");
|
||||
fireEvent.click(screen.getByTestId(CHIP_TESTID));
|
||||
fireEvent.click(screen.getByTestId("task-dependency-entry"));
|
||||
|
||||
expect(screen.getByTestId("task-dependency-chip-popover")).toBeTruthy();
|
||||
proceed();
|
||||
await waitFor(() => expect(screen.queryByTestId("task-dependency-chip-popover")).toBeNull());
|
||||
});
|
||||
|
||||
it("keeps dependency rows touch-sized in the drawer", () => {
|
||||
touchDrawerMock.value = true;
|
||||
renderChip([task({ id: "b", dependsOn: [{ id: "a", title: "A", status: "pending" }] })], "b");
|
||||
fireEvent.click(screen.getByTestId(CHIP_TESTID));
|
||||
|
||||
expect(screen.getByTestId("task-dependency-entry").className).toContain(
|
||||
"[@media(pointer:coarse)]:min-h-11",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders for a resolved dependency so the chain is still visible after it unblocks", () => {
|
||||
renderChip(
|
||||
[
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import { cn } from "@kandev/ui/lib/utils";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import TaskLink from "@/components/routing/task-link";
|
||||
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
|
||||
import { getTaskDependencies } from "@/lib/api/domains/task-dependencies-api";
|
||||
import type { TaskDependencyRef } from "@/lib/state/slices/kanban/types";
|
||||
@@ -153,15 +154,23 @@ function statusIcon(status?: string) {
|
||||
function DependencyRow({
|
||||
ref: entry,
|
||||
showStatus,
|
||||
touchTarget,
|
||||
onNavigated,
|
||||
}: {
|
||||
ref: TaskDependencyRef;
|
||||
showStatus: boolean;
|
||||
touchTarget: boolean;
|
||||
onNavigated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<a
|
||||
href={`/tasks/${entry.id}`}
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent min-w-0"
|
||||
<TaskLink
|
||||
taskId={entry.id}
|
||||
onNavigated={onNavigated}
|
||||
className={cn(
|
||||
"flex min-w-0 cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent",
|
||||
touchTarget && "[@media(pointer:coarse)]:min-h-11",
|
||||
)}
|
||||
data-testid="task-dependency-entry"
|
||||
>
|
||||
{showStatus ? (
|
||||
@@ -175,11 +184,19 @@ function DependencyRow({
|
||||
{t(`task:state.${entry.state}`, { defaultValue: entry.state })}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</TaskLink>
|
||||
);
|
||||
}
|
||||
|
||||
function DependencyLists({ data }: { data: DependencyChipData }) {
|
||||
function DependencyLists({
|
||||
data,
|
||||
touchTarget,
|
||||
onNavigated,
|
||||
}: {
|
||||
data: DependencyChipData;
|
||||
touchTarget: boolean;
|
||||
onNavigated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -189,7 +206,13 @@ function DependencyLists({ data }: { data: DependencyChipData }) {
|
||||
{t("task:dependencyBlockedBy")}
|
||||
</h4>
|
||||
{data.dependsOn.map((entry) => (
|
||||
<DependencyRow key={entry.id} ref={entry} showStatus />
|
||||
<DependencyRow
|
||||
key={entry.id}
|
||||
ref={entry}
|
||||
showStatus
|
||||
touchTarget={touchTarget}
|
||||
onNavigated={onNavigated}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
@@ -199,7 +222,13 @@ function DependencyLists({ data }: { data: DependencyChipData }) {
|
||||
{t("task:dependencyBlocks")}
|
||||
</h4>
|
||||
{data.blocks.map((entry) => (
|
||||
<DependencyRow key={entry.id} ref={entry} showStatus={false} />
|
||||
<DependencyRow
|
||||
key={entry.id}
|
||||
ref={entry}
|
||||
showStatus={false}
|
||||
touchTarget={touchTarget}
|
||||
onNavigated={onNavigated}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
@@ -293,7 +322,7 @@ export function TaskDependencyChip({ taskId }: { taskId: string | null }) {
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto p-2">
|
||||
<DependencyLists data={data} />
|
||||
<DependencyLists data={data} touchTarget onNavigated={() => setOpen(false)} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -313,7 +342,7 @@ export function TaskDependencyChip({ taskId }: { taskId: string | null }) {
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 p-2" data-testid="task-dependency-chip-popover">
|
||||
<DependencyLists data={data} />
|
||||
<DependencyLists data={data} touchTarget={false} onNavigated={() => setOpen(false)} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -302,7 +302,7 @@ test("connects and browses Azure work items, PRs, and feedback", async ({
|
||||
);
|
||||
await taskDialog.getByTestId("submit-start-agent-chevron").click();
|
||||
await testPage.getByTestId("submit-create-without-agent").click();
|
||||
await expect(testPage).toHaveURL(/\/tasks\//);
|
||||
await expect(testPage).toHaveURL(/\/(?:t|tasks)\//);
|
||||
await testPage.goto("/azure-devops");
|
||||
await testPage.getByTestId("azure-devops-work-items-mode").click();
|
||||
await testPage.getByTestId("azure-devops-search-button").click();
|
||||
|
||||
@@ -271,7 +271,7 @@ test("mobile board opens a focused column editor without horizontal overflow", a
|
||||
"Implement: Handle token rotation",
|
||||
);
|
||||
await testPage.getByRole("button", { name: "Create only" }).click();
|
||||
await expect(testPage).toHaveURL(/\/tasks\//);
|
||||
await expect(testPage).toHaveURL(/\/(?:t|tasks)\//);
|
||||
await testPage.goto("/azure-devops");
|
||||
await testPage.getByTestId("azure-devops-work-items-mode").click();
|
||||
await testPage.getByTestId("azure-devops-mobile-filter-button").click();
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Filename starts with "mobile-" so this runs on the mobile-chrome project.
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import {
|
||||
assertLocatorWithinViewportX,
|
||||
assertNoDocumentHorizontalOverflow,
|
||||
} from "../../helpers/layout-assertions";
|
||||
|
||||
test("mobile dependency links preserve the task workbench and touch layout", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const predecessor = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Mobile navigation predecessor",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
const target = await apiClient.createTask(seedData.workspaceId, "Mobile navigation target", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
blocked_by: [predecessor.id],
|
||||
});
|
||||
const dependent = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Mobile navigation dependent",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
blocked_by: [target.id],
|
||||
},
|
||||
);
|
||||
|
||||
await testPage.goto(`/t/${target.id}`);
|
||||
const taskTitle = testPage.getByTestId("mobile-task-picker-trigger");
|
||||
await expect(taskTitle).toContainText(target.title);
|
||||
|
||||
const documentRequests: string[] = [];
|
||||
const onRequest = (request: import("@playwright/test").Request) => {
|
||||
if (
|
||||
request.isNavigationRequest() &&
|
||||
request.frame() === testPage.mainFrame() &&
|
||||
request.resourceType() === "document"
|
||||
) {
|
||||
documentRequests.push(request.url());
|
||||
}
|
||||
};
|
||||
testPage.on("request", onRequest);
|
||||
|
||||
try {
|
||||
await testPage.evaluate(() => {
|
||||
(window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel =
|
||||
"mobile-dependency-links-stay-in-spa";
|
||||
});
|
||||
|
||||
const chip = testPage.getByTestId("task-dependency-chip");
|
||||
await chip.tap();
|
||||
const drawer = testPage.getByTestId("task-dependency-chip-drawer");
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(drawer.locator("[class*='overflow-y-auto']")).toHaveCount(1);
|
||||
await assertLocatorWithinViewportX(drawer, "dependency drawer");
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "dependency drawer");
|
||||
|
||||
const entries = drawer.getByTestId("task-dependency-entry");
|
||||
await expect(entries).toHaveCount(2);
|
||||
for (const entry of await entries.all()) {
|
||||
const box = await entry.boundingBox();
|
||||
expect(box, "dependency entry must have geometry").not.toBeNull();
|
||||
expect(box?.height ?? 0, "dependency entry touch target").toBeGreaterThanOrEqual(44);
|
||||
await assertLocatorWithinViewportX(entry, "dependency entry");
|
||||
}
|
||||
|
||||
const blockedBy = entries.filter({ hasText: predecessor.title });
|
||||
await expect(blockedBy).toHaveAttribute("href", `/t/${predecessor.id}`);
|
||||
await blockedBy.tap();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${predecessor.id}$`));
|
||||
await expect(testPage.getByTestId("mobile-task-picker-trigger")).toContainText(
|
||||
predecessor.title,
|
||||
);
|
||||
await expect(testPage.getByTestId("task-dependency-chip-drawer")).toHaveCount(0);
|
||||
expect(documentRequests).toHaveLength(0);
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() =>
|
||||
(window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel,
|
||||
),
|
||||
).toBe("mobile-dependency-links-stay-in-spa");
|
||||
|
||||
await testPage.goBack();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${target.id}$`));
|
||||
await expect(testPage.getByTestId("mobile-task-picker-trigger")).toContainText(target.title);
|
||||
expect(documentRequests).toHaveLength(0);
|
||||
|
||||
await chip.tap();
|
||||
const reopenedDrawer = testPage.getByTestId("task-dependency-chip-drawer");
|
||||
await expect(reopenedDrawer).toBeVisible();
|
||||
const blocks = reopenedDrawer
|
||||
.getByTestId("task-dependency-entry")
|
||||
.filter({ hasText: dependent.title });
|
||||
await expect(blocks).toHaveAttribute("href", `/t/${dependent.id}`);
|
||||
await blocks.tap();
|
||||
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${dependent.id}$`));
|
||||
await expect(testPage.getByTestId("mobile-task-picker-trigger")).toContainText(dependent.title);
|
||||
await expect(testPage.getByTestId("task-dependency-chip-drawer")).toHaveCount(0);
|
||||
expect(documentRequests).toHaveLength(0);
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() =>
|
||||
(window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel,
|
||||
),
|
||||
).toBe("mobile-dependency-links-stay-in-spa");
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile dependency navigation");
|
||||
} finally {
|
||||
testPage.off("request", onRequest);
|
||||
}
|
||||
});
|
||||
@@ -264,4 +264,95 @@ test.describe("Task dependencies", () => {
|
||||
await testPage.goto(`/t/${unrelated.id}`);
|
||||
await expect(testPage.getByTestId("task-dependency-chip")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("dependency links preserve the desktop task workbench", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const predecessor = await apiClient.createTask(seedData.workspaceId, "Navigation predecessor", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const target = await apiClient.createTask(seedData.workspaceId, "Navigation target", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
blocked_by: [predecessor.id],
|
||||
});
|
||||
const dependent = await apiClient.createTask(seedData.workspaceId, "Navigation dependent", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
blocked_by: [target.id],
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${target.id}`);
|
||||
const title = testPage.locator('[data-testid="task-topbar-title"]:visible');
|
||||
await expect(title).toHaveText(target.title);
|
||||
|
||||
const documentRequests: string[] = [];
|
||||
const onRequest = (request: import("@playwright/test").Request) => {
|
||||
if (
|
||||
request.isNavigationRequest() &&
|
||||
request.frame() === testPage.mainFrame() &&
|
||||
request.resourceType() === "document"
|
||||
) {
|
||||
documentRequests.push(request.url());
|
||||
}
|
||||
};
|
||||
testPage.on("request", onRequest);
|
||||
|
||||
try {
|
||||
await testPage.evaluate(() => {
|
||||
(window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel =
|
||||
"dependency-links-stay-in-spa";
|
||||
});
|
||||
|
||||
const chip = testPage.getByTestId("task-dependency-chip");
|
||||
await chip.click();
|
||||
const blockedBy = testPage
|
||||
.getByTestId("task-dependency-entry")
|
||||
.filter({ hasText: predecessor.title });
|
||||
await expect(blockedBy).toHaveAttribute("href", `/t/${predecessor.id}`);
|
||||
await blockedBy.click();
|
||||
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${predecessor.id}$`));
|
||||
await expect(title).toHaveText(predecessor.title);
|
||||
await expect(testPage.getByTestId("task-dependency-chip-popover")).toHaveCount(0);
|
||||
expect(documentRequests).toHaveLength(0);
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() =>
|
||||
(window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel,
|
||||
),
|
||||
).toBe("dependency-links-stay-in-spa");
|
||||
|
||||
await testPage.goBack();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${target.id}$`));
|
||||
await expect(title).toHaveText(target.title);
|
||||
expect(documentRequests).toHaveLength(0);
|
||||
|
||||
await chip.click();
|
||||
const blocks = testPage
|
||||
.getByTestId("task-dependency-entry")
|
||||
.filter({ hasText: dependent.title });
|
||||
await expect(blocks).toHaveAttribute("href", `/t/${dependent.id}`);
|
||||
await blocks.click();
|
||||
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${dependent.id}$`));
|
||||
await expect(title).toHaveText(dependent.title);
|
||||
await expect(testPage.getByTestId("task-dependency-chip-popover")).toHaveCount(0);
|
||||
expect(documentRequests).toHaveLength(0);
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() =>
|
||||
(window as Window & { __taskLinkDocumentSentinel?: string }).__taskLinkDocumentSentinel,
|
||||
),
|
||||
).toBe("dependency-links-stay-in-spa");
|
||||
} finally {
|
||||
testPage.off("request", onRequest);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Keep first-party task workbench navigation on the shared URL and link
|
||||
* authorities. This rule deliberately proves only the common static forms:
|
||||
* literal/template/concatenated task routes, simple local aliases, imported
|
||||
* linkToTask aliases, native task anchors, and window.location writes.
|
||||
*/
|
||||
|
||||
const TASK_ROUTE_PREFIX = /^\/(?:t|tasks)\//;
|
||||
const TASK_ROUTE_WITH_ID = /^\/(?:t|tasks)\/.+/;
|
||||
const LINKS_MODULE = /(?:^|\/)lib\/links(?:\.[cm]?[jt]s)?$/;
|
||||
|
||||
function unwrap(node) {
|
||||
if (!node) return null;
|
||||
if (
|
||||
node.type === "TSAsExpression" ||
|
||||
node.type === "TSTypeAssertion" ||
|
||||
node.type === "TSNonNullExpression" ||
|
||||
node.type === "ChainExpression"
|
||||
) {
|
||||
return unwrap(node.expression);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function lookupVariable(scope, name) {
|
||||
for (let current = scope; current; current = current.upper) {
|
||||
const found = current.variables.find((variable) => variable.name === name);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function variableForIdentifier(context, node) {
|
||||
if (node.type !== "Identifier") return null;
|
||||
return lookupVariable(context.sourceCode.getScope(node), node.name);
|
||||
}
|
||||
|
||||
function isLinksImport(def) {
|
||||
return def.type === "ImportBinding" && LINKS_MODULE.test(String(def.parent?.source?.value));
|
||||
}
|
||||
|
||||
function isLinkToTaskImport(def) {
|
||||
if (!isLinksImport(def)) return false;
|
||||
return def.node?.type === "ImportSpecifier" && def.node.imported?.name === "linkToTask";
|
||||
}
|
||||
|
||||
function isLinksNamespaceImport(def) {
|
||||
return isLinksImport(def) && def.node?.type === "ImportNamespaceSpecifier";
|
||||
}
|
||||
|
||||
function resolvesToLinkToTask(context, node, seen = new Set()) {
|
||||
const current = unwrap(node);
|
||||
if (!current) return false;
|
||||
|
||||
if (current.type === "MemberExpression") {
|
||||
const property = current.computed ? current.property : current.property;
|
||||
const propertyName = property.type === "Identifier" ? property.name : property.value;
|
||||
if (propertyName !== "linkToTask") return false;
|
||||
const namespace = current.object;
|
||||
if (namespace.type !== "Identifier") return false;
|
||||
const variable = variableForIdentifier(context, namespace);
|
||||
return Boolean(variable?.defs.some(isLinksNamespaceImport));
|
||||
}
|
||||
|
||||
if (current.type !== "Identifier") return false;
|
||||
const variable = variableForIdentifier(context, current);
|
||||
if (!variable || seen.has(variable)) return false;
|
||||
seen.add(variable);
|
||||
if (variable.defs.some(isLinkToTaskImport)) return true;
|
||||
|
||||
return variable.defs.some((def) => {
|
||||
if (def.type !== "Variable" || !def.node?.init) return false;
|
||||
return resolvesToLinkToTask(context, def.node.init, seen);
|
||||
});
|
||||
}
|
||||
|
||||
function staticStringFromVariable(context, node, seen) {
|
||||
const variable = variableForIdentifier(context, node);
|
||||
if (!variable || seen.has(variable)) return null;
|
||||
for (const def of variable.defs) {
|
||||
if (def.type !== "Variable" || !def.node?.init) continue;
|
||||
const branchSeen = new Set(seen);
|
||||
branchSeen.add(variable);
|
||||
const value = isStaticString(context, def.node.init, branchSeen);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isStaticString(context, node, seen = new Set()) {
|
||||
const current = unwrap(node);
|
||||
if (!current) return null;
|
||||
if (current.type === "Literal" && typeof current.value === "string") return current.value;
|
||||
if (current.type === "TemplateLiteral" && current.expressions.length === 0) {
|
||||
return current.quasis[0]?.value.cooked ?? "";
|
||||
}
|
||||
if (current.type === "BinaryExpression" && current.operator === "+") {
|
||||
const left = isStaticString(context, current.left, new Set(seen));
|
||||
const right = isStaticString(context, current.right, new Set(seen));
|
||||
return left !== null && right !== null ? left + right : null;
|
||||
}
|
||||
return current.type === "Identifier" ? staticStringFromVariable(context, current, seen) : null;
|
||||
}
|
||||
|
||||
function routeFromTemplate(node) {
|
||||
const first = node.quasis[0]?.value.cooked ?? "";
|
||||
if (!TASK_ROUTE_PREFIX.test(first)) return null;
|
||||
if (TASK_ROUTE_WITH_ID.test(first)) return "raw";
|
||||
return node.expressions.length > 0 ? "raw" : null;
|
||||
}
|
||||
|
||||
function routeFromBinary(context, node, seen) {
|
||||
const staticValue = isStaticString(context, node, new Set(seen));
|
||||
if (staticValue !== null) return TASK_ROUTE_WITH_ID.test(staticValue) ? "raw" : null;
|
||||
const left = isStaticString(context, node.left, new Set(seen));
|
||||
if (!left || !TASK_ROUTE_PREFIX.test(left)) return null;
|
||||
if (TASK_ROUTE_WITH_ID.test(left)) return "raw";
|
||||
// A dynamic right-hand side supplies the task segment. A static query or
|
||||
// hash after the bare prefix still has no task id and is not a detail route.
|
||||
const right = isStaticString(context, node.right, new Set(seen));
|
||||
return right === null ? "raw" : null;
|
||||
}
|
||||
|
||||
function combineRouteKinds(left, right) {
|
||||
if (left === "raw" || right === "raw") return "raw";
|
||||
if (left === "builder" || right === "builder") return "builder";
|
||||
return null;
|
||||
}
|
||||
|
||||
function routeKindFromVariable(context, node, seen) {
|
||||
const variable = variableForIdentifier(context, node);
|
||||
if (!variable || seen.has(variable)) return null;
|
||||
if (variable.defs.some(isLinkToTaskImport)) return "builder";
|
||||
for (const def of variable.defs) {
|
||||
if (def.type !== "Variable" || !def.node?.init) continue;
|
||||
const branchSeen = new Set(seen);
|
||||
branchSeen.add(variable);
|
||||
const value = routeKind(context, def.node.init, branchSeen);
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function routeKindFromComposite(context, node, seen) {
|
||||
const left = routeKind(context, node.left ?? node.consequent, new Set(seen));
|
||||
const right = routeKind(context, node.right ?? node.alternate, new Set(seen));
|
||||
return combineRouteKinds(left, right);
|
||||
}
|
||||
|
||||
function routeKind(context, node, seen = new Set()) {
|
||||
const current = unwrap(node);
|
||||
if (!current) return null;
|
||||
switch (current.type) {
|
||||
case "Literal":
|
||||
return typeof current.value === "string" && TASK_ROUTE_WITH_ID.test(current.value)
|
||||
? "raw"
|
||||
: null;
|
||||
case "TemplateLiteral":
|
||||
return routeFromTemplate(current);
|
||||
case "BinaryExpression":
|
||||
return current.operator === "+" ? routeFromBinary(context, current, seen) : null;
|
||||
case "ConditionalExpression":
|
||||
case "LogicalExpression":
|
||||
return routeKindFromComposite(context, current, seen);
|
||||
case "SequenceExpression":
|
||||
return routeKind(context, current.expressions.at(-1), seen);
|
||||
case "CallExpression":
|
||||
return resolvesToLinkToTask(context, current.callee) ? "builder" : null;
|
||||
case "Identifier":
|
||||
return routeKindFromVariable(context, current, seen);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isNativeAnchorHref(node) {
|
||||
const opening = node.parent;
|
||||
return (
|
||||
opening?.type === "JSXOpeningElement" &&
|
||||
opening.name?.type === "JSXIdentifier" &&
|
||||
opening.name.name === "a"
|
||||
);
|
||||
}
|
||||
|
||||
function hrefExpression(node) {
|
||||
if (!node) return null;
|
||||
if (node.type === "JSXExpressionContainer") return node.expression;
|
||||
return node;
|
||||
}
|
||||
|
||||
function isLocationObject(node) {
|
||||
const current = unwrap(node);
|
||||
if (!current) return false;
|
||||
if (current.type === "Identifier") return current.name === "location";
|
||||
return (
|
||||
current.type === "MemberExpression" &&
|
||||
!current.computed &&
|
||||
current.property.type === "Identifier" &&
|
||||
current.property.name === "location" &&
|
||||
current.object.type === "Identifier" &&
|
||||
(current.object.name === "window" || current.object.name === "globalThis")
|
||||
);
|
||||
}
|
||||
|
||||
function isLocationHref(node) {
|
||||
const current = unwrap(node);
|
||||
return (
|
||||
current?.type === "MemberExpression" &&
|
||||
((current.computed &&
|
||||
current.property.type === "Literal" &&
|
||||
current.property.value === "href") ||
|
||||
(!current.computed &&
|
||||
current.property.type === "Identifier" &&
|
||||
current.property.name === "href")) &&
|
||||
isLocationObject(current.object)
|
||||
);
|
||||
}
|
||||
|
||||
function isLocationMethod(node) {
|
||||
const current = unwrap(node);
|
||||
if (current?.type !== "MemberExpression" || !isLocationObject(current.object)) return false;
|
||||
const property = current.computed ? current.property : current.property;
|
||||
const name = property.type === "Identifier" ? property.name : property.value;
|
||||
return name === "assign" || name === "replace";
|
||||
}
|
||||
|
||||
function isRouteContainer(parent, node) {
|
||||
if (parent.type === "BinaryExpression" && parent.operator === "+") return true;
|
||||
if (parent.type === "ConditionalExpression") {
|
||||
return parent.consequent === node || parent.alternate === node;
|
||||
}
|
||||
if (parent.type === "LogicalExpression") return parent.right === node;
|
||||
if (parent.type === "TSAsExpression") return parent.expression === node;
|
||||
if (parent.type === "TSTypeAssertion") return parent.expression === node;
|
||||
if (parent.type === "TSNonNullExpression") return parent.expression === node;
|
||||
return parent.type === "ChainExpression" && parent.expression === node;
|
||||
}
|
||||
|
||||
function isNestedRoutePart(node, context) {
|
||||
const parent = node.parent;
|
||||
return Boolean(parent && isRouteContainer(parent, node) && routeKind(context, parent) === "raw");
|
||||
}
|
||||
|
||||
/** @type {import("eslint").Rule.RuleModule} */
|
||||
export const noTaskLinkBypass = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Require shared task URL and link primitives for task workbench navigation.",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
taskLinkBypass:
|
||||
"Use linkToTask() for task workbench URLs and TaskLink/AppLink for task anchors; do not hand-build or natively navigate task-detail links.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const reported = new WeakSet();
|
||||
const report = (node) => {
|
||||
if (!node || reported.has(node)) return;
|
||||
reported.add(node);
|
||||
context.report({ node, messageId: "taskLinkBypass" });
|
||||
};
|
||||
|
||||
return {
|
||||
JSXAttribute(node) {
|
||||
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "href") return;
|
||||
const expression = hrefExpression(node.value);
|
||||
const kind = routeKind(context, expression);
|
||||
if (!kind) return;
|
||||
if (kind === "builder" && !isNativeAnchorHref(node)) return;
|
||||
// Local raw aliases are reported at their defining expression. This
|
||||
// avoids a second diagnostic on the consuming JSX attribute.
|
||||
if (kind === "raw" && expression?.type === "Identifier") return;
|
||||
report(expression ?? node);
|
||||
},
|
||||
AssignmentExpression(node) {
|
||||
if (isLocationHref(node.left) && routeKind(context, node.right)) report(node.right);
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (isLocationMethod(node.callee) && routeKind(context, node.arguments[0])) {
|
||||
report(node.arguments[0]);
|
||||
}
|
||||
},
|
||||
Literal(node) {
|
||||
if (typeof node.value !== "string" || isNestedRoutePart(node, context)) return;
|
||||
if (node.parent?.type === "JSXAttribute") return;
|
||||
if (routeKind(context, node) === "raw") report(node);
|
||||
},
|
||||
TemplateLiteral(node) {
|
||||
if (isNestedRoutePart(node, context)) return;
|
||||
if (routeKind(context, node) === "raw") report(node);
|
||||
},
|
||||
BinaryExpression(node) {
|
||||
if (node.operator !== "+" || isNestedRoutePart(node, context)) return;
|
||||
if (routeKind(context, node) === "raw") report(node);
|
||||
},
|
||||
ConditionalExpression(node) {
|
||||
if (routeKind(context, node) === "raw") report(node);
|
||||
},
|
||||
LogicalExpression(node) {
|
||||
if (routeKind(context, node) === "raw") report(node);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const taskLinksPlugin = {
|
||||
rules: { "no-task-link-bypass": noTaskLinkBypass },
|
||||
};
|
||||
|
||||
export const TASK_LINK_RULE_ID = "task-links/no-task-link-bypass";
|
||||
@@ -0,0 +1,116 @@
|
||||
import { RuleTester } from "eslint";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { noTaskLinkBypass } from "./no-task-link-bypass.mjs";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: tseslint.parser as never,
|
||||
parserOptions: { ecmaVersion: 2022, sourceType: "module", ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
|
||||
ruleTester.run("no-task-link-bypass", noTaskLinkBypass, {
|
||||
valid: [
|
||||
{ code: 'const prefix = "/t/"; pathname.startsWith(prefix);' },
|
||||
{ code: 'for (const prefix of ["/t/", "/tasks/"]) pathname.startsWith(prefix);' },
|
||||
{ code: "const apiPath = `/api/tasks/${taskId}`;" },
|
||||
{ code: "const officePath = `/office/tasks/${taskId}`;" },
|
||||
{ code: "router.push(linkToTask(taskId));" },
|
||||
{
|
||||
code: `import { linkToTask } from "@/lib/links";
|
||||
import Link from "@/components/routing/app-link";
|
||||
<Link href={linkToTask(taskId)} />;`,
|
||||
},
|
||||
{
|
||||
code: `import { linkToTask } from "@/lib/links";
|
||||
import AppLink from "@/components/routing/app-link";
|
||||
<AppLink href={linkToTask(taskId)} />;`,
|
||||
},
|
||||
{ code: "<TaskLink taskId={taskId} />;" },
|
||||
{ code: 'const tasksPath = "/tasks";' },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'const href = "/t/task-1";',
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: "const href = `/tasks/${taskId}`;",
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: 'const href = "/t/" + taskId;',
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: 'const href = condition ? `/t/${taskId}` : "/tasks/fallback";',
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: "const href = condition && `/t/${taskId}`;",
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: '<a href="/t/task-1">Task</a>;',
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: '<Link href="/t/task-1">Task</Link>;',
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: '<AppLink href={"/tasks/task-1"}>Task</AppLink>;',
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: `import { linkToTask as taskHref } from "@/lib/links";
|
||||
<a href={taskHref(taskId)}>Task</a>;`,
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: `import { linkToTask } from "@/lib/links";
|
||||
const href = linkToTask(taskId);
|
||||
<a href={href}>Task</a>;`,
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: `import * as Links from "@/lib/links";
|
||||
<a href={Links.linkToTask(taskId)}>Task</a>;`,
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: "window.location.assign(`/tasks/${taskId}`);",
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: "window.location.href = `/t/${taskId}`;",
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: `function openTask(taskId: string) {
|
||||
const prefix = "/tasks/";
|
||||
const href = prefix + taskId;
|
||||
return <a href={href}>Task</a>;
|
||||
}`,
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
{
|
||||
code: `function openTask(taskId: string) {
|
||||
const prefix = "/tasks/";
|
||||
window.location.assign(prefix + taskId);
|
||||
}`,
|
||||
errors: [{ messageId: "taskLinkBypass" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("no-task-link-bypass rule", () => {
|
||||
it("keeps the diagnostic focused on first-party task workbench routes", () => {
|
||||
expect(noTaskLinkBypass.meta?.messages?.taskLinkBypass).toContain("TaskLink");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
e2eSleepPlugin,
|
||||
SLEEP_EXEMPT_FILES,
|
||||
} from "./eslint-rules/no-unsanctioned-sleep.mjs";
|
||||
import { taskLinksPlugin } from "./eslint-rules/no-task-link-bypass.mjs";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
{
|
||||
@@ -86,6 +87,24 @@ const eslintConfig = defineConfig([
|
||||
plugins: { i18next },
|
||||
rules: { "i18next/no-literal-string": ["error", noLiteralStringOptions] },
|
||||
},
|
||||
// First-party task workbench URLs and anchors have one URL authority and one
|
||||
// click transport. Tests and the authority module itself are excluded so the
|
||||
// rule checks callers rather than its own implementation and fixtures.
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
ignores: [
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.test-helpers.ts",
|
||||
"**/*.test-helpers.tsx",
|
||||
"**/*.test-utils.ts",
|
||||
"**/*.test-utils.tsx",
|
||||
"e2e/**",
|
||||
"lib/links.ts",
|
||||
],
|
||||
plugins: { "task-links": taskLinksPlugin },
|
||||
rules: { "task-links/no-task-link-bypass": "error" },
|
||||
},
|
||||
// E2E tests (Playwright): disable React hooks rules since Playwright's `use()` and
|
||||
// `test.extend()` patterns are falsely flagged, and relax test-specific limits.
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EntityReference } from "@/lib/types/entity-reference";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
|
||||
const IDENTITY_NAME = /^[a-z0-9][a-z0-9._:-]{0,127}$/;
|
||||
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/u;
|
||||
@@ -57,7 +58,7 @@ function hasSafeURL(reference: EntityReference): boolean {
|
||||
return false;
|
||||
}
|
||||
if (reference.provider === "kandev" && reference.kind === "task") {
|
||||
return reference.url === `/t/${encodeURIComponent(reference.id)}`;
|
||||
return reference.url === linkToTask(reference.id);
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(reference.url);
|
||||
|
||||
@@ -19,6 +19,23 @@ describe("task links", () => {
|
||||
expect(linkToTask("task-123", "plan")).toBe("/t/task-123?layout=plan");
|
||||
});
|
||||
|
||||
it("encodes the raw task id once and merges task query context", () => {
|
||||
const searchParams = new URLSearchParams([
|
||||
["layout", "old"],
|
||||
["tag", "first"],
|
||||
["tag", "second"],
|
||||
]);
|
||||
|
||||
expect(
|
||||
linkToTask("task/123%raw", {
|
||||
searchParams,
|
||||
layout: "plan",
|
||||
sessionId: "session 123",
|
||||
}),
|
||||
).toBe("/t/task%2F123%25raw?layout=plan&tag=first&tag=second&sessionId=session+123");
|
||||
expect(searchParams.toString()).toBe("layout=old&tag=first&tag=second");
|
||||
});
|
||||
|
||||
it("keeps /tasks for the task list route", () => {
|
||||
expect(linkToTasks()).toBe("/tasks");
|
||||
expect(linkToTasks("workspace-123")).toBe("/tasks?workspace=workspace-123");
|
||||
|
||||
+19
-3
@@ -1,6 +1,22 @@
|
||||
export function linkToTask(taskId: string, layout?: string): string {
|
||||
const base = `/t/${taskId}`;
|
||||
return layout ? `${base}?layout=${encodeURIComponent(layout)}` : base;
|
||||
export type TaskLinkOptions = {
|
||||
layout?: string;
|
||||
sessionId?: string;
|
||||
searchParams?: URLSearchParams;
|
||||
};
|
||||
|
||||
export function linkToTask(taskId: string, layout?: string): string;
|
||||
export function linkToTask(taskId: string, options?: TaskLinkOptions): string;
|
||||
export function linkToTask(taskId: string, layoutOrOptions?: string | TaskLinkOptions): string {
|
||||
const base = `/t/${encodeURIComponent(taskId)}`;
|
||||
const options =
|
||||
typeof layoutOrOptions === "string" ? { layout: layoutOrOptions } : (layoutOrOptions ?? {});
|
||||
const searchParams = new URLSearchParams(options.searchParams);
|
||||
|
||||
if (options.layout !== undefined) searchParams.set("layout", options.layout);
|
||||
if (options.sessionId !== undefined) searchParams.set("sessionId", options.sessionId);
|
||||
|
||||
const query = searchParams.toString();
|
||||
return query ? `${base}?${query}` : base;
|
||||
}
|
||||
|
||||
export function linkToTaskOverview({
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { ESLint } from "eslint";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const WEB_DIR = path.resolve(import.meta.dirname, "../..");
|
||||
const eslint = new ESLint({ cwd: WEB_DIR });
|
||||
|
||||
describe("task link eslint wiring", () => {
|
||||
it("guards a new production component in an arbitrary directory", async () => {
|
||||
const [result] = await eslint.lintText(
|
||||
`export function NewTaskLink({ taskId }: { taskId: string }) {
|
||||
return <a href={\`/t/\${taskId}\`} />;
|
||||
}
|
||||
`,
|
||||
{ filePath: path.join(WEB_DIR, "components/new-surface/task-link-fixture.tsx") },
|
||||
);
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
ruleId: "task-links/no-task-link-bypass",
|
||||
severity: 2,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user