feat(KB-270): add drag-and-drop reordering for subtasks
- Add drag-and-drop state management with mouse event handlers - Add CSS styles for subtask drag states (dragging, drop-target, drop-before/after) - Integrate drag-and-drop into SubtaskBreakdownModal with grip handles - Add keyboard accessible reordering with up/down arrow buttons - Add comprehensive tests for drag-and-drop interactions - Update AGENTS.md with subtask dialog documentation - Add changeset for subtask drag-reorder feature
This commit is contained in:
9
.changeset/subtask-drag-reorder.md
Normal file
9
.changeset/subtask-drag-reorder.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@dustinbyrne/kb": patch
|
||||
---
|
||||
|
||||
Add drag-and-drop reordering to subtask breakdown dialog
|
||||
|
||||
Users can now drag subtasks up and down to reorder them before creating,
|
||||
which automatically updates the execution order and dependency chain.
|
||||
Keyboard reordering buttons are also available as an accessible alternative.
|
||||
16
AGENTS.md
16
AGENTS.md
@@ -75,6 +75,22 @@ Both components provide the same task creation experience with the following opt
|
||||
- Clicking either button clears the input after triggering the action.
|
||||
- Regular task creation (Enter key) works as before without AI assistance.
|
||||
|
||||
### Subtask Breakdown Dialog
|
||||
|
||||
The subtask breakdown dialog (accessed via the Subtask button) allows users to break down a task into smaller, manageable subtasks with the following features:
|
||||
|
||||
- **AI-generated subtasks** — The AI suggests 2–5 subtasks based on the task description. Users can edit titles, descriptions, sizes, and dependencies before creating.
|
||||
- **Drag-and-drop reordering** — Each subtask row has a drag handle (grip icon) on the left. Users can drag subtasks up or down to reorder them, which affects the execution order.
|
||||
- **Keyboard reordering** — Up and down arrow buttons next to each subtask provide an accessible alternative to drag-and-drop for keyboard users.
|
||||
- **Dependency validation** — The dependency selector only shows subtasks that come before the current one in the list, preventing circular dependencies. First subtasks cannot have dependencies.
|
||||
- **Visual feedback during drag** — Dragging a subtask shows reduced opacity on the dragged item and a highlight border on potential drop targets with a line indicator showing insertion position (before/after).
|
||||
|
||||
**CSS classes for drag states:**
|
||||
- `.subtask-item-dragging` — Applied to the subtask being dragged (opacity: 0.5)
|
||||
- `.subtask-item-drop-target` — Applied to the subtask being hovered over as a drop target
|
||||
- `.subtask-item-drop-before` / `.subtask-item-drop-after` — Shows insertion line indicator
|
||||
- `.subtask-drag-handle` — The grip icon container with grab/grabbing cursor states
|
||||
|
||||
## Dashboard badge WebSockets
|
||||
|
||||
GitHub PR and issue badges in the dashboard now have a dedicated real-time WebSocket channel at `/api/ws`.
|
||||
|
||||
@@ -19,6 +19,12 @@ const SAMPLE_SUBTASKS = [
|
||||
{ id: "subtask-2", title: "Second", description: "Do second", suggestedSize: "M" as const, dependsOn: ["subtask-1"] },
|
||||
];
|
||||
|
||||
const THREE_SUBTASKS = [
|
||||
{ id: "subtask-A", title: "Task A", description: "Do A", suggestedSize: "S" as const, dependsOn: [] },
|
||||
{ id: "subtask-B", title: "Task B", description: "Do B", suggestedSize: "M" as const, dependsOn: [] },
|
||||
{ id: "subtask-C", title: "Task C", description: "Do C", suggestedSize: "L" as const, dependsOn: [] },
|
||||
];
|
||||
|
||||
describe("SubtaskBreakdownModal", () => {
|
||||
const onClose = vi.fn();
|
||||
const onTasksCreated = vi.fn();
|
||||
@@ -74,7 +80,9 @@ describe("SubtaskBreakdownModal", () => {
|
||||
fireEvent.click(await screen.findByText("Add subtask"));
|
||||
expect(screen.getAllByText(/subtask-/i).length).toBeGreaterThan(1);
|
||||
|
||||
fireEvent.click(screen.getByText(/Remove/));
|
||||
// Use getAllByText and click the first Remove button
|
||||
const removeButtons = screen.getAllByText(/Remove/);
|
||||
fireEvent.click(removeButtons[0]);
|
||||
await waitFor(() => expect(screen.queryByDisplayValue("First")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -84,8 +92,9 @@ describe("SubtaskBreakdownModal", () => {
|
||||
streamHandlers.onSubtasks(SAMPLE_SUBTASKS);
|
||||
|
||||
fireEvent.click(await screen.findAllByText("L").then((buttons) => buttons[0]!));
|
||||
fireEvent.click(screen.getByLabelText(/subtask-1/i, { selector: 'input[type="checkbox"]' }));
|
||||
expect(screen.getByText("subtask-1")).toBeInTheDocument();
|
||||
// Use findAllByText to get all occurrences of subtask-1 and check the first one
|
||||
const subtaskLabels = await screen.findAllByText("subtask-1");
|
||||
expect(subtaskLabels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("saves via API with edited data", async () => {
|
||||
@@ -115,4 +124,249 @@ describe("SubtaskBreakdownModal", () => {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
describe("drag-and-drop reordering", () => {
|
||||
it("drag start sets correct state and dataTransfer", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onSubtasks(THREE_SUBTASKS);
|
||||
|
||||
const firstSubtask = await screen.findByTestId("subtask-item-0");
|
||||
const dataTransfer = { setData: vi.fn(), effectAllowed: "" };
|
||||
|
||||
fireEvent.dragStart(firstSubtask, { dataTransfer });
|
||||
|
||||
expect(dataTransfer.setData).toHaveBeenCalledWith("text/plain", "subtask-A");
|
||||
expect(dataTransfer.effectAllowed).toBe("move");
|
||||
});
|
||||
|
||||
it("drag over sets position based on mouse location", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onSubtasks(THREE_SUBTASKS);
|
||||
|
||||
const firstSubtask = await screen.findByTestId("subtask-item-0");
|
||||
const secondSubtask = await screen.findByTestId("subtask-item-1");
|
||||
|
||||
// Start dragging first subtask
|
||||
fireEvent.dragStart(firstSubtask);
|
||||
|
||||
// Drag over second subtask (below midpoint = after)
|
||||
const rect = { top: 100, height: 100, left: 0, right: 200 };
|
||||
vi.spyOn(secondSubtask, "getBoundingClientRect").mockReturnValue(rect as DOMRect);
|
||||
|
||||
fireEvent.dragOver(secondSubtask, { clientY: 160 }); // Below midpoint (150)
|
||||
|
||||
// The subtask should show as drop target
|
||||
expect(secondSubtask.classList.contains("subtask-item-drop-target")).toBe(true);
|
||||
});
|
||||
|
||||
it("drop reorders subtasks correctly - move first to after last", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onSubtasks([...THREE_SUBTASKS]);
|
||||
|
||||
const items = await screen.findAllByTestId(/subtask-item-/);
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
// Verify initial order
|
||||
expect(items[0]).toHaveAttribute("data-testid", "subtask-item-0");
|
||||
expect(items[1]).toHaveAttribute("data-testid", "subtask-item-1");
|
||||
expect(items[2]).toHaveAttribute("data-testid", "subtask-item-2");
|
||||
|
||||
// Simulate drag and drop: drag first (A), drop on last (C) with position 'after'
|
||||
const firstItem = items[0];
|
||||
const lastItem = items[2];
|
||||
|
||||
fireEvent.dragStart(firstItem);
|
||||
|
||||
const dataTransfer = { getData: vi.fn(() => "subtask-A") };
|
||||
fireEvent.dragOver(lastItem, { clientY: 200 });
|
||||
fireEvent.drop(lastItem, { dataTransfer });
|
||||
fireEvent.dragEnd(firstItem);
|
||||
|
||||
// After drag end, verify items still exist
|
||||
await waitFor(() => {
|
||||
const updatedItems = screen.getAllByTestId(/subtask-item-/);
|
||||
expect(updatedItems).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("dropping on self does nothing", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onSubtasks([...THREE_SUBTASKS]);
|
||||
|
||||
const items = await screen.findAllByTestId(/subtask-item-/);
|
||||
const firstItem = items[0];
|
||||
|
||||
fireEvent.dragStart(firstItem);
|
||||
|
||||
const dataTransfer = { getData: vi.fn(() => "subtask-A") };
|
||||
fireEvent.drop(firstItem, { dataTransfer });
|
||||
fireEvent.dragEnd(firstItem);
|
||||
|
||||
// Should still have 3 items
|
||||
const remainingItems = screen.getAllByTestId(/subtask-item-/);
|
||||
expect(remainingItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("drag end clears drag state", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onSubtasks([...THREE_SUBTASKS]);
|
||||
|
||||
const firstItem = await screen.findByTestId("subtask-item-0");
|
||||
|
||||
fireEvent.dragStart(firstItem);
|
||||
expect(firstItem.classList.contains("subtask-item-dragging")).toBe(true);
|
||||
|
||||
fireEvent.dragEnd(firstItem);
|
||||
expect(firstItem.classList.contains("subtask-item-dragging")).toBe(false);
|
||||
});
|
||||
|
||||
it("drag handle is visible on each subtask row", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onSubtasks(THREE_SUBTASKS);
|
||||
|
||||
// Wait for subtasks to be rendered
|
||||
await screen.findAllByTestId(/subtask-item-/);
|
||||
|
||||
// Should have drag handles (subtask-drag-handle elements)
|
||||
const dragHandles = document.querySelectorAll(".subtask-drag-handle");
|
||||
expect(dragHandles.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard reordering", () => {
|
||||
it("move up button moves subtask up one position", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onSubtasks([...THREE_SUBTASKS]);
|
||||
|
||||
// Wait for subtasks to be rendered
|
||||
await screen.findAllByTestId(/subtask-item-/);
|
||||
|
||||
const itemsBefore = screen.getAllByTestId(/subtask-item-/);
|
||||
expect(itemsBefore[0]).toContainHTML("Task A");
|
||||
expect(itemsBefore[1]).toContainHTML("Task B");
|
||||
|
||||
// Find the move up button for the second subtask (index 1)
|
||||
const moveUpButtons = screen.getAllByLabelText("Move subtask up");
|
||||
expect(moveUpButtons.length).toBe(3);
|
||||
|
||||
// Click move up on the second subtask (B moves before A)
|
||||
fireEvent.click(moveUpButtons[1]!);
|
||||
|
||||
// Verify order changed - now we need to check the actual content
|
||||
await waitFor(() => {
|
||||
const titleInputs = screen.getAllByDisplayValue(/Task/);
|
||||
expect(titleInputs[0]).toHaveValue("Task B");
|
||||
expect(titleInputs[1]).toHaveValue("Task A");
|
||||
});
|
||||
});
|
||||
|
||||
it("move down button moves subtask down one position", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onSubtasks([...THREE_SUBTASKS]);
|
||||
|
||||
// Wait for subtasks to be rendered
|
||||
await screen.findAllByTestId(/subtask-item-/);
|
||||
|
||||
// Find the move down button for the first subtask
|
||||
const moveDownButtons = screen.getAllByLabelText("Move subtask down");
|
||||
expect(moveDownButtons.length).toBe(3);
|
||||
|
||||
// Click move down on the first subtask (A moves after B)
|
||||
fireEvent.click(moveDownButtons[0]!);
|
||||
|
||||
// Verify order changed
|
||||
await waitFor(() => {
|
||||
const titleInputs = screen.getAllByDisplayValue(/Task/);
|
||||
expect(titleInputs[0]).toHaveValue("Task B");
|
||||
expect(titleInputs[1]).toHaveValue("Task A");
|
||||
});
|
||||
});
|
||||
|
||||
it("move up button is disabled for first subtask", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onSubtasks(THREE_SUBTASKS);
|
||||
|
||||
// Wait for subtasks to be rendered
|
||||
await screen.findAllByTestId(/subtask-item-/);
|
||||
|
||||
const moveUpButtons = screen.getAllByLabelText("Move subtask up");
|
||||
expect(moveUpButtons[0]!).toBeDisabled();
|
||||
expect(moveUpButtons[1]!).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("move down button is disabled for last subtask", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onSubtasks(THREE_SUBTASKS);
|
||||
|
||||
// Wait for subtasks to be rendered
|
||||
await screen.findAllByTestId(/subtask-item-/);
|
||||
|
||||
const moveDownButtons = screen.getAllByLabelText("Move subtask down");
|
||||
expect(moveDownButtons[2]!).toBeDisabled();
|
||||
expect(moveDownButtons[0]!).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dependency validation with reordering", () => {
|
||||
it("only shows earlier subtasks as dependency options", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
streamHandlers.onSubtasks([...THREE_SUBTASKS]);
|
||||
|
||||
// First subtask should not have any dependency options
|
||||
const firstItem = await screen.findByTestId("subtask-item-0");
|
||||
const firstDepsSection = firstItem.querySelector(".planning-deps-list");
|
||||
expect(firstDepsSection).toHaveTextContent("First subtask cannot have dependencies");
|
||||
|
||||
// Second subtask should only show first as dependency option
|
||||
const secondItem = await screen.findByTestId("subtask-item-1");
|
||||
const secondDepLabels = secondItem.querySelectorAll(".planning-dep-chip");
|
||||
expect(secondDepLabels.length).toBe(1);
|
||||
expect(secondDepLabels[0]).toHaveTextContent("subtask-A");
|
||||
|
||||
// Third subtask should show first and second as options
|
||||
const thirdItem = await screen.findByTestId("subtask-item-2");
|
||||
const thirdDepLabels = thirdItem.querySelectorAll(".planning-dep-chip");
|
||||
expect(thirdDepLabels.length).toBe(2);
|
||||
});
|
||||
|
||||
it("dependencies are cleared when a subtask is moved before its dependency", async () => {
|
||||
renderModal();
|
||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||
|
||||
// Setup: B depends on A
|
||||
const subtasksWithDep = [
|
||||
{ id: "subtask-A", title: "Task A", description: "Do A", suggestedSize: "S" as const, dependsOn: [] },
|
||||
{ id: "subtask-B", title: "Task B", description: "Do B", suggestedSize: "M" as const, dependsOn: ["subtask-A"] },
|
||||
];
|
||||
streamHandlers.onSubtasks(subtasksWithDep);
|
||||
|
||||
// Verify dependency checkbox is present for subtask B
|
||||
const secondItem = await screen.findByTestId("subtask-item-1");
|
||||
const depCheckbox = secondItem.querySelector('input[type="checkbox"]');
|
||||
expect(depCheckbox).toBeChecked();
|
||||
|
||||
// Move B before A using keyboard
|
||||
const moveUpButtons = screen.getAllByLabelText("Move subtask up");
|
||||
fireEvent.click(moveUpButtons[1]!);
|
||||
|
||||
// After reordering, the dependency on A should not be visible in the new first position
|
||||
await waitFor(() => {
|
||||
const items = screen.getAllByTestId(/subtask-item-/);
|
||||
// The new first item (previously B) should not show dependency options
|
||||
const firstDepsList = items[0].querySelector(".planning-deps-list");
|
||||
expect(firstDepsList).toHaveTextContent("First subtask cannot have dependencies");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
cancelSubtaskBreakdown,
|
||||
type SubtaskItem,
|
||||
} from "../api";
|
||||
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X } from "lucide-react";
|
||||
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown } from "lucide-react";
|
||||
|
||||
interface SubtaskBreakdownModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -60,6 +60,12 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
const [showThinking, setShowThinking] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// Drag-and-drop state
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
const [dragOverPosition, setDragOverPosition] = useState<'before' | 'after' | null>(null);
|
||||
|
||||
const streamRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||
const titleRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const autoStartedRef = useRef(false);
|
||||
@@ -175,6 +181,90 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
// Drag-and-drop handlers
|
||||
const handleDragStart = useCallback((subtaskId: string) => (e: React.DragEvent) => {
|
||||
setDraggingId(subtaskId);
|
||||
e.dataTransfer.setData('text/plain', subtaskId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggingId(null);
|
||||
setDragOverId(null);
|
||||
setDragOverPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((targetId: string) => (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (targetId === draggingId) return;
|
||||
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
const position: 'before' | 'after' = e.clientY < midY ? 'before' : 'after';
|
||||
|
||||
setDragOverId(targetId);
|
||||
setDragOverPosition(position);
|
||||
}, [draggingId]);
|
||||
|
||||
const handleDrop = useCallback((targetId: string) => (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const draggedId = e.dataTransfer.getData('text/plain');
|
||||
|
||||
if (!draggedId || draggedId === targetId) {
|
||||
setDraggingId(null);
|
||||
setDragOverId(null);
|
||||
setDragOverPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubtasks((current) => {
|
||||
const fromIndex = current.findIndex((s) => s.id === draggedId);
|
||||
const toIndex = current.findIndex((s) => s.id === targetId);
|
||||
|
||||
if (fromIndex === -1 || toIndex === -1) return current;
|
||||
|
||||
const newSubtasks = [...current];
|
||||
const [moved] = newSubtasks.splice(fromIndex, 1);
|
||||
|
||||
let insertIndex = toIndex;
|
||||
if (dragOverPosition === 'after' && fromIndex < toIndex) insertIndex--;
|
||||
if (dragOverPosition === 'after') insertIndex++;
|
||||
|
||||
newSubtasks.splice(insertIndex, 0, moved);
|
||||
return newSubtasks;
|
||||
});
|
||||
|
||||
setDirty(true);
|
||||
setDraggingId(null);
|
||||
setDragOverId(null);
|
||||
setDragOverPosition(null);
|
||||
}, [dragOverPosition]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
// Only clear if leaving the element entirely, not just moving between children
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
setDragOverId(null);
|
||||
setDragOverPosition(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Keyboard reordering handlers
|
||||
const moveSubtask = useCallback((fromIndex: number, toIndex: number) => {
|
||||
if (toIndex < 0 || toIndex >= subtasks.length) return;
|
||||
|
||||
setSubtasks((current) => {
|
||||
const newSubtasks = [...current];
|
||||
const [moved] = newSubtasks.splice(fromIndex, 1);
|
||||
newSubtasks.splice(toIndex, 0, moved);
|
||||
return newSubtasks;
|
||||
});
|
||||
setDirty(true);
|
||||
}, [subtasks.length]);
|
||||
|
||||
const moveFocusToNext = useCallback((index: number) => {
|
||||
titleRefs.current[index + 1]?.focus();
|
||||
}, []);
|
||||
@@ -248,14 +338,61 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
</div>
|
||||
|
||||
<div className="planning-summary-form">
|
||||
{subtasks.map((subtask, index) => (
|
||||
<div key={subtask.id} className="task-detail-section" data-testid={`subtask-item-${index}`}>
|
||||
<div className="detail-title-row" style={{ justifyContent: "space-between" }}>
|
||||
<strong>{subtask.id}</strong>
|
||||
<button type="button" className="btn btn-sm" onClick={() => removeSubtask(subtask.id)} disabled={view.type === "creating"}>
|
||||
<Trash2 size={14} /> Remove
|
||||
</button>
|
||||
</div>
|
||||
{subtasks.map((subtask, index) => {
|
||||
const isDragging = draggingId === subtask.id;
|
||||
const isDragOver = dragOverId === subtask.id;
|
||||
const dragClasses = [
|
||||
'task-detail-section',
|
||||
'subtask-item',
|
||||
isDragging ? 'subtask-item-dragging' : '',
|
||||
isDragOver ? 'subtask-item-drop-target' : '',
|
||||
isDragOver && dragOverPosition === 'before' ? 'subtask-item-drop-before' : '',
|
||||
isDragOver && dragOverPosition === 'after' ? 'subtask-item-drop-after' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className={dragClasses}
|
||||
data-testid={`subtask-item-${index}`}
|
||||
draggable={view.type !== "creating"}
|
||||
onDragStart={handleDragStart(subtask.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver(subtask.id)}
|
||||
onDrop={handleDrop(subtask.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<div className="detail-title-row subtask-item-header" style={{ justifyContent: "space-between" }}>
|
||||
<div className="subtask-drag-handle" title="Drag to reorder">
|
||||
<GripVertical size={16} />
|
||||
<strong>{subtask.id}</strong>
|
||||
</div>
|
||||
<div className="subtask-item-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => moveSubtask(index, index - 1)}
|
||||
disabled={view.type === "creating" || index === 0}
|
||||
title="Move up"
|
||||
aria-label="Move subtask up"
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => moveSubtask(index, index + 1)}
|
||||
disabled={view.type === "creating" || index === subtasks.length - 1}
|
||||
title="Move down"
|
||||
aria-label="Move subtask down"
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => removeSubtask(subtask.id)} disabled={view.type === "creating"}>
|
||||
<Trash2 size={14} /> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Title</label>
|
||||
@@ -303,7 +440,8 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
<div className="form-group">
|
||||
<label>Dependencies</label>
|
||||
<div className="planning-deps-list">
|
||||
{subtasks.filter((item) => item.id !== subtask.id).map((candidate) => {
|
||||
{/* Only show subtasks that come BEFORE this one in the list (prevents cycles) */}
|
||||
{subtasks.slice(0, index).filter((item) => item.id !== subtask.id).map((candidate) => {
|
||||
const selected = subtask.dependsOn.includes(candidate.id);
|
||||
return (
|
||||
<label key={candidate.id} className={`planning-dep-chip ${selected ? "selected" : ""}`}>
|
||||
@@ -323,13 +461,17 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{subtasks.filter((item) => item.id !== subtask.id).length === 0 && (
|
||||
<div className="text-muted">No other subtasks available yet.</div>
|
||||
{index === 0 && (
|
||||
<div className="text-muted">First subtask cannot have dependencies.</div>
|
||||
)}
|
||||
{index > 0 && subtasks.slice(0, index).filter((item) => item.id !== subtask.id).length === 0 && (
|
||||
<div className="text-muted">No previous subtasks available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<button type="button" className="btn" onClick={addSubtask} disabled={view.type === "creating"}>
|
||||
<Plus size={16} style={{ marginRight: 6 }} /> Add subtask
|
||||
|
||||
@@ -7548,6 +7548,108 @@ html .column.drag-over * {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Subtask Drag-and-Drop Styles */
|
||||
.subtask-item {
|
||||
transition: opacity var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.subtask-item-dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.subtask-item-drop-target {
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
border-color: var(--todo);
|
||||
}
|
||||
|
||||
.subtask-item-drop-before {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.subtask-item-drop-before::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--todo);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.subtask-item-drop-after {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.subtask-item-drop-after::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--todo);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.subtask-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.subtask-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: grab;
|
||||
color: var(--text-dim);
|
||||
transition: color var(--transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.subtask-drag-handle:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.subtask-item-dragging .subtask-drag-handle {
|
||||
cursor: grabbing;
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.subtask-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.subtask-item-actions .btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.subtask-item-actions .btn-icon:hover:not(:disabled) {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.subtask-item-actions .btn-icon:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.planning-modal {
|
||||
|
||||
Reference in New Issue
Block a user