fix: restore Nest under candidates (#3727)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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