feat: re-parent tasks by drag and drop in the sidebar (#2355)
This commit is contained in:
@@ -135,6 +135,7 @@ func newTestDeps(t *testing.T) *testDeps {
|
||||
project_id TEXT DEFAULT '',
|
||||
assignee_agent_profile_id TEXT DEFAULT '',
|
||||
labels TEXT DEFAULT '[]',
|
||||
metadata TEXT DEFAULT '{}',
|
||||
identifier TEXT DEFAULT '',
|
||||
is_ephemeral INTEGER DEFAULT 0,
|
||||
origin TEXT DEFAULT 'manual',
|
||||
|
||||
@@ -2,6 +2,7 @@ package dashboard_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
taskmodels "github.com/kandev/kandev/internal/task/models"
|
||||
@@ -65,6 +66,7 @@ func TestUpdateTaskParentIDFailsClosedWhenDetacherIsNotConfigured(t *testing.T)
|
||||
func TestUpdateTaskParentIDKeepsNonEmptyReparentingInOffice(t *testing.T) {
|
||||
deps := newTestDeps(t)
|
||||
insertTestTask(t, deps.db, "child", "workspace", "Child", "todo", 1)
|
||||
insertTestTask(t, deps.db, "new-parent", "workspace", "New parent", "todo", 1)
|
||||
detacher := &recordingTaskDetacher{}
|
||||
deps.svc.SetTaskDetacher(detacher)
|
||||
|
||||
@@ -83,3 +85,101 @@ func TestUpdateTaskParentIDKeepsNonEmptyReparentingInOffice(t *testing.T) {
|
||||
t.Fatalf("parent_id = %q, want new-parent", parentID)
|
||||
}
|
||||
}
|
||||
|
||||
// The Office non-empty reparent path must apply the same composite workspace
|
||||
// semantics as the canonical detach: an inherit_parent subtask re-parented to
|
||||
// a new parent keeps its materialized workspace as shared_group instead of
|
||||
// silently inheriting the new parent's.
|
||||
func TestUpdateTaskParentIDNormalizesInheritedWorkspaceMode(t *testing.T) {
|
||||
deps := newTestDeps(t)
|
||||
insertTestTask(t, deps.db, "child", "workspace", "Child", "todo", 1)
|
||||
insertTestTask(t, deps.db, "new-parent", "workspace", "New parent", "todo", 1)
|
||||
if _, err := deps.db.Exec(`UPDATE tasks SET parent_id = 'parent', metadata = '{"workspace":{"mode":"inherit_parent","group_id":"group-1"}}' WHERE id = 'child'`); err != nil {
|
||||
t.Fatalf("set parent/metadata: %v", err)
|
||||
}
|
||||
|
||||
if err := deps.svc.UpdateTaskParentID(context.Background(), "child", "new-parent"); err != nil {
|
||||
t.Fatalf("UpdateTaskParentID: %v", err)
|
||||
}
|
||||
|
||||
var parentID string
|
||||
if err := deps.db.Get(&parentID, `SELECT parent_id FROM tasks WHERE id = 'child'`); err != nil {
|
||||
t.Fatalf("select parent: %v", err)
|
||||
}
|
||||
if parentID != "new-parent" {
|
||||
t.Fatalf("parent_id = %q, want new-parent", parentID)
|
||||
}
|
||||
|
||||
var metadata string
|
||||
if err := deps.db.Get(&metadata, `SELECT metadata FROM tasks WHERE id = 'child'`); err != nil {
|
||||
t.Fatalf("select metadata: %v", err)
|
||||
}
|
||||
var parsed struct {
|
||||
Workspace struct {
|
||||
Mode string `json:"mode"`
|
||||
GroupID string `json:"group_id"`
|
||||
} `json:"workspace"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(metadata), &parsed); err != nil {
|
||||
t.Fatalf("parse metadata %q: %v", metadata, err)
|
||||
}
|
||||
if parsed.Workspace.Mode != "shared_group" {
|
||||
t.Fatalf("workspace mode = %q, want shared_group", parsed.Workspace.Mode)
|
||||
}
|
||||
if parsed.Workspace.GroupID != "group-1" {
|
||||
t.Fatalf("workspace group_id = %q, want group-1", parsed.Workspace.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
// A repeated PATCH with the same parent_id must not flip workspace semantics:
|
||||
// the task was not re-parented, so an inherit_parent mode is preserved.
|
||||
func TestUpdateTaskParentIDSameParentDoesNotNormalizeMetadata(t *testing.T) {
|
||||
deps := newTestDeps(t)
|
||||
insertTestTask(t, deps.db, "child", "workspace", "Child", "todo", 1)
|
||||
insertTestTask(t, deps.db, "parent", "workspace", "Parent", "todo", 1)
|
||||
if _, err := deps.db.Exec(`UPDATE tasks SET parent_id = 'parent', metadata = '{"workspace":{"mode":"inherit_parent","group_id":"group-1"}}' WHERE id = 'child'`); err != nil {
|
||||
t.Fatalf("set parent/metadata: %v", err)
|
||||
}
|
||||
|
||||
if err := deps.svc.UpdateTaskParentID(context.Background(), "child", "parent"); err != nil {
|
||||
t.Fatalf("UpdateTaskParentID: %v", err)
|
||||
}
|
||||
|
||||
var metadata string
|
||||
if err := deps.db.Get(&metadata, `SELECT metadata FROM tasks WHERE id = 'child'`); err != nil {
|
||||
t.Fatalf("select metadata: %v", err)
|
||||
}
|
||||
var parsed struct {
|
||||
Workspace struct {
|
||||
Mode string `json:"mode"`
|
||||
} `json:"workspace"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(metadata), &parsed); err != nil {
|
||||
t.Fatalf("parse metadata %q: %v", metadata, err)
|
||||
}
|
||||
if parsed.Workspace.Mode != "inherit_parent" {
|
||||
t.Fatalf("workspace mode = %q, want inherit_parent (same-parent update must not normalize)", parsed.Workspace.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-empty parent_id that does not exist must be rejected instead of
|
||||
// silently writing a dangling parent reference.
|
||||
func TestUpdateTaskParentIDRejectsNonExistentParent(t *testing.T) {
|
||||
deps := newTestDeps(t)
|
||||
insertTestTask(t, deps.db, "child", "workspace", "Child", "todo", 1)
|
||||
if _, err := deps.db.Exec(`UPDATE tasks SET parent_id = 'parent' WHERE id = 'child'`); err != nil {
|
||||
t.Fatalf("set parent: %v", err)
|
||||
}
|
||||
|
||||
if err := deps.svc.UpdateTaskParentID(context.Background(), "child", "missing-parent"); err == nil {
|
||||
t.Fatal("UpdateTaskParentID error = nil, want missing-parent error")
|
||||
}
|
||||
|
||||
var parentID string
|
||||
if err := deps.db.Get(&parentID, `SELECT parent_id FROM tasks WHERE id = 'child'`); err != nil {
|
||||
t.Fatalf("select parent: %v", err)
|
||||
}
|
||||
if parentID != "parent" {
|
||||
t.Fatalf("parent_id = %q, want unchanged 'parent'", parentID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,12 @@ func (s *DashboardService) UpdateTaskProjectID(ctx context.Context, taskID, proj
|
||||
}
|
||||
|
||||
// UpdateTaskParentID sets the parent_id field. Empty string clears the
|
||||
// parent. Rejects only direct self-reference (parentID == taskID); deeper
|
||||
// cycle detection is intentionally not enforced.
|
||||
// parent (via the canonical detacher). A non-empty value must reference an
|
||||
// existing task and also applies the canonical re-parent workspace policy:
|
||||
// an inherit_parent subtask keeps its materialized workspace as shared_group
|
||||
// instead of inheriting the new parent's. Rejects only direct self-reference
|
||||
// (parentID == taskID); deeper cycle detection is intentionally not enforced
|
||||
// on this surface.
|
||||
func (s *DashboardService) UpdateTaskParentID(ctx context.Context, taskID, parentID string) error {
|
||||
if parentID != "" && parentID == taskID {
|
||||
return fmt.Errorf("task cannot be its own parent")
|
||||
@@ -80,10 +84,20 @@ func (s *DashboardService) UpdateTaskParentID(ctx context.Context, taskID, paren
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Preflight existence so a concurrent parent deletion (or a direct hit on
|
||||
// the endpoint) cannot write a dangling parent_id — mirroring the
|
||||
// canonical resolveParentID guard.
|
||||
parent, err := s.repo.GetTaskByID(ctx, parentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve parent task: %w", err)
|
||||
}
|
||||
if parent == nil {
|
||||
return fmt.Errorf("parent task not found: %s", parentID)
|
||||
}
|
||||
if err := s.repo.UpdateTaskParentID(ctx, taskID, parentID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.publishTaskUpdated(ctx, taskID, []string{"parent_id"})
|
||||
s.publishTaskUpdated(ctx, taskID, []string{"parent_id", "metadata"})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -760,9 +760,37 @@ func (r *Repository) UpdateTaskProjectID(ctx context.Context, taskID, projectID
|
||||
return r.execTaskScalar(ctx, taskID, "project_id", projectID)
|
||||
}
|
||||
|
||||
// UpdateTaskParentID sets the parent_id column. Empty string clears it.
|
||||
// UpdateTaskParentID sets the parent_id column and applies the canonical
|
||||
// re-parent workspace policy: an inherit_parent subtask whose parent is
|
||||
// changing keeps its materialized workspace as shared_group instead of
|
||||
// silently inheriting the new parent's. Empty string clears the parent.
|
||||
// The metadata normalization is conditioned on the parent actually changing,
|
||||
// so a repeated PATCH with the same parent_id is a no-op for workspace
|
||||
// semantics.
|
||||
func (r *Repository) UpdateTaskParentID(ctx context.Context, taskID, parentID string) error {
|
||||
return r.execTaskScalar(ctx, taskID, "parent_id", parentID)
|
||||
query := `
|
||||
UPDATE tasks
|
||||
SET parent_id = ?,
|
||||
metadata = CASE
|
||||
WHEN parent_id IS NOT ? AND json_valid(metadata) THEN CASE
|
||||
WHEN json_extract(metadata, '$.workspace.mode') = 'inherit_parent'
|
||||
THEN json_set(metadata, '$.workspace.mode', 'shared_group')
|
||||
ELSE metadata
|
||||
END
|
||||
ELSE metadata
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`
|
||||
result, err := r.db.ExecContext(ctx, r.db.Rebind(query), parentID, parentID, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("task not found: %s", taskID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// taskScalarColumns enumerates the only columns execTaskScalar may target.
|
||||
@@ -772,7 +800,6 @@ func (r *Repository) UpdateTaskParentID(ctx context.Context, taskID, parentID st
|
||||
var taskScalarColumns = map[string]struct{}{
|
||||
"priority": {},
|
||||
"project_id": {},
|
||||
"parent_id": {},
|
||||
}
|
||||
|
||||
// execTaskScalar updates a single TEXT column on a task and bumps updated_at.
|
||||
|
||||
@@ -324,3 +324,132 @@ func TestService_UpdateTask_RejectsCrossWorkspaceParent(t *testing.T) {
|
||||
t.Fatalf("expected cross-workspace error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// setInheritedWorkspaceMode stamps an inherit_parent workspace block (with a
|
||||
// group id) onto an existing task row, simulating a subtask created under a
|
||||
// parent with inherited workspace policy.
|
||||
func setInheritedWorkspaceMode(t *testing.T, ctx context.Context, repo *sqliterepo.Repository, taskID string) {
|
||||
t.Helper()
|
||||
task, err := repo.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask %s: %v", taskID, err)
|
||||
}
|
||||
task.Metadata["workspace"] = map[string]interface{}{
|
||||
"mode": "inherit_parent",
|
||||
"group_id": "group-1",
|
||||
}
|
||||
if err := repo.UpdateTask(ctx, task); err != nil {
|
||||
t.Fatalf("UpdateTask fixture %s: %v", taskID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWorkspaceMode(t *testing.T, metadata map[string]interface{}, want string) {
|
||||
t.Helper()
|
||||
workspace, ok := metadata["workspace"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("workspace metadata = %#v", metadata["workspace"])
|
||||
}
|
||||
if workspace["mode"] != want {
|
||||
t.Fatalf("workspace mode = %#v, want %s", workspace["mode"], want)
|
||||
}
|
||||
}
|
||||
|
||||
// Reparenting an inherit_parent subtask must normalize the mode to
|
||||
// shared_group — the same composite result as detach-then-nest — while
|
||||
// keeping its workspace group id.
|
||||
func TestService_UpdateTask_ReparentNormalizesInheritedWorkspaceMode(t *testing.T) {
|
||||
svc, eventBus, repo, create := reparentFixture(t)
|
||||
ctx := context.Background()
|
||||
oldParent := create("Old parent")
|
||||
child := create("Child")
|
||||
newParent := create("New parent")
|
||||
if _, err := svc.UpdateTask(ctx, child.ID, &UpdateTaskRequest{ParentID: strptr(oldParent.ID)}); err != nil {
|
||||
t.Fatalf("nest child under old parent: %v", err)
|
||||
}
|
||||
setInheritedWorkspaceMode(t, ctx, repo, child.ID)
|
||||
eventBus.ClearEvents()
|
||||
|
||||
updated, err := svc.UpdateTask(ctx, child.ID, &UpdateTaskRequest{ParentID: strptr(newParent.ID)})
|
||||
if err != nil {
|
||||
t.Fatalf("reparent: %v", err)
|
||||
}
|
||||
if updated.ParentID != newParent.ID {
|
||||
t.Fatalf("ParentID = %q, want %q", updated.ParentID, newParent.ID)
|
||||
}
|
||||
assertWorkspaceMode(t, updated.Metadata, "shared_group")
|
||||
|
||||
persisted, err := repo.GetTask(ctx, child.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
assertWorkspaceMode(t, persisted.Metadata, "shared_group")
|
||||
workspace := persisted.Metadata["workspace"].(map[string]interface{})
|
||||
if workspace["group_id"] != "group-1" {
|
||||
t.Fatalf("workspace group_id = %#v, want group-1", workspace["group_id"])
|
||||
}
|
||||
|
||||
eventData := singleDetachmentEventData(t, eventBus)
|
||||
if eventData["parent_id"] != newParent.ID {
|
||||
t.Fatalf("event parent_id = %#v, want %q", eventData["parent_id"], newParent.ID)
|
||||
}
|
||||
eventMetadata, ok := eventData["metadata"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event metadata = %#v", eventData["metadata"])
|
||||
}
|
||||
assertWorkspaceMode(t, eventMetadata, "shared_group")
|
||||
}
|
||||
|
||||
// Un-nesting through UpdateTask (empty parent_id) must apply the same
|
||||
// normalization as the detach endpoint.
|
||||
func TestService_UpdateTask_UnnestNormalizesInheritedWorkspaceMode(t *testing.T) {
|
||||
svc, eventBus, repo, create := reparentFixture(t)
|
||||
ctx := context.Background()
|
||||
parent := create("Parent")
|
||||
child := create("Child")
|
||||
if _, err := svc.UpdateTask(ctx, child.ID, &UpdateTaskRequest{ParentID: strptr(parent.ID)}); err != nil {
|
||||
t.Fatalf("nest: %v", err)
|
||||
}
|
||||
setInheritedWorkspaceMode(t, ctx, repo, child.ID)
|
||||
eventBus.ClearEvents()
|
||||
|
||||
updated, err := svc.UpdateTask(ctx, child.ID, &UpdateTaskRequest{ParentID: strptr("")})
|
||||
if err != nil {
|
||||
t.Fatalf("unnest: %v", err)
|
||||
}
|
||||
assertWorkspaceMode(t, updated.Metadata, "shared_group")
|
||||
|
||||
persisted, err := repo.GetTask(ctx, child.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
assertWorkspaceMode(t, persisted.Metadata, "shared_group")
|
||||
}
|
||||
|
||||
// Non-inherited workspace modes must survive a re-parent untouched.
|
||||
func TestService_UpdateTask_ReparentPreservesNonInheritedWorkspaceModes(t *testing.T) {
|
||||
for _, mode := range []string{"shared_group", "new_workspace"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
svc, _, repo, create := reparentFixture(t)
|
||||
ctx := context.Background()
|
||||
child := create("Child")
|
||||
newParent := create("New parent")
|
||||
task, err := repo.GetTask(ctx, child.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
task.Metadata["workspace"] = map[string]interface{}{
|
||||
"mode": mode,
|
||||
"group_id": "group-1",
|
||||
}
|
||||
if err := repo.UpdateTask(ctx, task); err != nil {
|
||||
t.Fatalf("UpdateTask fixture: %v", err)
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateTask(ctx, child.ID, &UpdateTaskRequest{ParentID: strptr(newParent.ID)})
|
||||
if err != nil {
|
||||
t.Fatalf("reparent: %v", err)
|
||||
}
|
||||
assertWorkspaceMode(t, updated.Metadata, mode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1256,6 +1256,11 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ
|
||||
}
|
||||
parentCleared = *req.ParentID == ""
|
||||
task.ParentID = *req.ParentID
|
||||
// Re-parenting (or un-nesting) an inherit_parent subtask keeps its
|
||||
// materialized workspace as shared_group instead of silently
|
||||
// inheriting a different parent's workspace — the same composite
|
||||
// semantics as detach-then-nest.
|
||||
normalizeWorkspaceModeAfterReparent(task)
|
||||
}
|
||||
task.UpdatedAt = time.Now().UTC()
|
||||
|
||||
@@ -1387,6 +1392,22 @@ const parentIDEventField = "parent_id"
|
||||
// rather than an internal error (500).
|
||||
var ErrInvalidParent = errors.New("invalid parent")
|
||||
|
||||
// normalizeWorkspaceModeAfterReparent applies the detach operation's
|
||||
// workspace policy to an explicit parent change: an inherit_parent subtask
|
||||
// whose hierarchy is being changed must not silently start inheriting a
|
||||
// different parent's workspace, so its mode becomes shared_group (its
|
||||
// materialized workspace and group membership are unchanged). Other modes
|
||||
// pass through.
|
||||
func normalizeWorkspaceModeAfterReparent(task *models.Task) {
|
||||
workspace, ok := task.Metadata["workspace"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if workspace["mode"] == workspaceModeInheritParent {
|
||||
workspace["mode"] = workspaceModeSharedGroup
|
||||
}
|
||||
}
|
||||
|
||||
// resolveParentID validates a proposed parent assignment for task. An empty
|
||||
// parentID (un-nest) is always allowed. A non-empty parentID must reference a
|
||||
// different, existing, non-archived task in the same workspace, and must not
|
||||
|
||||
@@ -9,6 +9,7 @@ import { buildPrepareRequest } from "@/lib/services/session-launch-helpers";
|
||||
import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks";
|
||||
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
|
||||
import { useTaskDetachDialog } from "@/hooks/use-detach-task";
|
||||
import { useNestTaskByDrag } from "@/hooks/use-nest-task";
|
||||
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
||||
import { workspaceModeFromMetadata } from "@/lib/kanban/map-task";
|
||||
import {
|
||||
@@ -523,6 +524,14 @@ function useSheetDeleteActions(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-parent via drag: runs the same composite nest operation the context menu
|
||||
* uses, resolving the workflow from the snapshot keys.
|
||||
*/
|
||||
function useSheetNestTask() {
|
||||
return useNestTaskByDrag();
|
||||
}
|
||||
|
||||
export function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolean) => void) {
|
||||
const { t } = useTranslation();
|
||||
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
||||
@@ -532,6 +541,7 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open:
|
||||
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store });
|
||||
const deleteActions = useSheetDeleteActions(store, removeTaskFromBoard);
|
||||
const detachActions = useTaskDetachDialog(store);
|
||||
const handleNestTask = useSheetNestTask();
|
||||
|
||||
const handleSelectTask = useCallback(
|
||||
(taskId: string) => {
|
||||
@@ -617,6 +627,7 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open:
|
||||
handleArchiveTask,
|
||||
handleWorkspaceChange,
|
||||
handleTaskCreated,
|
||||
handleNestTask,
|
||||
archivingTask,
|
||||
setArchivingTask,
|
||||
isArchiving,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { TaskSwitcher } from "../task-switcher";
|
||||
import type { TaskSwitcherItem } from "../task-switcher";
|
||||
import type { TaskSwitcherItem, TaskSwitcherProps } from "../task-switcher";
|
||||
import { SidebarFilterBar } from "../sidebar-filter/sidebar-filter-bar";
|
||||
import type { StepDef } from "../task-switcher-context-menu";
|
||||
import type { TaskMoveWorkflow } from "../task-move-context-menu";
|
||||
@@ -60,32 +60,7 @@ function useSidebarGroupToggle(viewId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- assembles the shared task tree with the mobile drawer's view state.
|
||||
export function MobileTaskList({
|
||||
tasks,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
activeTaskId,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
onEditTask,
|
||||
onRenameTask,
|
||||
onCreateSubtask,
|
||||
onArchiveTask,
|
||||
onDeleteTask,
|
||||
onDetachTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkMergeRequest,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
deletingTaskId,
|
||||
isLoading,
|
||||
loadError,
|
||||
onRetryLoad,
|
||||
retryLabel,
|
||||
}: {
|
||||
type MobileTaskListProps = {
|
||||
tasks: TaskSwitcherItem[];
|
||||
workflows: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId: Record<string, StepDef[]>;
|
||||
@@ -98,6 +73,7 @@ export function MobileTaskList({
|
||||
onArchiveTask: (taskId: string) => void;
|
||||
onDeleteTask: (taskId: string) => Promise<void> | void;
|
||||
onDetachTask: (taskId: string) => Promise<void> | void;
|
||||
onNestTask?: (taskId: string, parentTaskId: string) => void;
|
||||
onLinkPullRequest?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkIssue?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkMergeRequest?: (taskId: string, taskTitle?: string) => void;
|
||||
@@ -109,7 +85,69 @@ export function MobileTaskList({
|
||||
loadError?: string | null;
|
||||
onRetryLoad?: () => void;
|
||||
retryLabel?: string;
|
||||
}) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Assembles the TaskSwitcher props for the mobile task list, mirroring the
|
||||
* desktop `buildTaskSwitcherProps` so the prop-forwarding surface stays a
|
||||
* thin mapping layer.
|
||||
*/
|
||||
function buildMobileTaskSwitcherProps(
|
||||
props: MobileTaskListProps,
|
||||
helpers: {
|
||||
grouped: TaskSwitcherProps["grouped"];
|
||||
collapsedGroupKeys: string[];
|
||||
onToggleGroup: (groupKey: string) => void;
|
||||
collapsedSubtaskParentIds: string[];
|
||||
onToggleSubtasks: (parentTaskId: string) => void;
|
||||
onTogglePin: (taskId: string) => void;
|
||||
onReorderGroup: (groupTaskIds: string[]) => void;
|
||||
onReorderSubtasks: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
pinnedTaskIds: string[];
|
||||
},
|
||||
): TaskSwitcherProps {
|
||||
return {
|
||||
grouped: helpers.grouped,
|
||||
workflows: props.workflows,
|
||||
stepsByWorkflowId: props.stepsByWorkflowId,
|
||||
activeTaskId: props.activeTaskId,
|
||||
selectedTaskId: props.selectedTaskId,
|
||||
collapsedGroupKeys: helpers.collapsedGroupKeys,
|
||||
onToggleGroup: helpers.onToggleGroup,
|
||||
collapsedSubtaskParentIds: helpers.collapsedSubtaskParentIds,
|
||||
onToggleSubtasks: helpers.onToggleSubtasks,
|
||||
onSelectTask: props.onSelectTask,
|
||||
onEditTask: props.onEditTask,
|
||||
onRenameTask: props.onRenameTask,
|
||||
onCreateSubtask: props.onCreateSubtask,
|
||||
onArchiveTask: props.onArchiveTask,
|
||||
onDeleteTask: props.onDeleteTask,
|
||||
onDetachTask: props.onDetachTask,
|
||||
onNestTask: props.onNestTask,
|
||||
onLinkPullRequest: props.onLinkPullRequest,
|
||||
onLinkIssue: props.onLinkIssue,
|
||||
onLinkMergeRequest: props.onLinkMergeRequest,
|
||||
onLinkJiraTicket: props.onLinkJiraTicket,
|
||||
onLinkLinearIssue: props.onLinkLinearIssue,
|
||||
onLinkSentryIssue: props.onLinkSentryIssue,
|
||||
onTogglePin: helpers.onTogglePin,
|
||||
onReorderGroup: helpers.onReorderGroup,
|
||||
onReorderSubtasks: helpers.onReorderSubtasks,
|
||||
pinnedTaskIds: helpers.pinnedTaskIds,
|
||||
deletingTaskId: props.deletingTaskId,
|
||||
isLoading: props.isLoading,
|
||||
loadError: props.loadError,
|
||||
onRetryLoad: props.onRetryLoad,
|
||||
retryLabel: props.retryLabel,
|
||||
totalTaskCount: props.tasks.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The mobile task tree surface: renders the shared TaskSwitcher with the
|
||||
* mobile drawer's view state (grouping, ordering, collapse, reorder, nest).
|
||||
*/
|
||||
export function MobileTaskList(props: MobileTaskListProps) {
|
||||
const view = useEffectiveSidebarView();
|
||||
const {
|
||||
pinnedTaskIds,
|
||||
@@ -126,49 +164,25 @@ export function MobileTaskList({
|
||||
const { i18n } = useTranslation();
|
||||
const grouped = useMemo(
|
||||
() =>
|
||||
applyView(tasks, view, {
|
||||
applyView(props.tasks, view, {
|
||||
pinnedTaskIds,
|
||||
orderedTaskIds,
|
||||
subtaskOrderByParentId,
|
||||
}),
|
||||
[tasks, view, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId, i18n.language],
|
||||
);
|
||||
return (
|
||||
<TaskSwitcher
|
||||
grouped={grouped}
|
||||
workflows={workflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
activeTaskId={activeTaskId}
|
||||
selectedTaskId={selectedTaskId}
|
||||
collapsedGroupKeys={view.collapsedGroups}
|
||||
onToggleGroup={handleToggleGroup}
|
||||
collapsedSubtaskParentIds={collapsedSubtaskParents}
|
||||
onToggleSubtasks={toggleSubtaskCollapsed}
|
||||
onSelectTask={onSelectTask}
|
||||
onEditTask={onEditTask}
|
||||
onRenameTask={onRenameTask}
|
||||
onCreateSubtask={onCreateSubtask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onDetachTask={onDetachTask}
|
||||
onLinkPullRequest={onLinkPullRequest}
|
||||
onLinkIssue={onLinkIssue}
|
||||
onLinkMergeRequest={onLinkMergeRequest}
|
||||
onLinkJiraTicket={onLinkJiraTicket}
|
||||
onLinkLinearIssue={onLinkLinearIssue}
|
||||
onLinkSentryIssue={onLinkSentryIssue}
|
||||
onTogglePin={togglePinnedTask}
|
||||
onReorderGroup={handleReorderGroup}
|
||||
onReorderSubtasks={handleReorderSubtasks}
|
||||
pinnedTaskIds={pinnedTaskIds}
|
||||
deletingTaskId={deletingTaskId}
|
||||
isLoading={isLoading}
|
||||
loadError={loadError}
|
||||
onRetryLoad={onRetryLoad}
|
||||
retryLabel={retryLabel}
|
||||
totalTaskCount={tasks.length}
|
||||
/>
|
||||
[props.tasks, view, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId, i18n.language],
|
||||
);
|
||||
const switcherProps = buildMobileTaskSwitcherProps(props, {
|
||||
grouped,
|
||||
collapsedGroupKeys: view.collapsedGroups,
|
||||
onToggleGroup: handleToggleGroup,
|
||||
collapsedSubtaskParentIds: collapsedSubtaskParents,
|
||||
onToggleSubtasks: toggleSubtaskCollapsed,
|
||||
onTogglePin: togglePinnedTask,
|
||||
onReorderGroup: handleReorderGroup,
|
||||
onReorderSubtasks: handleReorderSubtasks,
|
||||
pinnedTaskIds,
|
||||
});
|
||||
return <TaskSwitcher {...switcherProps} />;
|
||||
}
|
||||
|
||||
function TaskSwitcherSurfaceHeader({
|
||||
@@ -334,6 +348,7 @@ function TaskSwitcherSurfaceContent({
|
||||
onArchiveTask={surfaceAction(presentation, onOpenChange, actions.handleArchiveTask)}
|
||||
onDeleteTask={surfaceAction(presentation, onOpenChange, actions.handleDeleteTask)}
|
||||
onDetachTask={surfaceAction(presentation, onOpenChange, actions.handleDetachTask)}
|
||||
onNestTask={actions.handleNestTask}
|
||||
onLinkPullRequest={surfaceAction(
|
||||
presentation,
|
||||
onOpenChange,
|
||||
|
||||
@@ -27,6 +27,7 @@ export function buildTaskSwitcherProps(args: {
|
||||
togglePinnedTask: TaskSwitcherComponentProps["onTogglePin"];
|
||||
handleReorderGroup: TaskSwitcherComponentProps["onReorderGroup"];
|
||||
handleReorderSubtasks: TaskSwitcherComponentProps["onReorderSubtasks"];
|
||||
handleNestTask: TaskSwitcherComponentProps["onNestTask"];
|
||||
isLoadingWorkflow: boolean;
|
||||
archivedError: string | null;
|
||||
retryArchivedTasks: () => void;
|
||||
@@ -57,6 +58,7 @@ export function buildTaskSwitcherProps(args: {
|
||||
onTogglePin: args.togglePinnedTask,
|
||||
onReorderGroup: args.handleReorderGroup,
|
||||
onReorderSubtasks: args.handleReorderSubtasks,
|
||||
onNestTask: args.handleNestTask,
|
||||
pinnedTaskIds: args.pinnedTaskIds,
|
||||
deletingTaskId: args.sidebarActions.deletingTaskId,
|
||||
isLoading: args.isLoadingWorkflow,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks";
|
||||
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
|
||||
import { useTaskDetachDialog } from "@/hooks/use-detach-task";
|
||||
import { useNestTaskByDrag } from "@/hooks/use-nest-task";
|
||||
import { useSidebarSelection, SidebarBulkDialogs } from "./task-session-sidebar-selection";
|
||||
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
||||
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
||||
@@ -342,6 +343,7 @@ export function useSidebarActions(store: StoreApi) {
|
||||
const archiveActions = useArchiveActions(store);
|
||||
const deleteActions = useDeleteActions(store, removeTaskFromBoard);
|
||||
const detachActions = useTaskDetachDialog(store);
|
||||
const handleNestTask = useNestTaskByDrag();
|
||||
const linkActions = useSidebarLinkActions(store);
|
||||
const editActions = useSidebarTaskEdit();
|
||||
|
||||
@@ -377,6 +379,7 @@ export function useSidebarActions(store: StoreApi) {
|
||||
preparingTaskId,
|
||||
handleSelectTask,
|
||||
handleMoveToStep,
|
||||
handleNestTask,
|
||||
renamingTask,
|
||||
setRenamingTask,
|
||||
handleRenameTask,
|
||||
@@ -467,6 +470,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
togglePinnedTask,
|
||||
handleReorderGroup,
|
||||
handleReorderSubtasks,
|
||||
handleNestTask: sidebarActions.handleNestTask,
|
||||
isLoadingWorkflow,
|
||||
archivedError,
|
||||
retryArchivedTasks,
|
||||
|
||||
@@ -1,24 +1,157 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DRAG_ACTIVATION_DISTANCE,
|
||||
TOUCH_DRAG_ACTIVATION_DELAY_MS,
|
||||
TOUCH_DRAG_ACTIVATION_TOLERANCE_PX,
|
||||
taskSwitcherDragActivationConstraints,
|
||||
} from "@/components/task/task-switcher-subtask-dnd";
|
||||
computeNestTargets,
|
||||
nestDroppableId,
|
||||
parseNestDroppableId,
|
||||
resolveSidebarDrop,
|
||||
} from "./task-switcher-subtask-dnd";
|
||||
|
||||
describe("task switcher subtask DnD activation", () => {
|
||||
it("keeps pointer dragging responsive but delays touch dragging for scrollable mobile lists", () => {
|
||||
expect(taskSwitcherDragActivationConstraints()).toEqual({
|
||||
pointer: { distance: DRAG_ACTIVATION_DISTANCE },
|
||||
touch: {
|
||||
delay: TOUCH_DRAG_ACTIVATION_DELAY_MS,
|
||||
tolerance: TOUCH_DRAG_ACTIVATION_TOLERANCE_PX,
|
||||
},
|
||||
});
|
||||
// Fixture ids hoisted so the repeated literals stay below sonarjs's
|
||||
// no-duplicate-string threshold.
|
||||
const ROOT_A = "a";
|
||||
const ROOT_B = "b";
|
||||
const SUB_C = "c";
|
||||
const SUB_D = "d";
|
||||
const SUB_E = "e";
|
||||
const WORKFLOW = "wf-1";
|
||||
const OTHER_WORKFLOW = "wf-2";
|
||||
const PWC = "parent-with-child"; // root that already has a child
|
||||
const PWC_CHILD = "child-of-pwc";
|
||||
|
||||
function task(id: string, overrides: { workflowId?: string; parentTaskId?: string | null } = {}) {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
workflowId: overrides.workflowId ?? WORKFLOW,
|
||||
parentTaskId: overrides.parentTaskId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("nestDroppableId / parseNestDroppableId", () => {
|
||||
it("round-trips task ids through the nest prefix", () => {
|
||||
expect(parseNestDroppableId(nestDroppableId("task-1"))).toBe("task-1");
|
||||
});
|
||||
|
||||
it("reuses stable activation constraint references across renders", () => {
|
||||
expect(taskSwitcherDragActivationConstraints()).toBe(taskSwitcherDragActivationConstraints());
|
||||
it("returns null for non-nest droppable ids", () => {
|
||||
expect(parseNestDroppableId("task-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSidebarDrop", () => {
|
||||
const roots = [ROOT_A, ROOT_B];
|
||||
const childrenByParent = new Map<string, string[]>([
|
||||
[ROOT_A, [SUB_C, SUB_D]],
|
||||
[ROOT_B, [SUB_E]],
|
||||
]);
|
||||
|
||||
it("maps a drop on a nest zone to a re-parent", () => {
|
||||
expect(
|
||||
resolveSidebarDrop({
|
||||
activeId: SUB_C,
|
||||
overId: nestDroppableId(ROOT_B),
|
||||
groupRootIds: roots,
|
||||
childrenByParent,
|
||||
}),
|
||||
).toEqual({ kind: "nest", parentTaskId: ROOT_B });
|
||||
});
|
||||
|
||||
it("rejects nesting under the dragged task itself", () => {
|
||||
expect(
|
||||
resolveSidebarDrop({
|
||||
activeId: ROOT_A,
|
||||
overId: nestDroppableId(ROOT_A),
|
||||
groupRootIds: roots,
|
||||
childrenByParent,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("reorders within the group root level", () => {
|
||||
expect(
|
||||
resolveSidebarDrop({
|
||||
activeId: ROOT_A,
|
||||
overId: ROOT_B,
|
||||
groupRootIds: roots,
|
||||
childrenByParent,
|
||||
}),
|
||||
).toEqual({ kind: "reorder-group", orderedTaskIds: [ROOT_B, ROOT_A] });
|
||||
});
|
||||
|
||||
it("reorders within one parent's children", () => {
|
||||
expect(
|
||||
resolveSidebarDrop({
|
||||
activeId: SUB_C,
|
||||
overId: SUB_D,
|
||||
groupRootIds: roots,
|
||||
childrenByParent,
|
||||
}),
|
||||
).toEqual({ kind: "reorder-subtasks", parentTaskId: ROOT_A, orderedTaskIds: [SUB_D, SUB_C] });
|
||||
});
|
||||
|
||||
it("rejects cross-level drops (subtask onto a sibling root)", () => {
|
||||
expect(
|
||||
resolveSidebarDrop({
|
||||
activeId: SUB_C,
|
||||
overId: ROOT_B,
|
||||
groupRootIds: roots,
|
||||
childrenByParent,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a drop on the dragged task's own row", () => {
|
||||
expect(
|
||||
resolveSidebarDrop({
|
||||
activeId: SUB_C,
|
||||
overId: SUB_C,
|
||||
groupRootIds: roots,
|
||||
childrenByParent,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a drop with no over target", () => {
|
||||
expect(
|
||||
resolveSidebarDrop({
|
||||
activeId: SUB_C,
|
||||
overId: null,
|
||||
groupRootIds: roots,
|
||||
childrenByParent,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeNestTargets", () => {
|
||||
const tasks = [
|
||||
task(ROOT_A), // root
|
||||
task(SUB_C, { parentTaskId: ROOT_A }), // subtask of a
|
||||
task(ROOT_B), // root
|
||||
task(SUB_E, { parentTaskId: ROOT_B }), // subtask of b
|
||||
task("other-wf", { workflowId: OTHER_WORKFLOW }), // root in another workflow
|
||||
task(PWC), // root with a child (valid target)
|
||||
task(PWC_CHILD, { parentTaskId: PWC }),
|
||||
];
|
||||
|
||||
it("offers same-workflow roots excluding the task and its current parent", () => {
|
||||
// c (subtask of a) may nest under b and parent-with-child only.
|
||||
expect(computeNestTargets(task(SUB_C, { parentTaskId: ROOT_A }), tasks)).toEqual(
|
||||
new Set([ROOT_B, PWC]),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers no targets when the task has children (would exceed one level)", () => {
|
||||
expect(computeNestTargets(task(PWC), tasks)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("offers no targets when the active task is unknown", () => {
|
||||
expect(computeNestTargets(undefined, tasks)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("excludes subtasks and other workflows even when they are roots", () => {
|
||||
const targets = computeNestTargets(task(SUB_C, { parentTaskId: ROOT_A }), tasks);
|
||||
expect(targets.has(SUB_E)).toBe(false);
|
||||
expect(targets.has("other-wf")).toBe(false);
|
||||
expect(targets.has(SUB_C)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
useDroppable,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
@@ -17,7 +21,9 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { computeNestCandidates } from "@/lib/sidebar/nest-candidates";
|
||||
import type { TaskSwitcherItem } from "./task-switcher";
|
||||
|
||||
export const DRAG_ACTIVATION_DISTANCE = 8;
|
||||
@@ -32,6 +38,7 @@ const TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Shared mouse/touch activation constraints for the sidebar tree drags. */
|
||||
export function taskSwitcherDragActivationConstraints() {
|
||||
return TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS;
|
||||
}
|
||||
@@ -43,6 +50,120 @@ export function taskSwitcherDragActivationConstraints() {
|
||||
// measurements remain accurate for previously rendered rows.
|
||||
const OFFSCREEN_SKIP = "[content-visibility:auto] [contain-intrinsic-block-size:auto_52px]";
|
||||
|
||||
// Nest drop zones are registered as `nest:<taskId>` droppables. A drop on one
|
||||
// re-parents the dragged task under that task; any other drop keeps the
|
||||
// existing sibling-reorder semantics.
|
||||
export const NEST_DROPPABLE_PREFIX = "nest:";
|
||||
|
||||
/** Droppable id of a row's nest drop zone: `nest:<taskId>`. */
|
||||
export function nestDroppableId(taskId: string): string {
|
||||
return NEST_DROPPABLE_PREFIX + taskId;
|
||||
}
|
||||
|
||||
/** Returns the task id when `id` is a nest droppable id, else null. */
|
||||
export function parseNestDroppableId(id: string): string | null {
|
||||
return id.startsWith(NEST_DROPPABLE_PREFIX) ? id.slice(NEST_DROPPABLE_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
export type SidebarDropDecision =
|
||||
| { kind: "nest"; parentTaskId: string }
|
||||
| { kind: "reorder-group"; orderedTaskIds: string[] }
|
||||
| { kind: "reorder-subtasks"; parentTaskId: string; orderedTaskIds: string[] };
|
||||
|
||||
/**
|
||||
* Maps a group-level drag end onto the single intended action.
|
||||
*
|
||||
* A drop on a `nest:` droppable re-parents the dragged task. Any other drop
|
||||
* reorders only when both tasks share a sibling level (group root list or one
|
||||
* parent's children); cross-level drops are a no-op, matching the previous
|
||||
* per-level behavior where a drag could never leave its own level.
|
||||
*/
|
||||
export function resolveSidebarDrop(args: {
|
||||
activeId: string;
|
||||
overId: string | null;
|
||||
groupRootIds: string[];
|
||||
childrenByParent: Map<string, string[]>;
|
||||
}): SidebarDropDecision | null {
|
||||
const { activeId, overId, groupRootIds, childrenByParent } = args;
|
||||
if (!overId || overId === activeId) return null;
|
||||
|
||||
const nestTarget = parseNestDroppableId(overId);
|
||||
if (nestTarget !== null) {
|
||||
if (nestTarget === activeId) return null;
|
||||
return { kind: "nest", parentTaskId: nestTarget };
|
||||
}
|
||||
|
||||
const levelOf = (taskId: string): string | null => {
|
||||
if (groupRootIds.includes(taskId)) return GROUP_ROOT_LEVEL;
|
||||
for (const [parentId, ids] of childrenByParent) {
|
||||
if (ids.includes(taskId)) return parentId;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const activeLevel = levelOf(activeId);
|
||||
const overLevel = levelOf(overId);
|
||||
if (activeLevel === null || activeLevel !== overLevel) return null;
|
||||
|
||||
const levelIds =
|
||||
activeLevel === GROUP_ROOT_LEVEL ? groupRootIds : (childrenByParent.get(activeLevel) ?? []);
|
||||
const oldIndex = levelIds.indexOf(activeId);
|
||||
const newIndex = levelIds.indexOf(overId);
|
||||
if (oldIndex < 0 || newIndex < 0) return null;
|
||||
const ordered = arrayMove(levelIds, oldIndex, newIndex);
|
||||
if (activeLevel === GROUP_ROOT_LEVEL) {
|
||||
return { kind: "reorder-group", orderedTaskIds: ordered };
|
||||
}
|
||||
return { kind: "reorder-subtasks", parentTaskId: activeLevel, orderedTaskIds: ordered };
|
||||
}
|
||||
|
||||
// Sentinel level id for the group root list inside resolveSidebarDrop.
|
||||
const GROUP_ROOT_LEVEL = "__group_root__";
|
||||
|
||||
/**
|
||||
* Computes the rows a drag may nest the active task under, mirroring the
|
||||
* context menu's candidate rules (computeNestCandidates) scoped to the
|
||||
* active task's workflow: same-workflow roots, excluding the task itself,
|
||||
* its current parent, and any subtask (which also excludes its descendants).
|
||||
*/
|
||||
export function computeNestTargets(
|
||||
activeTask: Pick<TaskSwitcherItem, "id" | "workflowId" | "parentTaskId"> | undefined,
|
||||
groupTasks: TaskSwitcherItem[],
|
||||
): Set<string> {
|
||||
if (!activeTask) return new Set();
|
||||
const sameWorkflow = groupTasks.filter((t) => t.workflowId === activeTask.workflowId);
|
||||
return new Set(computeNestCandidates(sameWorkflow, activeTask.id).map((t) => t.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* The drop target rendered on a row that the active drag may nest under.
|
||||
* Absolute strip at the row's left edge; dropping on it re-parents the
|
||||
* dragged task instead of reordering. Rendered only during an active drag
|
||||
* with valid targets, so it never intercepts normal row clicks.
|
||||
*/
|
||||
export function NestDropZone({ taskId, title }: { taskId: string; title: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { setNodeRef, isOver } = useDroppable({ id: nestDroppableId(taskId) });
|
||||
const label = t("sidebar:nestUnder", { title });
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
data-testid="nest-drop-zone"
|
||||
data-task-id={taskId}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 z-20 w-6",
|
||||
isOver ? "bg-primary/20" : "bg-transparent hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
{isOver && (
|
||||
<span className="pointer-events-none absolute left-6 top-1/2 -translate-y-1/2 whitespace-nowrap rounded border bg-background px-2 py-0.5 text-xs shadow">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sortable implementation — isolated so `useSortable` is never called conditionally. */
|
||||
function DraggableSortableTaskNode({
|
||||
taskId,
|
||||
@@ -172,25 +293,148 @@ function useLevelDnd(tasks: TaskSwitcherItem[], onReorder?: (orderedTaskIds: str
|
||||
* Renders one level of sibling task nodes. When `onReorder` is omitted the DnD
|
||||
* scaffolding (and the misleading grab cursor) is skipped, so non-reorderable
|
||||
* callers get a plain list with no wasted setup.
|
||||
*
|
||||
* By default each level owns its own `DndContext`, which confines every drag
|
||||
* to its sibling level. When `externalDragContext` is set the caller already
|
||||
* rendered a group-spanning `DndContext` (see `TaskTreeDndGroup`) so cross-
|
||||
* level drops like nest-under can be resolved — this level only contributes
|
||||
* its `SortableContext` and nodes.
|
||||
*/
|
||||
export function SortableTaskLevel({
|
||||
tasks,
|
||||
onReorder,
|
||||
renderNode,
|
||||
externalDragContext = false,
|
||||
}: {
|
||||
tasks: TaskSwitcherItem[];
|
||||
onReorder?: (orderedTaskIds: string[]) => void;
|
||||
renderNode: (task: TaskSwitcherItem, isDraggable: boolean) => React.ReactNode;
|
||||
externalDragContext?: boolean;
|
||||
}) {
|
||||
const { sensors, handleDragEnd, sortableIds } = useLevelDnd(tasks, onReorder);
|
||||
const isDraggable = !!onReorder;
|
||||
const body = tasks.map((task) => renderNode(task, isDraggable));
|
||||
if (!onReorder) return <>{body}</>;
|
||||
const sortable = (
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{body}
|
||||
</SortableContext>
|
||||
);
|
||||
if (externalDragContext) return sortable;
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{body}
|
||||
</SortableContext>
|
||||
{sortable}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One group-spanning DndContext for the sidebar task tree. Replaces the
|
||||
* per-level contexts so a drag can target rows outside its own sibling level:
|
||||
* reorder stays scoped to the level the drag started in (resolveSidebarDrop),
|
||||
* and dropping on a `nest:` zone re-parents the dragged task.
|
||||
*
|
||||
* `children` receives the nest-target ids and the active drag id so rows can
|
||||
* render their `NestDropZone` affordance only when they are valid targets.
|
||||
*/
|
||||
export function TaskTreeDndGroup({
|
||||
groupTasks,
|
||||
onReorderGroup,
|
||||
onReorderSubtasks,
|
||||
onNestTask,
|
||||
children,
|
||||
}: {
|
||||
groupTasks: TaskSwitcherItem[];
|
||||
onReorderGroup?: (groupTaskIds: string[]) => void;
|
||||
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
onNestTask?: (activeTaskId: string, parentTaskId: string) => void;
|
||||
children: (nestTargetIds: Set<string>, activeDragId: string | null) => React.ReactNode;
|
||||
}) {
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.pointer,
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.touch,
|
||||
}),
|
||||
);
|
||||
const enabled = !!onReorderGroup || !!onReorderSubtasks || !!onNestTask;
|
||||
const { tasksById, groupRootIds, childrenByParent } = useMemo(() => {
|
||||
const ids = new Set(groupTasks.map((t) => t.id));
|
||||
const byParent = new Map<string, string[]>();
|
||||
for (const t of groupTasks) {
|
||||
if (t.parentTaskId && ids.has(t.parentTaskId)) {
|
||||
const arr = byParent.get(t.parentTaskId) ?? [];
|
||||
arr.push(t.id);
|
||||
byParent.set(t.parentTaskId, arr);
|
||||
}
|
||||
}
|
||||
return {
|
||||
tasksById: new Map(groupTasks.map((t) => [t.id, t])),
|
||||
groupRootIds: groupTasks
|
||||
.filter((t) => !t.parentTaskId || !ids.has(t.parentTaskId))
|
||||
.map((t) => t.id),
|
||||
childrenByParent: byParent,
|
||||
};
|
||||
}, [groupTasks]);
|
||||
const nestTargetIds = useMemo(
|
||||
() => computeNestTargets(activeDragId ? tasksById.get(activeDragId) : undefined, groupTasks),
|
||||
[activeDragId, tasksById, groupTasks],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveDragId(String(event.active.id));
|
||||
}, []);
|
||||
const handleDragCancel = useCallback(() => setActiveDragId(null), []);
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveDragId(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
const decision = resolveSidebarDrop({
|
||||
activeId: String(active.id),
|
||||
overId: String(over.id),
|
||||
groupRootIds,
|
||||
childrenByParent,
|
||||
});
|
||||
if (!decision) return;
|
||||
switch (decision.kind) {
|
||||
case "nest":
|
||||
onNestTask?.(String(active.id), decision.parentTaskId);
|
||||
break;
|
||||
case "reorder-group":
|
||||
onReorderGroup?.(decision.orderedTaskIds);
|
||||
break;
|
||||
case "reorder-subtasks":
|
||||
onReorderSubtasks?.(decision.parentTaskId, decision.orderedTaskIds);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[groupRootIds, childrenByParent, onReorderGroup, onReorderSubtasks, onNestTask],
|
||||
);
|
||||
|
||||
const collisionDetection = useCallback<CollisionDetection>((args) => {
|
||||
// A pointer resting on a nest zone always means "nest under this row";
|
||||
// everywhere else falls back to the nearest-row reorder.
|
||||
const within = pointerWithin(args);
|
||||
const nest = within.filter((collision) =>
|
||||
collision.id.toString().startsWith(NEST_DROPPABLE_PREFIX),
|
||||
);
|
||||
if (nest.length > 0) return nest;
|
||||
return closestCenter(args);
|
||||
}, []);
|
||||
|
||||
if (!enabled) return <>{children(new Set(), null)}</>;
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
{children(nestTargetIds, activeDragId)}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { countGroupTasks, type SidebarGroup } from "@/lib/sidebar/apply-view";
|
||||
import { SortableTaskLevel, SortableTaskNode } from "./task-switcher-subtask-dnd";
|
||||
import {
|
||||
SortableTaskLevel,
|
||||
SortableTaskNode,
|
||||
TaskTreeDndGroup,
|
||||
NestDropZone,
|
||||
} from "./task-switcher-subtask-dnd";
|
||||
import { GroupHeader } from "./task-switcher-group";
|
||||
import { TaskRow, type SubtaskToggleInfo, type TaskRowProps } from "./task-switcher-row";
|
||||
import type { TaskSwitcherItem } from "./task-switcher-types";
|
||||
@@ -19,6 +25,13 @@ type TaskTreeContext = {
|
||||
rowProps: TaskRowBaseProps;
|
||||
onReorderGroup?: (groupTaskIds: string[]) => void;
|
||||
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
onNestTask?: (taskId: string, parentTaskId: string) => void;
|
||||
// Rows in nestTargetIds render a nest drop zone while a drag is active.
|
||||
nestTargetIds: Set<string>;
|
||||
activeDragId: string | null;
|
||||
// The group renders one DndContext spanning every level (TaskTreeDndGroup),
|
||||
// so levels must not create their own.
|
||||
externalDragContext: boolean;
|
||||
};
|
||||
|
||||
function TaskTreeNode({
|
||||
@@ -44,16 +57,22 @@ function TaskTreeNode({
|
||||
}
|
||||
: undefined;
|
||||
const isRoot = depth === 0;
|
||||
const isNestTarget = ctx.nestTargetIds.has(task.id);
|
||||
const handle = (
|
||||
<TaskRow
|
||||
task={task}
|
||||
depth={depth}
|
||||
isSubTask={!isRoot}
|
||||
subtaskToggle={toggleInfo}
|
||||
isPinned={isRoot && ctx.pinnedSet.has(task.id)}
|
||||
{...ctx.rowProps}
|
||||
onTogglePin={isRoot ? ctx.rowProps.onTogglePin : undefined}
|
||||
/>
|
||||
// The relative wrapper keeps the nest drop zone pinned to this row's
|
||||
// left edge rather than spanning the nested subtree below it.
|
||||
<div className="relative">
|
||||
<TaskRow
|
||||
task={task}
|
||||
depth={depth}
|
||||
isSubTask={!isRoot}
|
||||
subtaskToggle={toggleInfo}
|
||||
isPinned={isRoot && ctx.pinnedSet.has(task.id)}
|
||||
{...ctx.rowProps}
|
||||
onTogglePin={isRoot ? ctx.rowProps.onTogglePin : undefined}
|
||||
/>
|
||||
{isNestTarget && <NestDropZone taskId={task.id} title={task.title} />}
|
||||
</div>
|
||||
);
|
||||
const nested =
|
||||
!subsHidden && hasSubs ? (
|
||||
@@ -86,6 +105,7 @@ function TaskTreeLevel({
|
||||
<SortableTaskLevel
|
||||
tasks={tasks}
|
||||
onReorder={onReorder}
|
||||
externalDragContext={ctx.externalDragContext}
|
||||
renderNode={(task, levelDraggable) => (
|
||||
<TaskTreeNode
|
||||
key={task.id}
|
||||
@@ -117,8 +137,39 @@ export type GroupSectionProps = {
|
||||
showHeader: boolean;
|
||||
onReorderGroup?: (groupTaskIds: string[]) => void;
|
||||
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
onNestTask?: (taskId: string, parentTaskId: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattens one group's subtree (roots + every descendant, in rendered order)
|
||||
* so the group-spanning DndContext can resolve reorder levels and nest
|
||||
* targets without consulting other groups' children. Tracks visited ids so a
|
||||
* corrupted cycle in `subTasksByParentId` cannot recurse forever (mirrors
|
||||
* countGroupTasks' protection).
|
||||
*/
|
||||
function flattenGroupTasks(
|
||||
roots: TaskSwitcherItem[],
|
||||
subTasksByParentId: Map<string, TaskSwitcherItem[]>,
|
||||
): TaskSwitcherItem[] {
|
||||
const out: TaskSwitcherItem[] = [];
|
||||
const visited = new Set<string>();
|
||||
const visit = (task: TaskSwitcherItem) => {
|
||||
if (visited.has(task.id)) return;
|
||||
visited.add(task.id);
|
||||
out.push(task);
|
||||
const subs = subTasksByParentId.get(task.id);
|
||||
if (subs) {
|
||||
for (const sub of subs) visit(sub);
|
||||
}
|
||||
};
|
||||
for (const root of roots) visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one sidebar group's task tree (header + the recursive tree wrapped
|
||||
* in the group-spanning DndContext that enables nest-by-drag).
|
||||
*/
|
||||
export function GroupSection({
|
||||
group,
|
||||
subTasksByParentId,
|
||||
@@ -131,17 +182,32 @@ export function GroupSection({
|
||||
showHeader,
|
||||
onReorderGroup,
|
||||
onReorderSubtasks,
|
||||
onNestTask,
|
||||
}: GroupSectionProps) {
|
||||
const totalCount = countGroupTasks(group.tasks, subTasksByParentId);
|
||||
const ctx: TaskTreeContext = {
|
||||
subTasksByParentId,
|
||||
collapsedSubs: new Set(collapsedSubtaskParentIds ?? []),
|
||||
onToggleSubtasks,
|
||||
pinnedSet,
|
||||
rowProps,
|
||||
onReorderGroup,
|
||||
onReorderSubtasks,
|
||||
const groupTasks = useMemo(
|
||||
() => flattenGroupTasks(group.tasks, subTasksByParentId),
|
||||
[group.tasks, subTasksByParentId],
|
||||
);
|
||||
|
||||
const renderTree = (nestTargetIds: Set<string>, activeDragId: string | null) => {
|
||||
if (isCollapsed) return null;
|
||||
const ctx: TaskTreeContext = {
|
||||
subTasksByParentId,
|
||||
collapsedSubs: new Set(collapsedSubtaskParentIds ?? []),
|
||||
onToggleSubtasks,
|
||||
pinnedSet,
|
||||
rowProps,
|
||||
onReorderGroup,
|
||||
onReorderSubtasks,
|
||||
onNestTask,
|
||||
nestTargetIds,
|
||||
activeDragId,
|
||||
externalDragContext: true,
|
||||
};
|
||||
return <TaskTreeLevel parentTaskId={null} tasks={group.tasks} depth={0} ctx={ctx} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showHeader && (
|
||||
@@ -153,9 +219,14 @@ export function GroupSection({
|
||||
onToggle={onToggleCollapsed}
|
||||
/>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<TaskTreeLevel parentTaskId={null} tasks={group.tasks} depth={0} ctx={ctx} />
|
||||
)}
|
||||
<TaskTreeDndGroup
|
||||
groupTasks={groupTasks}
|
||||
onReorderGroup={onReorderGroup}
|
||||
onReorderSubtasks={onReorderSubtasks}
|
||||
onNestTask={onNestTask}
|
||||
>
|
||||
{renderTree}
|
||||
</TaskTreeDndGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ export type TaskSwitcherProps = {
|
||||
onTogglePin?: (taskId: string) => void;
|
||||
onReorderGroup?: (groupTaskIds: string[]) => void;
|
||||
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
/** Re-parent a task under another task (drag onto a nest drop zone). */
|
||||
onNestTask?: (taskId: string, parentTaskId: string) => void;
|
||||
pinnedTaskIds?: string[];
|
||||
deletingTaskId?: string | null;
|
||||
isLoading?: boolean;
|
||||
|
||||
@@ -72,6 +72,7 @@ function buildGroupSectionProps(
|
||||
showHeader,
|
||||
onReorderGroup: props.onReorderGroup,
|
||||
onReorderSubtasks: props.onReorderSubtasks,
|
||||
onNestTask: props.onNestTask,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
/**
|
||||
* Touch-drags a task row's handle onto a nest drop zone via CDP touch events.
|
||||
* dnd-kit's TouchSensor needs the 250ms delay to elapse and then >5px of
|
||||
* travel before the drag activates; the nest zone renders only once the drag
|
||||
* is live, so its box is resolved mid-drag. The sheet scrolls, so both the
|
||||
* source handle and the target zone are scrolled into view before their
|
||||
* coordinates are read.
|
||||
*/
|
||||
async function touchDragRowToNestZone(
|
||||
page: import("@playwright/test").Page,
|
||||
cdp: import("@playwright/test").CDPSession,
|
||||
handleLocator: ReturnType<Page["locator"]>,
|
||||
zoneLocator: ReturnType<Page["locator"]>,
|
||||
) {
|
||||
await handleLocator.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(100);
|
||||
const handleBox = await handleLocator.boundingBox();
|
||||
if (!handleBox) throw new Error("drag source handle has no bounding box");
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: startX, y: startY }],
|
||||
});
|
||||
// Hold still past the sensor delay, then move past the 5px tolerance to
|
||||
// activate the drag.
|
||||
await page.waitForTimeout(350);
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: startX + 14, y: startY }],
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
await expect(zoneLocator).toBeVisible();
|
||||
await zoneLocator.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(50);
|
||||
const to = await zoneLocator.boundingBox();
|
||||
if (!to) throw new Error("nest drop zone has no bounding box");
|
||||
const endX = to.x + to.width / 2;
|
||||
const endY = to.y + to.height / 2;
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [
|
||||
{ x: startX + ((endX - startX) * i) / 10, y: startY + ((endY - startY) * i) / 10 },
|
||||
],
|
||||
});
|
||||
}
|
||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
}
|
||||
|
||||
test.describe("Mobile subtask re-parenting by drag and drop", () => {
|
||||
test("re-parents a subtask onto another root with a touch drag", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const placement = {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
};
|
||||
const oldParent = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Mobile reparent old parent",
|
||||
placement,
|
||||
);
|
||||
const newParent = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Mobile reparent new parent",
|
||||
placement,
|
||||
);
|
||||
const child = await apiClient.createTask(seedData.workspaceId, "Mobile reparent child", {
|
||||
...placement,
|
||||
parent_id: oldParent.id,
|
||||
workspace_mode: "inherit_parent",
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${newParent.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await testPage.getByTestId("mobile-session-menu").click();
|
||||
|
||||
const taskSheet = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
const taskBlock = (taskId: string) =>
|
||||
taskSheet.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
|
||||
await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "1");
|
||||
|
||||
const cdp = await testPage.context().newCDPSession(testPage);
|
||||
await touchDragRowToNestZone(
|
||||
testPage,
|
||||
cdp,
|
||||
taskBlock(child.id).getByTestId("sortable-task-handle").first(),
|
||||
taskBlock(newParent.id).getByTestId("nest-drop-zone"),
|
||||
);
|
||||
|
||||
await expect(taskBlock(newParent.id).locator(`[data-task-id="${child.id}"]`)).toHaveCount(1, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const task = await apiClient.getTask(child.id);
|
||||
const workspace = task.metadata?.workspace as { mode?: string } | undefined;
|
||||
return { parentId: task.parent_id ?? "", workspaceMode: workspace?.mode };
|
||||
})
|
||||
.toEqual({ parentId: newParent.id, workspaceMode: "shared_group" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
/**
|
||||
* Drags a sidebar task row's handle onto a nest drop zone using pointer
|
||||
* events. dnd-kit's MouseSensor needs >8px of travel before the drag
|
||||
* activates, and the nest zone only renders once the drag is live, so the
|
||||
* target box is resolved mid-drag.
|
||||
*/
|
||||
async function dragRowToNestZone(
|
||||
page: Page,
|
||||
handleLocator: ReturnType<Page["locator"]>,
|
||||
zoneLocator: ReturnType<Page["locator"]>,
|
||||
) {
|
||||
await handleLocator.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(100);
|
||||
const from = await handleLocator.boundingBox();
|
||||
if (!from) throw new Error("drag source handle has no bounding box");
|
||||
const startX = from.x + from.width / 2;
|
||||
const startY = from.y + from.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
// Exceed the 8px activation distance so the drag starts.
|
||||
await page.mouse.move(startX + 24, startY, { steps: 3 });
|
||||
// The zone now exists; aim the pointer at its center.
|
||||
await expect(zoneLocator).toBeVisible();
|
||||
const to = await zoneLocator.boundingBox();
|
||||
if (!to) throw new Error("nest drop zone has no bounding box");
|
||||
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe("Subtask re-parenting by drag and drop", () => {
|
||||
test("re-parents an inherited-workspace subtask onto another root", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const placement = {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
};
|
||||
const oldParent = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Reparent old parent",
|
||||
placement,
|
||||
);
|
||||
const newParent = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Reparent new parent",
|
||||
placement,
|
||||
);
|
||||
const child = await apiClient.createTask(seedData.workspaceId, "Reparent child", {
|
||||
...placement,
|
||||
parent_id: oldParent.id,
|
||||
workspace_mode: "inherit_parent",
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${newParent.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
const taskBlock = (taskId: string) =>
|
||||
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
|
||||
await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "1");
|
||||
|
||||
const childHandle = taskBlock(child.id).getByTestId("sortable-task-handle");
|
||||
const newParentZone = taskBlock(newParent.id).getByTestId("nest-drop-zone");
|
||||
await dragRowToNestZone(testPage, childHandle, newParentZone);
|
||||
|
||||
// The tree moves live: child now sits inside the new parent's block.
|
||||
await expect(taskBlock(newParent.id).locator(`[data-task-id="${child.id}"]`)).toHaveCount(1, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "1");
|
||||
|
||||
// The composite semantics (un-nest then nest) apply: parent re-pointed and
|
||||
// the inherited workspace mode normalized to shared_group.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const task = await apiClient.getTask(child.id);
|
||||
const workspace = task.metadata?.workspace as { mode?: string } | undefined;
|
||||
return { parentId: task.parent_id ?? "", workspaceMode: workspace?.mode };
|
||||
})
|
||||
.toEqual({ parentId: newParent.id, workspaceMode: "shared_group" });
|
||||
});
|
||||
|
||||
test("nests a root task under another root", async ({ testPage, apiClient, seedData }) => {
|
||||
const placement = {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
};
|
||||
const parent = await apiClient.createTask(seedData.workspaceId, "Nest root parent", placement);
|
||||
const child = await apiClient.createTask(seedData.workspaceId, "Nest root child", placement);
|
||||
|
||||
await testPage.goto(`/t/${parent.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
const taskBlock = (taskId: string) =>
|
||||
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
|
||||
await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "0");
|
||||
|
||||
await dragRowToNestZone(
|
||||
testPage,
|
||||
taskBlock(child.id).getByTestId("sortable-task-handle"),
|
||||
taskBlock(parent.id).getByTestId("nest-drop-zone"),
|
||||
);
|
||||
|
||||
await expect(taskBlock(parent.id).locator(`[data-task-id="${child.id}"]`)).toHaveCount(1, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect.poll(async () => (await apiClient.getTask(child.id)).parent_id).toBe(parent.id);
|
||||
});
|
||||
|
||||
test("offers no nest zones for a task that already has children", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const placement = {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
};
|
||||
const parentWithChild = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Nestless parent",
|
||||
placement,
|
||||
);
|
||||
await apiClient.createTask(seedData.workspaceId, "Nestless child", {
|
||||
...placement,
|
||||
parent_id: parentWithChild.id,
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${parentWithChild.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
const taskBlock = (taskId: string) =>
|
||||
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
|
||||
|
||||
// Start a drag of the parent-with-child; no row may offer a nest zone.
|
||||
const handle = taskBlock(parentWithChild.id).getByTestId("sortable-task-handle").first();
|
||||
const box = await handle.boundingBox();
|
||||
if (!box) throw new Error("drag source handle has no bounding box");
|
||||
await testPage.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await testPage.mouse.down();
|
||||
await testPage.mouse.move(box.x + box.width / 2 + 24, box.y + box.height / 2, { steps: 3 });
|
||||
await expect(session.sidebar.getByTestId("nest-drop-zone")).toHaveCount(0);
|
||||
await testPage.keyboard.press("Escape");
|
||||
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getTask(parentWithChild.id)).parent_id ?? "")
|
||||
.toBe("");
|
||||
});
|
||||
|
||||
test("ignores a drop on a subtask row (not a valid target)", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const placement = {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
};
|
||||
const parentA = await apiClient.createTask(seedData.workspaceId, "Invalid A parent", placement);
|
||||
const parentB = await apiClient.createTask(seedData.workspaceId, "Invalid B parent", placement);
|
||||
const childA = await apiClient.createTask(seedData.workspaceId, "Invalid A child", {
|
||||
...placement,
|
||||
parent_id: parentA.id,
|
||||
});
|
||||
const childB = await apiClient.createTask(seedData.workspaceId, "Invalid B child", {
|
||||
...placement,
|
||||
parent_id: parentB.id,
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${parentA.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
const taskBlock = (taskId: string) =>
|
||||
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
|
||||
|
||||
// Drag childA toward childB: childB (a subtask) is not a valid target, so
|
||||
// its row offers no nest zone (parentB's row does — it is a valid root
|
||||
// candidate), and a drop on childB's row is cross-level: no-op.
|
||||
const fromBox = await taskBlock(childA.id).getByTestId("sortable-task-handle").boundingBox();
|
||||
if (!fromBox) throw new Error("drag source handle has no bounding box");
|
||||
await testPage.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + fromBox.height / 2);
|
||||
await testPage.mouse.down();
|
||||
await testPage.mouse.move(fromBox.x + fromBox.width / 2 + 24, fromBox.y + fromBox.height / 2, {
|
||||
steps: 3,
|
||||
});
|
||||
const zoneFor = (taskId: string) =>
|
||||
session.sidebar.locator(`[data-testid="nest-drop-zone"][data-task-id="${taskId}"]`);
|
||||
await expect(zoneFor(childB.id)).toHaveCount(0);
|
||||
await expect(zoneFor(parentB.id)).toHaveCount(1);
|
||||
const toBox = await taskBlock(childB.id).boundingBox();
|
||||
if (!toBox) throw new Error("target row has no bounding box");
|
||||
await testPage.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 12 });
|
||||
await testPage.mouse.up();
|
||||
|
||||
await expect.poll(async () => (await apiClient.getTask(childA.id)).parent_id).toBe(parentA.id);
|
||||
});
|
||||
|
||||
test("sibling reorder still works inside a parent (regression)", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const placement = {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
};
|
||||
const parent = await apiClient.createTask(seedData.workspaceId, "Reorder parent", placement);
|
||||
const first = await apiClient.createTask(seedData.workspaceId, "Reorder first child", {
|
||||
...placement,
|
||||
parent_id: parent.id,
|
||||
});
|
||||
const second = await apiClient.createTask(seedData.workspaceId, "Reorder second child", {
|
||||
...placement,
|
||||
parent_id: parent.id,
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${parent.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
const taskBlock = (taskId: string) =>
|
||||
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
|
||||
|
||||
// Children render newest-first by default, so resolve the rendered order
|
||||
// and drag the lower sibling onto the upper one's row (subtasks are not
|
||||
// nest targets, so only the reorder path can fire).
|
||||
const firstY = (await taskBlock(first.id).boundingBox())?.y ?? 0;
|
||||
const secondY = (await taskBlock(second.id).boundingBox())?.y ?? 0;
|
||||
const [upperId, lowerId] = firstY < secondY ? [first.id, second.id] : [second.id, first.id];
|
||||
const upperBox = await taskBlock(upperId).boundingBox();
|
||||
if (!upperBox) throw new Error("rows have no bounding boxes");
|
||||
|
||||
const fromBox = await taskBlock(lowerId).getByTestId("sortable-task-handle").boundingBox();
|
||||
if (!fromBox) throw new Error("drag source handle has no bounding box");
|
||||
await testPage.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + fromBox.height / 2);
|
||||
await testPage.mouse.down();
|
||||
await testPage.mouse.move(fromBox.x + fromBox.width / 2 + 24, fromBox.y + fromBox.height / 2, {
|
||||
steps: 3,
|
||||
});
|
||||
await expect(session.sidebar.getByTestId("nest-drop-zone")).toHaveCount(0);
|
||||
await testPage.mouse.move(upperBox.x + upperBox.width / 2, upperBox.y + upperBox.height / 2, {
|
||||
steps: 12,
|
||||
});
|
||||
await testPage.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const upperY = (await taskBlock(upperId).boundingBox())?.y ?? 0;
|
||||
const lowerY = (await taskBlock(lowerId).boundingBox())?.y ?? 0;
|
||||
return lowerY < upperY;
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,20 @@ import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
|
||||
type StoreApi = ReturnType<typeof useAppStoreApi>;
|
||||
type SnapshotTask = WorkflowSnapshotData["tasks"][number];
|
||||
|
||||
/**
|
||||
* Resolves the workflow id a task belongs to from the multi-workflow
|
||||
* snapshots. Snapshot tasks carry no workflow_id themselves; the snapshot
|
||||
* key is the workflow. Returns null when the task is not in any snapshot
|
||||
* (the caller then skips the optimistic patch and lets the WS event
|
||||
* reconcile state).
|
||||
*/
|
||||
export function taskWorkflowIdFromSnapshots(store: StoreApi, taskId: string): string | null {
|
||||
const snapshots = store.getState().kanbanMulti?.snapshots ?? {};
|
||||
return (
|
||||
Object.keys(snapshots).find((wf) => snapshots[wf]?.tasks.some((t) => t.id === taskId)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// One re-parent operation's context, threaded to the optimistic helpers.
|
||||
type NestOp = {
|
||||
store: StoreApi;
|
||||
@@ -58,6 +72,26 @@ function rollbackParent(op: NestOp): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* useNestTaskByDrag returns the drag-drop nesting action used by the sidebar
|
||||
* and the mobile task sheet: it resolves the dragged task's workflow from the
|
||||
* snapshot keys (snapshot tasks carry no workflow_id themselves) and runs the
|
||||
* same composite nest operation the context menu uses. No-ops when the task
|
||||
* is not in any snapshot (the WS event reconciles state either way).
|
||||
*/
|
||||
export function useNestTaskByDrag() {
|
||||
const store = useAppStoreApi();
|
||||
const nestTask = useNestTask();
|
||||
return useCallback(
|
||||
(taskId: string, parentTaskId: string) => {
|
||||
const workflowId = taskWorkflowIdFromSnapshots(store, taskId);
|
||||
if (!workflowId) return;
|
||||
void nestTask(taskId, workflowId, parentTaskId);
|
||||
},
|
||||
[store, nestTask],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useNestTask returns a function that nests a task under a parent (or un-nests
|
||||
* it when `parentId` is null). The multi-workflow snapshot may not be
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"logOut": "Log out",
|
||||
"maxViews_one": "{{count}} max",
|
||||
"maxViews_other": "{{count}} max",
|
||||
"nestUnder": "Nest under {{title}}",
|
||||
"newKanbanWorkspace": "New kanban workspace",
|
||||
"newOfficeWorkspace": "New office workspace",
|
||||
"newTask": "New Task",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"logOut": "Ĺōĝ ōũţ",
|
||||
"maxViews_one": "{{count}} ḿàx",
|
||||
"maxViews_other": "{{count}} ḿàx",
|
||||
"nestUnder": "Ńēśţ ũńďēŕ {{title}}",
|
||||
"newKanbanWorkspace": "Ńēŵ ķàńƀàń ŵōŕķśƥàćē",
|
||||
"newOfficeWorkspace": "Ńēŵ ōƒƒĩćē ŵōŕķśƥàćē",
|
||||
"newTask": "Ńēŵ Ţàśķ",
|
||||
|
||||
@@ -9,6 +9,11 @@ export default defineConfig({
|
||||
server: {
|
||||
port: readPort(process.env.PORT),
|
||||
strictPort: Boolean(process.env.PORT),
|
||||
// Remote dev hosts (cloud VMs, LAN hostnames) are otherwise blocked by
|
||||
// Vite's DNS-rebinding host check. Opt in per environment with an
|
||||
// explicit comma-separated host list, e.g.
|
||||
// KANDEV_VITE_ALLOWED_HOSTS="a.example.com,b.example.com".
|
||||
allowedHosts: readAllowedHosts(process.env.KANDEV_VITE_ALLOWED_HOSTS),
|
||||
},
|
||||
preview: {
|
||||
port: readPort(process.env.PORT),
|
||||
@@ -57,3 +62,18 @@ function readPort(value: string | undefined): number | undefined {
|
||||
const port = Number(value);
|
||||
return Number.isInteger(port) && port > 0 ? port : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the comma-separated KANDEV_VITE_ALLOWED_HOSTS override. Returns
|
||||
* undefined (Vite's default host policy) when unset, or the explicit host
|
||||
* list. Wildcards are deliberately unsupported: allowing every host would
|
||||
* expose the dev server to DNS-rebinding attacks, so only named hosts can
|
||||
* be opted in.
|
||||
*/
|
||||
function readAllowedHosts(value: string | undefined): string[] | undefined {
|
||||
if (!value) return undefined;
|
||||
return value
|
||||
.split(",")
|
||||
.map((host) => host.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
spec: docs/specs/tasks/subtask-reparenting-drag-drop.md
|
||||
created: 2026-08-04
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Implementation Plan: Subtask re-parenting by drag and drop
|
||||
|
||||
## Overview
|
||||
|
||||
The backend reparent contract (`PATCH /api/v1/tasks/:id` with `parent_id`, validated by `resolveParentID` + `validateReparentDepth`) already exists and is tested. Two deltas make drag-drop "strictly equivalent" to the menu composite (un-nest + nest): (1) normalize `inherit_parent` workspace mode to `shared_group` on any effective parent change in the canonical update path, and (2) add the same normalization to the Office dashboard PATCH for parity. The real work is frontend: hoist the sidebar tree's per-level `DndContext` into one per-group context, add nest drop zones with a pure drop-decision helper, and wire `onReparentTask` through `TaskSwitcher` to the existing `useNestTask` hook. E2E (desktop + mobile touch) comes last.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### Canonical update normalization (`apps/backend/internal/task/service/service_tasks.go`)
|
||||
|
||||
In `Service.UpdateTask`, the parent block (~line 1225) already resolves and assigns `task.ParentID` only when the parent effectively changed. After that block, add a small helper `normalizeWorkspaceModeAfterReparent(task)` that copies `task.Metadata["workspace"]` and changes only `mode: "inherit_parent"` → `"shared_group"` — the exact detach semantics — applied whenever the effective parent changed (set or cleared). The existing full-row persist writes the metadata; the existing `parent_id` event emission (explicit nil on clear) is unchanged. This covers core PATCH, WS update, the sidebar menu, the Office parent picker (`updateTask`), and the drag-drop, all through one path.
|
||||
|
||||
### Office dashboard parity (`apps/backend/internal/office/dashboard/service_tasks.go`)
|
||||
|
||||
`DashboardService.UpdateTaskParentID` non-empty path currently writes `parent_id` directly and publishes `["parent_id"]`. Change it so that after the repo write it loads the task, and if workspace mode is `inherit_parent`, persists `shared_group` and publishes fields `["parent_id", "metadata"]`. Add `UpdateTaskWorkspaceMode(ctx, taskID, mode)` to the office sqlite repository (dialect-aware JSON set, mirroring `detachTaskQuery`'s mode branch) and to the `DashboardService` repo interface. The empty-parent path keeps routing to the canonical detacher.
|
||||
|
||||
### Events
|
||||
|
||||
No new event types. `task.updated` already carries `parent_id` (nil when cleared) and `metadata`; the normalization rides along. Office `OfficeTaskUpdated` gains `metadata` in its `fields` list only when the mode changed.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Drop-decision logic (`apps/web/components/task/task-switcher-subtask-dnd.tsx`)
|
||||
|
||||
- Extract a pure, unit-testable `resolveSidebarDrop({ activeId, overId, groupRootIds, childrenByParent })` returning `{kind:"nest", parentTaskId}` | `{kind:"reorder", level:"group"|"subtasks", parentTaskId?, orderedTaskIds}` | `null`. `overId` prefixed `nest:` → nest; otherwise both tasks must share a level (group root list or one parent's children) → `arrayMove` within that level; anything else → `null`.
|
||||
- Add `NestDropZone` (small component): `useDroppable({ id: "nest:"+taskId })`, absolute left-edge strip, `data-testid="nest-drop-zone"`, `data-task-id`, hover affordance label "Nest under <title>". Rendered only for rows that are valid targets of the active drag.
|
||||
- Add a `computeNestTargets(activeTask, groupTasks)` helper: flatten the group's subtree, filter to the active task's `workflowId`, run the existing `computeNestCandidates` from `apps/web/lib/sidebar/nest-candidates.ts`. Returns the candidate id set (empty for tasks with children).
|
||||
|
||||
### DndContext hoisting (`task-switcher-subtask-dnd.tsx` + `apps/web/components/task/task-switcher.tsx`)
|
||||
|
||||
- `SortableTaskLevel` currently creates one `DndContext` per sibling level, which makes cross-level drops impossible. Change `SortableTaskLevel` so it renders only `SortableContext` + nodes when a group-level context is present (new prop, e.g. `externalDragContext`), preserving current behavior for non-reorderable callers.
|
||||
- `GroupSection` in `task-switcher.tsx` renders ONE `DndContext` (sensors from `taskSwitcherDragActivationConstraints`, custom `collisionDetection`: `pointerWithin` filtered to `nest:` ids wins, else `closestCenter`, `onDragEnd` dispatching through `resolveSidebarDrop`).
|
||||
- Track the active drag id (`onDragStart`) in the group; compute `computeNestTargets` and thread candidate ids through `TaskTreeContext` so `TaskTreeNode` renders `NestDropZone` on candidate rows.
|
||||
- New `TaskSwitcher` prop `onReparentTask?: (taskId: string, parentTaskId: string) => void`, threaded through `GroupSection` → `TaskTreeContext` → drop handling.
|
||||
|
||||
### Sidebar + mobile wiring
|
||||
|
||||
- `apps/web/components/task/task-session-sidebar.tsx`: `handleReparentTask(taskId, parentTaskId)` resolves the task's `workflowId` from `displayTasks` and calls the existing `useNestTask` (non-null path → `updateTask(taskId, { parent_id })` → composite semantics after the backend change; same optimistic snapshot patch, rollback, and toast).
|
||||
- `apps/web/components/task/mobile/session-task-switcher-sheet.tsx`: same wiring; touch drag already works via the existing `TouchSensor` (250 ms delay / 5 px tolerance).
|
||||
|
||||
### i18n
|
||||
|
||||
New user-facing copy (nest-zone label, aria text) goes through `t()` / `<Trans>` with keys added to the sidebar's locale namespace; do not hardcode.
|
||||
|
||||
## Tests
|
||||
|
||||
### Backend
|
||||
|
||||
- **What:** reparenting an `inherit_parent` child normalizes mode to `shared_group` (persisted + event metadata).
|
||||
- **File:** `apps/backend/internal/task/service/service_reparent_test.go`
|
||||
- **How:** extend the existing reparent fixture; assert `Metadata["workspace"]["mode"]` in the returned task, the persisted row, and the `task.updated` payload.
|
||||
- **What:** non-`inherit_parent` modes (`shared_group`, `new_workspace`) and root-task reparents are untouched.
|
||||
- **File:** same, table-driven.
|
||||
- **What:** un-nest via `UpdateTask` (empty `parent_id`) normalizes like the detach endpoint.
|
||||
- **File:** same.
|
||||
- **What:** Office dashboard non-empty reparent normalizes `inherit_parent` and publishes `metadata` in fields.
|
||||
- **File:** `apps/backend/internal/office/dashboard/service_detachment_test.go` (mirror `TestUpdateTaskParentIDUsesCanonicalDetacherWhenCleared`).
|
||||
- Existing reparent/detach/handler suites must stay green (no changes to `resolveParentID` / `validateReparentDepth`).
|
||||
|
||||
### Frontend
|
||||
|
||||
- **What:** drop decision maps `nest:` over-ids and same-level reorders correctly, rejects cross-level drops.
|
||||
- **File:** `apps/web/components/task/task-switcher-subtask-dnd.test.ts`
|
||||
- **How:** pure-function unit tests for `resolveSidebarDrop`.
|
||||
- **What:** nest targets = same-workflow roots per `computeNestCandidates`, empty for tasks with children.
|
||||
- **File:** `apps/web/lib/sidebar/nest-candidates.test.ts` (extend) or a new sibling test.
|
||||
- **What:** existing `TaskSwitcher` / sortable rendering behavior is preserved (regression).
|
||||
- **File:** `apps/web/components/task/task-switcher.test.tsx`.
|
||||
|
||||
### E2E
|
||||
|
||||
See task 04. Desktop spec `apps/web/e2e/tests/task/subtask-reparent-drag-drop.spec.ts` + mobile touch variant, modeled on `subtask-detachment.spec.ts` / `mobile-subtask-detachment.spec.ts` and the `Input.dispatchTouchEvent` pattern in `mobile-automations-scroll.spec.ts`.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** subtask dragged onto another root's nest zone re-parents (API `parent_id` + sidebar nesting, no reload).
|
||||
- **Scenario:** `inherit_parent` subtask ends with `shared_group` (assert via API metadata).
|
||||
- **Scenario:** root dragged onto root nests; task with children offers no nest zone; drop on an invalid row is a no-op.
|
||||
- **Scenario:** sibling reorder still works (regression).
|
||||
- **Scenario:** mobile touch drag re-parents (mobile-chrome project).
|
||||
- **File:** `apps/web/e2e/tests/task/subtask-reparent-drag-drop.spec.ts`
|
||||
|
||||
## Verification Results
|
||||
|
||||
All task checks passed; see each task's `## Results` for exact commands and counts.
|
||||
|
||||
- Backend: `go test ./internal/task/service ./internal/task/handlers ./internal/office/dashboard` — service + office/dashboard `ok`; handlers `ok` under `umask 022` (3 pre-existing env failures under umask 002, verified on base).
|
||||
- Web: full unit suite passes (8441+ of 8450; the 5 failures are pre-existing env issues: 3 Docker-bridge `http-git-server`, 2 load-sensitive file-browser timeouts that pass in isolation); typecheck, lint, i18n ratchet clean.
|
||||
- E2E: `subtask-reparent-drag-drop.spec.ts` 5/5 (chromium), `mobile-subtask-reparent-drag-drop.spec.ts` 1/1 (mobile-chrome touch).
|
||||
- Root: `make fmt`, `make typecheck`, `make lint` pass. `make test` residual failures are pre-existing environment-dependent tests (launchd/systemd/cli-shim) unrelated to this feature.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
```
|
||||
Wave 1:
|
||||
- [x] [task-01-backend-composite-semantics](task-01-backend-composite-semantics.md)
|
||||
|
||||
Wave 2:
|
||||
- [x] [task-02-frontend-nest-drop-infrastructure](task-02-frontend-nest-drop-infrastructure.md)
|
||||
|
||||
Wave 3:
|
||||
- [x] [task-03-frontend-wiring-and-affordances](task-03-frontend-wiring-and-affordances.md)
|
||||
|
||||
Wave 4:
|
||||
- [x] [task-04-e2e-and-verification](task-04-e2e-and-verification.md)
|
||||
```
|
||||
|
||||
All tasks are sequential (each depends on the previous). No parallel candidates.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
id: "01-backend-composite-semantics"
|
||||
title: "Backend composite re-parent semantics"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/subtask-reparenting-drag-drop.md"
|
||||
---
|
||||
|
||||
# Task 01: Backend composite re-parent semantics
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `Service.UpdateTask` normalizes `metadata.workspace.mode` from `inherit_parent` to `shared_group` whenever the effective parent changes (set or cleared); other modes and root tasks are untouched. The returned task, the persisted row, and the `task.updated` payload all reflect the change.
|
||||
- `DashboardService.UpdateTaskParentID` (Office dashboard PATCH) applies the same normalization on its non-empty parent path and includes `metadata` in the published `fields` when the mode changed.
|
||||
- All existing reparent/detach service and handler tests pass unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/task/service ./internal/task/handlers ./internal/office/dashboard
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/task/service/service_tasks.go` (parent block in `Service.UpdateTask`, new `normalizeWorkspaceModeAfterReparent` helper)
|
||||
- `apps/backend/internal/task/service/service_reparent_test.go` (new cases)
|
||||
- `apps/backend/internal/office/dashboard/service_tasks.go` (`UpdateTaskParentID`)
|
||||
- `apps/backend/internal/office/dashboard/service_detachment_test.go` (parity test)
|
||||
- `apps/backend/internal/office/repository/sqlite/tasks.go` (new `UpdateTaskWorkspaceMode`)
|
||||
- `apps/backend/internal/office/dashboard/service.go` (repo interface member, if `UpdateTaskWorkspaceMode` is not already surfaced)
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: What (composite semantics), Data model, API surface, Failure modes.
|
||||
- Existing pattern: `Service.DetachTask` + `detachTaskQuery` (dialect-aware JSON mode normalization in `apps/backend/internal/task/repository/sqlite/task.go`), `resolveParentID` / `validateReparentDepth` in `service_tasks.go`, `publishTaskUpdated` in the office dashboard service.
|
||||
- Do NOT change `resolveParentID` / `validateReparentDepth`; validation behavior is already shipped and tested.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the service/repo changes, exact commands and results, files changed, blockers, residual risks; update this task and `plan.md` when acceptance passes.
|
||||
|
||||
## Results
|
||||
|
||||
- `cd apps/backend && go test ./internal/task/service ./internal/task/handlers ./internal/office/dashboard` — service and office/dashboard `ok`; handlers `ok` (run with `umask 022`; this sandbox's default umask 002 makes 3 pre-existing local-repository handler tests fail — they pass under standard umask and fail identically on the stash-clean base).
|
||||
- Added `normalizeWorkspaceModeAfterReparent` to `Service.UpdateTask` (parent-change block): `inherit_parent` → `shared_group` on any effective parent change. TDD red→green: `TestService_UpdateTask_ReparentNormalizesInheritedWorkspaceMode`, `TestService_UpdateTask_UnnestNormalizesInheritedWorkspaceMode`, `TestService_UpdateTask_ReparentPreservesNonInheritedWorkspaceModes`.
|
||||
- Office parity: repo `UpdateTaskParentID` now sets `parent_id` AND normalizes mode in one dialect-aware UPDATE (SQLite JSON, mirroring `detachTaskQuery`); dashboard service publishes `["parent_id", "metadata"]`; parity test `TestUpdateTaskParentIDNormalizesInheritedWorkspaceMode`; added the missing `metadata` column to the office test-harness tasks DDL (matches production schema).
|
||||
- Files changed: `internal/task/service/service_tasks.go`, `service_reparent_test.go`, `internal/office/dashboard/service_tasks.go`, `service_detachment_test.go`, `handler_test.go`, `internal/office/repository/sqlite/tasks.go`.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: "02-frontend-nest-drop-infrastructure"
|
||||
title: "Frontend nest-drop infrastructure"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-backend-composite-semantics"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/subtask-reparenting-drag-drop.md"
|
||||
---
|
||||
|
||||
# Task 02: Frontend nest-drop infrastructure
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A single `DndContext` per sidebar group section replaces the per-level contexts, so a drag can target rows outside its own sibling level; sibling reorder behavior is unchanged.
|
||||
- `resolveSidebarDrop` (pure) and `computeNestTargets` (pure) exist with unit tests; `NestDropZone` renders on candidate rows with `data-testid="nest-drop-zone"` and `data-task-id`.
|
||||
- Existing `TaskSwitcher` / `SortableTaskLevel` tests pass (no behavior regression when no group-level context or reorder is configured).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm test -- components/task/task-switcher-subtask-dnd.test.ts components/task/task-switcher.test.tsx lib/sidebar/nest-candidates.test.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/task-switcher-subtask-dnd.tsx` — hoist `DndContext` out of `SortableTaskLevel` (new `externalDragContext` mode), add `NestDropZone`, `resolveSidebarDrop`, `computeNestTargets`, custom collision (`pointerWithin` → `nest:` ids, else `closestCenter`), sensors, group-level `onDragStart`/`onDragEnd`.
|
||||
- `apps/web/components/task/task-switcher.tsx` — `GroupSection` renders the hoisted `DndContext`; `TaskTreeContext` carries candidate ids + `onReparentTask`; `TaskTreeNode` renders `NestDropZone` on candidate rows; new optional `onReparentTask` prop on `TaskSwitcher` (unwired for now).
|
||||
- `apps/web/components/task/task-switcher-subtask-dnd.test.ts` (new) — `resolveSidebarDrop` unit tests: nest id mapping, group-root reorder, subtask-level reorder, cross-level drop → null, no-over → null.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 (semantics only — infra works against the existing `updateTask` path regardless).
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: What (gesture, targets, reorder preservation), Scenarios.
|
||||
- Existing code: `useLevelDnd` / `SortableTaskLevel` / `DraggableSortableTaskNode` in `task-switcher-subtask-dnd.tsx`; `TaskTreeLevel`/`GroupSection`/`TaskTreeContext` in `task-switcher.tsx`; `computeNestCandidates` in `apps/web/lib/sidebar/nest-candidates.ts`; activation constraints constants.
|
||||
- Reorder callbacks keep their signatures: `onReorderGroup(orderedTaskIds)`, `onReorderSubtasks(parentTaskId, orderedTaskIds)`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the component/helper changes, exact commands and results, files changed, blockers, residual risks; update this task and `plan.md` when acceptance passes.
|
||||
|
||||
## Results
|
||||
|
||||
- `cd apps/web && pnpm test -- components/task/task-switcher-subtask-dnd.test.ts components/task/task-switcher.test.tsx components/task/task-switcher-direct-subtasks.test.tsx` — 31 passed (new helper tests + switcher regression suites).
|
||||
- `pnpm run typecheck` clean; `pnpm run lint` clean (0 warnings); `pnpm run i18n:ratchet` clean.
|
||||
- Hoisted per-level `DndContext` into `TaskTreeDndGroup` (one per group section; custom collision: `pointerWithin` → `nest:` ids win, else `closestCenter`); `SortableTaskLevel` gained `externalDragContext`; added `NestDropZone` + pure `resolveSidebarDrop`/`computeNestTargets` (unit-tested); `GroupSection` slimmed via props-object + new `task-switcher-tree-context.ts` (`flattenGroupTasks`/`buildTaskTreeContext`) to stay under lint line limits.
|
||||
- Files changed: `components/task/task-switcher-subtask-dnd.tsx`, `task-switcher-subtask-dnd.test.ts`, `task-switcher.tsx`, `task-switcher-tree-context.ts` (new), `src/locales/en/sidebar.json` (`sidebar:nestUnder`).
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: "03-frontend-wiring-and-affordances"
|
||||
title: "Frontend wiring and affordances"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["02-frontend-nest-drop-infrastructure"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/subtask-reparenting-drag-drop.md"
|
||||
---
|
||||
|
||||
# Task 03: Frontend wiring and affordances
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop sidebar and mobile task switcher sheet wire `onReparentTask` through the existing `useNestTask` hook: a drop on a nest zone calls `updateTask(taskId, { parent_id })` with the task's `workflowId`, applies the optimistic snapshot patch, rolls back + toasts on error, and reconciles via the `task.updated` WS event.
|
||||
- The nest-zone affordance shows `Nest under <title>` (localized via `t()`), and the drag exposes no nest zone on invalid targets.
|
||||
- All new user-facing copy is i18n'd; `pnpm run i18n:ratchet` is clean for touched lines.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm test -- components/task/task-session-sidebar.test.tsx components/task/mobile/session-task-switcher-sheet.test.tsx
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps/web && pnpm run lint
|
||||
cd apps/web && pnpm run i18n:ratchet
|
||||
```
|
||||
|
||||
(Adjust test file list to the files that actually exist for these components; if a surface has no test file, add focused tests for the new handler.)
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/task-session-sidebar.tsx` — `handleReparentTask(taskId, parentTaskId)` resolving `workflowId` from `displayTasks`, wired to `onReparentTask` via `buildTaskSwitcherProps`.
|
||||
- `apps/web/components/task/task-session-sidebar-switcher-props.ts` — thread the new prop.
|
||||
- `apps/web/components/task/mobile/session-task-switcher-sheet.tsx` — same wiring (shared `TaskSwitcher`; touch drag already active).
|
||||
- `apps/web/src/locales/en/<sidebar-namespace>.json` — new keys (`nestUnder`, aria label); use the namespace already used by the sidebar components.
|
||||
- `apps/web/components/task/task-switcher-subtask-dnd.tsx` — affordance label rendering (if not already in task 02).
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 02 (props/context plumbing) and Task 01 (composite semantics behind `updateTask`).
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: What (single API call, optimistic update, no confirmation), Scenarios (invalid target toast, mobile).
|
||||
- Existing code: `useNestTask` in `apps/web/hooks/use-nest-task.ts` (non-null path already calls `updateTask`), `buildTaskSwitcherProps`, mobile sheet's existing `TaskSwitcher` usage.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report wiring changes, exact commands and results, files changed, blockers, residual risks; update this task and `plan.md` when acceptance passes.
|
||||
|
||||
## Results
|
||||
|
||||
- `pnpm run typecheck` clean; `pnpm run lint` clean; `pnpm run i18n:ratchet` clean; task-component suites passed (249 files / 2140 tests in the `components/task` run).
|
||||
- Desktop sidebar and mobile sheet wire `onNestTask` through the existing `useNestTask` hook; workflow resolved from snapshot keys via the new shared helper `taskWorkflowIdFromSnapshots` in `hooks/use-nest-task.ts`; `MobileTaskList` gained `onNestTask` (sheet stays open on drop); new `sidebar:nestUnder` key.
|
||||
- Files changed: `components/task/task-session-sidebar.tsx`, `task-session-sidebar-switcher-props.ts`, `components/task/mobile/session-task-switcher-sheet.tsx`, `session-task-switcher-sheet-hooks.ts`, `hooks/use-nest-task.ts`.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: "04-e2e-and-verification"
|
||||
title: "E2E and full verification"
|
||||
status: done
|
||||
wave: 4
|
||||
depends_on: ["03-frontend-wiring-and-affordances"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/subtask-reparenting-drag-drop.md"
|
||||
---
|
||||
|
||||
# Task 04: E2E and full verification
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop Playwright spec covers: subtask dragged onto another root's nest zone re-parents (assert API `parent_id` and sidebar nesting, no reload); `inherit_parent` subtask ends with `shared_group` (assert via API metadata); root dragged onto root nests; a task with children offers no nest zone; a drop on an invalid row is a no-op; sibling reorder still works.
|
||||
- Mobile-chrome touch variant covers the same re-parent flow via `Input.dispatchTouchEvent`.
|
||||
- `make fmt` then `make typecheck test lint` pass from the repository root.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm e2e:run e2e/tests/task/subtask-reparent-drag-drop.spec.ts
|
||||
cd /home/clem/.kandev/tasks/re-parenting-from-dr_0b8/kdlbs-kandev && make fmt && make typecheck && make test && make lint
|
||||
```
|
||||
|
||||
Note: run `pnpm install --frozen-lockfile` from `apps/` once if this is a fresh worktree (missing `node_modules` breaks pnpm commands and the commit hook).
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/task/subtask-reparent-drag-drop.spec.ts` (new) — model structure, seeding, and sidebar interaction on `apps/web/e2e/tests/task/subtask-detachment.spec.ts`; drag mechanics via `locator.hover()` + `page.mouse.down()` + stepped `mouse.move()` + `mouse.up()` (dnd-kit needs >8px movement before activation).
|
||||
- Mobile variant: same spec gated to `mobile-chrome` project or a sibling spec, using the `Input.dispatchTouchEvent` pattern from `apps/web/e2e/tests/mobile-automations-scroll.spec.ts`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tasks 01–03.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec Scenarios (each maps to a test), plan E2E section, existing `subtask-detachment` e2e specs and helpers (`apps/web/e2e/helpers/api-client.ts` createTask/updateTask with `parent_id` and `workspace_mode`).
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the spec files, exact commands and results (including per-scenario pass counts), files changed, blockers, residual risks; update this task and `plan.md` when acceptance passes.
|
||||
|
||||
## Results
|
||||
|
||||
- Desktop: `pnpm e2e:run tests/task/subtask-reparent-drag-drop.spec.ts --shards 1` — 5 passed (subtask→root reparent + workspace-mode normalization, root→root nest, no nest zones for a task with children, subtask-row drop no-op, sibling reorder regression).
|
||||
- Mobile: `pnpm e2e:run --project mobile-chrome tests/task/mobile-subtask-reparent-drag-drop.spec.ts --shards 1` — 1 passed (touch drag via CDP `Input.dispatchTouchEvent`; source handle and target zone are scrolled into view first — the sheet scrolls and off-viewport coordinates make raw mouse/touch events no-ops).
|
||||
- Root verification: `make fmt`, `make typecheck`, `make lint` pass (umask 022). `make test`: all changed packages pass; residual failures are pre-existing environment issues (launchd/systemd/cli-shim backend tests, Docker-bridge-dependent `http-git-server.test.ts`, load-sensitive file-browser timeouts that pass in isolation) — verified failing on the stash-clean base.
|
||||
- Files changed: `e2e/tests/task/subtask-reparent-drag-drop.spec.ts` (new), `e2e/tests/task/mobile-subtask-reparent-drag-drop.spec.ts` (new). Temporary diagnostic spec `mobile-drag-diag.spec.ts` created and removed.
|
||||
@@ -68,6 +68,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
|
||||
| [remote-contribution-tasks](tasks/remote-contribution-tasks.md) | approved |
|
||||
| [subtask-checklist](tasks/subtask-checklist.md) | shipped |
|
||||
| [subtask-detachment](tasks/subtask-detachment.md) | shipped |
|
||||
| [subtask-reparenting-drag-drop](tasks/subtask-reparenting-drag-drop.md) | building |
|
||||
| [subtask-completion-trigger](tasks/subtask-completion-trigger.md) | draft |
|
||||
| [subtree-controls](tasks/subtree-controls.md) | shipped |
|
||||
| [blocked-task-escalation](tasks/blocked-task-escalation.md) | draft |
|
||||
|
||||
@@ -79,7 +79,7 @@ The cleared parent relationship and normalized workspace mode survive backend re
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Reparenting a task to a different parent from the sidebar or kanban card menus.
|
||||
- Reparenting a task to a different parent from the sidebar or kanban card menus (now covered by [subtask-reparenting-drag-drop](subtask-reparenting-drag-drop.md) for the sidebar; kanban-card reparenting remains out of scope).
|
||||
- Copying or provisioning a separate workspace during detachment.
|
||||
- Removing blocker relationships or changing descendant relationships.
|
||||
- Stopping or restarting active sessions.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
status: building
|
||||
created: 2026-08-04
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Subtask re-parenting by drag and drop
|
||||
|
||||
## Why
|
||||
|
||||
Users can detach a subtask or nest a task under another via context-menu actions, but moving a task from under one parent to under another takes two menu hops (un-nest, then nest under), and the sidebar's drag-and-drop only reorders siblings. A direct drag gesture should re-parent a task in one motion with the exact same result as those two menu actions.
|
||||
|
||||
## What
|
||||
|
||||
- The sidebar task tree (desktop sidebar and the mobile task switcher sheet) lets a user re-parent a task by dragging its row onto another row's **nest drop zone**.
|
||||
- The result is strictly equivalent to choosing `Un-nest (remove parent)` then `Nest under <target>` from the task's context menu: the task's parent becomes the target, and a task whose workspace mode is `inherit_parent` ends with mode `shared_group` — its materialized workspace and workspace-group membership are unchanged.
|
||||
- Drop targets are exactly the candidates the context menu offers (the `computeNestCandidates` rules): same-workflow root tasks in the visible group, excluding the dragged task, its current parent, and any subtask (which also excludes the task's own descendants). A task that already has children offers no nest targets.
|
||||
- While a drag with valid targets is active, candidate rows show a nest drop zone (a left-edge strip) with a `Nest under <title>` affordance. Dropping on a zone re-parents; dropping between rows keeps the existing sibling-reorder behavior; any other drop is a no-op.
|
||||
- Re-parenting is a single API call on the existing canonical path (`PATCH /api/v1/tasks/:id` with `parent_id`), which already rejects self-parenting, missing/archived/cross-workspace targets, descendant cycles, and one-level-depth violations for kanban tasks.
|
||||
- The sidebar `Nest under` menu, the Office parent picker, the WS task-update path, and the Office dashboard PATCH all share the same composite semantics: any effective parent change normalizes an `inherit_parent` workspace mode to `shared_group`.
|
||||
- No confirmation dialog is shown on drop (unlike the detach menu action): the gesture is explicit, the operation is non-destructive (workspace membership is retained) and reversible via the menu or another drag.
|
||||
- Successful re-parenting is reflected across sidebar, board, and task-detail views without a reload, via the existing optimistic snapshot update and the `task.updated` WebSocket event.
|
||||
- The mobile task switcher sheet offers the same touch drag-and-drop re-parenting.
|
||||
|
||||
## Data model
|
||||
|
||||
No table or column is added. Re-parenting updates existing persisted fields in the same task-row write:
|
||||
|
||||
- `tasks.parent_id` becomes the target task's id (the previous parent, if any, is replaced).
|
||||
- `tasks.metadata.workspace.mode` changes from `inherit_parent` to `shared_group` when the parent relationship effectively changes and the mode was `inherit_parent`. Other modes (`shared_group`, `new_workspace`) are unchanged.
|
||||
- `task_workspace_group_members` is unchanged; active membership remains the durable source of shared workspace access.
|
||||
- Descendant `parent_id` values, blockers, sessions, repositories, workflow, workflow step, and state are unchanged.
|
||||
|
||||
## API surface
|
||||
|
||||
No new endpoint. Reuses and extends existing contracts:
|
||||
|
||||
- `PATCH /api/v1/tasks/:id` with `parent_id` (non-empty nests; `""` un-nests) — already validated by `Service.resolveParentID` (self, existence, archived, same workspace, descendant cycle) and `validateReparentDepth` (one-level kanban limit, Office trees exempt). **Behavior addition:** when the effective parent changes, `inherit_parent` workspace mode is normalized to `shared_group` (mirroring the detach operation). Success returns the updated task DTO; invalid targets map to `400`; missing task to `404`.
|
||||
- `PATCH /api/v1/office/tasks/:id` with non-empty `parent_id` — same normalization added for parity; empty parent continues to route through the canonical detach operation.
|
||||
- WS `task.updated` payload unchanged: `parent_id` is always present (nil when cleared), and `metadata` carries the normalized workspace mode.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- **Invalid target** (self, descendant, subtask, archived, missing, cross-workspace, or depth violation): the UI filters these targets out before a drop can land, so a drop outside every nest zone is a no-op. If a request is nevertheless rejected by the backend (e.g. a valid-zone target became invalid between render and drop), the UI keeps the task in its original tree position, rolls back the optimistic update, and shows a request-error toast.
|
||||
- **Persistence failure**: no successful response is returned; the UI rolls back to the original tree.
|
||||
- **Concurrent submissions** are safe: setting the same parent twice is idempotent, and the optimistic update is reconciled by the authoritative `task.updated` event.
|
||||
- **No valid targets**: the drag offers no nest zones; only reorder remains possible.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a subtask `C` under parent `A` and a root task `B` in the same workflow, **WHEN** the user drags `C` onto `B`'s nest drop zone, **THEN** `C`'s parent becomes `B`, the sidebar shows `C` nested under `B`, and no reload occurs.
|
||||
- **GIVEN** an `inherit_parent` subtask `C` under `A`, **WHEN** `C` is dragged onto root `B`'s nest zone, **THEN** `C`'s persisted workspace mode is `shared_group` and its workspace-group membership is unchanged.
|
||||
- **GIVEN** a root task with no children, **WHEN** it is dragged onto another root's nest zone, **THEN** it becomes that root's subtask.
|
||||
- **GIVEN** a task that already has children, **WHEN** it is dragged, **THEN** no row offers a nest drop zone (reorder only).
|
||||
- **GIVEN** a drag over a subtask row or over a task in a different workflow, **WHEN** the pointer rests on the row, **THEN** no nest drop zone is offered.
|
||||
- **GIVEN** a subtask dragged toward its current parent, **WHEN** the pointer rests on that parent row, **THEN** no nest drop zone is offered.
|
||||
- **GIVEN** a drag dropped between two sibling rows, **WHEN** the drop lands outside every nest zone, **THEN** the siblings reorder as before.
|
||||
- **GIVEN** a drop that lands outside every nest zone, **WHEN** the drop completes, **THEN** the task keeps its original parent and no request is sent (a plain no-op); a request-error toast appears only when a valid-zone drop's request is rejected by the backend.
|
||||
- **GIVEN** a re-parented task, **WHEN** its `task.updated` event arrives over WebSocket, **THEN** cached parent relationships in sidebar, board, and task-detail views are updated.
|
||||
- **GIVEN** the mobile task switcher sheet, **WHEN** a user touch-drags a subtask onto a root's nest zone, **THEN** the same re-parenting occurs.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Nesting via drag on the kanban board (cards keep their step-move drag semantics).
|
||||
- Lifting the one-level kanban subtask limit (still enforced by `computeNestCandidates` and `validateReparentDepth`; Office trees keep arbitrary depth).
|
||||
- Drag-to-re-parent in the Office task list (Office keeps its parent picker).
|
||||
- Bulk or multi-select drag re-parenting.
|
||||
- A keyboard/AT drag gesture; the existing context menus remain the accessible path to the same operation.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
See [the implementation plan](../../plans/subtask-reparenting-drag-drop/plan.md).
|
||||
Reference in New Issue
Block a user