feat(web): let the dependency picker find tasks by PR or MR number (#3311)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -3,11 +3,31 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import { TaskCreateDependencies } from "./task-create-dialog-dependencies";
|
||||
|
||||
const workspaceHydrationMocks = vi.hoisted(() => ({
|
||||
useWorkspacePRs: vi.fn(),
|
||||
useWorkspaceMRs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/github/use-task-pr", () => workspaceHydrationMocks);
|
||||
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => workspaceHydrationMocks);
|
||||
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
|
||||
type MockStore = {
|
||||
kanban: { tasks: KanbanState["tasks"] };
|
||||
kanbanMulti: { snapshots: Record<string, { tasks?: KanbanState["tasks"] }> };
|
||||
workspaces: { activeId: string | null };
|
||||
workspaceContextGeneration: number;
|
||||
taskMRs: { byWorkspaceId: Record<string, Record<string, TaskMR[]>> };
|
||||
taskPRs: {
|
||||
byTaskId: Record<string, TaskPR[]>;
|
||||
workspaceId?: string | null;
|
||||
workspaceContextGeneration?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const ALPHA_ID = "task-alpha";
|
||||
@@ -15,15 +35,54 @@ const BETA_ID = "task-beta";
|
||||
const ALPHA_TITLE = "Alpha task";
|
||||
const BETA_TITLE = "Beta task";
|
||||
const NO_DEPENDENCY_LABEL = "No dependency";
|
||||
const SEARCH_TASKS_LABEL = "Search tasks...";
|
||||
const SEARCH_TASKS_LABEL = "Search tasks or #PR/MR number...";
|
||||
const INFO_LABEL = "About task dependencies";
|
||||
const TWO_DEPENDENCIES_LABEL = "2 dependencies";
|
||||
const TRIGGER_TEST_ID = "task-create-dependencies-trigger";
|
||||
const POPOVER_TEST_ID = "task-create-dependencies-popover";
|
||||
|
||||
let mockStore: MockStore = {
|
||||
kanban: { tasks: [] },
|
||||
kanbanMulti: { snapshots: {} },
|
||||
};
|
||||
function emptyStore(): MockStore {
|
||||
return {
|
||||
kanban: { tasks: [] },
|
||||
kanbanMulti: { snapshots: {} },
|
||||
workspaces: { activeId: WORKSPACE_ID },
|
||||
workspaceContextGeneration: 0,
|
||||
taskMRs: { byWorkspaceId: {} },
|
||||
taskPRs: {
|
||||
byTaskId: {},
|
||||
workspaceId: WORKSPACE_ID,
|
||||
workspaceContextGeneration: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function taskPR(overrides: Partial<TaskPR> = {}): TaskPR {
|
||||
return {
|
||||
id: "pr-assoc-1",
|
||||
workspace_id: WORKSPACE_ID,
|
||||
task_id: ALPHA_ID,
|
||||
owner: "kdlbs",
|
||||
repo: "kandev",
|
||||
pr_number: 3295,
|
||||
pr_url: "https://github.com/kdlbs/kandev/pull/3295",
|
||||
pr_title: "fix flaky test",
|
||||
head_branch: "fix",
|
||||
base_branch: "main",
|
||||
author_login: "nova28",
|
||||
state: "merged",
|
||||
review_state: "",
|
||||
checks_state: "",
|
||||
mergeable_state: "mergeable",
|
||||
review_count: 0,
|
||||
pending_review_count: 0,
|
||||
comment_count: 0,
|
||||
unresolved_review_threads: 0,
|
||||
checks_total: 0,
|
||||
...overrides,
|
||||
} as TaskPR;
|
||||
}
|
||||
|
||||
let mockStore: MockStore = emptyStore();
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: MockStore) => unknown) => selector(mockStore),
|
||||
@@ -58,15 +117,24 @@ function ControlledDependencies({ initialValue = [] }: { initialValue?: string[]
|
||||
return <TaskCreateDependencies value={value} onChange={setValue} />;
|
||||
}
|
||||
|
||||
function optionTestId(taskId: string): string {
|
||||
return `task-create-dependency-option-${taskId}`;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockStore = {
|
||||
kanban: { tasks: [] },
|
||||
kanbanMulti: { snapshots: {} },
|
||||
};
|
||||
vi.clearAllMocks();
|
||||
mockStore = emptyStore();
|
||||
});
|
||||
|
||||
describe("TaskCreateDependencies", () => {
|
||||
it("hydrates change-request associations for the active workspace", () => {
|
||||
renderDependencies();
|
||||
|
||||
expect(workspaceHydrationMocks.useWorkspacePRs).toHaveBeenCalledWith(WORKSPACE_ID);
|
||||
expect(workspaceHydrationMocks.useWorkspaceMRs).toHaveBeenCalledWith(WORKSPACE_ID);
|
||||
});
|
||||
|
||||
it("starts with a no-dependency selector and its dependency icon", () => {
|
||||
renderDependencies();
|
||||
|
||||
@@ -77,6 +145,7 @@ describe("TaskCreateDependencies", () => {
|
||||
|
||||
it("shows searchable non-archived tasks with task icons and teaching help", async () => {
|
||||
mockStore = {
|
||||
...emptyStore(),
|
||||
kanban: {
|
||||
tasks: [
|
||||
task(ALPHA_ID, ALPHA_TITLE),
|
||||
@@ -93,14 +162,14 @@ describe("TaskCreateDependencies", () => {
|
||||
renderDependencies();
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
|
||||
|
||||
const picker = screen.getByTestId("task-create-dependencies-popover");
|
||||
const picker = screen.getByTestId(POPOVER_TEST_ID);
|
||||
expect(within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL)).toBeTruthy();
|
||||
expect(within(picker).getByTestId("task-create-dependency-info")).toBeTruthy();
|
||||
expect(within(picker).getByTestId(`task-create-dependency-option-${ALPHA_ID}`)).toBeTruthy();
|
||||
expect(within(picker).getByTestId(`task-create-dependency-option-${BETA_ID}`)).toBeTruthy();
|
||||
expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy();
|
||||
expect(within(picker).getByTestId(optionTestId(BETA_ID))).toBeTruthy();
|
||||
expect(within(picker).queryByText("Archived task")).toBeNull();
|
||||
expect(
|
||||
within(within(picker).getByTestId(`task-create-dependency-option-${ALPHA_ID}`)).getByTestId(
|
||||
within(within(picker).getByTestId(optionTestId(ALPHA_ID))).getByTestId(
|
||||
"task-create-dependency-task-icon",
|
||||
),
|
||||
).toBeTruthy();
|
||||
@@ -121,10 +190,10 @@ describe("TaskCreateDependencies", () => {
|
||||
|
||||
it("toggles multiple predecessors and exposes a localized count", () => {
|
||||
mockStore = {
|
||||
...emptyStore(),
|
||||
kanban: {
|
||||
tasks: [task(ALPHA_ID, ALPHA_TITLE), task(BETA_ID, BETA_TITLE)],
|
||||
},
|
||||
kanbanMulti: { snapshots: {} },
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -134,22 +203,22 @@ describe("TaskCreateDependencies", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("task-create-dependencies-trigger"));
|
||||
|
||||
fireEvent.click(screen.getByTestId(`task-create-dependency-option-${ALPHA_ID}`));
|
||||
fireEvent.click(screen.getByTestId(optionTestId(ALPHA_ID)));
|
||||
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(ALPHA_TITLE);
|
||||
|
||||
fireEvent.click(screen.getByTestId(`task-create-dependency-option-${BETA_ID}`));
|
||||
fireEvent.click(screen.getByTestId(optionTestId(BETA_ID)));
|
||||
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(TWO_DEPENDENCIES_LABEL);
|
||||
|
||||
fireEvent.click(screen.getByTestId(`task-create-dependency-option-${ALPHA_ID}`));
|
||||
fireEvent.click(screen.getByTestId(optionTestId(ALPHA_ID)));
|
||||
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(BETA_TITLE);
|
||||
});
|
||||
|
||||
it("clears all selected predecessors from the no-dependency entry", () => {
|
||||
mockStore = {
|
||||
...emptyStore(),
|
||||
kanban: {
|
||||
tasks: [task(ALPHA_ID, ALPHA_TITLE), task(BETA_ID, BETA_TITLE)],
|
||||
},
|
||||
kanbanMulti: { snapshots: {} },
|
||||
};
|
||||
|
||||
render(
|
||||
@@ -164,3 +233,125 @@ describe("TaskCreateDependencies", () => {
|
||||
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(NO_DEPENDENCY_LABEL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCreateDependencies change-request search", () => {
|
||||
it("narrows to the PR-linked task when searching by PR number and shows its badge", () => {
|
||||
const prTask = task(ALPHA_ID, ALPHA_TITLE, {
|
||||
statusSummary: { pull_request: { number: 3295, state: "merged" } } as never,
|
||||
});
|
||||
mockStore = {
|
||||
...emptyStore(),
|
||||
kanban: {
|
||||
tasks: [prTask, task(BETA_ID, BETA_TITLE)],
|
||||
},
|
||||
};
|
||||
|
||||
renderDependencies();
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
|
||||
|
||||
const picker = screen.getByTestId(POPOVER_TEST_ID);
|
||||
const alphaOption = within(picker).getByTestId(optionTestId(ALPHA_ID));
|
||||
expect(within(alphaOption).getByText("#3295")).toBeTruthy();
|
||||
|
||||
const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL);
|
||||
fireEvent.change(search, { target: { value: "3295" } });
|
||||
expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy();
|
||||
expect(within(picker).queryByTestId(optionTestId(BETA_ID))).toBeNull();
|
||||
});
|
||||
|
||||
it("narrows to the MR-linked task when searching with a leading #", () => {
|
||||
const mrTask = task(ALPHA_ID, ALPHA_TITLE);
|
||||
mockStore = {
|
||||
...emptyStore(),
|
||||
kanban: { tasks: [mrTask, task(BETA_ID, BETA_TITLE)] },
|
||||
taskMRs: {
|
||||
byWorkspaceId: {
|
||||
[WORKSPACE_ID]: {
|
||||
[ALPHA_ID]: [
|
||||
{
|
||||
id: "mr-1",
|
||||
task_id: ALPHA_ID,
|
||||
host: "gitlab.com",
|
||||
project_path: "kdlbs/kandev",
|
||||
mr_iid: 42,
|
||||
mr_url: "https://gitlab.com/kdlbs/kandev/-/merge_requests/42",
|
||||
mr_title: "Fix",
|
||||
head_branch: "fix",
|
||||
base_branch: "main",
|
||||
author_username: "nova28",
|
||||
state: "open",
|
||||
} as TaskMR,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderDependencies();
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
|
||||
|
||||
const picker = screen.getByTestId(POPOVER_TEST_ID);
|
||||
const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL);
|
||||
fireEvent.change(search, { target: { value: "#42" } });
|
||||
expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy();
|
||||
expect(within(picker).queryByTestId(optionTestId(BETA_ID))).toBeNull();
|
||||
});
|
||||
|
||||
it("lists unfiltered candidates most-recently-updated first", () => {
|
||||
mockStore = {
|
||||
...emptyStore(),
|
||||
kanban: {
|
||||
tasks: [
|
||||
task(ALPHA_ID, ALPHA_TITLE, { updatedAt: "2026-01-01T00:00:00Z" }),
|
||||
task(BETA_ID, BETA_TITLE, { updatedAt: "2026-06-01T00:00:00Z" }),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
renderDependencies();
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
|
||||
|
||||
const picker = screen.getByTestId(POPOVER_TEST_ID);
|
||||
const options = within(picker)
|
||||
.getAllByTestId(/task-create-dependency-option-/)
|
||||
.map((option) => option.getAttribute("data-testid"));
|
||||
expect(options).toEqual([optionTestId(BETA_ID), optionTestId(ALPHA_ID)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCreateDependencies multi-PR change requests", () => {
|
||||
it("shows a badge for every GitHub PR linked to a multi-repo task and finds it by any number", () => {
|
||||
const prTask = task(ALPHA_ID, ALPHA_TITLE, {
|
||||
statusSummary: { pull_request: { number: 1001, state: "open" } } as never,
|
||||
});
|
||||
mockStore = {
|
||||
...emptyStore(),
|
||||
kanban: {
|
||||
tasks: [prTask, task(BETA_ID, BETA_TITLE)],
|
||||
},
|
||||
taskPRs: {
|
||||
byTaskId: {
|
||||
[ALPHA_ID]: [
|
||||
taskPR({ id: "pr-assoc-1", pr_number: 1001, repo: "kandev" }),
|
||||
taskPR({ id: "pr-assoc-2", pr_number: 1002, repo: "kandev-docs" }),
|
||||
],
|
||||
},
|
||||
workspaceId: WORKSPACE_ID,
|
||||
workspaceContextGeneration: 0,
|
||||
},
|
||||
};
|
||||
|
||||
renderDependencies();
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
|
||||
|
||||
const picker = screen.getByTestId(POPOVER_TEST_ID);
|
||||
const alphaOption = within(picker).getByTestId(optionTestId(ALPHA_ID));
|
||||
expect(within(alphaOption).getByText("#1001")).toBeTruthy();
|
||||
expect(within(alphaOption).getByText("#1002")).toBeTruthy();
|
||||
|
||||
const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL);
|
||||
fireEvent.change(search, { target: { value: "1002" } });
|
||||
expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy();
|
||||
expect(within(picker).queryByTestId(optionTestId(BETA_ID))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,15 +21,28 @@ import {
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
|
||||
import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr";
|
||||
import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
changeRequestNumbers,
|
||||
compareDependencyCandidates,
|
||||
dependencyOptionValue,
|
||||
} from "./task-create-dialog-dependency-search";
|
||||
|
||||
// Keep stable empty fallbacks here. Creating a fresh array/object in a store
|
||||
// selector makes Object.is report a change on every read and can cause a
|
||||
// render loop, which previously blanked the dependency chip.
|
||||
const NO_TASKS: KanbanState["tasks"] = [];
|
||||
const NO_SNAPSHOTS: Record<string, { tasks?: KanbanState["tasks"] }> = {};
|
||||
const NO_MRS: TaskMR[] = [];
|
||||
const NO_MRS_BY_TASK_ID: Record<string, TaskMR[]> = {};
|
||||
const NO_PRS: TaskPR[] = [];
|
||||
const NO_PRS_BY_TASK_ID: Record<string, TaskPR[]> = {};
|
||||
|
||||
export type TaskCreateDependenciesProps = {
|
||||
/** Selected predecessor task IDs. */
|
||||
@@ -56,32 +69,60 @@ function useBoardTasks(): KanbanState["tasks"] {
|
||||
}, [boardTasks, snapshots]);
|
||||
}
|
||||
|
||||
function useMRsByTaskId(): Record<string, TaskMR[]> {
|
||||
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||
const byWorkspaceId = useAppStore((state) => state.taskMRs.byWorkspaceId);
|
||||
return (activeWorkspaceId && byWorkspaceId[activeWorkspaceId]) || NO_MRS_BY_TASK_ID;
|
||||
}
|
||||
|
||||
function usePRsByTaskId(): Record<string, TaskPR[]> {
|
||||
return useAppStore((state) => {
|
||||
const activeWorkspaceId = state.workspaces.activeId;
|
||||
if (
|
||||
!activeWorkspaceId ||
|
||||
state.taskPRs.workspaceId !== activeWorkspaceId ||
|
||||
state.taskPRs.workspaceContextGeneration !== state.workspaceContextGeneration
|
||||
) {
|
||||
return NO_PRS_BY_TASK_ID;
|
||||
}
|
||||
return state.taskPRs.byTaskId;
|
||||
});
|
||||
}
|
||||
|
||||
function DependencyTriggerLabel({
|
||||
value,
|
||||
selectedTitle,
|
||||
selectedNumber,
|
||||
t,
|
||||
}: {
|
||||
value: string[];
|
||||
selectedTitle?: string;
|
||||
t: (key: string, options?: { count?: number }) => string;
|
||||
selectedNumber?: number;
|
||||
t: (key: string, options?: { count?: number; number?: number }) => string;
|
||||
}) {
|
||||
if (value.length === 0) return t("task:noDependency");
|
||||
if (value.length === 1 && selectedTitle) return selectedTitle;
|
||||
if (value.length === 1 && selectedTitle) {
|
||||
if (selectedNumber === undefined) return selectedTitle;
|
||||
return `${t("task:dependencyChangeRequestLabel", { number: selectedNumber })} · ${selectedTitle}`;
|
||||
}
|
||||
return t("task:dependencyCount", { count: value.length });
|
||||
}
|
||||
|
||||
function DependencyOption({
|
||||
task,
|
||||
numbers,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
task: KanbanState["tasks"][number];
|
||||
numbers: number[];
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<CommandItem
|
||||
value={`${task.title} ${task.id}`}
|
||||
value={dependencyOptionValue(task, numbers)}
|
||||
onSelect={onSelect}
|
||||
className="min-h-12 cursor-pointer gap-2 md:min-h-11"
|
||||
data-testid={`task-create-dependency-option-${task.id}`}
|
||||
@@ -92,7 +133,16 @@ function DependencyOption({
|
||||
data-testid="task-create-dependency-task-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{task.title}</span>
|
||||
<span className="min-w-0 flex-1 line-clamp-2">{task.title}</span>
|
||||
{numbers.map((number) => (
|
||||
<span
|
||||
key={number}
|
||||
className="shrink-0 text-xs text-muted-foreground"
|
||||
aria-label={t("task:dependencyChangeRequestAria", { number })}
|
||||
>
|
||||
{t("task:dependencyChangeRequestLabel", { number })}
|
||||
</span>
|
||||
))}
|
||||
<IconCheck
|
||||
className={cn("h-4 w-4 shrink-0", selected ? "opacity-100" : "opacity-0")}
|
||||
aria-hidden="true"
|
||||
@@ -103,6 +153,8 @@ function DependencyOption({
|
||||
|
||||
type DependencyPickerContentProps = {
|
||||
candidates: KanbanState["tasks"];
|
||||
mrsByTaskId: Record<string, TaskMR[]>;
|
||||
prsByTaskId: Record<string, TaskPR[]>;
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
@@ -111,6 +163,8 @@ type DependencyPickerContentProps = {
|
||||
|
||||
function DependencyPickerContent({
|
||||
candidates,
|
||||
mrsByTaskId,
|
||||
prsByTaskId,
|
||||
value,
|
||||
onChange,
|
||||
setOpen,
|
||||
@@ -120,7 +174,7 @@ function DependencyPickerContent({
|
||||
return (
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] min-w-[min(320px,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] p-0"
|
||||
className="w-[var(--radix-popover-trigger-width)] min-w-[min(420px,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] p-0"
|
||||
data-testid="task-create-dependencies-popover"
|
||||
portalContainer={portalContainer}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
@@ -128,7 +182,7 @@ function DependencyPickerContent({
|
||||
<Command>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CommandInput placeholder={t("task:searchTasks")} />
|
||||
<CommandInput placeholder={t("task:searchTasksOrChangeRequest")} />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -173,6 +227,11 @@ function DependencyPickerContent({
|
||||
<DependencyOption
|
||||
key={task.id}
|
||||
task={task}
|
||||
numbers={changeRequestNumbers(
|
||||
task,
|
||||
mrsByTaskId[task.id] ?? NO_MRS,
|
||||
prsByTaskId[task.id] ?? NO_PRS,
|
||||
)}
|
||||
selected={value.includes(task.id)}
|
||||
onSelect={() => {
|
||||
onChange(
|
||||
@@ -194,17 +253,39 @@ export function TaskCreateDependencies({ value, onChange, disabled }: TaskCreate
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const portalContainer = useTaskCreateDialogPopoverContainer();
|
||||
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||
// The global new-task dialog is available outside the Kanban and Tasks
|
||||
// routes, which normally hydrate these association stores.
|
||||
useWorkspacePRs(activeWorkspaceId);
|
||||
useWorkspaceMRs(activeWorkspaceId);
|
||||
const tasks = useBoardTasks();
|
||||
const mrsByTaskId = useMRsByTaskId();
|
||||
const prsByTaskId = usePRsByTaskId();
|
||||
|
||||
const selectedTitle = useMemo(() => {
|
||||
const selectedTask = useMemo(() => {
|
||||
if (value.length !== 1) return undefined;
|
||||
return tasks.find((task) => task.id === value[0])?.title;
|
||||
return tasks.find((task) => task.id === value[0]);
|
||||
}, [tasks, value]);
|
||||
const selectedNumber = useMemo(() => {
|
||||
if (!selectedTask) return undefined;
|
||||
return changeRequestNumbers(
|
||||
selectedTask,
|
||||
mrsByTaskId[selectedTask.id] ?? NO_MRS,
|
||||
prsByTaskId[selectedTask.id] ?? NO_PRS,
|
||||
)[0];
|
||||
}, [selectedTask, mrsByTaskId, prsByTaskId]);
|
||||
const candidates = useMemo(
|
||||
() => tasks.filter((task) => !task.isArchived).sort((a, b) => a.title.localeCompare(b.title)),
|
||||
() => tasks.filter((task) => !task.isArchived).sort(compareDependencyCandidates),
|
||||
[tasks],
|
||||
);
|
||||
const triggerLabel = <DependencyTriggerLabel value={value} selectedTitle={selectedTitle} t={t} />;
|
||||
const triggerLabel = (
|
||||
<DependencyTriggerLabel
|
||||
value={value}
|
||||
selectedTitle={selectedTask?.title}
|
||||
selectedNumber={selectedNumber}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-w-0" data-testid="task-create-dependencies">
|
||||
@@ -239,6 +320,8 @@ export function TaskCreateDependencies({ value, onChange, disabled }: TaskCreate
|
||||
</PopoverTrigger>
|
||||
<DependencyPickerContent
|
||||
candidates={candidates}
|
||||
mrsByTaskId={mrsByTaskId}
|
||||
prsByTaskId={prsByTaskId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
setOpen={setOpen}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import {
|
||||
changeRequestNumbers,
|
||||
compareDependencyCandidates,
|
||||
dependencyOptionValue,
|
||||
} from "./task-create-dialog-dependency-search";
|
||||
|
||||
type Task = KanbanState["tasks"][number];
|
||||
|
||||
function task(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "task-1",
|
||||
workflowId: "workflow-1",
|
||||
workflowStepId: "step-1",
|
||||
title: "Task title",
|
||||
position: 0,
|
||||
isArchived: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mr(mrIid: number, overrides: Partial<TaskMR> = {}): TaskMR {
|
||||
return {
|
||||
task_id: "task-1",
|
||||
mr_iid: mrIid,
|
||||
...overrides,
|
||||
} as TaskMR;
|
||||
}
|
||||
|
||||
function pr(prNumber: number, overrides: Partial<TaskPR> = {}): TaskPR {
|
||||
return {
|
||||
id: `pr-assoc-${prNumber}`,
|
||||
task_id: "task-1",
|
||||
pr_number: prNumber,
|
||||
...overrides,
|
||||
} as TaskPR;
|
||||
}
|
||||
|
||||
describe("changeRequestNumbers", () => {
|
||||
it("returns the GitHub PR number from statusSummary", () => {
|
||||
const t = task({ statusSummary: { pull_request: { number: 3295, state: "open" } } as never });
|
||||
expect(changeRequestNumbers(t, [])).toEqual([3295]);
|
||||
});
|
||||
|
||||
it("returns GitLab MR numbers", () => {
|
||||
const t = task();
|
||||
expect(changeRequestNumbers(t, [mr(12), mr(7)])).toEqual([7, 12]);
|
||||
});
|
||||
|
||||
it("merges and de-duplicates GitHub and GitLab numbers, ascending", () => {
|
||||
const t = task({ statusSummary: { pull_request: { number: 12, state: "open" } } as never });
|
||||
expect(changeRequestNumbers(t, [mr(12), mr(7)])).toEqual([7, 12]);
|
||||
});
|
||||
|
||||
it("returns an empty array when neither source has a number", () => {
|
||||
const t = task();
|
||||
expect(changeRequestNumbers(t, [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("includes every linked GitHub PR number for a multi-repo task, de-duplicated with statusSummary", () => {
|
||||
const t = task({ statusSummary: { pull_request: { number: 1001, state: "open" } } as never });
|
||||
expect(changeRequestNumbers(t, [], [pr(1001), pr(1002)])).toEqual([1001, 1002]);
|
||||
});
|
||||
|
||||
it("merges GitHub PR, GitLab MR, and statusSummary numbers, ascending", () => {
|
||||
const t = task({ statusSummary: { pull_request: { number: 12, state: "open" } } as never });
|
||||
expect(changeRequestNumbers(t, [mr(7)], [pr(20)])).toEqual([7, 12, 20]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dependencyOptionValue", () => {
|
||||
it("includes the title, id, and both '#N' and bare 'N' forms for each number", () => {
|
||||
const t = task({ id: "task-1", title: "Fix login" });
|
||||
const value = dependencyOptionValue(t, [3295]);
|
||||
expect(value).toContain("Fix login");
|
||||
expect(value).toContain("task-1");
|
||||
expect(value).toContain("#3295");
|
||||
expect(value).toContain("3295");
|
||||
});
|
||||
|
||||
it("has no change-request tokens when there are no numbers", () => {
|
||||
const t = task({ id: "task-1", title: "Fix login" });
|
||||
expect(dependencyOptionValue(t, [])).toBe("Fix login task-1");
|
||||
});
|
||||
});
|
||||
|
||||
const EARLY = "2026-01-01T00:00:00Z";
|
||||
const LATE = "2026-02-01T00:00:00Z";
|
||||
|
||||
describe("compareDependencyCandidates", () => {
|
||||
it("orders by updatedAt descending", () => {
|
||||
const a = task({ id: "a", updatedAt: EARLY });
|
||||
const b = task({ id: "b", updatedAt: LATE });
|
||||
expect(compareDependencyCandidates(a, b)).toBeGreaterThan(0);
|
||||
expect(compareDependencyCandidates(b, a)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("uses createdAt to break equal updatedAt timestamps", () => {
|
||||
const a = task({ id: "a", updatedAt: EARLY, createdAt: LATE });
|
||||
const b = task({ id: "b", updatedAt: EARLY, createdAt: EARLY });
|
||||
expect(compareDependencyCandidates(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("falls back to createdAt descending when updatedAt is missing", () => {
|
||||
const a = task({ id: "a", createdAt: EARLY });
|
||||
const b = task({ id: "b", createdAt: LATE });
|
||||
expect(compareDependencyCandidates(a, b)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to title when both timestamps are missing", () => {
|
||||
const a = task({ id: "a", title: "Bravo" });
|
||||
const b = task({ id: "b", title: "Alpha" });
|
||||
expect(compareDependencyCandidates(a, b)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("treats a task with a timestamp as more recent than one without", () => {
|
||||
const a = task({ id: "a", updatedAt: EARLY });
|
||||
const b = task({ id: "b" });
|
||||
expect(compareDependencyCandidates(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("falls back to createdAt when updatedAt is malformed", () => {
|
||||
const a = task({ id: "a", updatedAt: "not-a-date", createdAt: EARLY });
|
||||
const b = task({ id: "b", updatedAt: LATE });
|
||||
expect(compareDependencyCandidates(a, b)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to title when both timestamps are malformed", () => {
|
||||
const a = task({ id: "a", title: "Bravo", updatedAt: "not-a-date" });
|
||||
const b = task({ id: "b", title: "Alpha", createdAt: "also-not-a-date" });
|
||||
expect(compareDependencyCandidates(a, b)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import { taskPRInfoFromSummary } from "@/components/task/task-pr-info";
|
||||
|
||||
type Task = KanbanState["tasks"][number];
|
||||
|
||||
/** GitHub PR + GitLab MR numbers linked to a task, de-duplicated and ascending. */
|
||||
export function changeRequestNumbers(
|
||||
task: Task,
|
||||
mrsForTask: TaskMR[],
|
||||
prsForTask: TaskPR[] = [],
|
||||
): number[] {
|
||||
const numbers = new Set<number>();
|
||||
const prNumber = taskPRInfoFromSummary(task.statusSummary)?.number;
|
||||
if (prNumber) numbers.add(prNumber);
|
||||
for (const pr of prsForTask) numbers.add(pr.pr_number);
|
||||
for (const mr of mrsForTask) numbers.add(mr.mr_iid);
|
||||
return [...numbers].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** cmdk search key: title, id, and both '#N' and bare 'N' for each change-request number. */
|
||||
export function dependencyOptionValue(task: Task, numbers: number[]): string {
|
||||
const parts = [task.title, task.id, ...numbers.map((n) => `#${n} ${n}`)];
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function timestampMs(task: Task): number | undefined {
|
||||
const updated = task.updatedAt ? Date.parse(task.updatedAt) : NaN;
|
||||
if (Number.isFinite(updated)) return updated;
|
||||
return createdAtMs(task);
|
||||
}
|
||||
|
||||
function createdAtMs(task: Task): number | undefined {
|
||||
const created = task.createdAt ? Date.parse(task.createdAt) : NaN;
|
||||
return Number.isFinite(created) ? created : undefined;
|
||||
}
|
||||
|
||||
/** Most-recently-updated first, using createdAt and then title as tie-breakers. */
|
||||
export function compareDependencyCandidates(a: Task, b: Task): number {
|
||||
const aTime = timestampMs(a);
|
||||
const bTime = timestampMs(b);
|
||||
if (aTime !== undefined && bTime !== undefined && aTime !== bTime) return bTime - aTime;
|
||||
if (aTime !== undefined && bTime === undefined) return -1;
|
||||
if (aTime === undefined && bTime !== undefined) return 1;
|
||||
const aCreated = createdAtMs(a);
|
||||
const bCreated = createdAtMs(b);
|
||||
if (aCreated !== undefined && bCreated !== undefined && aCreated !== bCreated) {
|
||||
return bCreated - aCreated;
|
||||
}
|
||||
return a.title.localeCompare(b.title);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { GITLAB_HOST, GITLAB_PROJECT, gitLabMR } from "../../helpers/gitlab";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
@@ -6,6 +8,7 @@ useRegularMode();
|
||||
|
||||
const DEPENDENCY_TRIGGER = "task-create-dependencies-trigger";
|
||||
const DEPENDENCY_OPTION_PREFIX = "task-create-dependency-option-";
|
||||
const SEARCH_PLACEHOLDER = "Search tasks or #PR/MR number...";
|
||||
|
||||
function taskIdFromUrl(url: string): string {
|
||||
const match = url.match(/\/t\/([^/?]+)/);
|
||||
@@ -13,6 +16,16 @@ function taskIdFromUrl(url: string): string {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function openDependencyPicker(
|
||||
dialog: Locator,
|
||||
): Promise<{ dependency: Locator; picker: Locator }> {
|
||||
const dependency = dialog.getByTestId(DEPENDENCY_TRIGGER);
|
||||
await dependency.click();
|
||||
const picker = dialog.getByTestId("task-create-dependencies-popover");
|
||||
await expect(picker).toBeVisible();
|
||||
return { dependency, picker };
|
||||
}
|
||||
|
||||
test.describe("Task-create dependency selector", () => {
|
||||
test("selects, clears, and persists multiple predecessor tasks", async ({
|
||||
testPage,
|
||||
@@ -114,7 +127,7 @@ test.describe("Task-create dependency selector", () => {
|
||||
"This task waits until every selected task completes successfully.",
|
||||
);
|
||||
|
||||
const search = picker.getByPlaceholder("Search tasks...");
|
||||
const search = picker.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
await search.fill("Dependency Alpha");
|
||||
const alphaOption = picker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${alpha.id}`);
|
||||
await expect(alphaOption).toBeVisible();
|
||||
@@ -160,4 +173,112 @@ test.describe("Task-create dependency selector", () => {
|
||||
await apiClient.deleteWorkflow(extraWorkflow.id).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("finds a candidate by its linked GitHub PR number and shows the badge", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const taskOptions = {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
};
|
||||
const withPR = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Task with a linked PR",
|
||||
taskOptions,
|
||||
);
|
||||
await apiClient.createTask(seedData.workspaceId, "Task without a linked PR", taskOptions);
|
||||
await apiClient.mockGitHubAssociateTaskPR({
|
||||
workspace_id: seedData.workspaceId,
|
||||
task_id: withPR.id,
|
||||
owner: "kandev-e2e",
|
||||
repo: "dependency-fixtures",
|
||||
pr_number: 188,
|
||||
pr_url: "https://github.test/kandev-e2e/dependency-fixtures/pull/188",
|
||||
pr_title: "Dependency fixture PR",
|
||||
head_branch: "feature/dependency-fixture",
|
||||
base_branch: "main",
|
||||
author_login: "e2e",
|
||||
state: "open",
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByTestId("task-create-advanced-settings-trigger").click();
|
||||
const { dependency, picker } = await openDependencyPicker(dialog);
|
||||
|
||||
const option = picker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${withPR.id}`);
|
||||
const search = picker.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
|
||||
// Search by bare number.
|
||||
await search.fill("188");
|
||||
await expect(option).toBeVisible({ timeout: 15_000 });
|
||||
await expect(option.locator('[aria-label="Linked pull or merge request #188"]')).toHaveText(
|
||||
"#188",
|
||||
);
|
||||
|
||||
// Search with a leading '#' matches the same candidate.
|
||||
await search.fill("#188");
|
||||
await expect(option).toBeVisible();
|
||||
|
||||
await option.click();
|
||||
await expect(dependency).toContainText("#188 · Task with a linked PR");
|
||||
});
|
||||
|
||||
test("finds a candidate by its linked GitLab MR number and shows the badge", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await apiClient.configureGitLab(seedData.workspaceId, GITLAB_HOST);
|
||||
const iid = 4321;
|
||||
await apiClient.mockGitLabAddMRs(seedData.workspaceId, GITLAB_PROJECT, [
|
||||
gitLabMR(iid, "Dependency fixture MR"),
|
||||
]);
|
||||
await apiClient.updateRepository(seedData.repositoryId, {
|
||||
provider: "gitlab",
|
||||
provider_host: GITLAB_HOST,
|
||||
provider_owner: "platform",
|
||||
provider_name: "kandev",
|
||||
});
|
||||
|
||||
const taskOptions = {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
};
|
||||
const withMR = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Task with a linked MR",
|
||||
taskOptions,
|
||||
);
|
||||
await apiClient.linkTaskGitLabMR(seedData.workspaceId, {
|
||||
task_id: withMR.id,
|
||||
repository_id: seedData.repositoryId,
|
||||
mr_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByTestId("task-create-advanced-settings-trigger").click();
|
||||
const { dependency, picker } = await openDependencyPicker(dialog);
|
||||
|
||||
const option = picker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${withMR.id}`);
|
||||
const search = picker.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
await search.fill(String(iid));
|
||||
await expect(option).toBeVisible({ timeout: 15_000 });
|
||||
await expect(option.locator(`[aria-label="Linked pull or merge request #${iid}"]`)).toHaveText(
|
||||
`#${iid}`,
|
||||
);
|
||||
await option.click();
|
||||
await expect(dependency).toContainText(`#${iid} · Task with a linked MR`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,19 @@ test.describe("Create task dependency selector on mobile", () => {
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await apiClient.mockGitHubAssociateTaskPR({
|
||||
workspace_id: seedData.workspaceId,
|
||||
task_id: predecessor.id,
|
||||
owner: "kandev-e2e",
|
||||
repo: "dependency-fixtures",
|
||||
pr_number: 188,
|
||||
pr_url: "https://github.test/kandev-e2e/dependency-fixtures/pull/188",
|
||||
pr_title: "Dependency fixture PR",
|
||||
head_branch: "feature/dependency-fixture",
|
||||
base_branch: "main",
|
||||
author_login: "e2e",
|
||||
state: "open",
|
||||
});
|
||||
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
@@ -88,8 +101,14 @@ test.describe("Create task dependency selector on mobile", () => {
|
||||
const option = picker.getByTestId(`task-create-dependency-option-${predecessor.id}`);
|
||||
await expect(option).toBeVisible();
|
||||
await expect(option.getByTestId("task-create-dependency-task-icon")).toBeVisible();
|
||||
const search = picker.getByPlaceholder("Search tasks or #PR/MR number...");
|
||||
await search.fill("188");
|
||||
await expect(option).toBeVisible();
|
||||
await expect(option.locator('[aria-label="Linked pull or merge request #188"]')).toHaveText(
|
||||
"#188",
|
||||
);
|
||||
await option.tap();
|
||||
await expect(trigger).toContainText("Mobile dependency predecessor");
|
||||
await expect(trigger).toContainText("#188 · Mobile dependency predecessor");
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile dependency selection");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,6 +347,8 @@
|
||||
"dependencyBlockedBy": "Blocked by",
|
||||
"dependencyBlocks": "Blocks",
|
||||
"dependencyChainHalted": "A task this one depends on failed. The chain is stopped until you retry it or remove the dependency.",
|
||||
"dependencyChangeRequestAria": "Linked pull or merge request #{{number}}",
|
||||
"dependencyChangeRequestLabel": "#{{number}}",
|
||||
"dependencyChipLabel": "Task dependencies",
|
||||
"dependencyCountBlockedBy_one": "{{count}} blocker",
|
||||
"dependencyCountBlockedBy_other": "{{count}} blockers",
|
||||
@@ -1301,6 +1303,7 @@
|
||||
"searchRepositories": "Search repositories...",
|
||||
"searchRepositoriesOrPasteARemote": "Search repositories or paste a remote URL",
|
||||
"searchTasks": "Search tasks...",
|
||||
"searchTasksOrChangeRequest": "Search tasks or #PR/MR number...",
|
||||
"searching": "Searching…",
|
||||
"searchingWorkItems": "Searching work items…",
|
||||
"selectASessionToOpenIts": "Select a session to open its worktree",
|
||||
|
||||
@@ -347,6 +347,8 @@
|
||||
"dependencyBlockedBy": "Ɓĺōćķēď ƀŷ",
|
||||
"dependencyBlocks": "Ɓĺōćķś",
|
||||
"dependencyChainHalted": "À ţàśķ ţĥĩś ōńē ďēƥēńďś ōń ƒàĩĺēď. Ţĥē ćĥàĩń ĩś śţōƥƥēď ũńţĩĺ ŷōũ ŕēţŕŷ ĩţ ōŕ ŕēḿōvē ţĥē ďēƥēńďēńćŷ.",
|
||||
"dependencyChangeRequestAria": "Ĺĩńķēď ƥũĺĺ ōŕ ḿēŕĝē ŕēqũēśţ #{{number}}",
|
||||
"dependencyChangeRequestLabel": "#{{number}}",
|
||||
"dependencyChipLabel": "Ţàśķ ďēƥēńďēńćĩēś",
|
||||
"dependencyCountBlockedBy_one": "{{count}} ƀĺōćķēŕ",
|
||||
"dependencyCountBlockedBy_other": "{{count}} ƀĺōćķēŕś",
|
||||
@@ -1301,6 +1303,7 @@
|
||||
"searchRepositories": "Śēàŕćĥ ŕēƥōśĩţōŕĩēś...",
|
||||
"searchRepositoriesOrPasteARemote": "Śēàŕćĥ ŕēƥōśĩţōŕĩēś ōŕ ƥàśţē à ŕēḿōţē ŨŔĹ",
|
||||
"searchTasks": "Śēàŕćĥ ţàśķś...",
|
||||
"searchTasksOrChangeRequest": "Śēàŕćĥ ţàśķś ōŕ #ƤŔ/ḾŔ ńũḿƀēŕ...",
|
||||
"searching": "Śēàŕćĥĩńĝ…",
|
||||
"searchingWorkItems": "Śēàŕćĥĩńĝ ŵōŕķ ĩţēḿś…",
|
||||
"selectASessionToOpenIts": "Śēĺēćţ à śēśśĩōń ţō ōƥēń ĩţś ŵōŕķţŕēē",
|
||||
|
||||
@@ -1250,6 +1250,7 @@
|
||||
"searchRepositories": "Pesquisar repositórios...",
|
||||
"searchRepositoriesOrPasteARemote": "Pesquise repositórios ou cole um URL remoto",
|
||||
"searchTasks": "Pesquisar tarefas...",
|
||||
"searchTasksOrChangeRequest": "Pesquisar tarefas ou número de PR/MR...",
|
||||
"searching": "A pesquisar…",
|
||||
"searchingWorkItems": "A pesquisar itens de trabalho…",
|
||||
"selectASessionToOpenIts": "Selecione uma sessão para abrir a respetiva worktree",
|
||||
@@ -1796,6 +1797,8 @@
|
||||
"dependencyBlockedBy": "Bloqueada por",
|
||||
"dependencyBlocks": "Bloqueia",
|
||||
"dependencyChainHalted": "Uma tarefa de que esta depende falhou. A cadeia fica parada até a repetir ou remover a dependência.",
|
||||
"dependencyChangeRequestAria": "Pull request ou merge request associado #{{number}}",
|
||||
"dependencyChangeRequestLabel": "#{{number}}",
|
||||
"dependencyChipLabel": "Dependências da tarefa",
|
||||
"dependencyCount_one": "{{count}} dependência",
|
||||
"dependencyCount_other": "{{count}} dependências",
|
||||
|
||||
@@ -1249,6 +1249,7 @@
|
||||
"searchRepositories": "搜索仓库…",
|
||||
"searchRepositoriesOrPasteARemote": "搜索仓库或粘贴远程 URL",
|
||||
"searchTasks": "搜索任务…",
|
||||
"searchTasksOrChangeRequest": "搜索任务或 PR/MR 编号…",
|
||||
"searching": "正在搜索…",
|
||||
"searchingWorkItems": "正在搜索工作项…",
|
||||
"selectASessionToOpenIts": "选择会话以打开其工作树",
|
||||
@@ -1796,6 +1797,8 @@
|
||||
"dependencyBlockedBy": "被阻塞于",
|
||||
"dependencyBlocks": "阻塞",
|
||||
"dependencyChainHalted": "该任务依赖的某个任务失败了。在你重试或移除该依赖之前,整条链都会停住。",
|
||||
"dependencyChangeRequestAria": "关联的拉取请求或合并请求 #{{number}}",
|
||||
"dependencyChangeRequestLabel": "#{{number}}",
|
||||
"dependencyChipLabel": "任务依赖",
|
||||
"dependencyCount_one": "{{count}} 个依赖",
|
||||
"dependencyCount_other": "{{count}} 个依赖",
|
||||
|
||||
@@ -1249,6 +1249,7 @@
|
||||
"searchRepositories": "搜尋儲存庫…",
|
||||
"searchRepositoriesOrPasteARemote": "搜尋儲存庫或貼上遠端 URL",
|
||||
"searchTasks": "搜尋任務…",
|
||||
"searchTasksOrChangeRequest": "搜尋任務或 PR/MR 編號…",
|
||||
"searching": "正在搜尋…",
|
||||
"searchingWorkItems": "正在搜尋工作項…",
|
||||
"selectASessionToOpenIts": "選擇工作階段以開啟其工作樹",
|
||||
@@ -1796,6 +1797,8 @@
|
||||
"dependencyBlockedBy": "被阻塞於",
|
||||
"dependencyBlocks": "阻塞",
|
||||
"dependencyChainHalted": "該任務依賴的某個任務失敗了。在你重試或移除該依賴之前,整條鏈都會停住。",
|
||||
"dependencyChangeRequestAria": "關聯的拉取要求或合併請求 #{{number}}",
|
||||
"dependencyChangeRequestLabel": "#{{number}}",
|
||||
"dependencyChipLabel": "任務依賴",
|
||||
"dependencyCount_one": "{{count}} 個依賴",
|
||||
"dependencyCount_other": "{{count}} 個依賴",
|
||||
|
||||
@@ -1249,6 +1249,7 @@
|
||||
"searchRepositories": "搜尋儲存庫…",
|
||||
"searchRepositoriesOrPasteARemote": "搜尋儲存庫或貼上遠端 URL",
|
||||
"searchTasks": "搜尋任務…",
|
||||
"searchTasksOrChangeRequest": "搜尋任務或 PR/MR 編號…",
|
||||
"searching": "正在搜尋…",
|
||||
"searchingWorkItems": "正在搜尋工作項…",
|
||||
"selectASessionToOpenIts": "選擇工作階段以開啟其工作樹",
|
||||
@@ -1796,6 +1797,8 @@
|
||||
"dependencyBlockedBy": "被阻塞於",
|
||||
"dependencyBlocks": "阻塞",
|
||||
"dependencyChainHalted": "該任務依賴的某個任務失敗了。在你重試或移除該依賴之前,整條鏈都會停住。",
|
||||
"dependencyChangeRequestAria": "關聯的提取要求或合併請求 #{{number}}",
|
||||
"dependencyChangeRequestLabel": "#{{number}}",
|
||||
"dependencyChipLabel": "任務依賴",
|
||||
"dependencyCount_one": "{{count}} 個依賴",
|
||||
"dependencyCount_other": "{{count}} 個依賴",
|
||||
|
||||
@@ -85,7 +85,9 @@ place for dependency selection and future less-common options.
|
||||
- The row exposes `aria-expanded="true"`, and the expanded content is connected
|
||||
to it through the normal collapsible relationship.
|
||||
- Closing the section preserves every selected predecessor. Reopening it
|
||||
reveals the existing selected title or localized count.
|
||||
reveals the existing selected task's trigger label (its title, or
|
||||
`#N · Title` when the task has a linked change-request number) or the
|
||||
localized dependency count for multiple predecessors.
|
||||
- The selector's search-row info control continues to explain dependency wait
|
||||
behavior without changing the disclosure state or selection.
|
||||
|
||||
@@ -124,8 +126,10 @@ place for dependency selection and future less-common options.
|
||||
- **GIVEN** the expanded selector with no predecessors, **THEN** its trigger
|
||||
shows the existing `No dependency` label and dependency icon.
|
||||
- **GIVEN** a selected predecessor, **WHEN** the user collapses and reopens the
|
||||
advanced section, **THEN** the selected task title or localized dependency
|
||||
count remains visible in the selector and the form state is unchanged.
|
||||
advanced section, **THEN** the selected task's trigger label (its title, its
|
||||
`#N · Title` change-request form, or a localized dependency count for
|
||||
multiple predecessors) remains visible in the selector and the form state is
|
||||
unchanged.
|
||||
- **GIVEN** a workflow selector that is visible, hidden for a single workflow,
|
||||
or locked by a caller, **WHEN** the create dialog renders, **THEN** the
|
||||
workflow's existing behavior is preserved and the advanced dependency
|
||||
|
||||
+21
-5
@@ -40,13 +40,22 @@ set of tasks.
|
||||
ability to choose multiple predecessor tasks by toggling task entries in the
|
||||
same selector.
|
||||
- With no predecessor selected, the trigger shows `No dependency` and the
|
||||
dependency icon. With one predecessor selected, it shows that task's title.
|
||||
With multiple predecessors selected, it shows a localized dependency count.
|
||||
dependency icon. With one predecessor selected, it shows that task's title,
|
||||
prefixed with a localized `#N ·` change-request label when the task has a
|
||||
linked GitHub PR or GitLab MR number. With multiple predecessors selected,
|
||||
it shows a localized dependency count.
|
||||
- The selector includes a `No dependency` entry that clears every selected
|
||||
predecessor. A selected task can be removed by toggling its entry off.
|
||||
- Every task entry in the selector displays a task icon. Archived tasks are not
|
||||
offered as candidates, and the existing candidate sources from both Kanban
|
||||
board slices remain available.
|
||||
- Every task entry in the selector displays a task icon. A task with a linked
|
||||
GitHub PR or GitLab MR number also displays a localized `#N` change-request
|
||||
badge for each linked number. Archived tasks are not offered as candidates,
|
||||
and the existing candidate sources from both Kanban board slices remain
|
||||
available.
|
||||
- Candidates are ordered most-recently-updated first, falling back to
|
||||
most-recently-created and then title when timestamps are unavailable or
|
||||
equal.
|
||||
- The selector's search field matches task title, task id, and any linked
|
||||
change-request number in either `#N` or bare `N` form.
|
||||
- The selector's search row has an info control on its right side. Hovering or
|
||||
focusing the control explains that dependencies wait for all selected tasks
|
||||
to finish successfully and that starting an agent starts this task
|
||||
@@ -104,6 +113,13 @@ set of tasks.
|
||||
the current task selection, and Escape or outside interaction dismisses it.
|
||||
- **GIVEN** an archived board task, **WHEN** the user searches the dependency
|
||||
selector, **THEN** the archived task is not offered.
|
||||
- **GIVEN** a candidate task with a linked GitHub PR or GitLab MR number,
|
||||
**WHEN** the user opens the selector, **THEN** that task's option shows a
|
||||
`#N` change-request badge, and selecting it as the sole predecessor shows
|
||||
the trigger as `#N · Title`.
|
||||
- **GIVEN** a candidate task with a linked change-request number, **WHEN** the
|
||||
user types that number or `#number` into the search field, **THEN** the
|
||||
matching task is offered.
|
||||
- **GIVEN** a Pixel 5-sized viewport, **WHEN** the user opens the create dialog
|
||||
and dependency selector, **THEN** the trigger and task rows remain reachable,
|
||||
the picker stays within the viewport, and the document has no horizontal
|
||||
|
||||
Reference in New Issue
Block a user