fix: restore Nest under candidates (#3727)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -111,6 +111,7 @@ func (s *DashboardService) UpdateTaskProjectID(ctx context.Context, taskID, proj
|
||||
}
|
||||
}
|
||||
s.publishTaskUpdated(ctx, taskID, []string{"project_id"})
|
||||
s.publishCanonicalTaskUpdated(ctx, taskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -93,3 +93,23 @@ func TestUpdateTaskStatus_NoPublisherWiredIsSafe(t *testing.T) {
|
||||
t.Fatalf("update task status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTaskProjectIDPublishesCanonicalTaskUpdatedForAssignAndClear(t *testing.T) {
|
||||
deps := newTestDeps(t)
|
||||
insertTestTask(t, deps.db, "project-transition", "ws-project", "Project transition", "todo", 0)
|
||||
insertTestProject(t, deps, "project-1", "ws-project")
|
||||
pub := &recordingTaskLifecyclePublisher{}
|
||||
deps.svc.SetTaskLifecyclePublisher(pub)
|
||||
|
||||
if err := deps.svc.UpdateTaskProjectID(context.Background(), "project-transition", "project-1"); err != nil {
|
||||
t.Fatalf("assign project: %v", err)
|
||||
}
|
||||
if err := deps.svc.UpdateTaskProjectID(context.Background(), "project-transition", ""); err != nil {
|
||||
t.Fatalf("clear project: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"project-transition", "project-transition"}
|
||||
if len(pub.published) != len(want) || pub.published[0] != want[0] || pub.published[1] != want[1] {
|
||||
t.Fatalf("published = %v, want %v", pub.published, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,6 +426,9 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas
|
||||
"updated_at": task.UpdatedAt.Format(time.RFC3339Nano),
|
||||
"is_ephemeral": task.IsEphemeral,
|
||||
"autopilot": task.Autopilot,
|
||||
// Always explicit because a project reassignment can change Office
|
||||
// ownership in either direction while a client has the task cached.
|
||||
"is_from_office": task.IsFromOffice,
|
||||
// Consumers that restore quick-chat tabs filter on origin, so it has to
|
||||
// travel with the event and not just the HTTP DTO.
|
||||
"origin": task.Origin,
|
||||
|
||||
@@ -717,6 +717,30 @@ func TestPublishTaskUpdated_EmitsAutopilot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishTaskUpdatedEmitsOfficeIdentityExplicitly(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
isFromOffice bool
|
||||
}{
|
||||
{name: "office", isFromOffice: true},
|
||||
{name: "kanban", isFromOffice: false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc, eventBus, _ := createTestService(t)
|
||||
svc.PublishTaskUpdated(context.Background(), &models.Task{
|
||||
ID: "task-office-identity", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1",
|
||||
IsFromOffice: tc.isFromOffice,
|
||||
})
|
||||
|
||||
data := singlePublishedEventData(t, eventBus)
|
||||
got, ok := data["is_from_office"].(bool)
|
||||
if !ok || got != tc.isFromOffice {
|
||||
t.Fatalf("is_from_office payload = %#v, want %t", data["is_from_office"], tc.isFromOffice)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishTaskUpdatedRedactsDeferredLaunchAttribution(t *testing.T) {
|
||||
svc, eventBus, _ := createTestService(t)
|
||||
task := &models.Task{
|
||||
|
||||
@@ -75,3 +75,12 @@ describe("toSheetItem remote executor projection", () => {
|
||||
expect(item.primarySessionId).toBe("session-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSheetItem Office identity projection", () => {
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4
|
||||
it("carries Office identity into the phone task drawer row", () => {
|
||||
const item = toSheetItem(task({ isFromOffice: true }), emptyCtx());
|
||||
|
||||
expect(item.isFromOffice).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,6 +114,7 @@ export function toSheetItem(
|
||||
primaryExecutorProfileId: task.primaryExecutorProfileId ?? undefined,
|
||||
workflowStepColor: facts.workflowStepColor,
|
||||
isArchived: task.isArchived === true,
|
||||
isFromOffice: task.isFromOffice,
|
||||
isRemoteExecutor: task.isRemoteExecutor,
|
||||
remoteExecutorId: task.primaryExecutorId ?? undefined,
|
||||
remoteExecutorType: task.primaryExecutorType ?? undefined,
|
||||
|
||||
@@ -20,6 +20,7 @@ export function buildMobileTaskSwitcherProps(
|
||||
): TaskSwitcherProps {
|
||||
return {
|
||||
grouped: helpers.grouped,
|
||||
nestHierarchyTasks: props.tasks,
|
||||
workflows: props.workflows,
|
||||
stepsByWorkflowId: props.stepsByWorkflowId,
|
||||
activeTaskId: props.activeTaskId,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from "@kandev/ui/context-menu";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useNestTask } from "@/hooks/use-nest-task";
|
||||
import { computeNestCandidates } from "@/lib/sidebar/nest-candidates";
|
||||
import type { TaskSwitcherItem } from "./task-switcher";
|
||||
@@ -16,6 +15,8 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
type TaskNestContextMenuItemsProps = {
|
||||
task: TaskSwitcherItem;
|
||||
nestCandidateTasks?: TaskSwitcherItem[];
|
||||
nestHierarchyTasks?: TaskSwitcherItem[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -25,25 +26,23 @@ type TaskNestContextMenuItemsProps = {
|
||||
* other tasks in the same workflow, excluding the task's own descendants
|
||||
* (which would create a cycle) and its current parent.
|
||||
*/
|
||||
export function TaskNestContextMenuItems({ task, disabled }: TaskNestContextMenuItemsProps) {
|
||||
export function TaskNestContextMenuItems({
|
||||
task,
|
||||
nestCandidateTasks = [],
|
||||
nestHierarchyTasks,
|
||||
disabled,
|
||||
}: TaskNestContextMenuItemsProps) {
|
||||
const { t } = useTranslation();
|
||||
const workflowId = task.workflowId;
|
||||
// Prefer the all-workflows snapshot; fall back to the active kanban tasks,
|
||||
// which the sidebar also renders from before the multi-snapshot fetch
|
||||
// resolves (e.g. initial /t/:id load). Without the fallback the menu would
|
||||
// show "No other tasks" even though rows are visible.
|
||||
const tasks = useAppStore((s) => {
|
||||
if (!workflowId) return undefined;
|
||||
return (
|
||||
s.kanbanMulti?.snapshots?.[workflowId]?.tasks ??
|
||||
(s.kanban?.workflowId === workflowId ? s.kanban?.tasks : undefined)
|
||||
);
|
||||
});
|
||||
const nestTask = useNestTask();
|
||||
|
||||
if (!workflowId || task.isArchived) return null;
|
||||
|
||||
const candidates = computeNestCandidates(tasks ?? [], task.id);
|
||||
const candidates = computeNestCandidates(
|
||||
nestCandidateTasks.filter((candidate) => candidate.workflowId === workflowId),
|
||||
task.id,
|
||||
nestHierarchyTasks ?? nestCandidateTasks,
|
||||
);
|
||||
const hasParent = Boolean(task.parentTaskId);
|
||||
|
||||
return (
|
||||
|
||||
@@ -233,6 +233,26 @@ describe("aggregateSidebarTasks active precedence", () => {
|
||||
|
||||
expect(result.allTasks[0].autopilot).toBe(true);
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4
|
||||
it("preserves projected Office identity through a newer partial active task", () => {
|
||||
const projected = makeTask("t1", "s1", {
|
||||
isFromOffice: true,
|
||||
updatedAt: "2026-08-03T00:00:00Z",
|
||||
});
|
||||
const active = makeTask("t1", "s1", {
|
||||
updatedAt: "2026-08-03T00:00:01Z",
|
||||
});
|
||||
|
||||
const result = aggregateSidebarTasks(
|
||||
{ "wf-1": makeSnapshot([makeStep("s1", 0)], [projected]) },
|
||||
"wf-1",
|
||||
[active],
|
||||
[makeStep("s1", 0)],
|
||||
);
|
||||
|
||||
expect(result.allTasks[0].isFromOffice).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateSidebarTasks task lifecycle freshness", () => {
|
||||
|
||||
@@ -114,6 +114,7 @@ function reconcileTaskProjection(
|
||||
// a partial WS event, so do not let it erase the value from the full
|
||||
// workflow projection.
|
||||
autopilot: active.autopilot ?? projected.autopilot,
|
||||
isFromOffice: active.isFromOffice ?? projected.isFromOffice,
|
||||
_workflowId: activeWorkflowId,
|
||||
};
|
||||
const task = activeTaskIsNewer(active, projected) ? activeTask : projected;
|
||||
|
||||
@@ -168,6 +168,15 @@ describe("buildSidebarItem", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSidebarItem Office identity", () => {
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4
|
||||
it("preserves isFromOffice for candidate filtering", () => {
|
||||
const item = buildSidebarItem(task({ isFromOffice: true }), emptyContext());
|
||||
|
||||
expect(item.isFromOffice).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSidebarItem — parked on background work", () => {
|
||||
it("carries the parked-on-background-work projection through to the sidebar row", () => {
|
||||
const item = buildSidebarItem(task({ parkedOnBackgroundWork: true }), emptyContext());
|
||||
|
||||
@@ -159,6 +159,7 @@ export function buildSidebarItem(
|
||||
remoteExecutorName: task.primaryExecutorName ?? undefined,
|
||||
createdAt: task.createdAt,
|
||||
isArchived: task.isArchived === true,
|
||||
isFromOffice: task.isFromOffice,
|
||||
parentTaskTitle: task.parentTaskId ? context.titleById.get(task.parentTaskId) : undefined,
|
||||
parentTaskId: task.parentTaskId ?? undefined,
|
||||
workspaceMode: task.workspaceMode,
|
||||
|
||||
@@ -14,6 +14,7 @@ type TaskSwitcherComponentProps = ComponentProps<typeof TaskSwitcher>;
|
||||
*/
|
||||
export function buildTaskSwitcherProps(args: {
|
||||
grouped: TaskSwitcherComponentProps["grouped"];
|
||||
nestHierarchyTasks: TaskSwitcherComponentProps["nestHierarchyTasks"];
|
||||
workflows: TaskSwitcherComponentProps["workflows"];
|
||||
stepsByWorkflowId: TaskSwitcherComponentProps["stepsByWorkflowId"];
|
||||
highlightedTaskId: string | null;
|
||||
@@ -49,6 +50,7 @@ export function buildTaskSwitcherProps(args: {
|
||||
}): TaskSwitcherComponentProps {
|
||||
return {
|
||||
grouped: args.grouped,
|
||||
nestHierarchyTasks: args.nestHierarchyTasks,
|
||||
workflows: args.workflows,
|
||||
stepsByWorkflowId: args.stepsByWorkflowId,
|
||||
activeTaskId: args.highlightedTaskId,
|
||||
|
||||
@@ -501,6 +501,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
);
|
||||
const switcherProps = buildTaskSwitcherProps({
|
||||
grouped,
|
||||
nestHierarchyTasks: displayTasks,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
highlightedTaskId,
|
||||
|
||||
@@ -47,6 +47,8 @@ type LinkActions = ReturnType<typeof selectTaskLinkActions>;
|
||||
type SingleGroupProps = Pick<
|
||||
SingleSelectionMenuProps,
|
||||
| "task"
|
||||
| "nestCandidateTasks"
|
||||
| "nestHierarchyTasks"
|
||||
| "isDeleting"
|
||||
| "isArchiving"
|
||||
| "isPinned"
|
||||
@@ -282,6 +284,8 @@ function SingleEditGroup({
|
||||
|
||||
function SingleRelationshipsGroup({
|
||||
task,
|
||||
nestCandidateTasks,
|
||||
nestHierarchyTasks,
|
||||
isDeleting,
|
||||
onCreateSubtask,
|
||||
onDetachTask,
|
||||
@@ -292,6 +296,8 @@ function SingleRelationshipsGroup({
|
||||
}: Pick<
|
||||
SingleGroupProps,
|
||||
| "task"
|
||||
| "nestCandidateTasks"
|
||||
| "nestHierarchyTasks"
|
||||
| "isDeleting"
|
||||
| "onCreateSubtask"
|
||||
| "onDetachTask"
|
||||
@@ -303,7 +309,14 @@ function SingleRelationshipsGroup({
|
||||
return (
|
||||
<>
|
||||
<TaskCreateSubtaskItem task={task} disabled={isDeleting} onCreateSubtask={onCreateSubtask} />
|
||||
{!task.isArchived && <TaskNestContextMenuItems task={task} disabled={isDeleting} />}
|
||||
{!task.isArchived && (
|
||||
<TaskNestContextMenuItems
|
||||
task={task}
|
||||
nestCandidateTasks={nestCandidateTasks}
|
||||
nestHierarchyTasks={nestHierarchyTasks}
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
)}
|
||||
{hasLinkGroup && linkActions && pluginLinkActions && (
|
||||
<TaskPluginLinkMenu
|
||||
disabled={isDeleting}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { TaskSwitcherItem } from "./task-switcher-types";
|
||||
const PLUGIN_ID = "example-task-actions";
|
||||
const PLUGIN_ACTION_LABEL = "Inspect task";
|
||||
const WORKFLOW_1_ID = "workflow-1";
|
||||
const WORKFLOW_1_NAME = "Workflow 1";
|
||||
const WORKFLOW_ID = WORKFLOW_1_ID;
|
||||
const STEP_ID = "step-1";
|
||||
const SEPARATOR_SLOT = "context-menu-separator";
|
||||
@@ -171,7 +172,7 @@ describe("TaskItemWithContextMenu — grouped single-task actions", () => {
|
||||
parentTaskId: "parent-1",
|
||||
})}
|
||||
workflows={[
|
||||
{ id: WORKFLOW_ID, name: "Workflow 1" },
|
||||
{ id: WORKFLOW_ID, name: WORKFLOW_1_NAME },
|
||||
{ id: "workflow-2", name: "Workflow 2" },
|
||||
]}
|
||||
stepsByWorkflowId={{
|
||||
@@ -246,7 +247,7 @@ describe("TaskItemWithContextMenu — grouped bulk actions", () => {
|
||||
task={task({ workflowId: WORKFLOW_ID, workflowStepId: STEP_ID })}
|
||||
selectedTaskIds={new Set(["task-1", "task-2"])}
|
||||
workflows={[
|
||||
{ id: WORKFLOW_ID, name: "Workflow 1" },
|
||||
{ id: WORKFLOW_ID, name: WORKFLOW_1_NAME },
|
||||
{ id: "workflow-2", name: "Workflow 2" },
|
||||
]}
|
||||
stepsByWorkflowId={{
|
||||
@@ -309,7 +310,7 @@ function renderWorkflowMoveMenu({
|
||||
workflowId: WORKFLOW_1_ID,
|
||||
workflowStepId: "step-1",
|
||||
})}
|
||||
workflows={[{ id: WORKFLOW_1_ID, name: "Workflow 1" }]}
|
||||
workflows={[{ id: WORKFLOW_1_ID, name: WORKFLOW_1_NAME }]}
|
||||
stepsByWorkflowId={{
|
||||
[WORKFLOW_1_ID]: [
|
||||
{ id: "step-1", title: "Todo" },
|
||||
|
||||
@@ -26,6 +26,10 @@ export { createTaskLinkSelectAction } from "./task-switcher-link-menu";
|
||||
|
||||
type ContextMenuProps = TaskLinkHandlers & {
|
||||
task: TaskSwitcherItem;
|
||||
nestCandidateTasks?: TaskSwitcherItem[];
|
||||
nestHierarchyTasks?: TaskSwitcherItem[];
|
||||
getNestCandidateTasks?: () => TaskSwitcherItem[];
|
||||
getNestHierarchyTasks?: () => TaskSwitcherItem[] | undefined;
|
||||
workflows?: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
steps?: StepDef[];
|
||||
@@ -55,19 +59,40 @@ type ContextMenuProps = TaskLinkHandlers & {
|
||||
isMixedWorkflowSelection?: boolean;
|
||||
};
|
||||
|
||||
type OpenedNestSources = {
|
||||
candidates?: TaskSwitcherItem[];
|
||||
hierarchy?: TaskSwitcherItem[];
|
||||
};
|
||||
|
||||
function useContextMenuOpenState(props: ContextMenuProps) {
|
||||
const [contextOpen, setContextOpen] = useState(false);
|
||||
const [openedNestSources, setOpenedNestSources] = useState<OpenedNestSources>({});
|
||||
const handleContextOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setOpenedNestSources({
|
||||
candidates: props.getNestCandidateTasks?.() ?? props.nestCandidateTasks,
|
||||
hierarchy: props.getNestHierarchyTasks?.() ?? props.nestHierarchyTasks,
|
||||
});
|
||||
}
|
||||
setContextOpen(open);
|
||||
};
|
||||
return { contextOpen, setContextOpen, openedNestSources, handleContextOpenChange };
|
||||
}
|
||||
|
||||
// This component coordinates the context menu and drag cancellation. Archive
|
||||
// state lives in its focused adapter so unavailable actions stay unavailable.
|
||||
export function TaskItemWithContextMenu(props: ContextMenuProps) {
|
||||
const { children, ...menuProps } = props;
|
||||
const { task, stepsByWorkflowId, steps, onRequestMoveOptions, onBeforeMoveOptionsOpen } = props;
|
||||
const [contextOpen, setContextOpen] = useState(false);
|
||||
const { contextOpen, setContextOpen, openedNestSources, handleContextOpenChange } =
|
||||
useContextMenuOpenState(props);
|
||||
const [menuKey, setMenuKey] = useState(0);
|
||||
const moveTasks = useTaskWorkflowMove();
|
||||
const closeMenu = () => {
|
||||
setContextOpen(false);
|
||||
setMenuKey((k) => k + 1);
|
||||
};
|
||||
const { handleOpenChange, triggerProps } = useMenuTouchDragCancel(setContextOpen);
|
||||
const { handleOpenChange, triggerProps } = useMenuTouchDragCancel(handleContextOpenChange);
|
||||
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||
const usesTouchDrawer = useTouchDrawer();
|
||||
const archive = useTaskSwitcherArchiveConfirmation({
|
||||
@@ -107,11 +132,17 @@ export function TaskItemWithContextMenu(props: ContextMenuProps) {
|
||||
// owns its own drawer, so the inline path stays off there too.
|
||||
const inlineSubmitWithOptions =
|
||||
!usesTouchDrawer && !onRequestMoveOptions ? submitMoveOptionsForStep : undefined;
|
||||
const contextMenuProps = buildTaskContextMenuItemsProps(props, closeMenu, moveTasks, {
|
||||
onMoveToStepWithOptions: handleMoveToStepWithOptions,
|
||||
onSubmitWithOptions: inlineSubmitWithOptions,
|
||||
isMoving,
|
||||
});
|
||||
const contextMenuProps = buildTaskContextMenuItemsProps(
|
||||
props,
|
||||
openedNestSources,
|
||||
closeMenu,
|
||||
moveTasks,
|
||||
{
|
||||
onMoveToStepWithOptions: handleMoveToStepWithOptions,
|
||||
onSubmitWithOptions: inlineSubmitWithOptions,
|
||||
isMoving,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -157,7 +188,11 @@ export function TaskItemWithContextMenu(props: ContextMenuProps) {
|
||||
|
||||
export type TaskContextMenuItemsProps = Omit<
|
||||
ContextMenuProps,
|
||||
"children" | "onBeforeMoveOptionsOpen" | "onRequestMoveOptions"
|
||||
| "children"
|
||||
| "onBeforeMoveOptionsOpen"
|
||||
| "onRequestMoveOptions"
|
||||
| "getNestCandidateTasks"
|
||||
| "getNestHierarchyTasks"
|
||||
> & {
|
||||
closeMenu: () => void;
|
||||
moveTasks: ReturnType<typeof useTaskWorkflowMove>;
|
||||
@@ -171,6 +206,7 @@ export type TaskContextMenuItemsProps = Omit<
|
||||
|
||||
function buildTaskContextMenuItemsProps(
|
||||
props: ContextMenuProps,
|
||||
nestSources: OpenedNestSources,
|
||||
closeMenu: () => void,
|
||||
moveTasks: ReturnType<typeof useTaskWorkflowMove>,
|
||||
moveOptions: {
|
||||
@@ -186,10 +222,14 @@ function buildTaskContextMenuItemsProps(
|
||||
children: _children,
|
||||
onRequestMoveOptions: _onRequestMoveOptions,
|
||||
onBeforeMoveOptionsOpen: _onBeforeMoveOptionsOpen,
|
||||
getNestCandidateTasks: _getNestCandidateTasks,
|
||||
getNestHierarchyTasks: _getNestHierarchyTasks,
|
||||
...rest
|
||||
} = props;
|
||||
return {
|
||||
...rest,
|
||||
nestCandidateTasks: nestSources.candidates ?? rest.nestCandidateTasks,
|
||||
nestHierarchyTasks: nestSources.hierarchy ?? rest.nestHierarchyTasks,
|
||||
onMoveToStepWithOptions: moveOptions.onMoveToStepWithOptions,
|
||||
onSubmitWithOptions: moveOptions.onSubmitWithOptions,
|
||||
moveOptionsBusy: moveOptions.isMoving,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { TaskItemWithContextMenu } from "./task-switcher-context-menu";
|
||||
import type { TaskSwitcherItem } from "./task-switcher-types";
|
||||
|
||||
const WORKFLOW_ID = "workflow-1";
|
||||
const STEP_ID = "step-1";
|
||||
|
||||
function task(overrides: Partial<TaskSwitcherItem> = {}): TaskSwitcherItem {
|
||||
return { id: "task-1", title: "Task 1", state: "IN_PROGRESS", ...overrides };
|
||||
}
|
||||
|
||||
function storeTask(item: TaskSwitcherItem) {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
workflowId: item.workflowId,
|
||||
workflowStepId: item.workflowStepId,
|
||||
position: 0,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("TaskItemWithContextMenu Nest under candidates", () => {
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.1
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.2
|
||||
it("uses rendered candidates when the workflow snapshot is partial", async () => {
|
||||
const current = task({ workflowId: WORKFLOW_ID, workflowStepId: STEP_ID });
|
||||
const target = task({
|
||||
id: "visible-target",
|
||||
title: "Visible target",
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowStepId: STEP_ID,
|
||||
});
|
||||
|
||||
render(
|
||||
<StateProvider
|
||||
initialState={
|
||||
{
|
||||
kanban: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
steps: [],
|
||||
tasks: [storeTask(current), storeTask(target)],
|
||||
},
|
||||
kanbanMulti: {
|
||||
isLoading: false,
|
||||
snapshots: {
|
||||
[WORKFLOW_ID]: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowName: "Workflow 1",
|
||||
steps: [],
|
||||
tasks: [storeTask(current)],
|
||||
isPlaceholder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never
|
||||
}
|
||||
>
|
||||
<ToastProvider>
|
||||
<TaskItemWithContextMenu task={current} nestCandidateTasks={[current, target]}>
|
||||
<div data-testid="task-row">Task 1</div>
|
||||
</TaskItemWithContextMenu>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId("task-row"));
|
||||
const nestUnder = await screen.findByRole("menuitem", { name: "Nest under" });
|
||||
fireEvent.pointerMove(nestUnder, { pointerType: "mouse" });
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: "Visible target" })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("captures the latest candidates when the menu opens", async () => {
|
||||
const current = task({ workflowId: WORKFLOW_ID, workflowStepId: STEP_ID });
|
||||
const staleTarget = task({
|
||||
id: "stale-target",
|
||||
title: "Stale target",
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowStepId: STEP_ID,
|
||||
});
|
||||
const latestTarget = task({
|
||||
id: "latest-target",
|
||||
title: "Latest target",
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowStepId: STEP_ID,
|
||||
});
|
||||
let candidates = [current, staleTarget];
|
||||
|
||||
render(
|
||||
<StateProvider>
|
||||
<ToastProvider>
|
||||
<TaskItemWithContextMenu task={current} getNestCandidateTasks={() => candidates}>
|
||||
<div data-testid="fresh-task-row">Task 1</div>
|
||||
</TaskItemWithContextMenu>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
candidates = [current, latestTarget];
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId("fresh-task-row"));
|
||||
const nestUnder = await screen.findByRole("menuitem", { name: "Nest under" });
|
||||
fireEvent.pointerMove(nestUnder, { pointerType: "mouse" });
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: "Latest target" })).not.toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: "Stale target" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const renderCounts = vi.hoisted(() => ({
|
||||
groups: new Map<string, number>(),
|
||||
nodes: new Map<string, number>(),
|
||||
rows: new Map<string, number>(),
|
||||
nestCandidateGetters: new Map<string, (() => TaskSwitcherItem[]) | undefined>(),
|
||||
}));
|
||||
|
||||
function increment(counts: Map<string, number>, key: string) {
|
||||
@@ -22,7 +23,18 @@ vi.mock("./task-item", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./task-switcher-context-menu", () => ({
|
||||
TaskItemWithContextMenu: ({ children }: { children: ReactElement }) => children,
|
||||
TaskItemWithContextMenu: ({
|
||||
task,
|
||||
children,
|
||||
getNestCandidateTasks,
|
||||
}: {
|
||||
task: TaskSwitcherItem;
|
||||
children: ReactElement;
|
||||
getNestCandidateTasks?: () => TaskSwitcherItem[];
|
||||
}) => {
|
||||
renderCounts.nestCandidateGetters.set(task.id, getNestCandidateTasks);
|
||||
return children;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./task-switcher-subtask-dnd", () => ({
|
||||
@@ -131,6 +143,7 @@ beforeEach(() => {
|
||||
renderCounts.groups.clear();
|
||||
renderCounts.nodes.clear();
|
||||
renderCounts.rows.clear();
|
||||
renderCounts.nestCandidateGetters.clear();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
@@ -189,6 +202,20 @@ describe("TaskSwitcher render stability", () => {
|
||||
|
||||
expect(latestHandler).toHaveBeenCalledWith(TASK_B);
|
||||
});
|
||||
|
||||
it("gives a skipped row the latest nest candidates", () => {
|
||||
const taskA = task(TASK_A, WORKFLOW_A);
|
||||
const taskB = task(TASK_B, WORKFLOW_A);
|
||||
const view = render(switcher(groupedTogether([taskA, taskB])));
|
||||
|
||||
view.rerender(switcher(groupedTogether([{ ...taskA, title: TASK_A_UPDATED }, { ...taskB }])));
|
||||
|
||||
expect(
|
||||
renderCounts.nestCandidateGetters
|
||||
.get(TASK_B)?.()
|
||||
.map((item) => item.title),
|
||||
).toEqual([TASK_A_UPDATED, TASK_B]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskSwitcher nested render stability", () => {
|
||||
|
||||
@@ -16,6 +16,9 @@ export type SubtaskToggleInfo = {
|
||||
|
||||
export type TaskRowProps = {
|
||||
task: TaskSwitcherItem;
|
||||
nestCandidateTasks?: TaskSwitcherItem[];
|
||||
getNestCandidateTasks?: () => TaskSwitcherItem[];
|
||||
getNestHierarchyTasks?: () => TaskSwitcherItem[] | undefined;
|
||||
isSubTask?: boolean;
|
||||
depth?: number;
|
||||
subtaskToggle?: SubtaskToggleInfo;
|
||||
@@ -66,6 +69,9 @@ function archiveAware<T>(value: T | undefined, isArchived: boolean): T | undefin
|
||||
|
||||
function getContextMenuProps(props: TaskRowProps, isArchived: boolean) {
|
||||
return {
|
||||
nestCandidateTasks: props.nestCandidateTasks,
|
||||
getNestCandidateTasks: props.getNestCandidateTasks,
|
||||
getNestHierarchyTasks: props.getNestHierarchyTasks,
|
||||
onEditTask: archiveAware(props.onEditTask, isArchived),
|
||||
onRenameTask: archiveAware(props.onRenameTask, isArchived),
|
||||
onArchiveTask: archiveAware(props.onArchiveTask, isArchived),
|
||||
|
||||
@@ -17,13 +17,24 @@ 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";
|
||||
const OFFICE_SUBJECT = "office-subject";
|
||||
const OFFICE_TARGET_ROOT = "office-target-root";
|
||||
const HIDDEN_CHILD = "hidden-child";
|
||||
|
||||
function task(id: string, overrides: { workflowId?: string; parentTaskId?: string | null } = {}) {
|
||||
function task(
|
||||
id: string,
|
||||
overrides: {
|
||||
workflowId?: string;
|
||||
parentTaskId?: string | null;
|
||||
isFromOffice?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
workflowId: overrides.workflowId ?? WORKFLOW,
|
||||
parentTaskId: overrides.parentTaskId ?? undefined,
|
||||
isFromOffice: overrides.isFromOffice,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,4 +165,39 @@ describe("computeNestTargets", () => {
|
||||
expect(targets.has("other-wf")).toBe(false);
|
||||
expect(targets.has(SUB_C)).toBe(false);
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.1
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4
|
||||
it("matches the shared Office candidates for a subject with children", () => {
|
||||
const officeTasks = [
|
||||
task(OFFICE_SUBJECT, { isFromOffice: true }),
|
||||
task("office-descendant", {
|
||||
parentTaskId: OFFICE_SUBJECT,
|
||||
isFromOffice: true,
|
||||
}),
|
||||
task(OFFICE_TARGET_ROOT, { isFromOffice: true }),
|
||||
task("office-target-child", {
|
||||
parentTaskId: OFFICE_TARGET_ROOT,
|
||||
isFromOffice: true,
|
||||
}),
|
||||
];
|
||||
|
||||
expect(computeNestTargets(officeTasks[0], officeTasks)).toEqual(
|
||||
new Set([OFFICE_TARGET_ROOT, "office-target-child"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes a descendant whose intermediate ancestor is hidden", () => {
|
||||
const hierarchy = [
|
||||
task(OFFICE_SUBJECT, { isFromOffice: true }),
|
||||
task(HIDDEN_CHILD, { parentTaskId: OFFICE_SUBJECT, isFromOffice: true }),
|
||||
task("visible-grandchild", { parentTaskId: HIDDEN_CHILD, isFromOffice: true }),
|
||||
task("valid-target", { isFromOffice: true }),
|
||||
];
|
||||
const visibleTasks = hierarchy.filter((candidate) => candidate.id !== HIDDEN_CHILD);
|
||||
|
||||
expect(computeNestTargets(hierarchy[0], visibleTasks, hierarchy)).toEqual(
|
||||
new Set(["valid-target"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,16 +122,20 @@ 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).
|
||||
* active task's workflow. Every mode excludes the task, its current parent,
|
||||
* and descendants; Kanban keeps the root-only depth limit while Office may
|
||||
* target tasks at any depth.
|
||||
*/
|
||||
export function computeNestTargets(
|
||||
activeTask: Pick<TaskSwitcherItem, "id" | "workflowId" | "parentTaskId"> | undefined,
|
||||
groupTasks: TaskSwitcherItem[],
|
||||
hierarchyTasks: readonly TaskSwitcherItem[] = groupTasks,
|
||||
): 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));
|
||||
return new Set(
|
||||
computeNestCandidates(sameWorkflow, activeTask.id, hierarchyTasks).map((task) => task.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,6 +332,47 @@ export function SortableTaskLevel({
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveNestDrag(
|
||||
groupTasks: TaskSwitcherItem[],
|
||||
tasksById: ReadonlyMap<string, TaskSwitcherItem>,
|
||||
getNestHierarchyTasks?: () => TaskSwitcherItem[] | undefined,
|
||||
) {
|
||||
const [activeDrag, setActiveDrag] = useState<{
|
||||
id: string;
|
||||
hierarchyTasks?: TaskSwitcherItem[];
|
||||
} | null>(null);
|
||||
const activeDragId = activeDrag?.id ?? null;
|
||||
const nestTargetIds = useMemo(
|
||||
() =>
|
||||
computeNestTargets(
|
||||
activeDragId ? tasksById.get(activeDragId) : undefined,
|
||||
groupTasks,
|
||||
activeDrag?.hierarchyTasks ?? groupTasks,
|
||||
),
|
||||
[activeDrag, activeDragId, tasksById, groupTasks],
|
||||
);
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
setActiveDrag({
|
||||
id: String(event.active.id),
|
||||
hierarchyTasks: getNestHierarchyTasks?.(),
|
||||
});
|
||||
},
|
||||
[getNestHierarchyTasks],
|
||||
);
|
||||
const clearActiveDrag = useCallback(() => setActiveDrag(null), []);
|
||||
return { activeDragId, nestTargetIds, handleDragStart, clearActiveDrag };
|
||||
}
|
||||
|
||||
type TaskTreeDndGroupProps = {
|
||||
groupTasks: TaskSwitcherItem[];
|
||||
getNestHierarchyTasks?: () => TaskSwitcherItem[] | undefined;
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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:
|
||||
@@ -339,18 +384,12 @@ export function SortableTaskLevel({
|
||||
*/
|
||||
export function TaskTreeDndGroup({
|
||||
groupTasks,
|
||||
getNestHierarchyTasks,
|
||||
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);
|
||||
}: TaskTreeDndGroupProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.pointer,
|
||||
@@ -378,18 +417,14 @@ export function TaskTreeDndGroup({
|
||||
childrenByParent: byParent,
|
||||
};
|
||||
}, [groupTasks]);
|
||||
const nestTargetIds = useMemo(
|
||||
() => computeNestTargets(activeDragId ? tasksById.get(activeDragId) : undefined, groupTasks),
|
||||
[activeDragId, tasksById, groupTasks],
|
||||
const { activeDragId, nestTargetIds, handleDragStart, clearActiveDrag } = useActiveNestDrag(
|
||||
groupTasks,
|
||||
tasksById,
|
||||
getNestHierarchyTasks,
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveDragId(String(event.active.id));
|
||||
}, []);
|
||||
const handleDragCancel = useCallback(() => setActiveDragId(null), []);
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveDragId(null);
|
||||
clearActiveDrag();
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
const decision = resolveSidebarDrop({
|
||||
@@ -411,7 +446,14 @@ export function TaskTreeDndGroup({
|
||||
break;
|
||||
}
|
||||
},
|
||||
[groupRootIds, childrenByParent, onReorderGroup, onReorderSubtasks, onNestTask],
|
||||
[
|
||||
clearActiveDrag,
|
||||
groupRootIds,
|
||||
childrenByParent,
|
||||
onReorderGroup,
|
||||
onReorderSubtasks,
|
||||
onNestTask,
|
||||
],
|
||||
);
|
||||
|
||||
const collisionDetection = useCallback<CollisionDetection>((args) => {
|
||||
@@ -432,7 +474,7 @@ export function TaskTreeDndGroup({
|
||||
collisionDetection={collisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragCancel={clearActiveDrag}
|
||||
>
|
||||
{children(nestTargetIds, activeDragId)}
|
||||
</DndContext>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { countGroupTasks, type SidebarGroup } from "@/lib/sidebar/apply-view";
|
||||
import {
|
||||
SortableTaskLevel,
|
||||
@@ -14,7 +14,14 @@ import type { TaskSwitcherItem } from "./task-switcher-types";
|
||||
|
||||
export type TaskRowBaseProps = Omit<
|
||||
TaskRowProps,
|
||||
"task" | "subtaskToggle" | "isPinned" | "isSubTask" | "depth"
|
||||
| "task"
|
||||
| "nestCandidateTasks"
|
||||
| "getNestCandidateTasks"
|
||||
| "getNestHierarchyTasks"
|
||||
| "subtaskToggle"
|
||||
| "isPinned"
|
||||
| "isSubTask"
|
||||
| "depth"
|
||||
>;
|
||||
|
||||
type TaskTreeContext = {
|
||||
@@ -23,6 +30,8 @@ type TaskTreeContext = {
|
||||
onToggleSubtasks?: (parentTaskId: string) => void;
|
||||
pinnedSet: Set<string>;
|
||||
rowProps: TaskRowBaseProps;
|
||||
getNestCandidateTasks: () => TaskSwitcherItem[];
|
||||
getNestHierarchyTasks?: () => TaskSwitcherItem[] | undefined;
|
||||
onReorderGroup?: (groupTaskIds: string[]) => void;
|
||||
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
onNestTask?: (taskId: string, parentTaskId: string) => void;
|
||||
@@ -74,6 +83,8 @@ function taskTreeNodeEqual(previous: TaskTreeNodeProps, next: TaskTreeNodeProps)
|
||||
previous.depth !== next.depth ||
|
||||
previous.isDraggable !== next.isDraggable ||
|
||||
previous.ctx.rowProps !== next.ctx.rowProps ||
|
||||
previous.ctx.getNestCandidateTasks !== next.ctx.getNestCandidateTasks ||
|
||||
previous.ctx.getNestHierarchyTasks !== next.ctx.getNestHierarchyTasks ||
|
||||
previous.ctx.onToggleSubtasks !== next.ctx.onToggleSubtasks ||
|
||||
previous.ctx.onReorderGroup !== next.ctx.onReorderGroup ||
|
||||
previous.ctx.onReorderSubtasks !== next.ctx.onReorderSubtasks ||
|
||||
@@ -125,6 +136,8 @@ const TaskTreeNode = memo(function TaskTreeNode({
|
||||
subtaskToggle={toggleInfo}
|
||||
isPinned={isRoot && ctx.pinnedSet.has(task.id)}
|
||||
{...ctx.rowProps}
|
||||
getNestCandidateTasks={ctx.getNestCandidateTasks}
|
||||
getNestHierarchyTasks={ctx.getNestHierarchyTasks}
|
||||
onTogglePin={isRoot ? ctx.rowProps.onTogglePin : undefined}
|
||||
/>
|
||||
{isNestTarget && <NestDropZone taskId={task.id} title={task.title} />}
|
||||
@@ -184,6 +197,7 @@ function getReorderHandler(parentTaskId: string | null, ctx: TaskTreeContext) {
|
||||
export type GroupSectionProps = {
|
||||
group: SidebarGroup;
|
||||
subTasksByParentId: Map<string, TaskSwitcherItem[]>;
|
||||
getNestHierarchyTasks?: () => TaskSwitcherItem[] | undefined;
|
||||
rowProps: TaskRowBaseProps;
|
||||
pinnedSet: Set<string>;
|
||||
isCollapsed: boolean;
|
||||
@@ -230,6 +244,7 @@ function groupSectionEqual(previous: GroupSectionProps, next: GroupSectionProps)
|
||||
if (
|
||||
previous.group !== next.group ||
|
||||
previous.rowProps !== next.rowProps ||
|
||||
previous.getNestHierarchyTasks !== next.getNestHierarchyTasks ||
|
||||
previous.pinnedSet !== next.pinnedSet ||
|
||||
previous.isCollapsed !== next.isCollapsed ||
|
||||
previous.onToggleGroup !== next.onToggleGroup ||
|
||||
@@ -286,6 +301,7 @@ function flattenGroupTasks(
|
||||
export const GroupSection = memo(function GroupSection({
|
||||
group,
|
||||
subTasksByParentId,
|
||||
getNestHierarchyTasks,
|
||||
rowProps,
|
||||
pinnedSet,
|
||||
isCollapsed,
|
||||
@@ -302,6 +318,11 @@ export const GroupSection = memo(function GroupSection({
|
||||
() => flattenGroupTasks(group.tasks, subTasksByParentId),
|
||||
[group.tasks, subTasksByParentId],
|
||||
);
|
||||
const groupTasksRef = useRef(groupTasks);
|
||||
useLayoutEffect(() => {
|
||||
groupTasksRef.current = groupTasks;
|
||||
}, [groupTasks]);
|
||||
const getNestCandidateTasks = useCallback(() => groupTasksRef.current, []);
|
||||
|
||||
const renderTree = (nestTargetIds: Set<string>) => {
|
||||
if (isCollapsed) return null;
|
||||
@@ -311,6 +332,8 @@ export const GroupSection = memo(function GroupSection({
|
||||
onToggleSubtasks,
|
||||
pinnedSet,
|
||||
rowProps,
|
||||
getNestCandidateTasks,
|
||||
getNestHierarchyTasks,
|
||||
onReorderGroup,
|
||||
onReorderSubtasks,
|
||||
onNestTask,
|
||||
@@ -333,6 +356,7 @@ export const GroupSection = memo(function GroupSection({
|
||||
)}
|
||||
<TaskTreeDndGroup
|
||||
groupTasks={groupTasks}
|
||||
getNestHierarchyTasks={getNestHierarchyTasks}
|
||||
onReorderGroup={onReorderGroup}
|
||||
onReorderSubtasks={onReorderSubtasks}
|
||||
onNestTask={onNestTask}
|
||||
|
||||
@@ -62,6 +62,7 @@ export type TaskSwitcherItem = {
|
||||
lastActivityAt?: string;
|
||||
createdAt?: string;
|
||||
isArchived?: boolean;
|
||||
isFromOffice?: boolean;
|
||||
primarySessionId?: string | null;
|
||||
hasPendingClarification?: boolean;
|
||||
hasPendingPermission?: boolean;
|
||||
@@ -81,6 +82,8 @@ export type TaskSwitcherItem = {
|
||||
|
||||
export type TaskSwitcherProps = {
|
||||
grouped: GroupedSidebarList;
|
||||
/** Complete unfiltered task set used only to validate hierarchy constraints. */
|
||||
nestHierarchyTasks?: TaskSwitcherItem[];
|
||||
workflows?: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
activeTaskId: string | null;
|
||||
|
||||
@@ -158,12 +158,14 @@ function buildGroupSectionProps(
|
||||
pinnedSet: Set<string>;
|
||||
collapsedSet: Set<string>;
|
||||
showHeader: boolean;
|
||||
getNestHierarchyTasks: () => TaskSwitcherProps["nestHierarchyTasks"];
|
||||
},
|
||||
): GroupSectionProps {
|
||||
const { group, rowProps, pinnedSet, collapsedSet, showHeader } = options;
|
||||
const { group, rowProps, pinnedSet, collapsedSet, showHeader, getNestHierarchyTasks } = options;
|
||||
return {
|
||||
group,
|
||||
subTasksByParentId: grouped.subTasksByParentId,
|
||||
getNestHierarchyTasks,
|
||||
rowProps,
|
||||
pinnedSet,
|
||||
isCollapsed: collapsedSet.has(group.key),
|
||||
@@ -218,6 +220,11 @@ export const TaskSwitcher = memo(function TaskSwitcher(props: TaskSwitcherProps)
|
||||
() => new Set(props.collapsedGroupKeys ?? []),
|
||||
[props.collapsedGroupKeys],
|
||||
);
|
||||
const nestHierarchyTasksRef = useRef(props.nestHierarchyTasks);
|
||||
useLayoutEffect(() => {
|
||||
nestHierarchyTasksRef.current = props.nestHierarchyTasks;
|
||||
}, [props.nestHierarchyTasks]);
|
||||
const getNestHierarchyTasks = useMemo(() => () => nestHierarchyTasksRef.current, []);
|
||||
|
||||
if (isLoading) return <TaskSwitcherSkeleton />;
|
||||
|
||||
@@ -258,6 +265,7 @@ export const TaskSwitcher = memo(function TaskSwitcher(props: TaskSwitcherProps)
|
||||
pinnedSet,
|
||||
collapsedSet,
|
||||
showHeader: showHeaders,
|
||||
getNestHierarchyTasks,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test, expect } from "../../fixtures/office-fixture";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
function taskId(task: Record<string, unknown>): string {
|
||||
return task.id as string;
|
||||
}
|
||||
|
||||
test.describe("Mobile Office sidebar Nest under", () => {
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.1
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.5
|
||||
test("nests an Office parent under another Office task", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
officeApi,
|
||||
seedData,
|
||||
}) => {
|
||||
const project = await officeApi.createProject(
|
||||
seedData.workspaceId,
|
||||
"Mobile Office nesting project",
|
||||
);
|
||||
const projectId = taskId(project);
|
||||
const subject = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Mobile Office nesting subject",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
const descendant = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Mobile Office nesting descendant",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
parent_id: subject.id,
|
||||
},
|
||||
);
|
||||
const target = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Mobile Office nesting target",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
officeApi.updateTaskProjectId(subject.id, projectId),
|
||||
officeApi.updateTaskProjectId(descendant.id, projectId),
|
||||
officeApi.updateTaskProjectId(target.id, projectId),
|
||||
]);
|
||||
await apiClient.saveUserSettings({
|
||||
workspace_id: seedData.workspaceId,
|
||||
workflow_filter_id: seedData.workflowId,
|
||||
keyboard_shortcuts: {},
|
||||
enable_preview_on_click: false,
|
||||
sidebar_views: [],
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${subject.id}`);
|
||||
await new SessionPage(testPage).waitForLoad();
|
||||
await testPage.getByTestId("mobile-session-menu").tap();
|
||||
|
||||
const taskSheet = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
const subjectRow = taskSheet
|
||||
.getByTestId("sidebar-task-item")
|
||||
.filter({ hasText: "Mobile Office nesting subject" });
|
||||
await expect(subjectRow).toBeVisible();
|
||||
await subjectRow.getByRole("button", { name: "Task actions" }).tap();
|
||||
await testPage.getByRole("menuitem", { name: "Nest under" }).tap();
|
||||
await expect(
|
||||
testPage.getByRole("menuitem", { name: "Mobile Office nesting descendant" }),
|
||||
).toHaveCount(0);
|
||||
await testPage.getByRole("menuitem", { name: "Mobile Office nesting target" }).tap();
|
||||
|
||||
const taskBlock = (id: string) =>
|
||||
taskSheet.locator(`[data-testid="sortable-task-block"][data-task-id="${id}"]`);
|
||||
await expect(taskBlock(subject.id)).toHaveAttribute("data-depth", "1", { timeout: 10_000 });
|
||||
await expect(taskBlock(descendant.id)).toHaveAttribute("data-depth", "2");
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getTask(subject.id)).parent_id ?? "")
|
||||
.toBe(target.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { test, expect } from "../../fixtures/office-fixture";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
function taskId(task: Record<string, unknown>): string {
|
||||
return task.id as string;
|
||||
}
|
||||
|
||||
test.describe("Office sidebar Nest under", () => {
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.1
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4
|
||||
test("nests an Office parent under another Office task", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
officeApi,
|
||||
seedData,
|
||||
}) => {
|
||||
const project = await officeApi.createProject(seedData.workspaceId, "Office nesting project");
|
||||
const projectId = taskId(project);
|
||||
const subject = await apiClient.createTask(seedData.workspaceId, "Office nesting subject", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const subjectId = subject.id;
|
||||
const descendant = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Office nesting descendant",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
parent_id: subjectId,
|
||||
},
|
||||
);
|
||||
const descendantId = descendant.id;
|
||||
const target = await apiClient.createTask(seedData.workspaceId, "Office nesting target", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const targetId = target.id;
|
||||
|
||||
await Promise.all([
|
||||
officeApi.updateTaskProjectId(subjectId, projectId),
|
||||
officeApi.updateTaskProjectId(descendantId, projectId),
|
||||
officeApi.updateTaskProjectId(targetId, projectId),
|
||||
]);
|
||||
await apiClient.saveUserSettings({
|
||||
workspace_id: seedData.workspaceId,
|
||||
workflow_filter_id: seedData.workflowId,
|
||||
keyboard_shortcuts: {},
|
||||
enable_preview_on_click: false,
|
||||
sidebar_views: [],
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${subjectId}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.sidebar).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const taskBlock = (id: string) =>
|
||||
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${id}"]`);
|
||||
await expect(taskBlock(subjectId)).toHaveAttribute("data-depth", "0");
|
||||
await expect(taskBlock(descendantId)).toHaveAttribute("data-depth", "1");
|
||||
await expect(taskBlock(targetId)).toHaveAttribute("data-depth", "0");
|
||||
|
||||
const subjectRow = session.sidebar
|
||||
.locator("[data-testid='sidebar-task-item']")
|
||||
.filter({ hasText: "Office nesting subject" });
|
||||
await subjectRow.hover();
|
||||
await subjectRow.getByRole("button", { name: "Task actions" }).click();
|
||||
await testPage.getByRole("menuitem", { name: "Nest under" }).hover();
|
||||
|
||||
await expect(testPage.getByRole("menuitem", { name: "Office nesting descendant" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await testPage.getByRole("menuitem", { name: "Office nesting target" }).click();
|
||||
|
||||
await expect(taskBlock(subjectId)).toHaveAttribute("data-depth", "1", { timeout: 10_000 });
|
||||
await expect(taskBlock(descendantId)).toHaveAttribute("data-depth", "2");
|
||||
await expect(
|
||||
taskBlock(targetId).locator(
|
||||
`[data-testid="sortable-task-block"][data-task-id="${subjectId}"]`,
|
||||
),
|
||||
).toHaveCount(1);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getTask(subjectId)).parent_id ?? "")
|
||||
.toBe(targetId);
|
||||
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
await expect(taskBlock(subjectId)).toHaveAttribute("data-depth", "1", { timeout: 10_000 });
|
||||
await expect(taskBlock(descendantId)).toHaveAttribute("data-depth", "2");
|
||||
});
|
||||
});
|
||||
@@ -172,6 +172,7 @@ test("keeps the desktop next-step form open when clicking a checkbox label", asy
|
||||
const form = testPage.getByTestId("proceed-next-step-options");
|
||||
await expect(form).toBeVisible();
|
||||
|
||||
await form.hover();
|
||||
await form.getByText("Skip the step prompt", { exact: true }).click();
|
||||
|
||||
await expect(form).toBeVisible();
|
||||
|
||||
@@ -67,6 +67,16 @@ function wsPayload(overrides: Partial<TaskLike> = {}): TaskLike {
|
||||
}
|
||||
|
||||
describe("toKanbanTask — HTTP DTO / WS payload parity", () => {
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4
|
||||
it("maps is_from_office to isFromOffice", () => {
|
||||
const officeProjection: Partial<TaskLike> = { is_from_office: true };
|
||||
const http = toKanbanTask(httpDTO(officeProjection));
|
||||
const ws = toKanbanTask(wsPayload(officeProjection));
|
||||
|
||||
expect(http.isFromOffice).toBe(true);
|
||||
expect(ws.isFromOffice).toBe(true);
|
||||
});
|
||||
|
||||
it("carries workspace identity and archived state through both task shapes", () => {
|
||||
const archivedAt = "2026-08-04T10:00:00Z";
|
||||
const http = toKanbanTask(httpDTO({ archived_at: archivedAt } as Partial<TaskLike>));
|
||||
|
||||
@@ -84,6 +84,7 @@ export type TaskLike = {
|
||||
primary_agent_profile_id?: string | null;
|
||||
labels?: string | string[] | null;
|
||||
is_remote_executor?: boolean;
|
||||
is_from_office?: boolean;
|
||||
parent_id?: string | null;
|
||||
assignee_user_id?: string;
|
||||
updated_at?: string;
|
||||
@@ -305,6 +306,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask {
|
||||
primaryAgentName: source.primary_agent_name ?? undefined,
|
||||
labels: pickLabels(source),
|
||||
assigneeUserId: pickAssignee(source.assignee_user_id),
|
||||
isFromOffice: source.is_from_office,
|
||||
parentTaskId: source.parent_id ?? undefined,
|
||||
workspaceMode: workspaceModeFromMetadata(source.metadata),
|
||||
updatedAt: source.updated_at,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeNestCandidates } from "./nest-candidates";
|
||||
|
||||
type T = { id: string; title: string; parentTaskId?: string | null };
|
||||
type T = {
|
||||
id: string;
|
||||
title: string;
|
||||
parentTaskId?: string | null;
|
||||
isFromOffice?: boolean;
|
||||
};
|
||||
|
||||
const tasks: T[] = [
|
||||
{ id: "a", title: "A" }, // root with a child
|
||||
@@ -9,6 +14,8 @@ const tasks: T[] = [
|
||||
{ id: "d", title: "D" }, // root, leaf
|
||||
{ id: "e", title: "E" }, // root, leaf
|
||||
];
|
||||
const OFFICE_ROOT = "office-root";
|
||||
const OFFICE_CHILD = "office-child";
|
||||
|
||||
describe("computeNestCandidates", () => {
|
||||
it("excludes the task itself", () => {
|
||||
@@ -52,4 +59,94 @@ describe("computeNestCandidates", () => {
|
||||
it("returns empty when the task is the only one", () => {
|
||||
expect(computeNestCandidates([{ id: "solo", title: "Solo" }], "solo")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty when the subject is missing", () => {
|
||||
expect(computeNestCandidates(tasks, "missing")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeNestCandidates for Office hierarchies", () => {
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4
|
||||
it("allows deep Office targets and excludes descendants", () => {
|
||||
const officeTasks: T[] = [
|
||||
{ id: OFFICE_ROOT, title: "Office root", isFromOffice: true },
|
||||
{
|
||||
id: OFFICE_CHILD,
|
||||
title: "Office child",
|
||||
parentTaskId: OFFICE_ROOT,
|
||||
isFromOffice: true,
|
||||
},
|
||||
{
|
||||
id: "office-grandchild",
|
||||
title: "Office grandchild",
|
||||
parentTaskId: OFFICE_CHILD,
|
||||
isFromOffice: true,
|
||||
},
|
||||
{ id: "office-target-root", title: "Target root", isFromOffice: true },
|
||||
{
|
||||
id: "office-target-child",
|
||||
title: "Target child",
|
||||
parentTaskId: "office-target-root",
|
||||
isFromOffice: true,
|
||||
},
|
||||
];
|
||||
|
||||
expect(computeNestCandidates(officeTasks, "office-root").map((task) => task.id)).toEqual([
|
||||
"office-target-root",
|
||||
"office-target-child",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the complete hierarchy to exclude descendants hidden by the current view", () => {
|
||||
const hierarchy: T[] = [
|
||||
{ id: "subject", title: "Subject", isFromOffice: true },
|
||||
{
|
||||
id: "hidden-child",
|
||||
title: "Hidden child",
|
||||
parentTaskId: "subject",
|
||||
isFromOffice: true,
|
||||
},
|
||||
{
|
||||
id: "visible-grandchild",
|
||||
title: "Visible grandchild",
|
||||
parentTaskId: "hidden-child",
|
||||
isFromOffice: true,
|
||||
},
|
||||
{ id: "valid-target", title: "Valid target", isFromOffice: true },
|
||||
];
|
||||
const visibleTasks = hierarchy.filter((task) => task.id !== "hidden-child");
|
||||
|
||||
expect(
|
||||
computeNestCandidates(visibleTasks, "subject", hierarchy).map((task) => task.id),
|
||||
).toEqual(["valid-target"]);
|
||||
});
|
||||
|
||||
it("allows an Office subject to nest under a non-Office subtask", () => {
|
||||
const mixedTasks: T[] = [
|
||||
{ id: "office-subject", title: "Office subject", isFromOffice: true },
|
||||
{ id: "kanban-root", title: "Kanban root" },
|
||||
{ id: "kanban-child", title: "Kanban child", parentTaskId: "kanban-root" },
|
||||
];
|
||||
|
||||
expect(computeNestCandidates(mixedTasks, "office-subject").map((task) => task.id)).toContain(
|
||||
"kanban-child",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a non-Office subject to nest under an Office subtask", () => {
|
||||
const mixedTasks: T[] = [
|
||||
{ id: "kanban-subject", title: "Kanban subject" },
|
||||
{ id: OFFICE_ROOT, title: "Office root", isFromOffice: true },
|
||||
{
|
||||
id: OFFICE_CHILD,
|
||||
title: "Office child",
|
||||
parentTaskId: OFFICE_ROOT,
|
||||
isFromOffice: true,
|
||||
},
|
||||
];
|
||||
|
||||
expect(computeNestCandidates(mixedTasks, "kanban-subject").map((task) => task.id)).toContain(
|
||||
OFFICE_CHILD,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,29 +6,54 @@ export type NestCandidate = {
|
||||
id: string;
|
||||
title: string;
|
||||
parentTaskId?: string | null;
|
||||
isFromOffice?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* computeNestCandidates returns the tasks that `taskId` may be nested under,
|
||||
* enforcing the one-level kanban subtask limit.
|
||||
* enforcing the one-level Kanban limit while allowing arbitrary-depth Office
|
||||
* hierarchies.
|
||||
*
|
||||
* When the task itself already has children, nesting it under any parent would
|
||||
* push those children to depth 2, so no candidate is offered. Otherwise a
|
||||
* candidate is any task that is NOT:
|
||||
* - the task itself,
|
||||
* - the task's current parent (selecting it would be a no-op), or
|
||||
* - already a subtask — nesting under a subtask would create a grandchild
|
||||
* (this also structurally excludes the task's own descendants, so no cycle
|
||||
* can be introduced).
|
||||
* Every hierarchy excludes the task, its current parent, and descendants.
|
||||
* Kanban additionally requires a childless subject and root candidate. Office
|
||||
* uses the backend's endpoint rule: either endpoint being Office lifts those
|
||||
* two depth restrictions.
|
||||
*
|
||||
* Order is preserved from the input list.
|
||||
*/
|
||||
export function computeNestCandidates<T extends NestCandidate>(tasks: T[], taskId: string): T[] {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
export function computeNestCandidates<T extends NestCandidate>(
|
||||
tasks: T[],
|
||||
taskId: string,
|
||||
hierarchyTasks: readonly NestCandidate[] = tasks,
|
||||
): T[] {
|
||||
const tasksById = new Map(hierarchyTasks.map((candidate) => [candidate.id, candidate]));
|
||||
const task = tasksById.get(taskId) ?? tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task) return [];
|
||||
|
||||
// A task with children cannot be nested without exceeding the one-level limit.
|
||||
if (tasks.some((t) => t.parentTaskId === taskId)) return [];
|
||||
const taskHasChildren = hierarchyTasks.some((candidate) => candidate.parentTaskId === taskId);
|
||||
const currentParent = task.parentTaskId ?? undefined;
|
||||
|
||||
const currentParent = task?.parentTaskId ?? undefined;
|
||||
return tasks.filter((t) => t.id !== taskId && t.id !== currentParent && !t.parentTaskId);
|
||||
return tasks.filter((candidate) => {
|
||||
const hierarchyCandidate = tasksById.get(candidate.id) ?? candidate;
|
||||
if (hierarchyCandidate.id === taskId || hierarchyCandidate.id === currentParent) return false;
|
||||
if (hasAncestor(hierarchyCandidate, taskId, tasksById)) return false;
|
||||
if (task.isFromOffice || hierarchyCandidate.isFromOffice) return true;
|
||||
return !taskHasChildren && !hierarchyCandidate.parentTaskId;
|
||||
});
|
||||
}
|
||||
|
||||
function hasAncestor(
|
||||
candidate: NestCandidate,
|
||||
ancestorId: string,
|
||||
tasksById: ReadonlyMap<string, NestCandidate>,
|
||||
): boolean {
|
||||
const visited = new Set<string>();
|
||||
let parentId = candidate.parentTaskId;
|
||||
while (parentId) {
|
||||
if (parentId === ancestorId) return true;
|
||||
if (visited.has(parentId)) return true;
|
||||
visited.add(parentId);
|
||||
parentId = tasksById.get(parentId)?.parentTaskId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -164,6 +164,8 @@ export type KanbanState = {
|
||||
primaryAgentName?: string | null;
|
||||
labels?: string[];
|
||||
isRemoteExecutor?: boolean;
|
||||
/** Backend-owned discriminator for task hierarchy rules. Absent means non-Office. */
|
||||
isFromOffice?: boolean;
|
||||
/** Human assignee (user id). Independent of any agent assignment. */
|
||||
assigneeUserId?: string;
|
||||
parentTaskId?: string | null;
|
||||
|
||||
@@ -134,6 +134,10 @@ export function mergeTaskUpdate(
|
||||
if (!hasPayloadField(payload, "metadata")) merged.metadata = existing.metadata;
|
||||
if (!hasPayloadField(payload, "labels")) merged.labels = existing.labels;
|
||||
if (!hasPayloadField(payload, "origin")) merged.origin = existing.origin;
|
||||
preserveOmittedField(existing, merged, payload, nextTask, {
|
||||
payloadKey: "is_from_office",
|
||||
taskField: "isFromOffice",
|
||||
});
|
||||
preserveOmittedField(existing, merged, payload, nextTask, {
|
||||
payloadKey: "task_pending_action",
|
||||
taskField: "taskPendingAction",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { registerTasksHandlers } from "./tasks";
|
||||
import { makeMessage, makeStore } from "./tasks.test-helpers";
|
||||
|
||||
const TASK_ID = "office-task";
|
||||
const WORKFLOW_ID = "office-workflow";
|
||||
const STEP_ID = "office-step";
|
||||
|
||||
function cachedOfficeTask() {
|
||||
return {
|
||||
id: TASK_ID,
|
||||
title: "Office task",
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowStepId: STEP_ID,
|
||||
position: 0,
|
||||
isFromOffice: true,
|
||||
};
|
||||
}
|
||||
|
||||
function storeWithCachedOfficeTask() {
|
||||
return makeStore({
|
||||
kanban: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
steps: [],
|
||||
tasks: [cachedOfficeTask()],
|
||||
},
|
||||
kanbanMulti: {
|
||||
isLoading: false,
|
||||
snapshots: {
|
||||
[WORKFLOW_ID]: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowName: "Office",
|
||||
steps: [],
|
||||
tasks: [cachedOfficeTask()],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe("task.updated Office identity preservation", () => {
|
||||
// @covers AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4
|
||||
it("preserves Office identity when a lightweight update omits it", () => {
|
||||
const store = storeWithCachedOfficeTask();
|
||||
|
||||
registerTasksHandlers(store)["task.updated"]!(
|
||||
makeMessage({
|
||||
task_id: TASK_ID,
|
||||
workflow_id: WORKFLOW_ID,
|
||||
workflow_step_id: STEP_ID,
|
||||
title: "Renamed Office task",
|
||||
state: "IN_PROGRESS",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.getState().kanban.tasks[0]?.isFromOffice).toBe(true);
|
||||
expect(store.getState().kanbanMulti.snapshots[WORKFLOW_ID]?.tasks[0]?.isFromOffice).toBe(true);
|
||||
});
|
||||
|
||||
it("clears Office identity when task.updated explicitly sets it to false", () => {
|
||||
const store = storeWithCachedOfficeTask();
|
||||
|
||||
registerTasksHandlers(store)["task.updated"]!(
|
||||
makeMessage({
|
||||
task_id: TASK_ID,
|
||||
workflow_id: WORKFLOW_ID,
|
||||
workflow_step_id: STEP_ID,
|
||||
title: "Project removed",
|
||||
state: "IN_PROGRESS",
|
||||
is_from_office: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.getState().kanban.tasks[0]?.isFromOffice).toBe(false);
|
||||
expect(store.getState().kanbanMulti.snapshots[WORKFLOW_ID]?.tasks[0]?.isFromOffice).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
created: 2026-09-16
|
||||
status: implemented
|
||||
requirements:
|
||||
- REQ-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/subtask-reparenting-drag-drop.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Fix Nest under candidates
|
||||
|
||||
## Overview
|
||||
|
||||
The sidebar can incorrectly disable `Nest under` for two independent reasons. Its context menu
|
||||
prefers any present multi-workflow snapshot even when that snapshot is a partial placeholder, and
|
||||
the shared eligibility helper applies the one-level Kanban rule to Office tasks despite the
|
||||
backend's explicit Office depth exemption. The correction preserves Office identity through the
|
||||
frontend projection, uses the rendered task group for menu and drag candidates, and makes their
|
||||
shared pure filter match the backend depth contract. Review validation also requires the complete
|
||||
unfiltered hierarchy for cycle checks, stable candidate access that preserves row memoization, and
|
||||
an explicit Office discriminator on canonical task updates after project reassignment.
|
||||
|
||||
No endpoint, persistence schema, localization, or layout change is required.
|
||||
|
||||
The smallest reproductions are focused frontend tests. An Office subject with a child and an
|
||||
eligible Office root produces an empty candidate array, while a context menu rendered beside an
|
||||
eligible task cannot find that task when the raw multi-workflow snapshot contains only the subject.
|
||||
Before the correction, the focused command reports two failures and 28 passes:
|
||||
|
||||
```bash
|
||||
(cd apps/web && pnpm test -- lib/sidebar/nest-candidates.test.ts components/task/task-switcher-context-menu.test.tsx)
|
||||
```
|
||||
|
||||
The permanent regressions are `allows deep Office targets and excludes descendants` in
|
||||
`lib/sidebar/nest-candidates.test.ts` and `uses rendered candidates when the workflow snapshot is
|
||||
partial` in `components/task/task-switcher-nest-context-menu.test.tsx`.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Preserve the existing backend Office discriminator through the sidebar task projection.
|
||||
- Publish the canonical task update after Office project reassignment and serialize its Office
|
||||
discriminator explicitly in both directions.
|
||||
- Make menu and drag candidates share the rendered task group and eligibility helper.
|
||||
- Validate descendants and child depth against the complete pre-filter hierarchy without adding
|
||||
hidden tasks to the candidate list.
|
||||
- Preserve memoized row stability while menus and drags read the latest committed candidate data.
|
||||
- Match the backend's existing Office depth exemption and retain Kanban's one-level rule.
|
||||
- Prove the correction with focused tests and an Office sidebar browser flow.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Backend validation, API shape, persistence, and workspace materialization.
|
||||
- Kanban card drag, sibling reorder, and the separate Office list parent picker.
|
||||
- New controls, menu copy, layout, touch gestures, or navigation.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. Preserve the DTO's optional `is_from_office` value through `toKanbanTask`, the Kanban snapshot
|
||||
type, desktop and phone sidebar projections, and `TaskSwitcherItem` as `isFromOffice`. Preserve
|
||||
the cached value when a lightweight `task.updated` event omits the field and when a partial
|
||||
active-board task is reconciled with a full multi-workflow projection.
|
||||
2. Extend `computeNestCandidates` with explicit descendant detection against a separate complete
|
||||
hierarchy. Preserve the current Kanban root-only and childless-subject rules, while allowing
|
||||
arbitrary-depth Office candidates when either endpoint is Office.
|
||||
3. Pass the flattened rendered `groupTasks` and complete pre-filter hierarchy through stable getters
|
||||
in the task-tree composition. Remove the menu's direct raw-snapshot selection. Keep drag target
|
||||
discovery on the same collections and helper without rerendering unaffected rows.
|
||||
4. Add `is_from_office` explicitly to canonical task events and publish one after Office project
|
||||
assignment or removal so live caches observe both transitions.
|
||||
5. Add focused projection, event, helper, menu, drag, and render-stability tests. Add an Office E2E
|
||||
that reproduces the disabled submenu with a parent task and proves successful deeper nesting.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
### UI-01: `Nest under` submenu with a visible eligible Office target
|
||||
|
||||
Current behavior established by the focused component reproduction:
|
||||
|
||||
```text
|
||||
Nest under > +--------------------+
|
||||
| No other tasks | disabled
|
||||
+--------------------+
|
||||
```
|
||||
|
||||
Required behavior:
|
||||
|
||||
```text
|
||||
Nest under > +--------------------+
|
||||
| Office target | selectable
|
||||
+--------------------+
|
||||
```
|
||||
|
||||
For a Kanban subject that already has a child, the disabled `No other tasks` state remains correct.
|
||||
Desktop retains the anchored submenu shown above. On phone, the existing inset menu sheet renders
|
||||
the same candidate row and retains its existing touch target, scroll, dismissal, and safe-area
|
||||
behavior. Spacing is illustrative; candidate presence and eligibility are required by
|
||||
AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.1 through .5.
|
||||
|
||||
## Tests
|
||||
|
||||
- AC `AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.1` and
|
||||
`AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.2`:
|
||||
`task-switcher-nest-context-menu.test.tsx`, `uses rendered candidates when the workflow snapshot
|
||||
is partial`; `task-switcher-subtask-dnd.test.ts`, `matches menu candidates for Office and Kanban
|
||||
tasks`.
|
||||
- AC `AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.3`: `nest-candidates.test.ts`, `keeps the
|
||||
one-level Kanban limit`.
|
||||
- AC `AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4`: `map-task.test.ts`, `maps is_from_office to
|
||||
isFromOffice`;
|
||||
`tasks-office-identity.test.ts`, `preserves Office identity when task.updated omits it`;
|
||||
`task-session-sidebar-aggregate.test.ts`, `preserves projected Office identity through a newer
|
||||
partial active task`;
|
||||
`task-session-sidebar-item.test.ts`, `preserves isFromOffice`;
|
||||
`session-task-switcher-sheet-item.test.ts`, `carries Office identity into the phone task drawer
|
||||
row`;
|
||||
`nest-candidates.test.ts`, `allows deep Office targets and excludes descendants`.
|
||||
- Filtered hierarchy and stable composition: `nest-candidates.test.ts`, `uses the complete hierarchy
|
||||
to exclude descendants hidden by the current view`; `task-switcher-render-stability.test.tsx`,
|
||||
`gives a skipped row the latest nest candidates`.
|
||||
- Live Office identity: backend dashboard and task-service event tests cover canonical publication
|
||||
for project assignment/removal and explicit true/false serialization; the frontend handler test
|
||||
covers clearing the cached identity.
|
||||
- AC `AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.5`: the shared component tests render the
|
||||
existing menu and drag paths without separate desktop and phone candidate logic.
|
||||
|
||||
## E2E tests
|
||||
|
||||
- AC `AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.1` through
|
||||
`AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4`: add `office-sidebar-nest-task.spec.ts`, Chromium
|
||||
scenario `nests an Office parent under another Office task`, asserting the target row, persisted
|
||||
parent, and deeper rendered hierarchy.
|
||||
- AC `AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.3`: rerun `sidebar-nest-task.spec.ts` in
|
||||
Chromium as the shallow Kanban regression.
|
||||
- AC `AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.5`: rerun
|
||||
`mobile-subtask-reparent-drag-drop.spec.ts` and add `mobile-office-sidebar-nest-task.spec.ts` in
|
||||
`mobile-chrome` to preserve the shared touch path and prove the Office candidate reaches the
|
||||
phone task sheet.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Correct Nest under candidates](task-01-correct-nest-under-candidates.md)
|
||||
|
||||
One sequential work order owns the projection, shared algorithm, both interaction paths, and browser
|
||||
proof because each layer is required for one observable result. A wave does not authorize
|
||||
delegation.
|
||||
|
||||
## Verification results
|
||||
|
||||
Implemented in one work order. The focused Vitest suite passes 107 tests across nine files, and the
|
||||
full frontend suite passes 18,454 tests with four skipped. Typecheck, full frontend lint, the i18n
|
||||
ratchet, and the complete Office dashboard and task service Go package tests pass. Chromium passes
|
||||
the existing Kanban nesting flow and the new Office depth-two nesting and reload flow. The
|
||||
`mobile-chrome` project passes the existing touch re-parenting cases and the new Office phone-sheet
|
||||
flow. Specification validation and the scoped diff check also pass.
|
||||
|
||||
## Risks
|
||||
|
||||
- The backend remains the final validator for races and hierarchy data absent from the complete
|
||||
client projection; rejected mutations must retain the existing rollback and error path.
|
||||
- Reading a raw snapshot in either consumer would recreate the menu/drag divergence.
|
||||
- Missing Office metadata must default to non-Office so the correction cannot relax Kanban rules.
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
id: "01-correct-nest-under-candidates"
|
||||
title: "Correct Nest under candidates"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.1
|
||||
- AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.2
|
||||
- AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.3
|
||||
- AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4
|
||||
- AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.5
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/subtask-reparenting-drag-drop.md
|
||||
---
|
||||
|
||||
# Task 01: Correct Nest under candidates
|
||||
|
||||
## Summary
|
||||
|
||||
The sidebar context menu and drag interaction offer the same eligible tasks from the rendered group,
|
||||
including arbitrary-depth Office targets, while Kanban keeps its one-level hierarchy.
|
||||
|
||||
## In scope
|
||||
|
||||
- Preserve the backend Office discriminator through HTTP mapping, lightweight WebSocket merges,
|
||||
active/snapshot reconciliation, and the sidebar projection.
|
||||
- Make canonical task updates serialize Office identity explicitly and publish after project
|
||||
assignment or removal.
|
||||
- Make `computeNestCandidates` distinguish Office from Kanban and reject descendants through the
|
||||
complete pre-filter hierarchy.
|
||||
- Supply the rendered group and complete hierarchy through stable getters, preserving row
|
||||
memoization, and remove the context menu's independent raw-snapshot lookup.
|
||||
- Keep drag target discovery on the same group and helper.
|
||||
- Add focused unit, component, and Office E2E regression coverage.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Backend validation, task PATCH semantics, and persistence.
|
||||
- Kanban card drag, sibling reorder, Office list parent picker, menu copy, and menu geometry.
|
||||
- Lifting the one-level Kanban hierarchy limit.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A visible eligible task remains selectable from `Nest under` when the corresponding raw
|
||||
multi-workflow snapshot is partial, and drag exposes exactly the same candidate identifiers.
|
||||
- Office subjects with children and Office targets at any depth are eligible except for self,
|
||||
current parent, and descendants, including a descendant behind a filtered intermediate ancestor;
|
||||
the existing Kanban root-only and childless-subject behavior remains unchanged.
|
||||
- Unaffected rows retain their memoization while a subsequently opened menu sees current candidates,
|
||||
and project assignment/removal updates cached Office identity without a reload.
|
||||
- The focused frontend suites, Office browser scenario, typecheck, lint, i18n ratchet, and spec
|
||||
validation pass.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
### UI-01: `Nest under` submenu with a visible eligible Office target
|
||||
|
||||
See the combined preview in
|
||||
[plan.md](plan.md#ui-01-nest-under-submenu-with-a-visible-eligible-office-target).
|
||||
|
||||
```text
|
||||
Before After
|
||||
+--------------------+ +--------------------+
|
||||
| No other tasks | | Office target |
|
||||
| disabled | | selectable |
|
||||
+--------------------+ +--------------------+
|
||||
```
|
||||
|
||||
Desktop keeps its anchored submenu. Phone uses the same candidate row in the existing inset menu
|
||||
sheet. No spacing, control, or navigation change is part of this work order.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm install --frozen-lockfile)
|
||||
(cd apps/web && pnpm test -- lib/kanban/map-task.test.ts lib/ws/handlers/tasks-office-identity.test.ts lib/sidebar/nest-candidates.test.ts components/task/task-session-sidebar-aggregate.test.ts components/task/task-session-sidebar-item.test.ts components/task/mobile/session-task-switcher-sheet-item.test.ts components/task/task-switcher-subtask-dnd.test.ts components/task/task-switcher-context-menu.test.tsx components/task/task-switcher-nest-context-menu.test.tsx)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm run lint)
|
||||
(cd apps/web && pnpm run i18n:ratchet)
|
||||
(cd apps/backend && go test ./internal/office/dashboard ./internal/task/service)
|
||||
(cd apps/web && pnpm e2e:run tests/task/sidebar-nest-task.spec.ts tests/task/office-sidebar-nest-task.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-subtask-reparent-drag-drop.spec.ts tests/task/mobile-office-sidebar-nest-task.spec.ts)
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check -- docs/specs/tasks docs/plans/fix-nest-under-candidates apps/web
|
||||
```
|
||||
|
||||
Run the complete block from the repository root.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/kanban/map-task.ts`
|
||||
- `apps/web/lib/kanban/map-task.test.ts`
|
||||
- `apps/web/lib/state/slices/kanban/types.ts`
|
||||
- `apps/web/lib/ws/handlers/task-merge.ts`
|
||||
- `apps/web/lib/ws/handlers/tasks-office-identity.test.ts`
|
||||
- `apps/web/lib/sidebar/nest-candidates.ts`
|
||||
- `apps/web/lib/sidebar/nest-candidates.test.ts`
|
||||
- `apps/web/components/task/task-switcher-types.ts`
|
||||
- `apps/web/components/task/task-session-sidebar-aggregate.ts`
|
||||
- `apps/web/components/task/task-session-sidebar-aggregate.test.ts`
|
||||
- `apps/web/components/task/task-session-sidebar-item.ts`
|
||||
- `apps/web/components/task/task-session-sidebar-item.test.ts`
|
||||
- `apps/web/components/task/mobile/session-task-switcher-sheet-item.ts`
|
||||
- `apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts`
|
||||
- `apps/web/components/task/task-switcher-tree.tsx`
|
||||
- `apps/web/components/task/task-switcher.tsx`
|
||||
- `apps/web/components/task/task-switcher-render-stability.test.tsx`
|
||||
- `apps/web/components/task/task-session-sidebar-switcher-props.ts`
|
||||
- `apps/web/components/task/mobile/session-task-switcher-sheet-props.ts`
|
||||
- `apps/web/components/task/task-switcher-row.tsx`
|
||||
- `apps/web/components/task/task-switcher-context-menu.tsx`
|
||||
- `apps/web/components/task/task-switcher-context-menu-items.tsx`
|
||||
- `apps/web/components/task/task-nest-context-menu.tsx`
|
||||
- `apps/web/components/task/task-switcher-context-menu.test.tsx`
|
||||
- `apps/web/components/task/task-switcher-nest-context-menu.test.tsx`
|
||||
- `apps/web/components/task/task-switcher-subtask-dnd.tsx`
|
||||
- `apps/web/components/task/task-switcher-subtask-dnd.test.ts`
|
||||
- `apps/web/e2e/tests/task/office-sidebar-nest-task.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-office-sidebar-nest-task.spec.ts`
|
||||
- `apps/backend/internal/office/dashboard/service_tasks.go`
|
||||
- `apps/backend/internal/office/dashboard/task_updated_events_test.go`
|
||||
- `apps/backend/internal/task/service/service_events.go`
|
||||
- `apps/backend/internal/task/service/service_events_test.go`
|
||||
|
||||
Use fewer files when the rendered-group value can be threaded through an existing context or prop.
|
||||
Do not introduce a general task registry for this local composition.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirement acceptance criteria and scenarios in
|
||||
`docs/specs/tasks/requirements/subtask-reparenting-drag-drop.md`.
|
||||
- Candidate-source and eligibility contracts in
|
||||
`docs/specs/tasks/system-design/subtask-reparenting-drag-drop.md`.
|
||||
- Existing canonical mutation path in `apps/web/hooks/use-nest-task.ts` and backend validation in
|
||||
`apps/backend/internal/task/service/service_tasks.go`.
|
||||
|
||||
## Risks
|
||||
|
||||
- A cycle filter based only on visible tasks would expose deeper descendants when a view hides an
|
||||
intermediate ancestor; follow ancestor chains in the complete hierarchy with a visited set.
|
||||
- Defaulting a missing Office discriminator to true would relax Kanban rules; absent values remain
|
||||
non-Office.
|
||||
- A menu-local store selector would let menu and drag diverge again; both consume the rendered
|
||||
group supplied by their common tree composition.
|
||||
- Passing changing candidate arrays directly to every row would defeat memoization; stable getters
|
||||
must resolve the latest committed arrays at interaction time.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented the shared rendered-group candidate path, Office-aware hierarchy filtering, Office
|
||||
identity projection and partial-update preservation, and descendant-cycle prevention. The new
|
||||
browser scenario proves an Office parent with a child can be nested under another Office task,
|
||||
persists the relationship, renders depth two, and survives reload.
|
||||
|
||||
Verification passed: frozen install; 107 focused Vitest tests across nine files; the full frontend
|
||||
suite with 18,454 passing tests and four skipped; TypeScript typecheck; full frontend lint; i18n
|
||||
ratchet; complete Office dashboard and task service Go package tests; desktop Chromium and
|
||||
`mobile-chrome` nesting flows; both specification validators; and the scoped diff check.
|
||||
@@ -171,6 +171,7 @@ signals, and task-scoped scheduling contracts.
|
||||
- [Prompt attachments](system-design/prompt-attachments.md)
|
||||
- [Task Actions Menu on Preview and Detail Surfaces](system-design/task-actions-menu.md)
|
||||
- [Task menu grouping](system-design/task-menu-grouping.md)
|
||||
- [Subtask re-parenting by drag and drop](system-design/subtask-reparenting-drag-drop.md)
|
||||
- [Task Archive Confirmation](system-design/archive-confirmation.md)
|
||||
- [Task removal navigation](system-design/removal-navigation.md)
|
||||
- [Task plan content size limit](system-design/plan-content-size-limit.md)
|
||||
|
||||
@@ -5,6 +5,7 @@ created: 2026-08-04
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Subtask re-parenting by drag and drop Requirements
|
||||
|
||||
## Overview
|
||||
@@ -15,11 +16,30 @@ This document is the migrated task-system source for the capability. The source
|
||||
|
||||
### REQ-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001: Subtask re-parenting by drag and drop
|
||||
|
||||
**Intent:** Preserve the observable task or workflow behavior recorded by the legacy specification.
|
||||
**Intent:** Let users re-parent tasks from the sidebar menu or drag gesture while presenting every
|
||||
eligible target that is already visible in the task group.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.1:** When a consumer uses this capability, the system shall provide the observable behavior and exclusions documented below.
|
||||
- **AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.1:** The sidebar `Nest under` submenu and the
|
||||
drag nest zones shall derive their targets from the same rendered task group and the same
|
||||
eligibility rules.
|
||||
- **AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.2:** When an eligible same-workflow target is
|
||||
visible in the rendered task group, the target shall remain available while workflow snapshots
|
||||
are loading, refreshing, or temporarily incomplete; the submenu shall show `No other tasks` only
|
||||
when that rendered group has no eligible target.
|
||||
- **AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.3:** For non-Office tasks, an eligible target shall
|
||||
be a root task, and a task that already has children shall have no nest target, preserving the
|
||||
one-level Kanban hierarchy.
|
||||
- **AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.4:** When the subject or target is an Office task,
|
||||
the UI shall allow arbitrary-depth re-parenting while excluding the subject, its current parent,
|
||||
and its descendants, including descendants whose intermediate ancestors are hidden by the active
|
||||
sidebar view. The backend remains authoritative for self, cycle, archive, workspace, and
|
||||
concurrency validation. Live project assignment and removal shall update the cached Office
|
||||
identity in both directions.
|
||||
- **AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.5:** Desktop sidebar and mobile task-switcher
|
||||
presentations shall use the same menu and drag eligibility without changing their existing
|
||||
pointer, touch, focus, dismissal, or scrolling behavior.
|
||||
|
||||
## Migrated source detail
|
||||
|
||||
@@ -31,7 +51,13 @@ Users can detach a subtask or nest a task under another via context-menu actions
|
||||
|
||||
- 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.
|
||||
- Drop targets are exactly the candidates the context menu offers (the `computeNestCandidates`
|
||||
rules) from the same-workflow tasks in the rendered group. The complete unfiltered task
|
||||
hierarchy is used only to validate ancestry and depth, so filtering an intermediate ancestor
|
||||
cannot expose a descendant as a target. Every mode excludes the subject and its current parent.
|
||||
Kanban candidates must be roots and a Kanban subject with children has no targets. Office
|
||||
candidates may be at any depth, and an Office subject may keep its descendants, but its
|
||||
descendants are excluded as targets so the UI cannot offer a known cycle.
|
||||
- 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`.
|
||||
@@ -56,11 +82,18 @@ 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.
|
||||
- WS `task.updated` includes an explicit `is_from_office` boolean so either Office-identity
|
||||
transition clears or establishes the cached value. `parent_id` is always present (nil when
|
||||
cleared), and `metadata` carries the normalized workspace mode. Office project reassignment
|
||||
publishes this canonical event after the write.
|
||||
|
||||
## 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.
|
||||
- **Invalid target** (self, descendant, archived, missing, cross-workspace, or a Kanban depth
|
||||
violation): the UI filters known-invalid targets 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 (for example, 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.
|
||||
@@ -70,8 +103,22 @@ No new endpoint. Reuses and extends existing contracts:
|
||||
- **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 Kanban task that already has children, **WHEN** it is dragged, **THEN** no row offers a nest drop zone (reorder only).
|
||||
- **GIVEN** a Kanban drag over a subtask row or a drag over a task in a different workflow, **WHEN** the pointer rests on the row, **THEN** no nest drop zone is offered.
|
||||
- **GIVEN** an Office task that already has a child and another eligible Office task in the same
|
||||
rendered group, **WHEN** the user opens `Nest under` or starts a re-parent drag, **THEN** the
|
||||
other task is offered and the resulting Office tree may be deeper than one level.
|
||||
- **GIVEN** an eligible target is visible while the stored multi-workflow snapshot is temporarily
|
||||
incomplete, **WHEN** the user opens `Nest under`, **THEN** the visible target is offered instead
|
||||
of a disabled `No other tasks` row.
|
||||
- **GIVEN** an Office task and one of its descendants, **WHEN** the menu or drag targets are shown,
|
||||
**THEN** that descendant is not offered as a parent.
|
||||
- **GIVEN** a sidebar filter hides the intermediate task between an Office subject and a visible
|
||||
descendant, **WHEN** the menu or drag targets are shown, **THEN** the visible descendant is still
|
||||
excluded as a parent.
|
||||
- **GIVEN** a Kanban task is assigned to or removed from an Office project, **WHEN** the canonical
|
||||
`task.updated` event arrives, **THEN** open sidebar caches immediately adopt the explicit new
|
||||
Office identity without a reload.
|
||||
- **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.
|
||||
@@ -91,4 +138,5 @@ No new endpoint. Reuses and extends existing contracts:
|
||||
|
||||
## Implementation plan
|
||||
|
||||
See [the implementation plan](../../../plans/subtask-reparenting-drag-drop/plan.md).
|
||||
See the original [drag-and-drop implementation plan](../../../plans/subtask-reparenting-drag-drop/plan.md)
|
||||
and the [Nest under candidate correction plan](../../../plans/fix-nest-under-candidates/plan.md).
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
status: current
|
||||
system: tasks
|
||||
requirements:
|
||||
- REQ-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001
|
||||
---
|
||||
|
||||
# Subtask re-parenting by drag and drop system design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design owns sidebar candidate discovery for `Nest under` and drag-to-re-parent. It makes the
|
||||
rendered task group the shared candidate source and carries the existing Office discriminator into
|
||||
that view model so the UI can apply the same depth boundary as the backend. The complete unfiltered
|
||||
task collection supplies hierarchy metadata for cycle and depth validation without adding hidden
|
||||
tasks to the candidate list.
|
||||
|
||||
The canonical parent mutation, workspace-mode normalization, optimistic update, rollback, and
|
||||
WebSocket reconciliation continues to use the canonical task event. Office project reassignment
|
||||
now invokes that publisher, and the event carries the authoritative Office identity explicitly.
|
||||
The persistence boundaries are described by [Detached Workspace Continuity](detached-workspace-continuity.md).
|
||||
No endpoint, database column, feature flag, or user-facing copy is added.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Criteria | Design section |
|
||||
| --------------------------------------------------- | --------------------------------------- |
|
||||
| AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.1 and .2 | Candidate source and propagation |
|
||||
| AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.3 and .4 | Eligibility algorithm |
|
||||
| AC-TASKS-SUBTASK-REPARENTING-DRAG-DROP-001.5 | Responsive interaction and verification |
|
||||
|
||||
## Candidate source and propagation
|
||||
|
||||
The task HTTP DTO already exposes `is_from_office`. `toKanbanTask` preserves it as
|
||||
`isFromOffice`, the Kanban snapshot task type owns that property, and both desktop and phone
|
||||
sidebar projections preserve it on `TaskSwitcherItem`. This is a data projection only; Office
|
||||
identity continues to come from the backend.
|
||||
|
||||
The mapped property remains optional on partial inputs: candidate checks treat an absent value as
|
||||
non-Office, and `mergeTaskUpdate` retains the cached value when a lightweight event omits
|
||||
`is_from_office`. Canonical `task.updated` events always carry an explicit true or false value, so
|
||||
project assignment and removal can establish or clear the cached identity. The Office project
|
||||
mutation publishes that canonical event after its task-row write. Active-board and multi-workflow
|
||||
reconciliation uses the same absent-value fallback so an unrelated partial event cannot erase
|
||||
Office identity from a full snapshot.
|
||||
|
||||
`GroupSection` already flattens the roots and descendants that it renders into `groupTasks` for
|
||||
drag candidate calculation. That collection is the candidate source for both drag and context-menu
|
||||
composition. Desktop and phone also pass their complete pre-view task list as hierarchy metadata.
|
||||
The menu must not reread `kanbanMulti.snapshots[workflowId].tasks`: a present placeholder or
|
||||
partially refreshed snapshot can contain fewer tasks than the reconciled group visible beside the
|
||||
menu.
|
||||
|
||||
Rows receive stable getter functions for both collections. The owning switcher and group update
|
||||
their refs after each committed render, so opening a menu or starting a drag reads current data
|
||||
without making every memoized sibling row rerender when one task changes.
|
||||
|
||||
Candidate order follows rendered group order. Candidates remain scoped to the subject's workflow,
|
||||
so mixed-workflow sidebar groups cannot create an invalid target.
|
||||
|
||||
## Eligibility algorithm
|
||||
|
||||
`computeNestCandidates` remains the shared pure function. Its input includes `id`, `parentTaskId`,
|
||||
and `isFromOffice`. It applies these rules in order:
|
||||
|
||||
1. Resolve the subject from the complete hierarchy, falling back to the rendered group. With no
|
||||
subject, return no candidates.
|
||||
2. Exclude the subject and its current parent in every mode.
|
||||
3. Exclude every rendered task whose ancestor chain reaches the subject. Follow parent links in the
|
||||
complete hierarchy, including ancestors hidden by filtering, and track visited identifiers so
|
||||
corrupt hierarchy data cannot loop in the browser.
|
||||
4. When neither the subject nor candidate is an Office task, require the candidate to be a root and
|
||||
return no candidates if the subject has a child. This preserves the one-level Kanban boundary.
|
||||
5. When either endpoint is an Office task, allow a candidate at any depth and allow a subject that
|
||||
has children. This mirrors `validateReparentDepth`, which exempts the mutation when either
|
||||
endpoint is Office.
|
||||
|
||||
The browser filter is an affordance, not an authorization boundary. `PATCH /api/v1/tasks/:id`
|
||||
continues to reject stale, archived, missing, cross-workspace, self, and cyclic targets.
|
||||
|
||||
## Mutation and failure flow
|
||||
|
||||
The context-menu selection and the drag nest zone call the existing `useNestTask` path with the
|
||||
subject workflow and target identifier. The request uses the canonical task PATCH and the existing
|
||||
optimistic snapshot update. A rejected request restores the prior tree and shows the existing
|
||||
request error. A successful `task.updated` event reconciles the sidebar, board, and task detail.
|
||||
|
||||
A temporary multi-workflow placeholder cannot suppress a target that is already present in the
|
||||
rendered group. A filtered-out task never becomes a candidate, but it remains available as ancestry
|
||||
metadata. If the rendered group itself contains no eligible task, the menu shows its existing
|
||||
disabled `No other tasks` row and drag shows no nest zone.
|
||||
|
||||
## Responsive interaction
|
||||
|
||||
Desktop and phone continue to use the same task-row context-menu and drag components. Desktop keeps
|
||||
the anchored submenu; the phone task switcher keeps its inset sheet, touch drag sensor, menu event
|
||||
containment, internal scroll owner, safe-area spacing, and current touch targets. Candidate data
|
||||
changes do not add controls or alter geometry.
|
||||
|
||||
## Verification
|
||||
|
||||
Pure tests cover Office and Kanban eligibility, mixed Office/Kanban endpoints, filtered intermediate
|
||||
ancestors, descendant exclusion, input order, and missing subjects. Projection and event tests prove
|
||||
`is_from_office` reaches `TaskSwitcherItem`, survives omitted fields, and changes explicitly in both
|
||||
directions. Component tests prove the context menu uses current rendered-group candidates without
|
||||
rerendering unaffected rows, and that menu and drag return the same candidates.
|
||||
|
||||
The existing Kanban sidebar E2E remains a shallow-hierarchy regression. Focused desktop and phone
|
||||
Office sidebar E2Es create a parent with a child and another target, select that target from `Nest
|
||||
under`, and assert the deeper persisted and rendered hierarchy. The existing mobile re-parenting
|
||||
E2E continues to cover the shared touch drag path.
|
||||
|
||||
## Implementation Plans
|
||||
|
||||
- [Fix Nest under candidates](../../../plans/fix-nest-under-candidates/plan.md)
|
||||
Reference in New Issue
Block a user