feat(FN-4618): add inline in-review move control with meta row
Adds an inline move-control button to the TaskCard meta row for in-review tasks, letting users move tasks directly from the board without opening the detail panel. Tests and a workflow-step allowlist entry were added alongside the CSS and component changes. Fusion-Task-Id: FN-4618
This commit is contained in:
@@ -122,7 +122,9 @@
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
row-gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-dim);
|
||||
@@ -130,6 +132,11 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-meta-move {
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-retry-badge {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1178,6 +1185,11 @@
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.card-meta-move .card-send-back-menu {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.card-send-back-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -815,6 +815,13 @@ function TaskCardComponent({
|
||||
&& !hasMatchingIssueInfoBadge
|
||||
&& !hasMatchingSourceIssue;
|
||||
const showInReviewMoveControl = task.column === "in-review" && Boolean(onMoveTask);
|
||||
const metaRowVisible =
|
||||
(task.dependencies?.length ?? 0) > 0
|
||||
|| queued
|
||||
|| task.status === "queued"
|
||||
|| Boolean(task.blockedBy)
|
||||
|| Boolean(task.overlapBlockedBy)
|
||||
|| Boolean(fanout && fanout.totalCount > 0);
|
||||
const branchMetadata = useMemo(() => getVisibleTaskCardBranches(task), [task.id, task.branch, task.baseBranch]);
|
||||
const hasBranchMetadata = Boolean(branchMetadata.branch || branchMetadata.baseBranch);
|
||||
const isAgentCreated = isAgentCreatedTask(task);
|
||||
@@ -1804,7 +1811,7 @@ function TaskCardComponent({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy || task.overlapBlockedBy || (fanout && fanout.totalCount > 0)) && (
|
||||
{metaRowVisible && (
|
||||
<div className="card-meta">
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<div className="card-dep-list">
|
||||
@@ -1839,6 +1846,37 @@ function TaskCardComponent({
|
||||
</span>
|
||||
)}
|
||||
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
|
||||
{showInReviewMoveControl && (
|
||||
<div className="card-meta-move">
|
||||
<div className="card-send-back" ref={sendBackRef}>
|
||||
<button
|
||||
className="card-send-back-btn"
|
||||
onClick={handleSendBackClick}
|
||||
title="Move task"
|
||||
aria-label="Move task"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showSendBackMenu}
|
||||
>
|
||||
Move
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
{showSendBackMenu && (
|
||||
<div className="card-send-back-menu" role="menu">
|
||||
{VALID_TRANSITIONS["in-review"].map((col) => (
|
||||
<button
|
||||
key={col}
|
||||
className="card-send-back-menu-item"
|
||||
role="menuitem"
|
||||
onClick={(e) => handleSendBackOptionClick(e, col)}
|
||||
>
|
||||
{col === "done" ? "Done (no merge)" : COLUMN_LABELS[col]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(task.assignedAgentId || taskProviders.length > 0) && (
|
||||
@@ -1864,7 +1902,7 @@ function TaskCardComponent({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showInReviewMoveControl && (
|
||||
{showInReviewMoveControl && !metaRowVisible && (
|
||||
<div className="card-bottom-row">
|
||||
<div className="card-bottom-right-row">
|
||||
<div className="card-send-back" ref={sendBackRef}>
|
||||
|
||||
@@ -1417,30 +1417,63 @@ describe("TaskCard", () => {
|
||||
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
|
||||
});
|
||||
|
||||
it("FN-4540 renders in-review Move control in card-bottom-right-row and keeps menu behavior", () => {
|
||||
const onMoveTask = vi.fn();
|
||||
it("renders in-review Move control inline in card-meta when meta row is visible", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-review" })}
|
||||
task={makeTask({ column: "in-review", blockedBy: "FN-777" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const moveButton = screen.getByRole("button", { name: "Move task" });
|
||||
const metaRow = container.querySelector(".card-meta");
|
||||
const bottomRow = container.querySelector(".card-bottom-row");
|
||||
|
||||
expect(metaRow).not.toBeNull();
|
||||
expect(metaRow?.contains(moveButton)).toBe(true);
|
||||
expect(moveButton.closest(".card-meta")).not.toBeNull();
|
||||
expect(bottomRow).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps in-review Move control in card-bottom-row when meta row is not visible", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-review", dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined, status: undefined as any })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const moveButton = screen.getByRole("button", { name: "Move task" });
|
||||
const bottomRow = container.querySelector(".card-bottom-row");
|
||||
|
||||
expect(moveButton.closest(".card-bottom-right-row")).not.toBeNull();
|
||||
expect(bottomRow).not.toBeNull();
|
||||
expect(bottomRow?.contains(moveButton)).toBe(true);
|
||||
expect(moveButton.closest(".card-meta")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "inline meta-row variant", task: makeTask({ column: "in-review", blockedBy: "FN-777" }) },
|
||||
{ name: "fallback bottom-row variant", task: makeTask({ column: "in-review", dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined, status: undefined as any }) },
|
||||
])("keeps Move dropdown behavior for $name", ({ task }) => {
|
||||
const onMoveTask = vi.fn();
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={onMoveTask}
|
||||
/>,
|
||||
);
|
||||
|
||||
const moveButton = screen.getByRole("button", { name: "Move task" });
|
||||
const actionsContainer = container.querySelector(".card-header-actions");
|
||||
const bottomRow = container.querySelector(".card-bottom-row");
|
||||
const bottomRight = moveButton.closest(".card-bottom-right-row");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Move task" }));
|
||||
|
||||
expect(actionsContainer).not.toBeNull();
|
||||
expect(actionsContainer?.contains(moveButton)).toBe(false);
|
||||
expect(bottomRow).not.toBeNull();
|
||||
expect(bottomRight).not.toBeNull();
|
||||
expect(bottomRow?.contains(bottomRight as HTMLElement)).toBe(true);
|
||||
expect(getComputedStyle(bottomRow as HTMLElement).justifyContent).toBe("flex-end");
|
||||
expect(screen.getAllByRole("menuitem").length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(moveButton);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Done (no merge)" }));
|
||||
|
||||
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "done", undefined);
|
||||
|
||||
@@ -14,6 +14,8 @@ describe("workflow-step readonly allowlist policy", () => {
|
||||
"grep",
|
||||
"find",
|
||||
"ls",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
"fn_web_fetch",
|
||||
"fn_task_show",
|
||||
"fn_task_list",
|
||||
|
||||
Reference in New Issue
Block a user