fix(kanban): compact multiple workflow swimlane heights (#3604)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import { memo, useCallback, useMemo, type Ref } from "react";
|
||||
import { useColumnNaturalHeight } from "@/hooks/domains/kanban/use-column-natural-height";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { Task, type KanbanPresentation } from "./kanban-card";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
@@ -26,6 +27,7 @@ export interface WorkflowStep {
|
||||
}
|
||||
|
||||
interface KanbanColumnProps {
|
||||
onNaturalHeightChange?: (stepId: string, height: number) => void;
|
||||
step: WorkflowStep;
|
||||
tasks: Task[];
|
||||
presentation?: KanbanPresentation;
|
||||
@@ -48,7 +50,15 @@ interface KanbanColumnProps {
|
||||
externalLinkAvailability: KanbanExternalLinkAvailability;
|
||||
}
|
||||
|
||||
function ColumnHeader({ step, tasks }: { step: WorkflowStep; tasks: Task[] }) {
|
||||
function ColumnHeader({
|
||||
step,
|
||||
tasks,
|
||||
headerRef,
|
||||
}: {
|
||||
step: WorkflowStep;
|
||||
tasks: Task[];
|
||||
headerRef: Ref<HTMLDivElement>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const admittedTaskCount = countAdmittedTasks(tasks);
|
||||
const overWipLimit = isOverWipLimit(admittedTaskCount, step.wip_limit);
|
||||
@@ -56,7 +66,7 @@ function ColumnHeader({ step, tasks }: { step: WorkflowStep; tasks: Task[] }) {
|
||||
const queuedCount = partitionWipTasks(tasks, step.id).queued.length;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between pb-2 mb-3 px-1">
|
||||
<div ref={headerRef} className="flex shrink-0 items-center justify-between pb-2 mb-3 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("w-2 h-2 rounded-full", step.color)} />
|
||||
<h2 className="font-semibold text-sm">{step.title}</h2>
|
||||
@@ -102,6 +112,7 @@ function externalLinkAvailabilityEqual(
|
||||
|
||||
function columnCallbacksEqual(previous: KanbanColumnProps, next: KanbanColumnProps): boolean {
|
||||
return (
|
||||
previous.onNaturalHeightChange === next.onNaturalHeightChange &&
|
||||
previous.onPreviewTask === next.onPreviewTask &&
|
||||
previous.onOpenTask === next.onOpenTask &&
|
||||
previous.onEditTask === next.onEditTask &&
|
||||
@@ -137,6 +148,7 @@ function kanbanColumnPropsEqual(previous: KanbanColumnProps, next: KanbanColumnP
|
||||
}
|
||||
|
||||
export const KanbanColumn = memo(function KanbanColumn({
|
||||
onNaturalHeightChange,
|
||||
step,
|
||||
tasks,
|
||||
presentation = "desktop",
|
||||
@@ -160,6 +172,17 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: step.id,
|
||||
});
|
||||
const { columnRef, headerRef, onContentHeightChange } = useColumnNaturalHeight(
|
||||
step.id,
|
||||
onNaturalHeightChange,
|
||||
);
|
||||
const setColumnRef = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
columnRef.current = element;
|
||||
setNodeRef(element);
|
||||
},
|
||||
[columnRef, setNodeRef],
|
||||
);
|
||||
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||
|
||||
// Access repositories from store to pass repository names to cards
|
||||
@@ -176,7 +199,7 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
ref={setColumnRef}
|
||||
data-testid={`kanban-column-${step.id}`}
|
||||
className={cn(
|
||||
"flex flex-col flex-1 h-full min-w-0 px-3 py-2 sm:min-h-[200px]",
|
||||
@@ -185,9 +208,10 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
)}
|
||||
>
|
||||
{/* Column Header */}
|
||||
{!hideHeader && <ColumnHeader step={step} tasks={tasks} />}
|
||||
{!hideHeader && <ColumnHeader step={step} tasks={tasks} headerRef={headerRef} />}
|
||||
|
||||
<VirtualizedColumnTaskList
|
||||
onContentHeightChange={onContentHeightChange}
|
||||
orderedTasks={orderedTasks}
|
||||
queuedStartIndex={admitted.length}
|
||||
queuedCount={queued.length}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { WorkflowStep } from "@/components/kanban-column";
|
||||
import { getKanbanColumnGridTemplate, KANBAN_COLUMN_MIN_PX } from "./kanban-grid-template";
|
||||
|
||||
type AdaptiveDesktopKanbanProps = {
|
||||
columnHeight?: string;
|
||||
steps: WorkflowStep[];
|
||||
isDragging?: boolean;
|
||||
renderColumn: (step: WorkflowStep) => ReactNode;
|
||||
@@ -34,6 +35,7 @@ type PanStart = {
|
||||
};
|
||||
|
||||
export function AdaptiveDesktopKanban({
|
||||
columnHeight,
|
||||
steps,
|
||||
isDragging = false,
|
||||
renderColumn,
|
||||
@@ -81,7 +83,11 @@ export function AdaptiveDesktopKanban({
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="desktop-kanban-layout" className="h-full min-h-0 min-w-0">
|
||||
<div
|
||||
data-testid="desktop-kanban-layout"
|
||||
className="h-full min-h-0 min-w-0"
|
||||
style={{ height: columnHeight ? "auto" : undefined }}
|
||||
>
|
||||
<div
|
||||
ref={scrollWindowRef}
|
||||
data-testid="desktop-kanban-scroll-window"
|
||||
@@ -89,6 +95,7 @@ export function AdaptiveDesktopKanban({
|
||||
isPanCandidate ? "cursor-grabbing" : ""
|
||||
} ${isPanning ? "select-none" : ""} ${isDragging ? "scrollbar-hide" : ""}`}
|
||||
style={{
|
||||
height: columnHeight ? "auto" : undefined,
|
||||
containerType: "inline-size",
|
||||
scrollSnapType: isPanning ? "none" : undefined,
|
||||
}}
|
||||
@@ -100,6 +107,7 @@ export function AdaptiveDesktopKanban({
|
||||
<div
|
||||
className="flex h-full min-h-0"
|
||||
style={{
|
||||
height: columnHeight,
|
||||
width: `calc(max(100cqw, ${steps.length * KANBAN_COLUMN_MIN_PX}px) + ${
|
||||
isDragging ? KANBAN_DRAG_END_RESERVE : "0px"
|
||||
})`,
|
||||
@@ -123,20 +131,24 @@ export function AdaptiveDesktopKanban({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isDragging && (
|
||||
<div
|
||||
data-testid="desktop-kanban-drag-end-reserve"
|
||||
aria-hidden="true"
|
||||
className="h-full flex-none"
|
||||
style={{ width: KANBAN_DRAG_END_RESERVE }}
|
||||
/>
|
||||
)}
|
||||
{isDragging && <DragEndReserve />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DragEndReserve() {
|
||||
return (
|
||||
<div
|
||||
data-testid="desktop-kanban-drag-end-reserve"
|
||||
aria-hidden="true"
|
||||
className="h-full flex-none"
|
||||
style={{ width: KANBAN_DRAG_END_RESERVE }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function isInteractiveTarget(target: EventTarget | null, boundary: HTMLElement): boolean {
|
||||
if (!(target instanceof Element)) return true;
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ type WorkflowItemProps = {
|
||||
isMultiSelectMode?: boolean;
|
||||
onToggleMultiSelect?: () => void;
|
||||
fillHeight?: boolean;
|
||||
compactHeight?: boolean;
|
||||
mobileWorkflowNavigation?: MobileWorkflowNavigation;
|
||||
onToggleStepVisibility: (workflowId: string, stepId: string) => void;
|
||||
onToggleAutoHideEmpty: (workflowId: string) => void;
|
||||
@@ -359,6 +360,7 @@ type WorkflowItemsProps = {
|
||||
ViewComponent: ComponentType<ViewContentProps>;
|
||||
hideHeaders: boolean;
|
||||
fillHeight: boolean;
|
||||
compactHeight: boolean;
|
||||
isMobileKanban: boolean;
|
||||
onToggleStepVisibility: (workflowId: string, stepId: string) => void;
|
||||
onToggleAutoHideEmpty: (workflowId: string) => void;
|
||||
@@ -378,6 +380,7 @@ function WorkflowItems({
|
||||
ViewComponent,
|
||||
hideHeaders,
|
||||
fillHeight,
|
||||
compactHeight,
|
||||
isMobileKanban,
|
||||
onToggleStepVisibility,
|
||||
onToggleAutoHideEmpty,
|
||||
@@ -400,6 +403,7 @@ function WorkflowItems({
|
||||
ViewComponent={ViewComponent}
|
||||
hideHeader={hideHeaders}
|
||||
fillHeight={fillHeight && !collapsed}
|
||||
compactHeight={compactHeight}
|
||||
isSortable={canSortWorkflows && !isMobileKanban}
|
||||
isCollapsed={collapsed}
|
||||
toggleCollapse={toggleCollapse}
|
||||
@@ -580,7 +584,8 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) {
|
||||
hideHeaders={hideHeaders}
|
||||
onToggleStepVisibility={onToggleStepVisibility}
|
||||
onToggleAutoHideEmpty={onToggleAutoHideEmpty}
|
||||
fillHeight={view.id === "kanban"}
|
||||
fillHeight={view.id === "kanban" && renderedWorkflows.length === 1}
|
||||
compactHeight={view.id === "kanban" && !isMobile && renderedWorkflows.length > 1}
|
||||
isMobileKanban={isMobileKanban}
|
||||
canSortWorkflows={canSortWorkflows}
|
||||
isCollapsed={isCollapsed}
|
||||
|
||||
@@ -49,11 +49,13 @@ import { cn } from "@kandev/ui/lib/utils";
|
||||
import { useKanbanExternalLinkAvailability } from "@/components/kanban-external-link-availability";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { useCompactSwimlaneHeight } from "@/hooks/domains/kanban/use-compact-swimlane-height";
|
||||
|
||||
const TASK_POINTER_SENSOR_OPTIONS = { activationConstraint: { distance: 8 } };
|
||||
const TASK_TOUCH_SENSOR_OPTIONS = { activationConstraint: { delay: 250, tolerance: 5 } };
|
||||
|
||||
export type SwimlaneKanbanContentProps = {
|
||||
compactHeight?: boolean;
|
||||
workflowId: string;
|
||||
steps: WorkflowStep[];
|
||||
moveTargetSteps: WorkflowStep[];
|
||||
@@ -342,6 +344,8 @@ function MobileKanbanLayout({
|
||||
}
|
||||
|
||||
function TabletKanbanLayout({
|
||||
columnHeight,
|
||||
onNaturalHeightChange,
|
||||
steps,
|
||||
moveTargetSteps,
|
||||
tasks,
|
||||
@@ -367,6 +371,7 @@ function TabletKanbanLayout({
|
||||
<div
|
||||
className="flex h-full min-h-0 gap-2 overflow-x-auto snap-x snap-mandatory scrollbar-hide"
|
||||
data-testid="tablet-kanban-layout"
|
||||
style={{ height: columnHeight }}
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
@@ -378,6 +383,7 @@ function TabletKanbanLayout({
|
||||
)}
|
||||
>
|
||||
<KanbanColumn
|
||||
onNaturalHeightChange={onNaturalHeightChange}
|
||||
step={step}
|
||||
tasks={getTasksForStep(step.id)}
|
||||
presentation="desktop"
|
||||
@@ -404,6 +410,8 @@ function TabletKanbanLayout({
|
||||
}
|
||||
|
||||
function DesktopKanbanLayout({
|
||||
columnHeight,
|
||||
onNaturalHeightChange,
|
||||
steps,
|
||||
moveTargetSteps,
|
||||
tasks,
|
||||
@@ -428,11 +436,13 @@ function DesktopKanbanLayout({
|
||||
|
||||
return (
|
||||
<AdaptiveDesktopKanban
|
||||
columnHeight={columnHeight}
|
||||
steps={steps}
|
||||
isDragging={isDragging}
|
||||
renderColumn={(step) => (
|
||||
<div className={cn("h-full", temporaryStepIds.has(step.id) && "opacity-70")}>
|
||||
<KanbanColumn
|
||||
onNaturalHeightChange={onNaturalHeightChange}
|
||||
step={step}
|
||||
tasks={getTasksForStep(step.id)}
|
||||
presentation="desktop"
|
||||
@@ -497,6 +507,7 @@ function renderKanbanLayout({
|
||||
}
|
||||
|
||||
export function SwimlaneKanbanContent({
|
||||
compactHeight = false,
|
||||
workflowId,
|
||||
steps,
|
||||
moveTargetSteps,
|
||||
@@ -535,7 +546,13 @@ export function SwimlaneKanbanContent({
|
||||
moveTargetSteps,
|
||||
isMobile,
|
||||
});
|
||||
const sizing = useCompactSwimlaneHeight(
|
||||
compactHeight && !isMobile,
|
||||
displaySteps,
|
||||
!!drag.activeTask,
|
||||
);
|
||||
const sharedProps = useSharedKanbanLayoutProps({
|
||||
...sizing,
|
||||
drag,
|
||||
moveTargetSteps,
|
||||
displayTasks,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useMemo, useRef } from "react";
|
||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import type { WorkflowStep } from "../kanban-column";
|
||||
import type { KanbanExternalLinkAvailability } from "../kanban-external-link-availability";
|
||||
|
||||
type VirtualizedColumnTaskListProps = {
|
||||
onContentHeightChange?: (height: number, element: HTMLDivElement) => void;
|
||||
orderedTasks: Task[];
|
||||
queuedStartIndex: number;
|
||||
queuedCount: number;
|
||||
@@ -132,6 +133,7 @@ function useStableExternalLinkAvailability(
|
||||
}
|
||||
|
||||
export function VirtualizedColumnTaskList({
|
||||
onContentHeightChange,
|
||||
orderedTasks,
|
||||
queuedStartIndex,
|
||||
queuedCount,
|
||||
@@ -167,6 +169,10 @@ export function VirtualizedColumnTaskList({
|
||||
getItemKey: (index) => orderedTasks[index]?.id ?? index,
|
||||
overscan: 5,
|
||||
});
|
||||
const totalHeight = virtualizer.getTotalSize();
|
||||
useLayoutEffect(() => {
|
||||
if (scrollRef.current) onContentHeightChange?.(totalHeight, scrollRef.current);
|
||||
}, [onContentHeightChange, totalHeight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,64 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
|
||||
import { missingGitHealth } from "./health-fixtures";
|
||||
import {
|
||||
withHeightWorkflows,
|
||||
expectCompactColumn,
|
||||
expectNoDocumentOverflow,
|
||||
} from "./swimlane-height-helpers";
|
||||
|
||||
test("multiple workflows keep a full-height focused phone board across the tablet boundary", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
// @covers AC-UI-ADAPTIVE-KANBAN-002.6
|
||||
await withHeightWorkflows(apiClient, seedData, async (second) => {
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
const phoneSize = testPage.viewportSize()!;
|
||||
for (const width of [phoneSize.width, 767]) {
|
||||
await testPage.setViewportSize({ width, height: phoneSize.height });
|
||||
await expect(mobile.mobileKanbanLayout()).toHaveCount(1);
|
||||
await expect(mobile.mobileKanbanLayout()).toHaveCSS("display", "flex");
|
||||
await expect(mobile.mobileKanbanLayout()).toHaveCSS("overflow-y", "hidden");
|
||||
await expect
|
||||
.poll(async () => (await mobile.mobileKanbanLayout().boundingBox())!.height)
|
||||
.toBeGreaterThan(400);
|
||||
const boardBox = (await mobile.swimlaneContainer.boundingBox())!;
|
||||
const layoutBox = (await mobile.mobileKanbanLayout().boundingBox())!;
|
||||
expect(layoutBox.y + layoutBox.height).toBeLessThanOrEqual(boardBox.y + boardBox.height);
|
||||
await expectNoDocumentOverflow(testPage);
|
||||
}
|
||||
await testPage.setViewportSize(phoneSize);
|
||||
await mobile.boardNavigator.tap();
|
||||
await mobile.workflowItem(second.workflowId).tap();
|
||||
const drawer = testPage.getByTestId("mobile-board-navigator-drawer");
|
||||
await expect(drawer).toBeVisible();
|
||||
await drawer.getByTestId(`column-tab-${second.stepIndex}`).tap();
|
||||
await expect(drawer).not.toBeVisible();
|
||||
await expect(mobile.taskCard(second.taskId)).toBeInViewport();
|
||||
await testInfo.attach("focused-phone", {
|
||||
body: await testPage.screenshot({ path: testInfo.outputPath("focused-phone.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await mobile.taskCard(second.taskId).tap();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${second.taskId}`));
|
||||
await apiClient.saveUserSettings({ workflow_filter_id: "" });
|
||||
await testPage.setViewportSize({ width: 768, height: phoneSize.height });
|
||||
await testPage.goto("/");
|
||||
await expect(testPage.getByTestId("mobile-kanban-layout")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("tablet-kanban-layout")).toHaveCount(2);
|
||||
await expectCompactColumn(testPage.getByTestId(`kanban-column-${second.stepId}`));
|
||||
await expectNoDocumentOverflow(testPage);
|
||||
await testPage.setViewportSize(phoneSize);
|
||||
await expect(mobile.mobileKanbanLayout()).toHaveCount(1);
|
||||
await expect
|
||||
.poll(async () => (await mobile.mobileKanbanLayout().boundingBox())!.height)
|
||||
.toBeGreaterThan(400);
|
||||
expect((await apiClient.getUserSettings()).settings.kanban_view_mode ?? "").toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile kanban view", () => {
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
|
||||
export async function withHeightWorkflows(
|
||||
api: ApiClient,
|
||||
seed: SeedData,
|
||||
run: (second: {
|
||||
workflowId: string;
|
||||
stepId: string;
|
||||
taskId: string;
|
||||
seedWorkflowTaskId: string;
|
||||
stepIndex: number;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const { settings } = await api.getUserSettings();
|
||||
const workflow = await api.createWorkflow(seed.workspaceId, "Compact second", "simple");
|
||||
try {
|
||||
const { steps } = await api.listWorkflowSteps(workflow.id);
|
||||
const step = steps.find((item) => item.is_start_step) ?? steps[0];
|
||||
const first = await api.createTask(seed.workspaceId, "Sparse first card", {
|
||||
workflow_id: seed.workflowId,
|
||||
workflow_step_id: seed.startStepId,
|
||||
});
|
||||
const second = await api.createTask(seed.workspaceId, "Sparse second card", {
|
||||
workflow_id: workflow.id,
|
||||
workflow_step_id: step.id,
|
||||
});
|
||||
await api.saveUserSettings({ workflow_filter_id: "", repository_ids: [] });
|
||||
await run({
|
||||
workflowId: workflow.id,
|
||||
stepId: step.id,
|
||||
taskId: second.id,
|
||||
seedWorkflowTaskId: first.id,
|
||||
stepIndex: steps.findIndex((item) => item.id === step.id),
|
||||
});
|
||||
} finally {
|
||||
await api.deleteWorkflow(workflow.id);
|
||||
const restored = await api.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
workflow_filter_id: settings.workflow_filter_id ?? "",
|
||||
repository_ids: settings.repository_ids ?? [],
|
||||
enable_preview_on_click: settings.enable_preview_on_click ?? false,
|
||||
kanban_hidden_step_ids: settings.kanban_hidden_step_ids ?? {},
|
||||
workflow_ids_with_auto_hide_empty_steps:
|
||||
settings.workflow_ids_with_auto_hide_empty_steps ?? [],
|
||||
kanban_view_mode: settings.kanban_view_mode ?? "",
|
||||
});
|
||||
expect.soft(restored.ok, "Restore user settings after swimlane height test").toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectCompactColumn(column: Locator) {
|
||||
await expect
|
||||
.poll(async () => (await column.boundingBox())?.height ?? 0)
|
||||
.toBeGreaterThanOrEqual(200);
|
||||
await expect.poll(async () => (await column.boundingBox())!.height).toBeLessThanOrEqual(400);
|
||||
}
|
||||
|
||||
export async function selectHeightWorkflow(page: Page, name: string) {
|
||||
await page.getByTestId("display-button").click();
|
||||
await page.getByTestId("display-workflow-filter").click();
|
||||
await page.getByRole("listbox").getByRole("option", { name, exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function expectNoDocumentOverflow(page: Page) {
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(0);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import {
|
||||
withHeightWorkflows,
|
||||
expectCompactColumn,
|
||||
selectHeightWorkflow,
|
||||
expectNoDocumentOverflow,
|
||||
} from "./swimlane-height-helpers";
|
||||
import {
|
||||
seedLargeColumnTasks,
|
||||
expectBoundedMountedCards,
|
||||
scrollColumnToBottom,
|
||||
taskCards,
|
||||
} from "./large-column-virtualization-helpers";
|
||||
|
||||
test("two sparse workflows fit without scrolling the board", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
// @covers AC-UI-ADAPTIVE-KANBAN-002.1, AC-UI-ADAPTIVE-KANBAN-002.2
|
||||
await testPage.setViewportSize({ width: 1440, height: 900 });
|
||||
const { settings } = await apiClient.getUserSettings();
|
||||
const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Compact second", "simple");
|
||||
try {
|
||||
const { steps } = await apiClient.listWorkflowSteps(workflow.id);
|
||||
const step = steps.find((item) => item.is_start_step) ?? steps[0];
|
||||
await apiClient.createTask(seedData.workspaceId, "Sparse first card", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const second = await apiClient.createTask(seedData.workspaceId, "Sparse second card", {
|
||||
workflow_id: workflow.id,
|
||||
workflow_step_id: step.id,
|
||||
});
|
||||
await apiClient.saveUserSettings({ workflow_filter_id: "", repository_ids: [] });
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await expect(kanban.taskCard(second.id)).toBeVisible();
|
||||
const board = testPage.getByTestId("swimlane-container");
|
||||
await testInfo.attach("sparse-lane-geometry", {
|
||||
body: JSON.stringify({
|
||||
board: await board.boundingBox(),
|
||||
headers: await testPage
|
||||
.getByTestId("swimlane-header")
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getBoundingClientRect().toJSON()),
|
||||
),
|
||||
secondCard: await kanban.taskCard(second.id).boundingBox(),
|
||||
scrollTop: await board.evaluate((element) => element.scrollTop),
|
||||
}),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await testInfo.attach("sparse-workflows", {
|
||||
body: await testPage.screenshot({ path: testInfo.outputPath("sparse-workflows.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await board.boundingBox();
|
||||
const card = await kanban.taskCard(second.id).boundingBox();
|
||||
return card!.y + card!.height <= bounds!.y + bounds!.height;
|
||||
})
|
||||
.toBe(true);
|
||||
expect(await board.evaluate((element) => element.scrollTop)).toBe(0);
|
||||
const column = kanban.columnByStepId(seedData.startStepId);
|
||||
const height = (await column.boundingBox())!.height;
|
||||
expect(height).toBeGreaterThanOrEqual(200);
|
||||
expect(height).toBeLessThanOrEqual(400);
|
||||
} finally {
|
||||
await apiClient.deleteWorkflow(workflow.id);
|
||||
await apiClient.saveUserSettings({
|
||||
workflow_filter_id: settings.workflow_filter_id ?? "",
|
||||
repository_ids: (settings.repository_ids as string[]) ?? [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("compact lanes survive collapse, workflow filters and preview resizing", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
// @covers AC-UI-ADAPTIVE-KANBAN-002.3, AC-UI-ADAPTIVE-KANBAN-002.4, AC-UI-ADAPTIVE-KANBAN-002.5
|
||||
await testPage.setViewportSize({ width: 1440, height: 900 });
|
||||
await withHeightWorkflows(apiClient, seedData, async (second) => {
|
||||
await apiClient.saveUserSettings({ enable_preview_on_click: true });
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const firstColumn = kanban.columnByStepId(seedData.startStepId);
|
||||
const secondColumn = kanban.columnByStepId(second.stepId);
|
||||
await expectCompactColumn(firstColumn);
|
||||
const toggle = testPage
|
||||
.getByTestId("swimlane-header")
|
||||
.getByRole("button", { name: "Compact second 1", exact: true });
|
||||
await toggle.click();
|
||||
await expect(secondColumn).toHaveCount(0);
|
||||
await expectCompactColumn(firstColumn);
|
||||
await toggle.click();
|
||||
await expectCompactColumn(secondColumn);
|
||||
await selectHeightWorkflow(testPage, "E2E Workflow");
|
||||
await expect(secondColumn).toHaveCount(0);
|
||||
await expect.poll(async () => (await firstColumn.boundingBox())!.height).toBeGreaterThan(400);
|
||||
await testPage.reload();
|
||||
await expect.poll(async () => (await firstColumn.boundingBox())!.height).toBeGreaterThan(400);
|
||||
await selectHeightWorkflow(testPage, "All Workflows");
|
||||
await expectCompactColumn(secondColumn);
|
||||
await kanban.taskCard(second.seedWorkflowTaskId).click();
|
||||
await expect(testPage.getByTestId("task-preview-panel")).toBeVisible();
|
||||
await expectCompactColumn(firstColumn);
|
||||
await expectCompactColumn(secondColumn);
|
||||
await expectNoDocumentOverflow(testPage);
|
||||
await testInfo.attach("compact-preview", {
|
||||
body: await testPage.screenshot({ path: testInfo.outputPath("compact-preview.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expectCompactColumn(firstColumn);
|
||||
});
|
||||
});
|
||||
|
||||
test("dense and sparse workflows size independently and keep the final task reachable", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
// @covers AC-UI-ADAPTIVE-KANBAN-002.2, AC-UI-ADAPTIVE-KANBAN-002.7, AC-UI-ADAPTIVE-KANBAN-001.7
|
||||
test.setTimeout(120_000);
|
||||
await testPage.setViewportSize({ width: 1440, height: 900 });
|
||||
await withHeightWorkflows(apiClient, seedData, async (second) => {
|
||||
await seedLargeColumnTasks(apiClient, seedData, "Dense height", 439);
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const dense = kanban.columnByStepId(seedData.startStepId);
|
||||
const sparse = kanban.columnByStepId(second.stepId);
|
||||
await expect.poll(async () => (await dense.boundingBox())?.height).toBe(400);
|
||||
await expect.poll(async () => (await sparse.boundingBox())?.height).toBe(200);
|
||||
await expectBoundedMountedCards(dense);
|
||||
await scrollColumnToBottom(dense.getByTestId("kanban-column-scroll"));
|
||||
const oldest = dense.getByTestId(`task-card-${second.seedWorkflowTaskId}`);
|
||||
await expect(oldest).toBeInViewport();
|
||||
await expectBoundedMountedCards(dense);
|
||||
expect(await taskCards(dense).count()).toBeLessThan(50);
|
||||
await expectNoDocumentOverflow(testPage);
|
||||
await oldest.click();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${second.seedWorkflowTaskId}`));
|
||||
});
|
||||
});
|
||||
|
||||
test("live content grows the lane after drag cancellation and shrinks after removal", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
// @covers AC-UI-ADAPTIVE-KANBAN-002.5, AC-UI-ADAPTIVE-KANBAN-002.7
|
||||
await testPage.setViewportSize({ width: 1440, height: 900 });
|
||||
await withHeightWorkflows(apiClient, seedData, async (second) => {
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const column = kanban.columnByStepId(second.stepId);
|
||||
await expect.poll(async () => (await column.boundingBox())?.height).toBe(200);
|
||||
const cardBox = (await kanban.taskCard(second.taskId).boundingBox())!;
|
||||
await testPage.mouse.move(cardBox.x + 80, cardBox.y + 30);
|
||||
await testPage.mouse.down();
|
||||
await testPage.mouse.move(cardBox.x + 100, cardBox.y + 30, { steps: 4 });
|
||||
await expect(testPage.getByTestId("desktop-kanban-drag-end-reserve")).toHaveCount(1);
|
||||
const added = await Promise.all(
|
||||
["Second", "Third", "Fourth", "Fifth"].map((title) =>
|
||||
apiClient.createTask(seedData.workspaceId, title, {
|
||||
workflow_id: second.workflowId,
|
||||
workflow_step_id: second.stepId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (const task of added) await expect(kanban.taskCard(task.id)).toBeAttached();
|
||||
expect((await column.boundingBox())!.height).toBe(200);
|
||||
await testPage.keyboard.press("Escape");
|
||||
await testPage.mouse.up();
|
||||
await expect.poll(async () => (await column.boundingBox())!.height).toBeGreaterThan(200);
|
||||
expect((await column.boundingBox())!.height).toBeLessThan(400);
|
||||
const lane = testPage.getByTestId("desktop-kanban-lane-grid").filter({ has: column });
|
||||
const heights = await lane
|
||||
.locator("[data-kanban-step-id]")
|
||||
.evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().height));
|
||||
expect(Math.max(...heights) - Math.min(...heights)).toBeLessThanOrEqual(1);
|
||||
const scroll = column.getByTestId("kanban-column-scroll");
|
||||
expect(
|
||||
await scroll.evaluate((element) => element.scrollHeight - element.clientHeight),
|
||||
).toBeLessThanOrEqual(1);
|
||||
for (const task of added) await apiClient.deleteTask(task.id);
|
||||
await expect.poll(async () => (await column.boundingBox())!.height).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test("a retained empty workflow keeps compact recovery controls", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
// @covers AC-UI-ADAPTIVE-KANBAN-002.5
|
||||
await withHeightWorkflows(apiClient, seedData, async (second) => {
|
||||
await apiClient.deleteTask(second.taskId);
|
||||
await apiClient.saveUserSettings({
|
||||
kanban_hidden_step_ids: { [second.workflowId]: [second.stepId] },
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await expectCompactColumn(kanban.columnByStepId(seedData.startStepId));
|
||||
await testPage.getByTestId(`columns-menu-${second.workflowId}`).click();
|
||||
await testPage.getByTestId(`columns-menu-step-${second.stepId}`).click();
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(testPage.getByTestId(`columns-menu-${second.workflowId}`)).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () => (await kanban.columnByStepId(seedData.startStepId).boundingBox())!.height)
|
||||
.toBeGreaterThan(400);
|
||||
});
|
||||
});
|
||||
|
||||
test("tablet workflows retain two-column snapping inside compact lanes", async ({
|
||||
tabletTestPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
// @covers AC-UI-ADAPTIVE-KANBAN-002.1, AC-UI-ADAPTIVE-KANBAN-002.7
|
||||
await withHeightWorkflows(apiClient, seedData, async (second) => {
|
||||
const kanban = new KanbanPage(tabletTestPage);
|
||||
await kanban.goto();
|
||||
await expect(tabletTestPage.getByTestId("tablet-kanban-layout")).toHaveCount(2);
|
||||
await expectCompactColumn(kanban.columnByStepId(seedData.startStepId));
|
||||
await expectCompactColumn(kanban.columnByStepId(second.stepId));
|
||||
await expect(kanban.taskCard(second.taskId)).toBeInViewport();
|
||||
await expectNoDocumentOverflow(tabletTestPage);
|
||||
await testInfo.attach("compact-tablet", {
|
||||
body: await tabletTestPage.screenshot({ path: testInfo.outputPath("compact-tablet.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { useColumnNaturalHeight } from "./use-column-natural-height";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("reports logical rows plus chrome, remeasures chrome and disconnects outside compact mode", () => {
|
||||
const disconnect = vi.fn();
|
||||
let resize = () => {};
|
||||
const observer = vi.fn(
|
||||
class {
|
||||
constructor(callback: () => void) {
|
||||
resize = callback;
|
||||
}
|
||||
observe = vi.fn();
|
||||
disconnect = disconnect;
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("ResizeObserver", observer);
|
||||
const report = vi.fn();
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ enabled }) => useColumnNaturalHeight("step", enabled ? report : undefined),
|
||||
{ initialProps: { enabled: false } },
|
||||
);
|
||||
expect(observer).not.toHaveBeenCalled();
|
||||
expect(result.current.onContentHeightChange).toBeUndefined();
|
||||
const column = document.createElement("div");
|
||||
column.style.padding = "8px";
|
||||
column.style.height = "400px";
|
||||
const header = document.createElement("div");
|
||||
header.style.marginBottom = "12px";
|
||||
let headerHeight = 28;
|
||||
vi.spyOn(header, "getBoundingClientRect").mockImplementation(
|
||||
() => ({ height: headerHeight }) as DOMRect,
|
||||
);
|
||||
const scroll = document.createElement("div");
|
||||
scroll.style.paddingTop = "4px";
|
||||
scroll.style.paddingBottom = "0px";
|
||||
column.append(header, scroll);
|
||||
document.body.append(column);
|
||||
result.current.columnRef.current = column;
|
||||
result.current.headerRef.current = header;
|
||||
rerender({ enabled: true });
|
||||
act(() => result.current.onContentHeightChange!(190, scroll));
|
||||
expect(report).toHaveBeenLastCalledWith("step", 250);
|
||||
headerHeight = 40;
|
||||
act(() => resize());
|
||||
expect(report).toHaveBeenLastCalledWith("step", 262);
|
||||
act(() => result.current.onContentHeightChange!(90, scroll));
|
||||
expect(report).toHaveBeenLastCalledWith("step", 162);
|
||||
rerender({ enabled: false });
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
rerender({ enabled: true });
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(2);
|
||||
column.remove();
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
|
||||
export function useColumnNaturalHeight(
|
||||
stepId: string,
|
||||
onNaturalHeightChange?: (stepId: string, height: number) => void,
|
||||
) {
|
||||
const columnRef = useRef<HTMLDivElement | null>(null);
|
||||
const headerRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<{ height: number; element: HTMLDivElement } | null>(null);
|
||||
const report = useCallback(() => {
|
||||
const column = columnRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!column || !content || !onNaturalHeightChange) return;
|
||||
const columnStyle = getComputedStyle(column);
|
||||
const scrollStyle = getComputedStyle(content.element);
|
||||
const header = headerRef.current;
|
||||
const headerHeight = header
|
||||
? header.getBoundingClientRect().height + parseFloat(getComputedStyle(header).marginBottom)
|
||||
: 0;
|
||||
const padding = [columnStyle, scrollStyle].reduce(
|
||||
(total, style) => total + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom),
|
||||
0,
|
||||
);
|
||||
onNaturalHeightChange(stepId, Math.ceil(content.height + headerHeight + padding));
|
||||
}, [onNaturalHeightChange, stepId]);
|
||||
const onContentHeightChange = useCallback(
|
||||
(height: number, element: HTMLDivElement) => {
|
||||
contentRef.current = { height, element };
|
||||
report();
|
||||
},
|
||||
[report],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (!onNaturalHeightChange) return;
|
||||
const observer = new ResizeObserver(report);
|
||||
if (columnRef.current) observer.observe(columnRef.current);
|
||||
if (headerRef.current) observer.observe(headerRef.current);
|
||||
report();
|
||||
return () => observer.disconnect();
|
||||
}, [onNaturalHeightChange, report]);
|
||||
return {
|
||||
columnRef,
|
||||
headerRef,
|
||||
onContentHeightChange: onNaturalHeightChange ? onContentHeightChange : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useCompactSwimlaneHeight } from "./use-compact-swimlane-height";
|
||||
|
||||
const steps = ["a", "b"].map((id) => ({ id, title: id, color: "" }));
|
||||
|
||||
describe("compact swimlane sizing", () => {
|
||||
// @covers AC-UI-ADAPTIVE-KANBAN-002.1, AC-UI-ADAPTIVE-KANBAN-002.5
|
||||
it("uses the tallest current column and discards removed measurements", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ visible }) => useCompactSwimlaneHeight(true, visible, false),
|
||||
{ initialProps: { visible: steps } },
|
||||
);
|
||||
expect(result.current.columnHeight).toBe("clamp(12.5rem, 0px, 25rem)");
|
||||
const report = result.current.onNaturalHeightChange!;
|
||||
act(() => {
|
||||
report("a", 270);
|
||||
report("b", 600);
|
||||
});
|
||||
expect(result.current.columnHeight).toBe("clamp(12.5rem, 600px, 25rem)");
|
||||
expect(result.current.onNaturalHeightChange).toBe(report);
|
||||
rerender({ visible: [steps[0]] });
|
||||
expect(result.current.columnHeight).toBe("clamp(12.5rem, 270px, 25rem)");
|
||||
act(() => report("b", 600));
|
||||
rerender({ visible: steps });
|
||||
expect(result.current.columnHeight).toBe("clamp(12.5rem, 270px, 25rem)");
|
||||
act(() => report("a", 220));
|
||||
expect(result.current.columnHeight).toBe("clamp(12.5rem, 220px, 25rem)");
|
||||
});
|
||||
|
||||
it("freezes during drag and applies pending heights after cancellation", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ dragging }) => useCompactSwimlaneHeight(true, steps, dragging),
|
||||
{ initialProps: { dragging: false } },
|
||||
);
|
||||
act(() => result.current.onNaturalHeightChange!("a", 270));
|
||||
rerender({ dragging: true });
|
||||
act(() => result.current.onNaturalHeightChange!("a", 350));
|
||||
expect(result.current.columnHeight).toBe("clamp(12.5rem, 270px, 25rem)");
|
||||
rerender({ dragging: false });
|
||||
expect(result.current.columnHeight).toBe("clamp(12.5rem, 350px, 25rem)");
|
||||
});
|
||||
|
||||
it("disables reporting and clears measurements outside compact mode", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useCompactSwimlaneHeight(enabled, steps, false),
|
||||
{ initialProps: { enabled: true } },
|
||||
);
|
||||
act(() => result.current.onNaturalHeightChange!("a", 350));
|
||||
const lateReport = result.current.onNaturalHeightChange!;
|
||||
rerender({ enabled: false });
|
||||
expect(result.current.columnHeight).toBeUndefined();
|
||||
expect(result.current.onNaturalHeightChange).toBeUndefined();
|
||||
act(() => lateReport("a", 350));
|
||||
rerender({ enabled: true });
|
||||
expect(result.current.columnHeight).toBe("clamp(12.5rem, 0px, 25rem)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { WorkflowStep } from "@/components/kanban-column";
|
||||
|
||||
export function useCompactSwimlaneHeight(
|
||||
enabled: boolean,
|
||||
steps: WorkflowStep[],
|
||||
isDragging: boolean,
|
||||
) {
|
||||
const [heights, setHeights] = useState<Record<string, number>>({});
|
||||
const scopeRef = useRef({ enabled, steps });
|
||||
scopeRef.current = { enabled, steps };
|
||||
const onNaturalHeightChange = useCallback((stepId: string, height: number) => {
|
||||
const scope = scopeRef.current;
|
||||
if (!scope.enabled || !scope.steps.some((step) => step.id === stepId)) return;
|
||||
setHeights((previous) =>
|
||||
previous[stepId] === height ? previous : { ...previous, [stepId]: height },
|
||||
);
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
setHeights((previous) => {
|
||||
const entries = Object.entries(previous).filter(
|
||||
([id]) => enabled && steps.some((step) => step.id === id),
|
||||
);
|
||||
return entries.length === Object.keys(previous).length
|
||||
? previous
|
||||
: Object.fromEntries(entries);
|
||||
});
|
||||
}, [enabled, steps]);
|
||||
const naturalHeight = Math.max(0, ...steps.map((step) => heights[step.id] ?? 0));
|
||||
const nextHeight = `clamp(12.5rem, ${naturalHeight}px, 25rem)`;
|
||||
const settledHeight = useRef(nextHeight);
|
||||
useLayoutEffect(() => {
|
||||
if (!isDragging) settledHeight.current = nextHeight;
|
||||
}, [isDragging, nextHeight]);
|
||||
const columnHeight = isDragging ? settledHeight.current : nextHeight;
|
||||
return {
|
||||
columnHeight: enabled ? columnHeight : undefined,
|
||||
onNaturalHeightChange: enabled ? onNaturalHeightChange : undefined,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { WorkflowStep } from "@/components/kanban-column";
|
||||
import type { KanbanExternalLinkAvailability } from "@/components/kanban-external-link-availability";
|
||||
|
||||
export type SharedKanbanLayoutProps = {
|
||||
columnHeight?: string;
|
||||
onNaturalHeightChange?: (stepId: string, height: number) => void;
|
||||
steps: WorkflowStep[];
|
||||
moveTargetSteps: WorkflowStep[];
|
||||
tasks: Task[];
|
||||
@@ -46,6 +48,8 @@ export function useSharedKanbanLayoutProps(
|
||||
const { drag, displayTasks, externalLinkAvailability } = options;
|
||||
return useMemo(
|
||||
() => ({
|
||||
columnHeight: options.columnHeight,
|
||||
onNaturalHeightChange: options.onNaturalHeightChange,
|
||||
steps: drag.renderedSteps,
|
||||
moveTargetSteps: options.moveTargetSteps,
|
||||
tasks: displayTasks,
|
||||
@@ -67,6 +71,8 @@ export function useSharedKanbanLayoutProps(
|
||||
isDragging: !!drag.activeTask,
|
||||
}),
|
||||
[
|
||||
options.columnHeight,
|
||||
options.onNaturalHeightChange,
|
||||
drag.renderedSteps,
|
||||
drag.moveTaskToStep,
|
||||
drag.temporaryStepIds,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { WorkflowStep } from "@/components/kanban-column";
|
||||
import type { MoveTaskError } from "@/hooks/use-drag-and-drop";
|
||||
|
||||
export type ViewContentProps = {
|
||||
compactHeight?: boolean;
|
||||
workflowId: string;
|
||||
steps: WorkflowStep[];
|
||||
moveTargetSteps: WorkflowStep[];
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
created: 2026-09-11
|
||||
status: complete
|
||||
requirements:
|
||||
- REQ-UI-ADAPTIVE-KANBAN-001
|
||||
- REQ-UI-ADAPTIVE-KANBAN-002
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/adaptive-kanban.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Fix plan: Compact workflow swimlanes
|
||||
|
||||
## Overview
|
||||
|
||||
Give sparse workflows compact heights while preserving bounded virtualized columns. One sequential work order owns the regression, correction, and focused verification.
|
||||
The UI system owns this presentation contract. Task and workspace state remain under their existing owners.
|
||||
|
||||
## Evidence and root cause
|
||||
|
||||
The user screenshot shows a sparse first workflow occupying most of the board, with another workflow header at the bottom.
|
||||
The source trace identifies the height allocation responsible for this geometry:
|
||||
|
||||
1. `SwimlaneContainer` passes `fillHeight={view.id === "kanban"}` for all Kanban workflows.
|
||||
2. `WorkflowItems` enables that value for each expanded workflow.
|
||||
3. `SortableWorkflowItem` applies `h-full min-h-0 flex-1` inside a desktop block container.
|
||||
4. `SwimlaneSection` propagates the definite parent height through its content.
|
||||
|
||||
Thus every expanded lane receives a board-sized area regardless of its task count.
|
||||
The separate `sm:min-h-[200px]` in `KanbanColumn` is not the cause of viewport-sized blank space.
|
||||
Commit `6a4ba4cbc8` introduced the desktop full-height path for large-column virtualization.
|
||||
Removing the entire height chain risks unbounded virtualized content or an empty viewport.
|
||||
|
||||
Evidence is a read-only source/history trace plus the supplied screenshot. No live browser reproduction ran during package preparation.
|
||||
The minimal browser reproduction uses All Workflows, two expanded workflows, and one short card in each workflow.
|
||||
At 1440 by 900, the second header and its first card must fit without outer scrolling after the correction.
|
||||
|
||||
## Requirement conformance
|
||||
|
||||
The existing Adaptive Kanban contract covers width and virtualization but lacks a multi-workflow height rule.
|
||||
`REQ-UI-ADAPTIVE-KANBAN-002` adds that missing rule in the existing requirement document.
|
||||
The implementation uses the approved 400px maximum. The 200px minimum preserves the existing column floor.
|
||||
The paired design is current after implementation and rendered verification.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Content-sensitive compact heights for multiple desktop and tablet lanes.
|
||||
- Full-height single-workflow and phone views.
|
||||
- Collapsed and retained-empty lane recovery.
|
||||
- Bounded virtualization and unchanged card interactions.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Manual lane resizing or a saved height preference.
|
||||
- Pipeline, List, or task lifecycle redesign.
|
||||
- Backend, dependency, localization, and public documentation changes.
|
||||
- Commits and publication.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Use the mode and measurement boundaries in the [system design](../../specs/ui/system-design/adaptive-kanban.md#workflow-height-allocation).
|
||||
Keep all sizing state local. Reuse virtualizer totals and actual column chrome measurements.
|
||||
Keep the lane height fixed during active task drag. Recompute after drag completion.
|
||||
Preserve callback identity and update the existing column memo comparator for new props.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: Desktop Home, All Workflows, two sparse expanded lanes.
|
||||
|
||||
```text
|
||||
Before After
|
||||
[Workflow A / Columns] [Workflow A / Columns]
|
||||
[Todo] [Plan] [Done] [Todo] [Plan] [Done]
|
||||
[card] [card]
|
||||
| | compact column area
|
||||
| full board height [Workflow B / Columns]
|
||||
| [Todo] [Review] [Done]
|
||||
| [card]
|
||||
[Workflow B / Columns]
|
||||
```
|
||||
|
||||
UI-02: Desktop mixed state and phone Home.
|
||||
|
||||
```text
|
||||
Desktop Phone
|
||||
[Workflow A > / Columns] [Workflow / Step v]
|
||||
[Workflow B v / Columns] [card]
|
||||
[Todo] [Plan] [Done] [card]
|
||||
[card] | focused column scroll
|
||||
| dense column scroll | fills available height
|
||||
| bounded column area [Create task]
|
||||
```
|
||||
|
||||
Header order, compact multiple lanes, and focused phone composition are structural requirements.
|
||||
Spacing and labels are illustrative. UI-01 covers 002.1-002.2. UI-02 covers 002.3 and 002.6.
|
||||
An empty retained lane keeps its header and existing empty guidance. A collapsed lane contains only its header.
|
||||
Tablet keeps its existing two-column horizontal snap view inside each compact lane.
|
||||
|
||||
## Tests
|
||||
|
||||
The primary RED test is browser geometry, not class-name presence.
|
||||
A small measurement reducer or height helper needs unit coverage only if implementation extracts one.
|
||||
Existing render-isolation tests protect stable lane and card updates.
|
||||
|
||||
## E2E tests
|
||||
|
||||
| Criteria | File and scenario |
|
||||
| --- | --- |
|
||||
| 002.1-002.2 | New `swimlane-height.spec.ts`: two sparse lanes fit, mixed dense lane caps, last task reachable. |
|
||||
| 002.3-002.5 | Same file: collapse/expand, filter to one lane, clear filter, retained-empty recovery, preview resize. |
|
||||
| 002.6 | Existing `mobile-kanban.spec.ts`: add full-height focused-lane geometry with multiple workflows and breakpoint transitions. |
|
||||
| 002.1, 002.7 | New `swimlane-height.spec.ts`: coarse-pointer tablet case through `tabletTestPage`. |
|
||||
| 001.5-001.7, 002.7 | Existing desktop/tablet/mobile large-column suites plus a 440-task lane beside a sparse lane. |
|
||||
| 001.4, 001.9-001.10, 002.7 | Existing board, auto-hide, WIP, and workflow-sorting suites. |
|
||||
|
||||
All named files live under `apps/web/e2e/tests/kanban/`, except `workflow/workflow-sorting.spec.ts`.
|
||||
Desktop and tablet use the `chromium` project. Phone uses `mobile-chrome`.
|
||||
The new phone assertions use the canonical device, then 767px and 768px with the same pointer mode.
|
||||
They prove branch selection and preserve the saved desktop view.
|
||||
|
||||
## Companion packages
|
||||
|
||||
The completed adaptive-kanban, kanban-large-column-virtualization, and kanban-drag-column-width-stability packages remain historical delivery records.
|
||||
This package adds height coverage and retains their width, drag, and virtualization acceptance conditions.
|
||||
Their recorded test counts are historical, not verification results for this repair.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Compact workflow heights](task-01-compact-workflow-heights.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
Implemented and verified on 2026-09-11.
|
||||
|
||||
- The sparse-workflow geometry test failed before production changes and passed after the correction.
|
||||
- The final managed build passed for the backend, Vite assets, and fixture plugin.
|
||||
- All 22 targeted Chromium tests and 29 mobile tests passed without retries on the final build.
|
||||
- All 14 focused hook and render-stability tests passed.
|
||||
- Typecheck, targeted ESLint, Prettier, and the i18n ratchet passed.
|
||||
- All 36 specification-linter tests, full specification lint, and `git diff --check` passed.
|
||||
- Desktop, tablet, and phone captures match the structural previews. No unresolved visual differences were identified.
|
||||
|
||||
Task 01 records the commands, intermediate failures, and verification scope.
|
||||
No dependency, backend source, public guide, or saved preference changed. No commit or publication was performed.
|
||||
|
||||
## Risks
|
||||
|
||||
- Measuring constrained boxes can create a height feedback loop. Natural content totals avoid this dependency.
|
||||
- Dynamic card metadata changes height. Tests must allow measurement settlement without fixed sleeps.
|
||||
- Compact lanes still require both outer workflow scrolling and internal task scrolling.
|
||||
- A CSS-only removal of `h-full` can break virtualization. The definite column area is required.
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
id: "01-compact-workflow-heights"
|
||||
title: "Compact workflow heights"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-ADAPTIVE-KANBAN-001
|
||||
- REQ-UI-ADAPTIVE-KANBAN-002
|
||||
acceptance_criteria:
|
||||
- AC-UI-ADAPTIVE-KANBAN-001.4
|
||||
- AC-UI-ADAPTIVE-KANBAN-001.5
|
||||
- AC-UI-ADAPTIVE-KANBAN-001.6
|
||||
- AC-UI-ADAPTIVE-KANBAN-001.7
|
||||
- AC-UI-ADAPTIVE-KANBAN-001.9
|
||||
- AC-UI-ADAPTIVE-KANBAN-001.10
|
||||
- AC-UI-ADAPTIVE-KANBAN-002.1
|
||||
- AC-UI-ADAPTIVE-KANBAN-002.2
|
||||
- AC-UI-ADAPTIVE-KANBAN-002.3
|
||||
- AC-UI-ADAPTIVE-KANBAN-002.4
|
||||
- AC-UI-ADAPTIVE-KANBAN-002.5
|
||||
- AC-UI-ADAPTIVE-KANBAN-002.6
|
||||
- AC-UI-ADAPTIVE-KANBAN-002.7
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/adaptive-kanban.md
|
||||
---
|
||||
|
||||
# Task 01: Compact workflow heights
|
||||
|
||||
## Summary
|
||||
|
||||
Replace repeated full-board heights with bounded content heights for multiple visible workflows.
|
||||
Preserve the definite viewport required by virtualized columns.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add the browser geometry regression before production changes.
|
||||
- Implement compact-mode selection, natural-height reports, and local measurement cleanup.
|
||||
- Preserve full-height single-workflow and phone behavior.
|
||||
- Cover collapse, empty recovery, filters, previews, tablet, and virtualized task access.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Backend contracts, new settings, dependencies, Pipeline changes, and publication.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The sparse two-workflow browser assertion fails on the original full-height behavior and passes after the correction.
|
||||
- All compact sizing and mode-transition scenarios satisfy 002.1-002.6, including the mixed dense/sparse case and retained-empty controls.
|
||||
- Focused interaction and virtualization suites pass, with no stale measurements, document overflow, or saved-preference changes.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01 and UI-02 excerpt from the [full preview](plan.md#ascii-ui-preview):
|
||||
|
||||
```text
|
||||
Desktop Phone
|
||||
[Workflow A / Columns] [Workflow / Step v]
|
||||
[Todo] [Plan] [Done] [card]
|
||||
[card] | focused column scroll
|
||||
[Workflow B / Columns] | fills available height
|
||||
[Todo] [Review] [Done] [Create task]
|
||||
[card]
|
||||
```
|
||||
|
||||
Multiple desktop/tablet lanes remain compact even when another visible lane is collapsed (002.1-002.3).
|
||||
Phone retains the existing navigator and full-height focused column (002.6).
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
1. Install workspace dependencies if absent.
|
||||
2. Add `swimlane-height.spec.ts` with the two sparse workflows at 1440 by 900.
|
||||
3. Run the targeted RED command. Record the second-header/card bounding boxes and outer scroll position.
|
||||
4. Implement the sizing path in the paired design.
|
||||
5. Add the remaining scenario matrix from the plan.
|
||||
6. Run the targeted checks and inspect desktop, tablet, and phone captures.
|
||||
7. Record results and promote the paired design only after all work passes.
|
||||
|
||||
Use `testPage` before seeding settings. Use disposable workflows and restore baseline settings in cleanup.
|
||||
Scope selectors by workflow and step IDs. Add stable lane selectors only when existing selectors cannot identify the geometry.
|
||||
Compare bounding boxes and scroll reachability. Visibility assertions alone do not prove compact sizing.
|
||||
Wait for finite animations and stable measurements, without fixed sleeps.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from the repository root. Each command starts from an independent directory context.
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm install --frozen-lockfile)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/kanban/swimlane-height.spec.ts)
|
||||
```
|
||||
|
||||
The first browser run must fail for excessive lane height before production changes.
|
||||
After implementation, run:
|
||||
|
||||
```bash
|
||||
(cd apps/web && pnpm test -- --run components/kanban/swimlane-container.render-stability.test.tsx components/kanban/swimlane-kanban-content.render-stability.test.tsx components/kanban/virtualized-column-task-list.render-stability.test.tsx)
|
||||
(cd apps/web && pnpm test -- --run hooks/domains/kanban/use-compact-swimlane-height.test.ts hooks/domains/kanban/use-column-natural-height.test.ts)
|
||||
(cd apps/web && pnpm exec eslint components/kanban/swimlane-container.tsx components/kanban/swimlane-section.tsx components/kanban/swimlane-kanban-content.tsx components/kanban/adaptive-desktop-kanban.tsx components/kanban/virtualized-column-task-list.tsx components/kanban-column.tsx lib/kanban/view-registry.ts e2e/tests/kanban/swimlane-height.spec.ts e2e/tests/kanban/mobile-kanban.spec.ts)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm exec eslint hooks/domains/kanban/use-compact-swimlane-height.ts hooks/domains/kanban/use-column-natural-height.ts hooks/domains/kanban/use-shared-kanban-layout-props.ts hooks/domains/kanban/use-compact-swimlane-height.test.ts hooks/domains/kanban/use-column-natural-height.test.ts e2e/tests/kanban/swimlane-height-helpers.ts)
|
||||
(cd apps/web && pnpm run i18n:ratchet)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/kanban/swimlane-height.spec.ts tests/kanban/large-column-virtualization.spec.ts tests/kanban/tablet-large-column-virtualization.spec.ts tests/kanban/kanban-board.spec.ts tests/kanban/auto-hide-empty-columns.spec.ts tests/kanban/wip-overflow-queue.spec.ts tests/workflow/workflow-sorting.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/kanban/mobile-large-column-virtualization.spec.ts)
|
||||
python3 scripts/lint-spec-files.test.py
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Use managed builds for current assets. Run suites sequentially with the runner's resource limits.
|
||||
If helper extraction adds a unit suite, add its exact path to this block and record its result.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/kanban/swimlane-container.tsx`
|
||||
- `apps/web/components/kanban/swimlane-section.tsx`
|
||||
- `apps/web/components/kanban/swimlane-kanban-content.tsx`
|
||||
- `apps/web/components/kanban/adaptive-desktop-kanban.tsx`
|
||||
- `apps/web/components/kanban/virtualized-column-task-list.tsx`
|
||||
- `apps/web/components/kanban-column.tsx`
|
||||
- `apps/web/lib/kanban/view-registry.ts`
|
||||
- `apps/web/e2e/tests/kanban/swimlane-height.spec.ts` (new)
|
||||
- `apps/web/e2e/tests/kanban/mobile-kanban.spec.ts`
|
||||
- This plan, work order, and paired system design for completion results.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. Existing virtualization and adaptive width packages are complete.
|
||||
|
||||
## Risks
|
||||
|
||||
Natural-height reports must exclude allocated viewport height. New callback props must not bypass the memo comparator or trigger unrelated lane renders.
|
||||
Long columns must remain scrollable after a lane shrinks, expands, or returns from collapse.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirements](../../specs/ui/requirements/adaptive-kanban.md), especially REQ-UI-ADAPTIVE-KANBAN-002.
|
||||
- [System design](../../specs/ui/system-design/adaptive-kanban.md), workflow allocation and height measurement.
|
||||
- Existing `workflow-filter.spec.ts` for two-workflow setup and cleanup.
|
||||
- Existing large-column suites and `tabletTestPage` for virtualization and coarse-pointer evidence.
|
||||
- Existing render-isolation suites for stable lane and card updates.
|
||||
|
||||
## Results
|
||||
|
||||
Implementation and final browser verification are complete (2026-09-11).
|
||||
|
||||
- The original browser test failed because the second card extended below the board at outer scroll position zero.
|
||||
- The same geometry assertion passed after compact sizing replaced repeated full-board heights.
|
||||
- Fourteen hook and render-stability tests pass. They cover height aggregation, drag freeze, observer cleanup, and late reports from hidden columns.
|
||||
- Typecheck, targeted ESLint, formatting, and the i18n ratchet pass.
|
||||
- The managed runner rebuilt the backend, Vite assets, and fixture plugin after the final source change.
|
||||
- The final mobile command in Verification passed all 29 tests. The final Chromium matrix passed all 22 tests.
|
||||
- The Chromium matrix used `--no-build` to reuse the same freshly built assets after the mobile run. Both runs used one worker and zero retries.
|
||||
- Specification validation passed all 36 linter tests, full specification lint, and the final diff check.
|
||||
- Desktop and tablet captures match UI-01. The phone capture matches UI-02, with one focused column and its existing navigator.
|
||||
- Public guides do not specify lane dimensions. No public documentation change is needed.
|
||||
|
||||
Intermediate test corrections included an ambiguous collapse selector, short-card fixture sizing, the phone-only navigation helper, and response cleanup.
|
||||
One earlier browser run failed during shared fixture setup with a workspace-update 404, before rendering the board.
|
||||
The affected drag tests then passed without retries. No backend code changed.
|
||||
|
||||
The final unit regression also rejected late reports after a column was hidden or compact mode was disabled.
|
||||
The first version failed both cases. The completed hook ignores reports outside the current visible-column scope.
|
||||
|
||||
No acceptance criteria remain open. The implementation phase did not create a commit, publication, or additional task.
|
||||
|
||||
### PR review remediation
|
||||
|
||||
PR review clarified the helper field as `seedWorkflowTaskId` and changed the settings-restoration assertion to a named soft assertion.
|
||||
The cleanup failure still fails the test, but it does not replace an error from the test body.
|
||||
These changes affect test diagnostics only. Product behavior, screenshots, and durable requirements remain unchanged.
|
||||
|
||||
The following commands passed after the helper changes (2026-09-11):
|
||||
|
||||
```bash
|
||||
(cd apps/web && pnpm e2e:run --host --no-build --project chromium tests/kanban/swimlane-height.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/kanban/mobile-kanban.spec.ts -- --grep 'multiple workflows keep a full-height')
|
||||
(cd apps/web && pnpm exec eslint e2e/tests/kanban/swimlane-height-helpers.ts e2e/tests/kanban/swimlane-height.spec.ts)
|
||||
```
|
||||
|
||||
All six Chromium scenarios and the affected phone scenario passed with zero retries.
|
||||
The runs reused the unchanged production assets. Remote CI and review completion remain pending until validation of the final PR head.
|
||||
@@ -2,7 +2,7 @@
|
||||
status: active
|
||||
system: ui
|
||||
created: 2026-07-27
|
||||
updated: 2026-09-05
|
||||
updated: 2026-09-11
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
@@ -31,6 +31,22 @@ People use Kandev in portrait desktop windows, beside a task preview, and with a
|
||||
- **AC-UI-ADAPTIVE-KANBAN-001.9:** When a pointer drag starts or ends without a change to the rendered workflow steps, each desktop column SHALL retain its computed width.
|
||||
- **AC-UI-ADAPTIVE-KANBAN-001.10:** A desktop pointer drag MAY extend the workflow's internal scroll range for drag anchoring, but it SHALL NOT widen the document or show a transient horizontal scrollbar solely for that reserve.
|
||||
|
||||
### REQ-UI-ADAPTIVE-KANBAN-002: Compact workflow swimlanes
|
||||
|
||||
**Intent:** Users can scan several workflows without a viewport of blank space between sparse lanes.
|
||||
|
||||
The column area excludes the workflow header and the horizontal scrollbar. Pixel values assume the standard root font.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-UI-ADAPTIVE-KANBAN-002.1:** When several workflow lanes are visible on desktop or tablet, each expanded column area shall fit its tallest column between 200px and 400px.
|
||||
- **AC-UI-ADAPTIVE-KANBAN-002.2:** Columns in one lane shall share a height. Content beyond the height limit shall remain reachable through each column's internal scroll.
|
||||
- **AC-UI-ADAPTIVE-KANBAN-002.3:** A collapsed lane shall occupy only its header. Other visible lanes shall retain compact sizing, even when only one remains expanded.
|
||||
- **AC-UI-ADAPTIVE-KANBAN-002.4:** When exactly one workflow lane is visible, its expanded board shall fill the available height. Filters shall determine visibility before sizing.
|
||||
- **AC-UI-ADAPTIVE-KANBAN-002.5:** Task changes, column visibility changes, and preview resizing shall update compact heights. Empty retained lanes shall keep their header and existing recovery controls.
|
||||
- **AC-UI-ADAPTIVE-KANBAN-002.6:** Phone Kanban shall retain one focused workflow and column, its navigator, internal scroll, and direct task navigation.
|
||||
- **AC-UI-ADAPTIVE-KANBAN-002.7:** Height changes shall preserve virtualization, task order, WIP boundaries, drag actions, and saved display preferences. Workflow overflow shall remain inside the board.
|
||||
|
||||
## Migrated source detail
|
||||
|
||||
## Why
|
||||
@@ -129,3 +145,5 @@ desktop.
|
||||
[Adaptive Kanban implementation plan](../../../plans/adaptive-kanban/plan.md)
|
||||
|
||||
[Large-column virtualization repair plan](../../../plans/kanban-large-column-virtualization/plan.md)
|
||||
|
||||
[Compact workflow swimlane repair plan](../../../plans/kanban-swimlane-height/plan.md)
|
||||
|
||||
@@ -3,7 +3,9 @@ status: current
|
||||
system: ui
|
||||
requirements:
|
||||
- REQ-UI-ADAPTIVE-KANBAN-001
|
||||
- REQ-UI-ADAPTIVE-KANBAN-002
|
||||
created: 2026-09-05
|
||||
updated: 2026-09-11
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
@@ -13,13 +15,16 @@ owners:
|
||||
|
||||
The UI system owns the desktop Kanban grid, its contained horizontal overflow, and its drag-time geometry.
|
||||
|
||||
This design covers desktop column sizing and drag scroll anchoring. It does not change workflow data, move permissions, or task persistence.
|
||||
This design covers column sizing, workflow heights, and drag scroll anchoring. It does not change workflow data, move permissions, or task persistence.
|
||||
|
||||
Workflow-height allocation, horizontal sizing, and drag behavior are implemented and covered by focused browser tests.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design sections |
|
||||
| --- | --- |
|
||||
| `REQ-UI-ADAPTIVE-KANBAN-001` | [Components and responsibilities](#components-and-responsibilities), [Drag control flow](#drag-control-flow), [Responsive boundaries](#responsive-boundaries) |
|
||||
| `REQ-UI-ADAPTIVE-KANBAN-002` | [Workflow height allocation](#workflow-height-allocation), [Height measurement](#height-measurement), [Responsive boundaries](#responsive-boundaries) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
@@ -57,14 +62,71 @@ If the source step no longer exists, the hook keeps the current scroll position.
|
||||
|
||||
## Responsive boundaries
|
||||
|
||||
The desktop layout uses this design. The tablet layout keeps its two-column snap-scrolling surface.
|
||||
The desktop layout uses this design. The tablet layout keeps its two-column snap-scrolling surface and uses the same workflow height allocation.
|
||||
|
||||
The phone layout keeps one focused column and separate touch drop targets. This correction does not change mobile composition or touch behavior.
|
||||
|
||||
The existing mobile auto-hide E2E scenario covers the nearest mobile surface. It proves touch destinations and document-width containment.
|
||||
|
||||
## Workflow height allocation
|
||||
|
||||
`SwimlaneContainer` selects the sizing mode after `useRenderedWorkflowLayout` resolves visible workflows.
|
||||
|
||||
- Phone Kanban retains its existing full-height focused workflow.
|
||||
- A single rendered desktop or tablet workflow retains full-height columns.
|
||||
- Several rendered workflows use compact heights. Collapsed workflows still count toward this condition.
|
||||
- Pipeline retains its existing content height.
|
||||
|
||||
The sortable wrapper must not assign the parent height to every compact lane. `SwimlaneSection` retains its header outside the collapse guard.
|
||||
An optional presentation prop on `ViewContentProps` carries the compact mode into `SwimlaneKanbanContent`.
|
||||
That component owns a definite column-area height between 200px and 400px. Constants use root-font-scaled units equivalent to these values.
|
||||
The workflow header and native horizontal scrollbar add their own measured height outside this column area.
|
||||
The tablet layout receives the definite height directly. The desktop inner track receives it through `AdaptiveDesktopKanban`.
|
||||
The desktop outer scroll window uses intrinsic height so its native horizontal scrollbar stays outside the column area.
|
||||
`KanbanDragSurface` keeps the existing wrapper, while descendant columns resolve `h-full` against the bounded track.
|
||||
|
||||
The outer swimlane container owns vertical travel between workflows. Each column owns vertical travel through its tasks.
|
||||
The desktop scroll window retains horizontal overflow. The document never becomes a board scroll owner.
|
||||
On a short viewport, the outer container scrolls instead of compressing lanes below the minimum.
|
||||
|
||||
## Height measurement
|
||||
|
||||
`VirtualizedColumnTaskList` already obtains its logical content height from `virtualizer.getTotalSize()`.
|
||||
An optional callback reports this total after layout, including measured rows and the WIP divider.
|
||||
`KanbanColumn` adds its measured header, spacing, and padding before reporting its natural height to the workflow.
|
||||
The total must not come from the constrained column box or its viewport height.
|
||||
|
||||
The workflow computes `clamp(max(column natural heights), minimum, maximum)` over the current display steps.
|
||||
The empty set uses the minimum only when an actual column area renders. Existing no-column empty states retain intrinsic height.
|
||||
Initial unmeasured columns use the minimum. The virtualizer then mounts its first window and supplies estimates plus measured row heights.
|
||||
Callbacks and measurements remain local to the lane. Unchanged values cause no state update.
|
||||
`useCompactSwimlaneHeight` owns the lane measurements and drag freeze. `useColumnNaturalHeight` observes column chrome and adds logical row totals.
|
||||
Removed steps discard their measurements. Width changes remeasure mounted rows and header chrome.
|
||||
Observers disconnect on unmount and remain disabled outside compact mode.
|
||||
|
||||
During a task drag, the lane retains its last settled height so temporary destinations do not move the target vertically.
|
||||
After drop or cancellation, the current projection determines the height again.
|
||||
New callbacks must participate in `KanbanColumn` memo comparisons and remain stable to preserve render isolation.
|
||||
|
||||
No setting, API, dependency, migration, or new user-facing copy is required.
|
||||
This extends the existing layout boundary and requires no new ADR.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
Home remains the entry point. `MobileColumnTabs` is the shipped navigator exemplar.
|
||||
The hierarchy remains workflow, step, then tasks. The temporary drawer selects context, while the focused column owns task scrolling.
|
||||
Task taps navigate directly. Existing dynamic viewport sizing, safe-area clearance, and touch controls remain in place.
|
||||
The compact mode never applies below the canonical 768px phone boundary.
|
||||
Responsive changes do not overwrite saved desktop preferences.
|
||||
|
||||
## Test strategy
|
||||
|
||||
- A desktop geometry regression seeds two sparse workflows and asserts that both headers and first cards fit at 1440 by 900.
|
||||
- Mixed sparse and dense lanes prove independent heights, bounded mounts, and access to the last task.
|
||||
- Collapse, filters, empty retained lanes, and preview resizing prove mode changes and measurement cleanup.
|
||||
- Tablet coverage uses `tabletTestPage`. Phone coverage uses the configured Pixel 5 and widths adjacent to 768px.
|
||||
- The implementation plan names the exact suites and acceptance mappings.
|
||||
|
||||
- Component tests assert that drag state uses a trailing spacer and does not use end padding.
|
||||
- The Chromium Kanban E2E scenario compares every rendered desktop column before and during drag.
|
||||
- The same Chromium scenario covers a board whose minimum track width exceeds the viewport and verifies the added scroll range.
|
||||
|
||||
Reference in New Issue
Block a user