feat: add sidebar task editing (#2200)
This commit is contained in:
@@ -124,4 +124,35 @@ describe("MobileTaskList", () => {
|
||||
|
||||
expect(onDetachTask).toHaveBeenCalledWith("child");
|
||||
});
|
||||
|
||||
it("opens edit from the touch task actions button", () => {
|
||||
const onEditTask = vi.fn();
|
||||
const editableTask = task("editable");
|
||||
render(
|
||||
<ToastProvider>
|
||||
<MobileTaskList
|
||||
tasks={[editableTask]}
|
||||
workflows={[]}
|
||||
stepsByWorkflowId={{}}
|
||||
activeTaskId={null}
|
||||
selectedTaskId={null}
|
||||
onSelectTask={vi.fn()}
|
||||
onEditTask={onEditTask}
|
||||
onArchiveTask={vi.fn()}
|
||||
onDeleteTask={vi.fn()}
|
||||
onDetachTask={vi.fn()}
|
||||
deletingTaskId={null}
|
||||
/>
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
const row = screen
|
||||
.getByText(editableTask.title)
|
||||
.closest<HTMLElement>("[data-testid='sidebar-task-item']");
|
||||
expect(row).not.toBeNull();
|
||||
fireEvent.click(within(row!).getByRole("button", { name: "Task actions" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Edit" }));
|
||||
|
||||
expect(onEditTask).toHaveBeenCalledWith(editableTask);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useSidebarTaskLinking } from "../task-session-sidebar-task-linking";
|
||||
import { useSheetData, useSheetActions } from "./session-task-switcher-sheet-hooks";
|
||||
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
|
||||
import { useMobileTaskRename } from "./use-mobile-task-rename";
|
||||
import { SidebarTaskEditDialog, useSidebarTaskEdit } from "../task-session-sidebar-edit";
|
||||
|
||||
type SessionTaskSwitcherSheetProps = {
|
||||
open: boolean;
|
||||
@@ -50,6 +51,14 @@ function useMobileTaskLinking(workspaceId: string | null) {
|
||||
};
|
||||
}
|
||||
|
||||
function useSidebarGroupToggle(viewId: string) {
|
||||
const toggleSidebarGroupCollapsed = useAppStore((s) => s.toggleSidebarGroupCollapsed);
|
||||
return useCallback(
|
||||
(groupKey: string) => toggleSidebarGroupCollapsed(viewId, groupKey),
|
||||
[toggleSidebarGroupCollapsed, viewId],
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileTaskList({
|
||||
tasks,
|
||||
workflows,
|
||||
@@ -57,6 +66,7 @@ export function MobileTaskList({
|
||||
activeTaskId,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
onEditTask,
|
||||
onRenameTask,
|
||||
onCreateSubtask,
|
||||
onArchiveTask,
|
||||
@@ -77,6 +87,7 @@ export function MobileTaskList({
|
||||
activeTaskId: string | null;
|
||||
selectedTaskId: string | null;
|
||||
onSelectTask: (taskId: string) => void;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onCreateSubtask?: (taskId: string, taskTitle: string) => void;
|
||||
onArchiveTask: (taskId: string) => void;
|
||||
@@ -100,13 +111,9 @@ export function MobileTaskList({
|
||||
handleReorderGroup,
|
||||
handleReorderSubtasks,
|
||||
} = useSidebarTaskPrefs();
|
||||
const toggleSidebarGroupCollapsed = useAppStore((s) => s.toggleSidebarGroupCollapsed);
|
||||
const collapsedSubtaskParents = useAppStore((s) => s.collapsedSubtaskParents);
|
||||
const toggleSubtaskCollapsed = useAppStore((s) => s.toggleSubtaskCollapsed);
|
||||
const handleToggleGroup = useCallback(
|
||||
(groupKey: string) => toggleSidebarGroupCollapsed(view.id, groupKey),
|
||||
[toggleSidebarGroupCollapsed, view.id],
|
||||
);
|
||||
const handleToggleGroup = useSidebarGroupToggle(view.id);
|
||||
const grouped = useMemo(
|
||||
() =>
|
||||
applyView(tasks, view, {
|
||||
@@ -128,6 +135,7 @@ export function MobileTaskList({
|
||||
collapsedSubtaskParentIds={collapsedSubtaskParents}
|
||||
onToggleSubtasks={toggleSubtaskCollapsed}
|
||||
onSelectTask={onSelectTask}
|
||||
onEditTask={onEditTask}
|
||||
onRenameTask={onRenameTask}
|
||||
onCreateSubtask={onCreateSubtask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
@@ -248,6 +256,7 @@ type TaskSwitcherSurfaceContentProps = {
|
||||
data: ReturnType<typeof useSheetData>;
|
||||
actions: ReturnType<typeof useSheetActions>;
|
||||
rename: ReturnType<typeof useMobileTaskRename>;
|
||||
edit: ReturnType<typeof useSidebarTaskEdit>;
|
||||
linking: ReturnType<typeof useMobileTaskLinking>;
|
||||
};
|
||||
|
||||
@@ -280,6 +289,7 @@ function TaskSwitcherSurfaceContent({
|
||||
data,
|
||||
actions,
|
||||
rename,
|
||||
edit,
|
||||
linking,
|
||||
}: TaskSwitcherSurfaceContentProps) {
|
||||
return (
|
||||
@@ -303,6 +313,7 @@ function TaskSwitcherSurfaceContent({
|
||||
activeTaskId={data.activeTaskId}
|
||||
selectedTaskId={data.selectedTaskId}
|
||||
onSelectTask={actions.handleSelectTask}
|
||||
onEditTask={surfaceAction(presentation, onOpenChange, edit.handleEditTask)}
|
||||
onRenameTask={surfaceAction(presentation, onOpenChange, rename.handleRenameTask)}
|
||||
onCreateSubtask={onCreateSubtask}
|
||||
onArchiveTask={surfaceAction(presentation, onOpenChange, actions.handleArchiveTask)}
|
||||
@@ -354,6 +365,7 @@ function TaskSwitcherDialogs({
|
||||
data,
|
||||
actions,
|
||||
rename,
|
||||
edit,
|
||||
linking,
|
||||
subtaskTarget,
|
||||
onSubtaskTargetChange,
|
||||
@@ -365,6 +377,7 @@ function TaskSwitcherDialogs({
|
||||
data: ReturnType<typeof useSheetData>;
|
||||
actions: ReturnType<typeof useSheetActions>;
|
||||
rename: ReturnType<typeof useMobileTaskRename>;
|
||||
edit: ReturnType<typeof useSidebarTaskEdit>;
|
||||
linking: ReturnType<typeof useMobileTaskLinking>;
|
||||
subtaskTarget: { id: string; title: string } | null;
|
||||
onSubtaskTargetChange: (target: { id: string; title: string } | null) => void;
|
||||
@@ -382,6 +395,12 @@ function TaskSwitcherDialogs({
|
||||
onSuccess={actions.handleTaskCreated}
|
||||
/>
|
||||
<MobileSubtaskDialog target={subtaskTarget} onTargetChange={onSubtaskTargetChange} />
|
||||
<SidebarTaskEditDialog
|
||||
target={edit.editingTask}
|
||||
onTargetChange={edit.setEditingTask}
|
||||
workspaceId={workspaceId}
|
||||
stepsByWorkflowId={data.stepsByWorkflowId}
|
||||
/>
|
||||
<TaskArchiveConfirmDialog
|
||||
open={actions.archivingTask !== null}
|
||||
onOpenChange={(open) => {
|
||||
@@ -439,6 +458,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
|
||||
const data = useSheetData(workspaceId);
|
||||
const actions = useSheetActions(workspaceId, onOpenChange);
|
||||
const rename = useMobileTaskRename();
|
||||
const edit = useSidebarTaskEdit();
|
||||
const linking = useMobileTaskLinking(workspaceId);
|
||||
const openQuickChat = useQuickChatLauncher(workspaceId);
|
||||
const handleQuickChat = useCallback(() => {
|
||||
@@ -467,6 +487,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
|
||||
data={data}
|
||||
actions={actions}
|
||||
rename={rename}
|
||||
edit={edit}
|
||||
linking={linking}
|
||||
/>
|
||||
);
|
||||
@@ -501,6 +522,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
|
||||
data={data}
|
||||
actions={actions}
|
||||
rename={rename}
|
||||
edit={edit}
|
||||
linking={linking}
|
||||
subtaskTarget={subtaskTarget}
|
||||
onSubtaskTargetChange={setSubtaskTarget}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { TaskExternalLinkDialog } from "./task-external-link-dialog";
|
||||
import { TaskGitHubIssueDialog } from "./task-github-issue-dialog";
|
||||
import { TaskGitHubPRDialog } from "./task-github-pr-dialog";
|
||||
import { TaskMRLinkDialog } from "@/components/gitlab/task-mr-link-dialog";
|
||||
import { SidebarTaskEditDialog, type SidebarTaskEditTarget } from "./task-session-sidebar-edit";
|
||||
import type { StepDef } from "./task-switcher-context-menu";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import type {
|
||||
SidebarExternalLinkTarget,
|
||||
@@ -51,6 +53,8 @@ export type SidebarDialogsActions = {
|
||||
setLinkingMergeRequestTask: (next: LinkTarget) => void;
|
||||
linkingExternalIssueTask: SidebarExternalLinkTarget | null;
|
||||
setLinkingExternalIssueTask: (next: SidebarExternalLinkTarget | null) => void;
|
||||
editingTask: SidebarTaskEditTarget | null;
|
||||
setEditingTask: (next: SidebarTaskEditTarget | null) => void;
|
||||
};
|
||||
|
||||
function SidebarSubtaskDialog({
|
||||
@@ -76,10 +80,12 @@ export function SidebarDialogs({
|
||||
actions,
|
||||
repositories,
|
||||
workspaceId,
|
||||
stepsByWorkflowId,
|
||||
}: {
|
||||
actions: SidebarDialogsActions;
|
||||
repositories: Repository[];
|
||||
workspaceId: string | null;
|
||||
stepsByWorkflowId: Record<string, StepDef[]>;
|
||||
}) {
|
||||
const {
|
||||
renamingTask,
|
||||
@@ -100,6 +106,8 @@ export function SidebarDialogs({
|
||||
setDetachingTask,
|
||||
detachingTaskId,
|
||||
handleDetachConfirm,
|
||||
editingTask,
|
||||
setEditingTask,
|
||||
} = actions;
|
||||
return (
|
||||
<>
|
||||
@@ -140,6 +148,12 @@ export function SidebarDialogs({
|
||||
onDismiss={() => setDetachingTask(null)}
|
||||
onConfirm={handleDetachConfirm}
|
||||
/>
|
||||
<SidebarTaskEditDialog
|
||||
target={editingTask}
|
||||
onTargetChange={setEditingTask}
|
||||
workspaceId={workspaceId}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
/>
|
||||
<SidebarLinkDialogs actions={actions} repositories={repositories} workspaceId={workspaceId} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
import { buildSidebarTaskEditTarget } from "./task-session-sidebar-edit";
|
||||
|
||||
function sourceTask(overrides: Partial<KanbanState["tasks"][number]> = {}) {
|
||||
return {
|
||||
id: "task-1",
|
||||
title: "Authoritative title",
|
||||
description: "Authoritative description",
|
||||
workflowStepId: "step-source",
|
||||
state: "IN_PROGRESS" as const,
|
||||
repositoryId: "repo-1",
|
||||
...overrides,
|
||||
} as KanbanState["tasks"][number];
|
||||
}
|
||||
|
||||
describe("buildSidebarTaskEditTarget", () => {
|
||||
it("combines clicked workflow metadata with authoritative task fields", () => {
|
||||
expect(
|
||||
buildSidebarTaskEditTarget(
|
||||
{ id: "task-1", workflowId: "workflow-clicked", workflowStepId: "step-item" },
|
||||
sourceTask(),
|
||||
),
|
||||
).toEqual({
|
||||
id: "task-1",
|
||||
title: "Authoritative title",
|
||||
description: "Authoritative description",
|
||||
workflowId: "workflow-clicked",
|
||||
workflowStepId: "step-source",
|
||||
state: "IN_PROGRESS",
|
||||
repositoryId: "repo-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no target when the item has no workflow", () => {
|
||||
expect(buildSidebarTaskEditTarget({ id: "task-1" }, sourceTask())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns no target when the source task cannot be resolved", () => {
|
||||
expect(
|
||||
buildSidebarTaskEditTarget(
|
||||
{ id: "task-1", workflowId: "workflow-1", workflowStepId: "step-1" },
|
||||
null,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the sidebar step when the source task has no step", () => {
|
||||
expect(
|
||||
buildSidebarTaskEditTarget(
|
||||
{ id: "task-1", workflowId: "workflow-1", workflowStepId: "step-item" },
|
||||
sourceTask({ workflowStepId: undefined }),
|
||||
)?.workflowStepId,
|
||||
).toBe("step-item");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAppStoreApi } from "@/components/state-provider";
|
||||
import { TaskCreateDialog } from "@/components/task-create-dialog";
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
import type { Task } from "@/lib/types/http";
|
||||
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
||||
import type { StepDef } from "./task-switcher-context-menu";
|
||||
import type { TaskSwitcherItem } from "./task-switcher";
|
||||
|
||||
export type SidebarTaskEditTarget = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
workflowId: string;
|
||||
workflowStepId: string;
|
||||
state?: Task["state"];
|
||||
repositoryId?: string;
|
||||
};
|
||||
|
||||
export function buildSidebarTaskEditTarget(
|
||||
item: Pick<TaskSwitcherItem, "id" | "workflowId" | "workflowStepId">,
|
||||
sourceTask: KanbanState["tasks"][number] | null,
|
||||
): SidebarTaskEditTarget | null {
|
||||
if (!sourceTask || !item.workflowId) return null;
|
||||
const workflowStepId = sourceTask.workflowStepId ?? item.workflowStepId;
|
||||
if (!workflowStepId) return null;
|
||||
|
||||
return {
|
||||
id: sourceTask.id,
|
||||
title: sourceTask.title,
|
||||
description: sourceTask.description,
|
||||
workflowId: item.workflowId,
|
||||
workflowStepId,
|
||||
state: sourceTask.state,
|
||||
repositoryId: sourceTask.repositoryId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSidebarTaskEdit() {
|
||||
const store = useAppStoreApi();
|
||||
const [editingTask, setEditingTask] = useState<SidebarTaskEditTarget | null>(null);
|
||||
|
||||
const handleEditTask = useCallback(
|
||||
(item: TaskSwitcherItem) => {
|
||||
const state = store.getState();
|
||||
const sourceTask = findTaskInSnapshots(
|
||||
item.id,
|
||||
state.kanbanMulti.snapshots,
|
||||
state.kanban.tasks,
|
||||
);
|
||||
const target = buildSidebarTaskEditTarget(item, sourceTask);
|
||||
if (target) setEditingTask(target);
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
return { editingTask, setEditingTask, handleEditTask };
|
||||
}
|
||||
|
||||
export function SidebarTaskEditDialog({
|
||||
target,
|
||||
onTargetChange,
|
||||
workspaceId,
|
||||
stepsByWorkflowId,
|
||||
}: {
|
||||
target: SidebarTaskEditTarget | null;
|
||||
onTargetChange: (target: SidebarTaskEditTarget | null) => void;
|
||||
workspaceId: string | null;
|
||||
stepsByWorkflowId: Record<string, StepDef[]>;
|
||||
}) {
|
||||
const steps = target ? (stepsByWorkflowId[target.workflowId] ?? []) : [];
|
||||
return (
|
||||
<TaskCreateDialog
|
||||
open={target !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onTargetChange(null);
|
||||
}}
|
||||
mode="edit"
|
||||
workspaceId={workspaceId}
|
||||
workflowId={target?.workflowId ?? null}
|
||||
lockedFields={{ workflow: true }}
|
||||
defaultStepId={target?.workflowStepId ?? null}
|
||||
steps={steps}
|
||||
editingTask={
|
||||
target
|
||||
? {
|
||||
id: target.id,
|
||||
title: target.title,
|
||||
description: target.description,
|
||||
workflowStepId: target.workflowStepId,
|
||||
state: target.state,
|
||||
repositoryId: target.repositoryId,
|
||||
}
|
||||
: null
|
||||
}
|
||||
initialValues={
|
||||
target
|
||||
? {
|
||||
title: target.title,
|
||||
description: target.description,
|
||||
state: target.state,
|
||||
repositoryId: target.repositoryId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export function buildTaskSwitcherProps(args: {
|
||||
collapsedSubtaskParentIds: args.collapsedSubtaskParents,
|
||||
onToggleSubtasks: args.toggleSubtaskCollapsed,
|
||||
onSelectTask: args.sidebarActions.handleSelectTask,
|
||||
onEditTask: args.sidebarActions.handleEditTask,
|
||||
onRenameTask: args.sidebarActions.handleRenameTask,
|
||||
onCreateSubtask: args.sidebarActions.handleCreateSubtask,
|
||||
onArchiveTask: args.sidebarActions.handleArchiveTask,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useSidebarLinkActions } from "./task-session-sidebar-link-actions";
|
||||
import { buildArchivedSidebarItem } from "./task-session-sidebar-archived-item";
|
||||
import { useSidebarTaskLinking } from "./task-session-sidebar-task-linking";
|
||||
import { buildSidebarItem } from "./task-session-sidebar-item";
|
||||
import { useSidebarTaskEdit } from "./task-session-sidebar-edit";
|
||||
|
||||
type TaskSessionSidebarProps = {
|
||||
workspaceId: string | null;
|
||||
@@ -322,6 +323,7 @@ export function useSidebarActions(store: StoreApi) {
|
||||
const deleteActions = useDeleteActions(store, removeTaskFromBoard);
|
||||
const detachActions = useTaskDetachDialog(store);
|
||||
const linkActions = useSidebarLinkActions(store);
|
||||
const editActions = useSidebarTaskEdit();
|
||||
|
||||
const [renamingTask, setRenamingTask] = useState<{ id: string; title: string } | null>(null);
|
||||
const [creatingSubtask, setCreatingSubtask] = useState<{ id: string; title: string } | null>(
|
||||
@@ -366,6 +368,7 @@ export function useSidebarActions(store: StoreApi) {
|
||||
...archiveActions,
|
||||
...deleteActions,
|
||||
...detachActions,
|
||||
...editActions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -456,6 +459,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
actions={sidebarActions}
|
||||
repositories={repositories}
|
||||
workspaceId={workspaceId}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
/>
|
||||
<SidebarBulkDialogs selection={selection} />
|
||||
</PanelRoot>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { cloneElement, isValidElement, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconBrandGitlab,
|
||||
IconBrandSentry,
|
||||
IconCopy,
|
||||
IconCircleDot,
|
||||
IconEdit,
|
||||
IconGitPullRequest,
|
||||
IconLink,
|
||||
IconPencil,
|
||||
@@ -52,6 +54,7 @@ type ContextMenuProps = {
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
steps?: StepDef[];
|
||||
children: React.ReactElement<{ menuOpen?: boolean }>;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onArchiveTask?: (taskId: string) => void;
|
||||
onCreateSubtask?: (taskId: string, taskTitle: string) => void;
|
||||
@@ -85,6 +88,7 @@ export function TaskItemWithContextMenu({
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
children,
|
||||
onEditTask,
|
||||
onRenameTask,
|
||||
onArchiveTask,
|
||||
onCreateSubtask,
|
||||
@@ -128,6 +132,7 @@ export function TaskItemWithContextMenu({
|
||||
workflows={workflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
steps={steps}
|
||||
onEditTask={onEditTask}
|
||||
onRenameTask={onRenameTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onCreateSubtask={onCreateSubtask}
|
||||
@@ -170,6 +175,7 @@ function TaskContextMenuItems(props: TaskContextMenuItemsProps) {
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
onEditTask,
|
||||
onRenameTask,
|
||||
onArchiveTask,
|
||||
onCreateSubtask,
|
||||
@@ -221,23 +227,17 @@ function TaskContextMenuItems(props: TaskContextMenuItemsProps) {
|
||||
|
||||
// Acting on a lone selected row (Pin / Delete) must drop it from the selection
|
||||
// so later plain clicks navigate instead of toggling.
|
||||
const withClear = (handler?: (id: string) => void) =>
|
||||
actingOnSelection && onClearSelection && handler
|
||||
? (id: string) => {
|
||||
onClearSelection();
|
||||
handler(id);
|
||||
}
|
||||
: handler;
|
||||
const onDelete = withClear(onDeleteTask);
|
||||
const onDetach = withClear(onDetachTask);
|
||||
const onDelete = withSelectionClear(actingOnSelection, onClearSelection, onDeleteTask);
|
||||
const onDetach = withSelectionClear(actingOnSelection, onClearSelection, onDetachTask);
|
||||
return (
|
||||
<>
|
||||
<TaskPinItem
|
||||
taskId={task.id}
|
||||
isPinned={isPinned}
|
||||
disabled={isDeleting}
|
||||
onTogglePin={withClear(onTogglePin)}
|
||||
onTogglePin={withSelectionClear(actingOnSelection, onClearSelection, onTogglePin)}
|
||||
/>
|
||||
<TaskEditItem task={task} disabled={isDeleting} onEditTask={onEditTask} />
|
||||
<TaskRenameItem task={task} disabled={isDeleting} onRenameTask={onRenameTask} />
|
||||
<TaskCreateSubtaskItem task={task} disabled={isDeleting} onCreateSubtask={onCreateSubtask} />
|
||||
<ContextMenuItem disabled>
|
||||
@@ -275,6 +275,18 @@ function TaskContextMenuItems(props: TaskContextMenuItemsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function withSelectionClear(
|
||||
actingOnSelection: boolean,
|
||||
onClearSelection: (() => void) | undefined,
|
||||
handler: ((id: string) => void) | undefined,
|
||||
) {
|
||||
if (!actingOnSelection || !onClearSelection || !handler) return handler;
|
||||
return (id: string) => {
|
||||
onClearSelection();
|
||||
handler(id);
|
||||
};
|
||||
}
|
||||
|
||||
/** Reduced menu shown when 2+ tasks are selected — only bulk-valid actions. */
|
||||
function BulkSelectionMenuItems({
|
||||
task,
|
||||
@@ -435,6 +447,25 @@ function TaskRenameItem({
|
||||
);
|
||||
}
|
||||
|
||||
function TaskEditItem({
|
||||
task,
|
||||
disabled,
|
||||
onEditTask,
|
||||
}: {
|
||||
task: TaskSwitcherItem;
|
||||
disabled?: boolean;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!onEditTask || task.isArchived || !task.workflowId || !task.workflowStepId) return null;
|
||||
return (
|
||||
<ContextMenuItem disabled={disabled} onSelect={() => onEditTask(task)}>
|
||||
<IconEdit className="mr-2 h-4 w-4" />
|
||||
{t("common:edit")}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskMoveItems({
|
||||
task,
|
||||
workflows,
|
||||
|
||||
@@ -41,6 +41,9 @@ function grouped(): GroupedSidebarList {
|
||||
};
|
||||
}
|
||||
|
||||
const TEST_WORKFLOW_ID = "workflow-1";
|
||||
const DETACH_MENU_LABEL = "Detach from parent";
|
||||
|
||||
function renderSwitcher(collapsedSubtaskParentIds: string[] = []) {
|
||||
return render(
|
||||
<Providers>
|
||||
@@ -151,13 +154,13 @@ describe("TaskSwitcher — workflow completion icons", () => {
|
||||
item("Turn finished", undefined, {
|
||||
state: "REVIEW",
|
||||
sessionState: "COMPLETED",
|
||||
workflowId: "workflow-1",
|
||||
workflowId: TEST_WORKFLOW_ID,
|
||||
workflowStepId: "step-middle",
|
||||
}),
|
||||
item("Workflow complete", undefined, {
|
||||
state: "REVIEW",
|
||||
sessionState: "COMPLETED",
|
||||
workflowId: "workflow-1",
|
||||
workflowId: TEST_WORKFLOW_ID,
|
||||
workflowStepId: finalStepId,
|
||||
}),
|
||||
item("Missing workflow metadata", undefined, {
|
||||
@@ -174,7 +177,7 @@ describe("TaskSwitcher — workflow completion icons", () => {
|
||||
subTasksByParentId: new Map(),
|
||||
}}
|
||||
stepsByWorkflowId={{
|
||||
"workflow-1": [
|
||||
[TEST_WORKFLOW_ID]: [
|
||||
{ id: "step-start", title: "Start" },
|
||||
{ id: "step-middle", title: "Middle" },
|
||||
{ id: finalStepId, title: "Done" },
|
||||
@@ -247,11 +250,35 @@ describe("TaskSwitcher — detach menu", () => {
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText(CHILD.title));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Detach from parent" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: DETACH_MENU_LABEL }));
|
||||
|
||||
expect(onDetachTask).toHaveBeenCalledWith(CHILD.id);
|
||||
});
|
||||
|
||||
it("clears a lone selected task before detaching it", () => {
|
||||
const calls: string[] = [];
|
||||
const onClearSelection = vi.fn(() => calls.push("clear"));
|
||||
const onDetachTask = vi.fn(() => calls.push("detach"));
|
||||
render(
|
||||
<Providers>
|
||||
<TaskSwitcher
|
||||
grouped={grouped()}
|
||||
activeTaskId={null}
|
||||
selectedTaskId={null}
|
||||
onSelectTask={vi.fn()}
|
||||
onDetachTask={onDetachTask}
|
||||
onClearSelection={onClearSelection}
|
||||
selectedTaskIds={new Set([CHILD.id])}
|
||||
/>
|
||||
</Providers>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText(CHILD.title));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: DETACH_MENU_LABEL }));
|
||||
|
||||
expect(calls).toEqual(["clear", "detach"]);
|
||||
});
|
||||
|
||||
it("omits detach for root and multi-selection menus", () => {
|
||||
const { rerender } = render(
|
||||
<Providers>
|
||||
@@ -266,7 +293,7 @@ describe("TaskSwitcher — detach menu", () => {
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText(ROOT.title));
|
||||
expect(screen.queryByRole("menuitem", { name: "Detach from parent" })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: DETACH_MENU_LABEL })).toBeNull();
|
||||
|
||||
rerender(
|
||||
<Providers>
|
||||
@@ -281,7 +308,7 @@ describe("TaskSwitcher — detach menu", () => {
|
||||
</Providers>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByText(CHILD.title));
|
||||
expect(screen.queryByRole("menuitem", { name: "Detach from parent" })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: DETACH_MENU_LABEL })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -307,6 +334,105 @@ describe("TaskSwitcher — create subtask menu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskSwitcher — edit menu", () => {
|
||||
it("passes the clicked task to the edit action", () => {
|
||||
const editableTask = item("Editable task", undefined, {
|
||||
workflowId: TEST_WORKFLOW_ID,
|
||||
workflowStepId: "step-1",
|
||||
});
|
||||
const onEditTask = vi.fn();
|
||||
|
||||
render(
|
||||
<Providers>
|
||||
<TaskSwitcher
|
||||
grouped={{
|
||||
groups: [{ key: "__all__", label: "All", tasks: [editableTask] }],
|
||||
subTasksByParentId: new Map(),
|
||||
}}
|
||||
activeTaskId={null}
|
||||
selectedTaskId={null}
|
||||
onSelectTask={vi.fn()}
|
||||
onEditTask={onEditTask}
|
||||
/>
|
||||
</Providers>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText(editableTask.title));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Edit" }));
|
||||
|
||||
expect(onEditTask).toHaveBeenCalledWith(editableTask);
|
||||
});
|
||||
|
||||
it("omits edit for archived rows and multi-selection menus", () => {
|
||||
const archivedTask = item("Archived task", undefined, {
|
||||
isArchived: true,
|
||||
workflowId: TEST_WORKFLOW_ID,
|
||||
workflowStepId: "step-1",
|
||||
});
|
||||
const editableTask = item("Editable task", undefined, {
|
||||
workflowId: TEST_WORKFLOW_ID,
|
||||
workflowStepId: "step-1",
|
||||
});
|
||||
const onEditTask = vi.fn();
|
||||
const { rerender } = render(
|
||||
<Providers>
|
||||
<TaskSwitcher
|
||||
grouped={{
|
||||
groups: [{ key: "__all__", label: "All", tasks: [archivedTask, editableTask] }],
|
||||
subTasksByParentId: new Map(),
|
||||
}}
|
||||
activeTaskId={null}
|
||||
selectedTaskId={null}
|
||||
onSelectTask={vi.fn()}
|
||||
onEditTask={onEditTask}
|
||||
/>
|
||||
</Providers>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText(archivedTask.title));
|
||||
expect(screen.queryByRole("menuitem", { name: "Edit" })).toBeNull();
|
||||
|
||||
rerender(
|
||||
<Providers>
|
||||
<TaskSwitcher
|
||||
grouped={{
|
||||
groups: [{ key: "__all__", label: "All", tasks: [archivedTask, editableTask] }],
|
||||
subTasksByParentId: new Map(),
|
||||
}}
|
||||
activeTaskId={null}
|
||||
selectedTaskId={null}
|
||||
onSelectTask={vi.fn()}
|
||||
onEditTask={onEditTask}
|
||||
selectedTaskIds={new Set([editableTask.id, archivedTask.id])}
|
||||
/>
|
||||
</Providers>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByText(editableTask.title));
|
||||
expect(screen.queryByRole("menuitem", { name: "Edit" })).toBeNull();
|
||||
});
|
||||
|
||||
it("omits edit when a task lacks workflow metadata", () => {
|
||||
const task = item("No workflow task");
|
||||
render(
|
||||
<Providers>
|
||||
<TaskSwitcher
|
||||
grouped={{
|
||||
groups: [{ key: "__all__", label: "All", tasks: [task] }],
|
||||
subTasksByParentId: new Map(),
|
||||
}}
|
||||
activeTaskId={null}
|
||||
selectedTaskId={null}
|
||||
onSelectTask={vi.fn()}
|
||||
onEditTask={vi.fn()}
|
||||
/>
|
||||
</Providers>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText(task.title));
|
||||
expect(screen.queryByRole("menuitem", { name: "Edit" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskSwitcher — external issue link menu", () => {
|
||||
it("offers configured external issue providers from the task context menu", async () => {
|
||||
const onLinkMergeRequest = vi.fn();
|
||||
|
||||
@@ -58,6 +58,7 @@ type TaskSwitcherProps = {
|
||||
collapsedSubtaskParentIds?: string[];
|
||||
onToggleSubtasks?: (parentTaskId: string) => void;
|
||||
onSelectTask: (taskId: string) => void;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onArchiveTask?: (taskId: string) => void;
|
||||
onCreateSubtask?: (taskId: string, taskTitle: string) => void;
|
||||
@@ -144,6 +145,7 @@ type TaskRowProps = {
|
||||
activeTaskId: string | null;
|
||||
selectedTaskId: string | null;
|
||||
onSelectTask: (taskId: string) => void;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onArchiveTask?: (taskId: string) => void;
|
||||
onCreateSubtask?: (taskId: string, taskTitle: string) => void;
|
||||
@@ -171,26 +173,6 @@ type TaskRowProps = {
|
||||
isMixedWorkflowSelection?: boolean;
|
||||
};
|
||||
|
||||
function taskLinkHandlerProps(props: Pick<TaskRowProps, keyof TaskLinkHandlerProps>) {
|
||||
return {
|
||||
onLinkPullRequest: props.onLinkPullRequest,
|
||||
onLinkIssue: props.onLinkIssue,
|
||||
onLinkMergeRequest: props.onLinkMergeRequest,
|
||||
onLinkJiraTicket: props.onLinkJiraTicket,
|
||||
onLinkLinearIssue: props.onLinkLinearIssue,
|
||||
onLinkSentryIssue: props.onLinkSentryIssue,
|
||||
};
|
||||
}
|
||||
|
||||
type TaskLinkHandlerProps = {
|
||||
onLinkPullRequest?: TaskLinkHandler;
|
||||
onLinkIssue?: TaskLinkHandler;
|
||||
onLinkMergeRequest?: TaskLinkHandler;
|
||||
onLinkJiraTicket?: TaskLinkHandler;
|
||||
onLinkLinearIssue?: TaskLinkHandler;
|
||||
onLinkSentryIssue?: TaskLinkHandler;
|
||||
};
|
||||
|
||||
function TaskRow({
|
||||
task,
|
||||
isSubTask,
|
||||
@@ -231,12 +213,12 @@ function TaskRow({
|
||||
workflows={workflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
steps={taskSteps}
|
||||
{...props}
|
||||
onRenameTask={onRenameTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onCreateSubtask={onCreateSubtask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
onDetachTask={onDetachTask}
|
||||
{...taskLinkHandlerProps(props)}
|
||||
onMoveToStep={onMoveToStep}
|
||||
onTogglePin={onTogglePin}
|
||||
isPinned={isPinned}
|
||||
@@ -409,6 +391,7 @@ type GroupSectionProps = {
|
||||
onToggleSubtasks?: (parentTaskId: string) => void;
|
||||
showHeader: boolean;
|
||||
onSelectTask: (taskId: string) => void;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
onArchiveTask?: (taskId: string) => void;
|
||||
onCreateSubtask?: (taskId: string, taskTitle: string) => void;
|
||||
@@ -451,6 +434,7 @@ function GroupSection({
|
||||
onToggleSubtasks,
|
||||
showHeader,
|
||||
onSelectTask,
|
||||
onEditTask,
|
||||
onRenameTask,
|
||||
onArchiveTask,
|
||||
onCreateSubtask,
|
||||
@@ -491,6 +475,7 @@ function GroupSection({
|
||||
activeTaskId,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
onEditTask,
|
||||
onRenameTask,
|
||||
onArchiveTask,
|
||||
onCreateSubtask,
|
||||
@@ -538,6 +523,7 @@ function GroupSection({
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- assembles the shared recursive task-tree props.
|
||||
export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
grouped,
|
||||
workflows,
|
||||
@@ -549,6 +535,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
collapsedSubtaskParentIds,
|
||||
onToggleSubtasks,
|
||||
onSelectTask,
|
||||
onEditTask,
|
||||
onRenameTask,
|
||||
onArchiveTask,
|
||||
onCreateSubtask,
|
||||
@@ -607,6 +594,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
showHeader={showHeaders}
|
||||
onSelectTask={onSelectTask}
|
||||
onEditTask={onEditTask}
|
||||
onRenameTask={onRenameTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onCreateSubtask={onCreateSubtask}
|
||||
|
||||
@@ -251,6 +251,7 @@ test.describe("Mobile sidebar task actions", () => {
|
||||
expect(menuOverflow.scrollHeight).toBeGreaterThan(menuOverflow.clientHeight);
|
||||
for (const actionName of [
|
||||
"Pin",
|
||||
"Edit",
|
||||
"Rename",
|
||||
"Duplicate",
|
||||
"Archive",
|
||||
@@ -267,6 +268,86 @@ test.describe("Mobile sidebar task actions", () => {
|
||||
await expect(diffStats).toBeVisible();
|
||||
});
|
||||
|
||||
test("edits a started task from the phone drawer and closes the drawer", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Phone sidebar edit target", {
|
||||
description: "Phone prompt stays locked",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
await apiClient.updateTaskState(task.id, "IN_PROGRESS");
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await testPage.getByTestId("mobile-session-menu").tap();
|
||||
|
||||
const drawer = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
const taskRow = drawer
|
||||
.getByTestId("sidebar-task-item")
|
||||
.filter({ hasText: "Phone sidebar edit target" });
|
||||
await taskRow.getByRole("button", { name: "Task actions" }).tap();
|
||||
await testPage.getByRole("menuitem", { name: "Edit", exact: true }).tap();
|
||||
|
||||
await expect(drawer).toBeHidden();
|
||||
const dialog = testPage.getByRole("dialog");
|
||||
await expect(dialog.getByTestId("task-title-input")).toBeEnabled();
|
||||
await expect(dialog.getByTestId("task-description-input")).toBeDisabled();
|
||||
await expect(dialog.getByTestId("task-description-input")).toHaveValue(
|
||||
"Phone prompt stays locked",
|
||||
);
|
||||
await prCapture.screenshot("phone-sidebar-task-edit-dialog", {
|
||||
caption: "Phone sidebar task editor with started-task prompt locked",
|
||||
});
|
||||
await dialog.getByTestId("task-title-input").fill("Phone sidebar edit updated");
|
||||
await dialog.getByRole("button", { name: "Update", exact: true }).tap();
|
||||
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getTask(task.id)).title)
|
||||
.toBe("Phone sidebar edit updated");
|
||||
});
|
||||
|
||||
test("keeps the tablet task sheet behind the sidebar editor", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
await testPage.setViewportSize({ width: 820, height: 900 });
|
||||
const task = await apiClient.seedTask(seedData.workspaceId, "Tablet sidebar edit target", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
await testPage.goto(`/t/${task.task_id}`);
|
||||
await expect(testPage.getByTestId("tablet-task-layout")).toBeVisible();
|
||||
await testPage.evaluate(
|
||||
"window.__KANDEV_E2E_STORE__?.getState().setMobileSessionTaskSwitcherOpen(true)",
|
||||
);
|
||||
|
||||
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
const taskRow = sheet
|
||||
.getByTestId("sidebar-task-item")
|
||||
.filter({ hasText: "Tablet sidebar edit target" });
|
||||
await taskRow.click({ button: "right" });
|
||||
await testPage.getByRole("menuitem", { name: "Edit", exact: true }).click();
|
||||
|
||||
const editor = testPage
|
||||
.getByRole("dialog")
|
||||
.filter({ has: testPage.getByTestId("task-title-input") });
|
||||
await expect(editor.getByTestId("task-title-input")).toBeVisible();
|
||||
await expect(sheet).toBeVisible();
|
||||
await prCapture.screenshot("tablet-sidebar-task-edit-dialog", {
|
||||
caption: "Tablet sidebar task editor with task sheet retained",
|
||||
});
|
||||
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(editor).toHaveCount(0);
|
||||
await expect(sheet).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens create subtask from the mobile task actions menu", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - Repo group headers: letter avatar, task count, collapsible
|
||||
* - Subtasks (parent_id): nested under parent with arrow indicator
|
||||
* - Unassigned group: tasks with no repo
|
||||
* - Context menu: Create Subtask, Rename, Archive, Delete options via right-click
|
||||
* - Context menu: Edit, Create Subtask, Rename, Archive, Delete options via right-click
|
||||
* - Active task highlight: selected task has aria/visual selection state
|
||||
*/
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
@@ -215,6 +215,9 @@ test.describe("Sidebar layout — context menu", () => {
|
||||
await taskRow.click({ button: "right" });
|
||||
|
||||
// Context menu items should be visible
|
||||
await expect(testPage.getByRole("menuitem", { name: "Edit", exact: true })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(testPage.getByRole("menuitem", { name: "Rename" })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
@@ -234,6 +237,57 @@ test.describe("Sidebar layout — context menu", () => {
|
||||
// Dismiss
|
||||
await testPage.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("opens the existing editor and persists sidebar task edits", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
const originalTitle = "Sidebar edit target";
|
||||
const updatedTitle = "Sidebar edit target updated";
|
||||
const task = await apiClient.createTask(seedData.workspaceId, originalTitle, {
|
||||
description: "Sidebar edit description",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const navigationTask = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Sidebar edit navigation task",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
},
|
||||
);
|
||||
await apiClient.updateTaskState(task.id, "IN_PROGRESS");
|
||||
await testPage.goto(`/t/${navigationTask.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
const initialUrl = testPage.url();
|
||||
|
||||
const taskRow = session.sidebar
|
||||
.locator("[data-testid='sidebar-task-item']")
|
||||
.filter({ hasText: originalTitle });
|
||||
await expect(taskRow).toBeVisible({ timeout: 10_000 });
|
||||
await taskRow.click({ button: "right" });
|
||||
await testPage.getByRole("menuitem", { name: "Edit", exact: true }).click();
|
||||
|
||||
const dialog = testPage.getByRole("dialog");
|
||||
await expect(dialog.getByTestId("task-title-input")).toHaveValue(originalTitle);
|
||||
await expect(dialog.getByTestId("task-description-input")).toHaveValue(
|
||||
"Sidebar edit description",
|
||||
);
|
||||
await prCapture.screenshot("desktop-sidebar-task-edit-dialog", {
|
||||
caption: "Desktop sidebar task editor",
|
||||
});
|
||||
await dialog.getByTestId("task-title-input").fill(updatedTitle);
|
||||
await dialog.getByRole("button", { name: "Update", exact: true }).click();
|
||||
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(testPage).toHaveURL(initialUrl);
|
||||
await expect.poll(async () => (await apiClient.getTask(task.id)).title).toBe(updatedTitle);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar layout — task timestamps and sorting", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"contextCompactionCountHelp": "Kandev infers this count from observed context usage drops. Missing samples or provider resets can make it approximate.",
|
||||
"aboutContextCompactionCount": "About inferred compaction count",
|
||||
"cancel": "Cancel",
|
||||
"edit": "Edit",
|
||||
"createAWorkspaceToConfigureThisIntegration": "Create a workspace to configure this integration.",
|
||||
"dAgo": "{{diffDay}}d ago",
|
||||
"executors": "Executors",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"contextCompactionCountHelp": "Ķàńďēv ĩńƒēŕś ţĥĩś ćōũńţ ƒŕōḿ ōƀśēŕvēď ćōńţēxţ ũśàĝē ďŕōƥś. Ḿĩśśĩńĝ śàḿƥĺēś ōŕ ƥŕōvĩďēŕ ŕēśēţś ćàń ḿàķē ĩţ àƥƥŕōxĩḿàţē.",
|
||||
"aboutContextCompactionCount": "Àƀōũţ ĩńƒēŕŕēď ćōḿƥàćţĩōń ćōũńţ",
|
||||
"cancel": "Ćàńćēĺ",
|
||||
"edit": "Ēďĩţ",
|
||||
"createAWorkspaceToConfigureThisIntegration": "Ćŕēàţē à ŵōŕķśƥàćē ţō ćōńƒĩĝũŕē ţĥĩś ĩńţēĝŕàţĩōń.",
|
||||
"dAgo": "{{diffDay}}ď àĝō",
|
||||
"executors": "Ēxēćũţōŕś",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
spec: docs/specs/tasks/sidebar-task-edit.md
|
||||
created: 2026-08-03
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Implementation Plan: Sidebar Task Editing
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the shared sidebar `TaskSwitcher` single-task menu with an Edit callback, then route both the desktop sidebar and phone/tablet task switcher through one small edit controller and the existing `TaskCreateDialog` in edit mode. The implementation reuses current task snapshots and workflow-step data, leaves Rename intact, and relies on the existing `PATCH /api/v1/tasks/:taskId` plus `task.updated` reconciliation. No backend, schema, or public API changes are required.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Shared single-task menu contract
|
||||
|
||||
- Update `apps/web/components/task/task-switcher.tsx` so `TaskSwitcher`, `TaskRow`, and `TaskItemWithContextMenu` can receive `onEditTask(task)` for the exact `TaskSwitcherItem` whose menu was opened.
|
||||
- Update `apps/web/components/task/task-switcher-context-menu.tsx` to render a localized **Edit** item next to **Rename** for one live task. Keep it out of the existing multi-selection menu and omit it for archived or workflow-less synthetic rows; use the existing processing state to disable it while deletion is in progress.
|
||||
- Add `common:edit` to `apps/web/src/locales/en/common.json` and resolve it at render time with `useTranslation()`.
|
||||
- Regenerate `apps/web/src/locales/pseudo/common.json` as the checked-in locale synchronization artifact.
|
||||
|
||||
### Shared edit controller and dialog
|
||||
|
||||
- Add `apps/web/components/task/task-session-sidebar-edit.tsx` containing:
|
||||
- a pure target builder that combines the selected `TaskSwitcherItem`'s workflow identity with the authoritative task from `findTaskInSnapshots()`;
|
||||
- `useSidebarTaskEdit()`, which resolves the clicked task from `kanbanMulti.snapshots` with the active `kanban.tasks` fallback and owns the open edit target;
|
||||
- `SidebarTaskEditDialog`, which renders the existing `TaskCreateDialog` with `mode="edit"`, the target workflow and step list, and matching `editingTask` / `initialValues` fields.
|
||||
- Do not navigate or change `tasks.activeTaskId` when opening or saving. Let the existing edit submit path and `task.updated` handler update both active Kanban state and multi-workflow snapshots.
|
||||
- If the target cannot be resolved or lacks workflow/step metadata, do not open the dialog.
|
||||
|
||||
### Desktop sidebar wiring
|
||||
|
||||
- Update `apps/web/components/task/task-session-sidebar.tsx` to compose `useSidebarTaskEdit()` into `useSidebarActions()`.
|
||||
- Update `apps/web/components/task/task-session-sidebar-switcher-props.ts` to pass `handleEditTask` into the shared switcher.
|
||||
- Update `apps/web/components/task/task-session-sidebar-dialogs.tsx` to host `SidebarTaskEditDialog` with the active workspace and `stepsByWorkflowId`; closing it clears only the edit target.
|
||||
|
||||
### Phone and tablet wiring
|
||||
|
||||
- Update `apps/web/components/task/mobile/session-task-switcher-sheet.tsx` to use the same edit controller, pass Edit through `MobileTaskList`, and render the shared edit dialog with `useSheetData().stepsByWorkflowId`.
|
||||
- Wrap the edit callback with the existing `surfaceAction()` policy: the phone `Drawer` closes before the editor opens, while the tablet `Sheet` stays mounted behind it.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
- **Desktop outcome / mobile entry:** desktop uses right-click on a sidebar row; phone and tablet use the row's visible **Task actions** control.
|
||||
- **Nearest shipped exemplar:** `apps/web/components/task/mobile/session-task-switcher-sheet.tsx` supplies the existing inset bottom `Drawer`, wider `Sheet`, action handoff, safe-area padding, and single internal scroll owner. `apps/web/components/task/task-switcher-context-menu.tsx` supplies the shared responsive menu primitive.
|
||||
- **Hierarchy and primary action:** selecting a task remains the row's primary action. Edit stays a secondary action in the explicit overflow menu and opens the established focused task editor.
|
||||
- **Presentation rationale:** editing is a focused, potentially multi-field operation, so it uses the existing modal editor. The temporary phone task navigator closes first to avoid stacked bottom surfaces; the roomier tablet sheet retains context behind the modal.
|
||||
- **Geometry:** keep the drawer's `88dvh` bound, safe-area padding, and internal task-list scroll. The existing mobile menu treatment owns viewport containment and at least 44px action rows; no document-level horizontal scrolling is added.
|
||||
- **Shared logic:** target resolution, form values, validation, submission, and task state are shared. Only the phone/tablet surface-dismissal behavior remains presentation-specific.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** one eligible task exposes Edit and invokes the callback with the exact clicked task, while multi-selection and archived rows do not expose it.
|
||||
- **File:** `apps/web/components/task/task-switcher.test.tsx`
|
||||
- **How:** render the shared switcher, open the Radix context menu, invoke Edit, and assert callback/visibility rules.
|
||||
- **What:** the sidebar edit target uses the clicked task's workflow and authoritative snapshot fields, refuses incomplete targets, and supplies the existing dialog with correct edit props and workflow-specific steps.
|
||||
- **File:** `apps/web/components/task/task-session-sidebar-edit.test.tsx`
|
||||
- **How:** unit-test the pure target builder and render the dialog with a mocked `TaskCreateDialog`.
|
||||
- **What:** the touch task list forwards Edit through the same shared menu contract.
|
||||
- **File:** `apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx`
|
||||
- **How:** open the visible task-actions button, select Edit, and assert the target task is passed.
|
||||
- Existing `TaskCreateDialog` unit tests remain the authority for edit validation and started-task field locks; this feature does not duplicate that form logic.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** GIVEN a non-active desktop sidebar task, WHEN Edit is opened, saved, and the editor closes, THEN the route stays on the original task while the target's persisted title and sidebar row update.
|
||||
- **File:** `apps/web/e2e/tests/task/sidebar-layout.spec.ts`
|
||||
- **What to verify:** Edit is present alongside Rename; target values are prefilled; save persists; route and active task are unchanged.
|
||||
- **Scenario:** GIVEN a started task in the phone task drawer, WHEN Edit is selected, THEN the drawer closes, the title stays editable, the prompt stays locked, and saving is visible after reopening the drawer.
|
||||
- **File:** `apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts`
|
||||
- **What to verify:** visible task-actions entry, responsive menu row, no stacked drawer, lifecycle locks, persistence, and updated sidebar copy.
|
||||
- **Scenario:** GIVEN the tablet task-switcher sheet, WHEN Edit is opened and canceled, THEN the sheet remains visible with the unchanged task row.
|
||||
- **File:** `apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts`
|
||||
- **What to verify:** tablet sheet retention and cancel behavior.
|
||||
- Extend the existing mobile action-menu geometry assertion in `mobile-sidebar-task-actions.spec.ts` to include **Edit**, retaining viewport containment, internal scrolling, and 44px row coverage after the menu grows.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- Focused unit tests followed Red-Green-Refactor: the pre-implementation run reported 2 expected Edit-menu failures with 16 passing tests; the final run passed 24 tests across the three focused files, including unresolved-source, missing-workflow, and selection-clear guards added during PR review.
|
||||
- `cd apps/web && pnpm run typecheck` passed.
|
||||
- `cd apps/web && pnpm run i18n:check` and `pnpm run i18n:ratchet` passed after regenerating the pseudo locale.
|
||||
- Targeted ESLint for all changed frontend files passed with no warnings; `git diff --check` passed.
|
||||
- `cd apps/web && pnpm run build` passed.
|
||||
- Managed Playwright passed 9 Chromium sidebar tests and 10 mobile-chrome sidebar/action tests. The final capture runs also passed 2 Chromium and 2 mobile-chrome tests and produced four validated, compressed screenshots under the ignored `.pr-assets/` directory.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [Task 01: Add the shared sidebar edit flow](task-01-shared-sidebar-edit-flow.md)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [x] [Task 02: Prove desktop and mobile edit parity](task-02-sidebar-edit-e2e.md) — depends on Task 01.
|
||||
|
||||
The tasks are sequential because Task 02 exercises the UI and selectors introduced by Task 01. No subagent use is implied by this plan.
|
||||
|
||||
## Risks
|
||||
|
||||
- Sidebar rows aggregate tasks across workflows; using the globally active workflow would initialize the wrong step list. The edit target must retain the clicked row's workflow identity.
|
||||
- The synthetic archived row is not backed by a complete editable task snapshot. It must not expose Edit.
|
||||
- Phone action selection must close the task drawer before opening the dialog, while tablet behavior intentionally differs; reusing `surfaceAction()` preserves that breakpoint contract.
|
||||
- The menu already has substantial mobile height. Adding Edit must preserve bottom-sheet containment, internal scrolling, and access to destructive actions.
|
||||
- Task updates arrive through the existing WebSocket reconciliation path; tests should assert eventual sidebar state and must not add a second competing cache-update implementation.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
id: "01-shared-sidebar-edit-flow"
|
||||
title: "Add shared sidebar edit flow"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/sidebar-task-edit.md"
|
||||
---
|
||||
|
||||
# Task 01: Add shared sidebar edit flow
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A live single-task sidebar menu exposes localized **Edit** alongside **Rename**, while archived rows and multi-task menus do not.
|
||||
- Desktop, phone, and tablet task lists open the existing `TaskCreateDialog` for the clicked task using its own workflow, step, lifecycle state, description, and primary repository without changing the active route/task.
|
||||
- Phone selection closes the task drawer before the editor opens; tablet selection retains the sheet behind the editor.
|
||||
|
||||
## Verification
|
||||
|
||||
Run in Red-Green-Refactor order, observing the focused tests fail before production changes:
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm install --frozen-lockfile)
|
||||
(cd apps && pnpm --filter @kandev/web test -- components/task/task-switcher.test.tsx components/task/task-session-sidebar-edit.test.ts components/task/mobile/session-task-switcher-sheet.test.tsx)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm run i18n:check)
|
||||
(cd apps/web && pnpm run i18n:ratchet)
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/task-switcher.tsx`
|
||||
- `apps/web/components/task/task-switcher-context-menu.tsx`
|
||||
- `apps/web/components/task/task-switcher.test.tsx`
|
||||
- `apps/web/components/task/task-session-sidebar-edit.tsx`
|
||||
- `apps/web/components/task/task-session-sidebar-edit.test.tsx`
|
||||
- `apps/web/components/task/task-session-sidebar.tsx`
|
||||
- `apps/web/components/task/task-session-sidebar-switcher-props.ts`
|
||||
- `apps/web/components/task/task-session-sidebar-dialogs.tsx`
|
||||
- `apps/web/components/task/mobile/session-task-switcher-sheet.tsx`
|
||||
- `apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx`
|
||||
- `apps/web/src/locales/en/common.json`
|
||||
- `apps/web/src/locales/pseudo/common.json`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. The menu contract, shared edit controller, and both responsive hosts share types and files and should land as one vertical slice.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: `What`, `Failure modes`, and all desktop/phone/tablet scenarios.
|
||||
- Plan sections: `Shared single-task menu contract`, `Shared edit controller and dialog`, `Desktop sidebar wiring`, `Phone and tablet wiring`, and `Mobile design contract`.
|
||||
- Existing patterns: `useTaskCRUD()` / `KanbanBoardDialogs` for Kanban edit props, `findTaskInSnapshots()` for sidebar target resolution, and `surfaceAction()` for phone-versus-tablet overlay behavior.
|
||||
|
||||
## Risks
|
||||
|
||||
- Do not initialize the dialog from global `kanban.workflowId`; the clicked task may come from another workflow snapshot.
|
||||
- Do not duplicate `TaskCreateDialog` validation/submission or add a second cache mutation path.
|
||||
- New user-facing copy must use i18n at render time.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the red test evidence, final files changed, exact commands and outcomes, blockers, responsive behavior, and remaining risks. Reconcile this file's likely-file list with the actual diff, set this task to `done`, and synchronize its checkbox plus verification results in `plan.md`.
|
||||
|
||||
## Results
|
||||
|
||||
- RED evidence: before the production menu/controller wiring, the focused switcher run reported 2 expected missing-Edit failures and 16 passing tests.
|
||||
- GREEN evidence: `cd apps && pnpm --filter @kandev/web test -- components/task/task-switcher.test.tsx components/task/task-session-sidebar-edit.test.ts components/task/mobile/session-task-switcher-sheet.test.tsx` passed all 24 tests, including the unresolved-source, missing-workflow, and selection-clear guards added during PR review.
|
||||
- `cd apps/web && pnpm run typecheck` passed. The i18n checks passed from `apps/web` (`i18n:check` confirmed 1464 referenced keys and pseudo locale sync; `i18n:ratchet` reported zero added or modified violations). The original `cd apps && pnpm run i18n:check` path was corrected because the scripts are owned by `@kandev/web`.
|
||||
- Targeted ESLint across the changed task/sidebar files passed with no warnings, and `git diff --check` passed.
|
||||
- The actual implementation spans the shared switcher/context menu, shared edit controller/dialog, desktop sidebar hosts, mobile/tablet sheet host, locale files, and focused unit tests. Phone selection closes the drawer before opening the editor; tablet keeps the sheet mounted behind it. No duplicate task update/cache path was added.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
id: "02-sidebar-edit-e2e"
|
||||
title: "Prove sidebar edit parity"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-shared-sidebar-edit-flow"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/sidebar-task-edit.md"
|
||||
---
|
||||
|
||||
# Task 02: Prove sidebar edit parity
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop Playwright coverage edits a non-active sidebar task, verifies persistence and updated sidebar copy, and proves the current route/task did not change.
|
||||
- Phone Playwright coverage opens Edit from the visible task-actions menu, proves the drawer-to-dialog handoff and started-task locks, saves, and sees the updated row after reopening the drawer.
|
||||
- Tablet coverage cancels Edit and returns to the still-open task-switcher sheet; the expanded phone menu remains viewport-contained, internally scrollable, and touch-sized with Edit included.
|
||||
|
||||
## Verification
|
||||
|
||||
Use TDD: add the new scenarios first and observe their missing-Edit failure before implementing or adjusting selectors. The managed runner rebuilds the production Vite bundle and backend fixtures before running both owning projects.
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm install --frozen-lockfile)
|
||||
(cd apps/web && pnpm e2e:run --host --project chromium tests/task/sidebar-layout.spec.ts -- --workers=1)
|
||||
(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/task/sidebar-layout.spec.ts tests/task/mobile-sidebar-task-actions.spec.ts -- --workers=1)
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Confirm Playwright discovers tests in both `chromium` and `mobile-chrome`, and record the final test counts plus any screenshot or trace paths.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/task/sidebar-layout.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 01: the shared Edit menu contract, edit controller, and responsive host wiring must exist.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential after Task 01. These tests consume Task 01's menu item, dialog wiring, and responsive presentation behavior.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Every scenario in `docs/specs/tasks/sidebar-task-edit.md`.
|
||||
- Plan `E2E Tests` and `Mobile design contract` sections.
|
||||
- Existing patterns: `SessionPage.sidebar`, the visible **Task actions** button, managed `prCapture` evidence, API seeding/polling, and the current mobile menu geometry assertions.
|
||||
|
||||
## Risks
|
||||
|
||||
- Scope locators to the visible sidebar/drawer because task switchers can remain mounted in more than one responsive surface.
|
||||
- Dropdown/context menus can detach during WebSocket updates; retry the open-select sequence using the established sidebar helper pattern if required rather than increasing timeouts.
|
||||
- The E2E runner serves the production build; do not use `--no-build` after frontend changes.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report RED and GREEN evidence, exact discovered/passed test counts, production-build evidence, screenshots/traces, cleanup, blockers, and remaining risks. Reconcile the likely-file list with the actual diff, set this task to `done`, and synchronize its checkbox plus verification results in `plan.md`.
|
||||
|
||||
## Results
|
||||
|
||||
- RED evidence: the new desktop and phone scenarios initially failed because the Edit menu item was not wired; the first desktop editor attempt also exposed the existing split Start/Update control for a pending task, so the scenario now intentionally uses a started task, matching the existing editor contract. The first phone seed helper did not persist descriptions, so that scenario now uses the API create helper to verify the locked prompt value.
|
||||
- GREEN evidence: the managed Chromium sidebar suite passed 9 tests, including the context-menu and persistence scenarios. The managed mobile-chrome sidebar/action suite passed 10 tests, including phone drawer dismissal and tablet sheet retention. The final PR capture runs independently passed 2 Chromium and 2 mobile-chrome tests.
|
||||
- `cd apps/web && pnpm run build` passed before the managed E2E runs. Four fresh screenshots (desktop menu/editor, phone editor, tablet editor) were inspected, compressed with `pngquant`, and validated against the manifest; the asset directory is ignored and is not part of the source diff.
|
||||
- `git diff --check` passed. No blockers remain.
|
||||
@@ -80,6 +80,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
|
||||
| [runtime-state-publication-order](tasks/runtime-state-publication-order.md) | shipped |
|
||||
| [agent-generated-titles](tasks/agent-generated-titles.md) | approved |
|
||||
| [task-create-executor-default](tasks/task-create-executor-default.md) | approved |
|
||||
| [sidebar-task-edit](tasks/sidebar-task-edit.md) | approved |
|
||||
| [explicit-completion-signal](workflow/explicit-completion-signal/spec.md) | shipped |
|
||||
| [conditional-session-settings](workflow-session-settings/spec.md) | approved |
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
status: approved
|
||||
created: 2026-08-03
|
||||
owner: Kandev
|
||||
---
|
||||
|
||||
# Sidebar Task Editing
|
||||
|
||||
## Why
|
||||
|
||||
People working from a task detail page can rename, move, archive, or delete another task from the sidebar, but they must return to the Kanban board to open that task's full editor. The sidebar should offer the same edit capability as a Kanban card without changing task context or introducing a second editing experience.
|
||||
|
||||
## What
|
||||
|
||||
- The submenu for one non-archived sidebar task includes **Edit** in addition to the existing **Rename** action.
|
||||
- **Edit** opens the existing task edit dialog for the task whose submenu was invoked; it does not navigate to or select that task.
|
||||
- The dialog is initialized from that task's title, description, lifecycle state, primary repository, workflow, and workflow step, including when the target belongs to a different workflow than the task currently open.
|
||||
- The sidebar entry uses the same task edit behavior as the Kanban card: pending tasks expose the existing editable fields, while started tasks keep the title editable and retain the existing locks on prompt and workspace-source fields.
|
||||
- Saving uses the existing task update contract and the normal task-update state propagation so every visible sidebar instance reflects the saved values. Canceling leaves the task unchanged.
|
||||
- Edit remains a single-task action. It is not offered for a multi-task selection or for the synthetic archived-task sidebar row.
|
||||
- Desktop users reach Edit from the task row context menu. Phone and tablet users reach it from the visible task-actions control; the existing responsive menu treatment keeps action rows touch-sized.
|
||||
- On phones, choosing Edit dismisses the task-switcher drawer before opening the task editor. On tablets, the task-switcher sheet remains available behind the editor so canceling returns to the same task list.
|
||||
- The new menu label is localized and the menu item remains keyboard accessible through the existing menu primitives.
|
||||
|
||||
## API surface
|
||||
|
||||
No new API is introduced. Saving continues to use `PATCH /api/v1/tasks/:taskId` through the existing task edit dialog, with its current title, description, and repository update payload and validation behavior.
|
||||
|
||||
## Permissions
|
||||
|
||||
Edit follows the same access rules as editing from a Kanban card. The sidebar does not add a new permission or bypass backend authorization.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- If validation fails, the existing editor displays its validation state and does not submit an update.
|
||||
- If the task update request fails, the existing editor surfaces its normal failure feedback and the sidebar keeps the last confirmed task values; the sidebar entry point does not invent a different retry or rollback path.
|
||||
- If a row no longer resolves to a live task or lacks workflow metadata, the full Edit action is unavailable; other valid sidebar actions continue to work.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a non-archived task appears in the desktop sidebar, **WHEN** the user opens its submenu, **THEN** Edit appears alongside Rename and the other single-task actions.
|
||||
- **GIVEN** the sidebar target is not the currently open task, **WHEN** the user chooses Edit, **THEN** the task edit dialog opens with the target task's values and the current route and active task do not change.
|
||||
- **GIVEN** the target belongs to another workflow, **WHEN** the edit dialog opens, **THEN** it uses the target workflow and step options rather than the currently selected Kanban workflow.
|
||||
- **GIVEN** a task has already started, **WHEN** Edit is opened from the sidebar, **THEN** the title remains editable while the prompt and workspace-source controls retain the existing started-task locks.
|
||||
- **GIVEN** the user changes a valid field and saves, **WHEN** the update succeeds, **THEN** the editor closes and the saved value appears in the sidebar and in the persisted task.
|
||||
- **GIVEN** the editor contains unsaved changes, **WHEN** the user cancels, **THEN** the task and sidebar row retain their prior values.
|
||||
- **GIVEN** a phone user opens the task switcher and a task's visible actions menu, **WHEN** the user chooses Edit, **THEN** the task switcher closes and the same edit dialog opens with touch-accessible controls.
|
||||
- **GIVEN** a tablet user opens Edit from the task-switcher sheet, **WHEN** the user cancels the editor, **THEN** the sheet is still open at the task list.
|
||||
- **GIVEN** several tasks are selected or the row represents an archived task, **WHEN** its task menu opens, **THEN** the single-task Edit action is absent.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Replacing or removing the lightweight Rename action.
|
||||
- Adding bulk editing or editing archived tasks.
|
||||
- Changing which task fields are editable at each lifecycle state.
|
||||
- Adding a new backend endpoint, persistence model, permission, or feature flag.
|
||||
- Changing Office's separate inline task-property editing surface.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
See [`../../plans/sidebar-task-edit/plan.md`](../../plans/sidebar-task-edit/plan.md).
|
||||
Reference in New Issue
Block a user