diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 908cb7b684..91d96a7c19 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -594,10 +594,11 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str "showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar, "simplified": settings.SystemMetricsDisplay.Simplified, }, - "appStatusBarEnabled": settings.AppStatusBarEnabled, - "appStatusBarOrder": mapAppStatusBarOrder(settings.AppStatusBarOrder), - "hiddenWorkflowStepIds": stringSliceMap(settings.KanbanHiddenStepIDs), - "loaded": true, + "appStatusBarEnabled": settings.AppStatusBarEnabled, + "appStatusBarOrder": mapAppStatusBarOrder(settings.AppStatusBarOrder), + "hiddenWorkflowStepIds": stringSliceMap(settings.KanbanHiddenStepIDs), + "workflowIdsWithAutoHideEmptySteps": stringSlice(settings.WorkflowIDsWithAutoHideEmptySteps), + "loaded": true, } } diff --git a/apps/backend/internal/backendapp/boot_state_user_settings_test.go b/apps/backend/internal/backendapp/boot_state_user_settings_test.go index 8ae0be523a..76e553f51b 100644 --- a/apps/backend/internal/backendapp/boot_state_user_settings_test.go +++ b/apps/backend/internal/backendapp/boot_state_user_settings_test.go @@ -103,6 +103,20 @@ func TestMapUserSettingsStateDefaultsHiddenWorkflowStepIdsToEmptyMap(t *testing. } } +func TestMapUserSettingsStateDefaultsAutoHideWorkflowIDsToEmptySlice(t *testing.T) { + state := mapUserSettingsState(userdto.UserSettingsResponse{ + Settings: userdto.UserSettingsDTO{}, + }, "workspace-1") + + got, ok := state["workflowIdsWithAutoHideEmptySteps"].([]string) + if !ok || len(got) != 0 || got == nil { + t.Fatalf( + "workflowIdsWithAutoHideEmptySteps = %#v, want non-nil empty []string", + state["workflowIdsWithAutoHideEmptySteps"], + ) + } +} + // TestMapUserSettingsStateIncludesAppStatusBarOrder verifies boot state carries the app status bar order. func TestMapUserSettingsStateIncludesAppStatusBarOrder(t *testing.T) { state := mapUserSettingsState(userdto.UserSettingsResponse{ diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 2d314cdf0d..c22005385e 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -107,6 +107,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS AppStatusBarEnabled: req.AppStatusBarEnabled, AppStatusBarOrder: req.AppStatusBarOrder, KanbanHiddenStepIDs: req.KanbanHiddenStepIDs, + WorkflowIDsWithAutoHideEmptySteps: req.WorkflowIDsWithAutoHideEmptySteps, }) if err != nil { return dto.UserSettingsResponse{}, err diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 7cb13b0441..b770dd3771 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -72,6 +72,7 @@ type UserSettingsDTO struct { AppStatusBarEnabled bool `json:"app_status_bar_enabled"` AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"` + WorkflowIDsWithAutoHideEmptySteps []string `json:"workflow_ids_with_auto_hide_empty_steps"` Revision int64 `json:"revision"` UpdatedAt string `json:"updated_at"` } @@ -148,6 +149,7 @@ type UpdateUserSettingsRequest struct { AppStatusBarEnabled *bool `json:"app_status_bar_enabled,omitempty"` AppStatusBarOrder *models.AppStatusBarOrder `json:"app_status_bar_order,omitempty"` KanbanHiddenStepIDs *map[string][]string `json:"kanban_hidden_step_ids,omitempty"` + WorkflowIDsWithAutoHideEmptySteps *[]string `json:"workflow_ids_with_auto_hide_empty_steps,omitempty"` } type SystemMetricsDisplaySettingsPatch struct { @@ -298,6 +300,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { AppStatusBarEnabled: settings.AppStatusBarEnabled, AppStatusBarOrder: settings.AppStatusBarOrder, KanbanHiddenStepIDs: settings.KanbanHiddenStepIDs, + WorkflowIDsWithAutoHideEmptySteps: append([]string{}, settings.WorkflowIDsWithAutoHideEmptySteps...), Revision: settings.Revision, UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), } diff --git a/apps/backend/internal/user/dto/dto_test.go b/apps/backend/internal/user/dto/dto_test.go index 34815c6f4e..09c62b2ed2 100644 --- a/apps/backend/internal/user/dto/dto_test.go +++ b/apps/backend/internal/user/dto/dto_test.go @@ -101,6 +101,58 @@ func TestKanbanHiddenStepIDsRequestDecode(t *testing.T) { }) } +func TestWorkflowIDsWithAutoHideEmptyStepsContract(t *testing.T) { + field, ok := reflect.TypeFor[UpdateUserSettingsRequest]().FieldByName("WorkflowIDsWithAutoHideEmptySteps") + if !ok || field.Tag.Get("json") != "workflow_ids_with_auto_hide_empty_steps,omitempty" { + t.Fatalf("WorkflowIDsWithAutoHideEmptySteps patch field = %+v, want JSON preference field", field) + } + + defaultSettings := FromUserSettings(&models.UserSettings{}) + encoded, err := json.Marshal(defaultSettings) + if err != nil { + t.Fatalf("encode default settings: %v", err) + } + var serialized struct { + WorkflowIDs []string `json:"workflow_ids_with_auto_hide_empty_steps"` + } + if err := json.Unmarshal(encoded, &serialized); err != nil { + t.Fatalf("decode default settings: %v", err) + } + if serialized.WorkflowIDs == nil || len(serialized.WorkflowIDs) != 0 { + t.Fatalf("serialized default WorkflowIDs = %#v, want non-nil empty", serialized.WorkflowIDs) + } + + want := []string{"wf-a", "wf-b"} + source := &models.UserSettings{} + sourceField := reflect.ValueOf(source).Elem().FieldByName("WorkflowIDsWithAutoHideEmptySteps") + if !sourceField.IsValid() { + t.Fatal("models.UserSettings.WorkflowIDsWithAutoHideEmptySteps field is absent") + } + sourceField.Set(reflect.ValueOf(want)) + settings := FromUserSettings(source) + value := reflect.ValueOf(settings) + mapped := value.FieldByName("WorkflowIDsWithAutoHideEmptySteps") + if !mapped.IsValid() || !reflect.DeepEqual(mapped.Interface(), want) { + t.Fatalf("WorkflowIDsWithAutoHideEmptySteps = %#v, want %#v", mapped, want) + } + + var omitted UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{}`), &omitted); err != nil { + t.Fatalf("decode omitted preference: %v", err) + } + if omitted.WorkflowIDsWithAutoHideEmptySteps != nil { + t.Fatalf("omitted WorkflowIDsWithAutoHideEmptySteps = %#v, want nil", omitted.WorkflowIDsWithAutoHideEmptySteps) + } + + var cleared UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{"workflow_ids_with_auto_hide_empty_steps":[]}`), &cleared); err != nil { + t.Fatalf("decode cleared preference: %v", err) + } + if cleared.WorkflowIDsWithAutoHideEmptySteps == nil || len(*cleared.WorkflowIDsWithAutoHideEmptySteps) != 0 { + t.Fatalf("cleared WorkflowIDsWithAutoHideEmptySteps = %#v, want non-nil empty slice", cleared.WorkflowIDsWithAutoHideEmptySteps) + } +} + // TestTasksListShowDetailsDTO verifies the DTO mapping and the nil-versus-explicit-false patch semantics. func TestTasksListShowDetailsDTO(t *testing.T) { if !FromUserSettings(&models.UserSettings{TasksListShowDetails: true}).TasksListShowDetails { diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 38552f3abb..7e8dd7a28c 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -142,6 +142,7 @@ type UserSettings struct { AppStatusBarEnabled bool `json:"app_status_bar_enabled"` AppStatusBarOrder AppStatusBarOrder `json:"app_status_bar_order"` KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"` + WorkflowIDsWithAutoHideEmptySteps []string `json:"workflow_ids_with_auto_hide_empty_steps"` Revision int64 `json:"revision"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index a02178b957..81368cb9a4 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "reflect" + "sort" "strings" "time" @@ -97,6 +98,7 @@ type UpdateUserSettingsRequest struct { AppStatusBarEnabled *bool AppStatusBarOrder *models.AppStatusBarOrder KanbanHiddenStepIDs *map[string][]string + WorkflowIDsWithAutoHideEmptySteps *[]string } type SystemMetricsDisplaySettingsPatch struct { @@ -360,6 +362,15 @@ func applyWorkspaceAndTaskListPreferences(settings *models.UserSettings, req *Up } settings.KanbanHiddenStepIDs = *req.KanbanHiddenStepIDs } + if req.WorkflowIDsWithAutoHideEmptySteps != nil { + workflowIDs, err := normalizeWorkflowIDsWithAutoHideEmptySteps( + *req.WorkflowIDsWithAutoHideEmptySteps, + ) + if err != nil { + return err + } + settings.WorkflowIDsWithAutoHideEmptySteps = workflowIDs + } return nil } @@ -375,9 +386,37 @@ const ( // validateKanbanHiddenStepIDs, which both the REST and WebSocket update // paths call, so it isn't bypassable by whichever transport skips a // transport-level guard. - maxKanbanHiddenStepIDsTotalBytes = maxUserPreferenceBlobBytes + maxKanbanHiddenStepIDsTotalBytes = maxUserPreferenceBlobBytes + maxWorkflowIDsWithAutoHideEmptySteps = 200 ) +func normalizeWorkflowIDsWithAutoHideEmptySteps(workflowIDs []string) ([]string, error) { + unique := make(map[string]struct{}, len(workflowIDs)) + for _, workflowID := range workflowIDs { + unique[workflowID] = struct{}{} + } + if len(unique) > maxWorkflowIDsWithAutoHideEmptySteps { + return nil, fmt.Errorf( + "workflow_ids_with_auto_hide_empty_steps: max %d workflow ids allowed", + maxWorkflowIDsWithAutoHideEmptySteps, + ) + } + totalBytes := 0 + normalized := make([]string, 0, len(unique)) + for workflowID := range unique { + totalBytes += len(workflowID) + if totalBytes > maxUserPreferenceBlobBytes { + return nil, fmt.Errorf( + "workflow_ids_with_auto_hide_empty_steps: max %d bytes allowed", + maxUserPreferenceBlobBytes, + ) + } + normalized = append(normalized, workflowID) + } + sort.Strings(normalized) + return normalized, nil +} + // validateKanbanHiddenStepIDs bounds the per-workflow hidden-step-id map so a // single settings write cannot grow the users.settings JSON blob unboundedly // on the shared single-writer SQLite connection. @@ -883,6 +922,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "app_status_bar_enabled": settings.AppStatusBarEnabled, "app_status_bar_order": settings.AppStatusBarOrder, "kanban_hidden_step_ids": settings.KanbanHiddenStepIDs, + "workflow_ids_with_auto_hide_empty_steps": settings.WorkflowIDsWithAutoHideEmptySteps, "revision": settings.Revision, "updated_at": settings.UpdatedAt.Format(time.RFC3339), } diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index 3a8b6dc485..d621685ad3 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -1060,6 +1060,75 @@ func TestApplyWorkspaceAndTaskListPreferencesKanbanHiddenStepIDs(t *testing.T) { } } +func TestApplyWorkspaceAndTaskListPreferencesAutoHideWorkflowIDs(t *testing.T) { + makeWorkflowIDs := func(n int) []string { + ids := make([]string, n) + for i := range ids { + ids[i] = fmt.Sprintf("wf-%d", i) + } + return ids + } + duplicateWorkflowIDs := make([]string, maxWorkflowIDsWithAutoHideEmptySteps+1) + for i := range duplicateWorkflowIDs { + duplicateWorkflowIDs[i] = "wf-duplicate" + } + + tests := []struct { + name string + value []string + want []string + wantErr string + }{ + {name: "empty list clears the preference", value: []string{}, want: []string{}}, + { + name: "normalizes duplicate workflow ids", + value: []string{"wf-b", "wf-a", "wf-b"}, + want: []string{"wf-a", "wf-b"}, + }, + { + name: "applies the count limit after deduplication", + value: duplicateWorkflowIDs, + want: []string{"wf-duplicate"}, + }, + { + name: "rejects too many workflow ids", + value: makeWorkflowIDs(maxWorkflowIDsWithAutoHideEmptySteps + 1), + wantErr: fmt.Sprintf("workflow_ids_with_auto_hide_empty_steps: max %d workflow ids allowed", maxWorkflowIDsWithAutoHideEmptySteps), + }, + { + name: "rejects an oversized preference", + value: []string{strings.Repeat("x", maxUserPreferenceBlobBytes+1)}, + wantErr: fmt.Sprintf("workflow_ids_with_auto_hide_empty_steps: max %d bytes allowed", maxUserPreferenceBlobBytes), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings := &models.UserSettings{ + WorkflowIDsWithAutoHideEmptySteps: []string{"wf-existing"}, + } + err := applyWorkspaceAndTaskListPreferences(settings, &UpdateUserSettingsRequest{ + WorkflowIDsWithAutoHideEmptySteps: &tt.value, + }) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want error containing %q", err, tt.wantErr) + } + if !reflect.DeepEqual(settings.WorkflowIDsWithAutoHideEmptySteps, []string{"wf-existing"}) { + t.Fatalf("rejected update changed settings to %#v", settings.WorkflowIDsWithAutoHideEmptySteps) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(settings.WorkflowIDsWithAutoHideEmptySteps, tt.want) { + t.Fatalf("WorkflowIDsWithAutoHideEmptySteps = %#v, want %#v", settings.WorkflowIDsWithAutoHideEmptySteps, tt.want) + } + }) + } +} + // makeSidebarViews builds n SidebarView fixtures with distinct IDs and names. func makeSidebarViews(n int) []models.SidebarView { views := make([]models.SidebarView, n) diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 749545c9ca..9ec8636519 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -619,6 +619,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { "app_status_bar_enabled": settings.AppStatusBarEnabled, "app_status_bar_order": normalizeAppStatusBarOrder(settings.AppStatusBarOrder), "kanban_hidden_step_ids": settings.KanbanHiddenStepIDs, + "workflow_ids_with_auto_hide_empty_steps": settings.WorkflowIDsWithAutoHideEmptySteps, }) } @@ -698,6 +699,7 @@ func defaultUserSettings(userID string) *models.UserSettings { AppStatusBarEnabled: false, AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}), KanbanHiddenStepIDs: map[string][]string{}, + WorkflowIDsWithAutoHideEmptySteps: []string{}, } } @@ -781,6 +783,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin AppStatusBarEnabled *bool `json:"app_status_bar_enabled"` AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` KanbanHiddenStepIDs json.RawMessage `json:"kanban_hidden_step_ids"` + WorkflowIDsWithAutoHideEmptySteps json.RawMessage `json:"workflow_ids_with_auto_hide_empty_steps"` } if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil { return nil, err @@ -912,9 +915,21 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin } settings.LastSeenDisplay = normalizeLastSeenDisplayStored(payload.LastSeenDisplay) settings.KanbanHiddenStepIDs = decodeKanbanHiddenStepIDs(payload.KanbanHiddenStepIDs) + settings.WorkflowIDsWithAutoHideEmptySteps = decodeStringIDs(payload.WorkflowIDsWithAutoHideEmptySteps) return settings, nil } +func decodeStringIDs(raw json.RawMessage) []string { + if len(raw) == 0 || string(raw) == "null" { + return []string{} + } + var ids []string + if err := json.Unmarshal(raw, &ids); err != nil || ids == nil { + return []string{} + } + return ids +} + // normalizeLastSeenDisplayStored maps a stored JSON value to the canonical // display mode: only a string exactly equal to "relative" is accepted. Every // other JSON type (number, object, boolean, null) and every other string diff --git a/apps/backend/internal/user/store/sqlite_test.go b/apps/backend/internal/user/store/sqlite_test.go index 71bc4ee839..3b560e5463 100644 --- a/apps/backend/internal/user/store/sqlite_test.go +++ b/apps/backend/internal/user/store/sqlite_test.go @@ -885,6 +885,56 @@ func TestSQLiteRepositoryKanbanHiddenStepIDsDefaultAndRoundTrip(t *testing.T) { } } +func TestSQLiteRepositoryWorkflowIDsWithAutoHideEmptyStepsDefaultAndRoundTrip(t *testing.T) { + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + repo, err := newSQLiteRepositoryWithDB(conn, conn) + if err != nil { + t.Fatalf("new repo: %v", err) + } + + ctx := context.Background() + settings, err := repo.GetUserSettings(ctx, DefaultUserID) + if err != nil { + t.Fatalf("get defaults: %v", err) + } + if settings.WorkflowIDsWithAutoHideEmptySteps == nil || len(settings.WorkflowIDsWithAutoHideEmptySteps) != 0 { + t.Fatalf("default WorkflowIDsWithAutoHideEmptySteps = %#v, want non-nil empty", settings.WorkflowIDsWithAutoHideEmptySteps) + } + settings.WorkflowIDsWithAutoHideEmptySteps = []string{"wf-a", "wf-b"} + upsertUserSettingsForTest(t, repo, ctx, settings) + got, err := repo.GetUserSettings(ctx, DefaultUserID) + if err != nil { + t.Fatalf("get settings: %v", err) + } + if !reflect.DeepEqual(got.WorkflowIDsWithAutoHideEmptySteps, settings.WorkflowIDsWithAutoHideEmptySteps) { + t.Fatalf("WorkflowIDsWithAutoHideEmptySteps = %#v, want %#v", got.WorkflowIDsWithAutoHideEmptySteps, settings.WorkflowIDsWithAutoHideEmptySteps) + } +} + +func TestDecodeWorkflowIDsWithAutoHideEmptyStepsFallsBackToEmpty(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + }{ + {name: "missing", raw: nil}, + {name: "null", raw: json.RawMessage(`null`)}, + {name: "non-array", raw: json.RawMessage(`{"workflow":"wf-a"}`)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := decodeStringIDs(tt.raw) + if got == nil || len(got) != 0 { + t.Fatalf("decodeStringIDs(%s) = %#v, want non-nil empty", tt.raw, got) + } + }) + } +} + // TestScanUserSettingsKanbanHiddenStepIDsCorruptFallsBackToEmpty verifies corrupt kanban_hidden_step_ids values fall back to empty while sibling fields still load. func TestScanUserSettingsKanbanHiddenStepIDsCorruptFallsBackToEmpty(t *testing.T) { tests := []struct { diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index 5de6c7c4d6..f74cfd9d97 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -611,6 +611,7 @@ export function KanbanCardShell({ data-testid={`task-card-${task.id}`} className={cn( "group max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0", + "touch-none md:touch-auto", needsAction(task) && !isSelected && "border-l-2 border-l-amber-500", isDragging && "opacity-50 z-50", isSelected && "ring-1 ring-primary/60 border-primary/60", diff --git a/apps/web/components/kanban/adaptive-desktop-kanban.tsx b/apps/web/components/kanban/adaptive-desktop-kanban.tsx index c77c35ca4d..d6783b6ad6 100644 --- a/apps/web/components/kanban/adaptive-desktop-kanban.tsx +++ b/apps/web/components/kanban/adaptive-desktop-kanban.tsx @@ -2,14 +2,21 @@ import type { ReactNode } from "react"; import type { WorkflowStep } from "@/components/kanban-column"; -import { getKanbanColumnGridTemplate } from "./kanban-grid-template"; +import { getKanbanColumnGridTemplate, KANBAN_COLUMN_MIN_PX } from "./kanban-grid-template"; type AdaptiveDesktopKanbanProps = { steps: WorkflowStep[]; + isDragging?: boolean; renderColumn: (step: WorkflowStep) => ReactNode; }; -export function AdaptiveDesktopKanban({ steps, renderColumn }: AdaptiveDesktopKanbanProps) { +export const KANBAN_DRAG_END_PADDING = `max(0px, calc(100% - ${KANBAN_COLUMN_MIN_PX}px))`; + +export function AdaptiveDesktopKanban({ + steps, + isDragging = false, + renderColumn, +}: AdaptiveDesktopKanbanProps) { return (
{steps.map((step) => ( -
+
{renderColumn(step)}
))} diff --git a/apps/web/components/kanban/columns-menu.test.tsx b/apps/web/components/kanban/columns-menu.test.tsx index d27ef57748..9eeccd737b 100644 --- a/apps/web/components/kanban/columns-menu.test.tsx +++ b/apps/web/components/kanban/columns-menu.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ColumnsMenu } from "./columns-menu"; @@ -19,6 +19,8 @@ function baseProps(overrides: Partial> ], hiddenStepIds: [] as string[], onToggle: vi.fn(), + autoHideEmpty: false, + onToggleAutoHide: vi.fn(), ...overrides, }; } @@ -77,6 +79,36 @@ describe("ColumnsMenu — one workflow's steps", () => { expect(onToggle).toHaveBeenCalledExactlyOnceWith(WF.id, "step-z"); }); + it("reports the workflow when automatic empty-column hiding is toggled", () => { + const onToggleAutoHide = vi.fn(); + render(); + openMenu(); + + fireEvent.click(screen.getByTestId(`columns-menu-auto-hide-empty-${WF.id}`)); + + expect(onToggleAutoHide).toHaveBeenCalledExactlyOnceWith(WF.id); + }); + + it("separates display behavior from individual column visibility", () => { + render(); + openMenu(); + + const toggle = screen.getByTestId(`columns-menu-auto-hide-empty-${WF.id}`); + const menu = toggle.closest('[role="menu"]'); + expect(menu).not.toBeNull(); + const menuQueries = within(menu!); + const label = menuQueries.getByText("Auto-hide empty columns"); + expect(label.classList.contains("whitespace-normal")).toBe(true); + expect(label.classList.contains("truncate")).toBe(false); + expect(menuQueries.queryByText("Show empty steps again while moving tasks.")).toBeNull(); + expect( + Array.from(menu!.querySelectorAll('[data-slot="dropdown-menu-label"]')).map( + (element) => element.textContent, + ), + ).toEqual(["Display Options", "Columns"]); + expect(menu!.querySelectorAll('[data-slot="dropdown-menu-separator"]')).toHaveLength(1); + }); + it("ignores a hidden id that no longer matches a live step", () => { render(); openMenu(); diff --git a/apps/web/components/kanban/columns-menu.tsx b/apps/web/components/kanban/columns-menu.tsx index 9dbaa511d5..9d288e2c35 100644 --- a/apps/web/components/kanban/columns-menu.tsx +++ b/apps/web/components/kanban/columns-menu.tsx @@ -7,11 +7,13 @@ import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; import { IconColumns3 } from "@tabler/icons-react"; import { cn } from "@kandev/ui/lib/utils"; import { useTranslation } from "react-i18next"; +import { sortWorkflowStepsByPosition } from "@/lib/kanban/auto-hide-empty-columns"; export type ColumnsMenuStep = { id: string; title: string; position: number }; @@ -23,6 +25,8 @@ export type ColumnsMenuProps = { /** Persisted hidden ids for this workflow; ids with no live step are inert. */ hiddenStepIds: string[]; onToggle: (workflowId: string, stepId: string) => void; + autoHideEmpty: boolean; + onToggleAutoHide: (workflowId: string) => void; /** * Phone rows must clear 44 CSS px. Content is identical either way — this * only relaxes menu density for touch. @@ -45,13 +49,12 @@ export function ColumnsMenu({ steps, hiddenStepIds, onToggle, + autoHideEmpty, + onToggleAutoHide, touchTargets = false, }: ColumnsMenuProps) { const { t } = useTranslation(); - const orderedSteps = useMemo( - () => [...steps].sort((a, b) => a.position - b.position || a.id.localeCompare(b.id)), - [steps], - ); + const orderedSteps = useMemo(() => sortWorkflowStepsByPosition(steps), [steps]); const hiddenSet = useMemo(() => new Set(hiddenStepIds), [hiddenStepIds]); return ( @@ -71,6 +74,19 @@ export function ColumnsMenu({ + {t("kanban:displayOptions")} + onToggleAutoHide(workflowId)} + onSelect={(event) => event.preventDefault()} + > + + {t("kanban:autoHideEmptyColumns")} + + + {t("kanban:columns")} {orderedSteps.map((step) => ( + {t("kanban:allEmptyStepsAutoHidden")} +
+ ); +} + +export function getDesktopEmptyState( + isMobile: boolean, + displaySteps: WorkflowStep[], + moveTargetSteps: WorkflowStep[], +) { + if (isMobile || displaySteps.length > 0) return undefined; + if (!areAllEmptyStepsAutoHidden(displaySteps, moveTargetSteps)) return null; + return ; +} diff --git a/apps/web/components/kanban/graph2-step-node.test.tsx b/apps/web/components/kanban/graph2-step-node.test.tsx index 5be542b17e..e4d6db52cf 100644 --- a/apps/web/components/kanban/graph2-step-node.test.tsx +++ b/apps/web/components/kanban/graph2-step-node.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { StateProvider } from "@/components/state-provider"; @@ -155,3 +155,65 @@ describe("Graph2StepNode — waiting-for-input variants", () => { expect(container.querySelector(ICON_LOADER2)).toBeNull(); }); }); + +describe("Graph2StepNode — hidden destination disclosure", () => { + function renderNextMove(nextStepHidden: boolean) { + render( + + + undefined} + onPreviewTask={() => undefined} + /> + + , + ); + const currentStep = screen.getByRole("button", { name: "In Progress" }); + fireEvent.mouseEnter(currentStep.parentElement!); + return screen.getByRole("button", { name: "Move to Done" }); + } + + it("shows the destination tooltip only when the destination is hidden", async () => { + const hiddenTargetButton = renderNextMove(true); + fireEvent.focus(hiddenTargetButton); + await waitFor(() => expect(screen.getByRole("tooltip").textContent).toBe("Move to Done")); + + cleanup(); + const visibleTargetButton = renderNextMove(false); + fireEvent.focus(visibleTargetButton); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + + it("exposes move controls when the current node receives keyboard focus", () => { + render( + + + undefined} + onPreviewTask={() => undefined} + /> + + , + ); + + fireEvent.focus(screen.getByRole("button", { name: "In Progress" })); + + expect(screen.getByRole("button", { name: "Move to Done" })).not.toBeNull(); + }); +}); diff --git a/apps/web/components/kanban/graph2-step-node.tsx b/apps/web/components/kanban/graph2-step-node.tsx index b6665ea302..6390a90e75 100644 --- a/apps/web/components/kanban/graph2-step-node.tsx +++ b/apps/web/components/kanban/graph2-step-node.tsx @@ -14,6 +14,8 @@ import { linkToTask } from "@/lib/links"; import type { Task } from "@/components/kanban-card"; import type { WorkflowStep } from "@/components/kanban-column"; import { useTaskPendingInput } from "@/hooks/use-task-pending-input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTranslation } from "react-i18next"; type StepPhase = "past" | "current" | "future"; @@ -31,6 +33,10 @@ export type Graph2StepNodeProps = { onPreviewTask: (task: Task) => void; prevStepId?: string; nextStepId?: string; + prevStepTitle?: string; + nextStepTitle?: string; + prevStepHidden?: boolean; + nextStepHidden?: boolean; isMoving?: boolean; }; @@ -62,17 +68,22 @@ function FutureNode({ step }: { step: WorkflowStep }) { function MoveButton({ direction, isMoving, + label, + showTooltip, onClick, }: { direction: "left" | "right"; isMoving?: boolean; + label: string; + showTooltip: boolean; onClick: (e: React.MouseEvent) => void; }) { const posClass = direction === "left" ? "-left-3" : "-right-3"; const Icon = direction === "left" ? IconChevronLeft : IconChevronRight; - return ( + const button = ( ); + if (!showTooltip) return button; + return ( + + {button} + {label} + + ); } export function Graph2StepNode({ @@ -97,10 +115,16 @@ export function Graph2StepNode({ onMoveTask, prevStepId, nextStepId, + prevStepTitle, + nextStepTitle, + prevStepHidden = false, + nextStepHidden = false, isMoving, }: Graph2StepNodeProps) { + const { t } = useTranslation(); const router = useRouter(); const [isHovered, setIsHovered] = useState(false); + const [isFocused, setIsFocused] = useState(false); const pendingInput = useTaskPendingInput(task.primarySessionId, { taskId: task.id, taskPendingAction: task.taskPendingAction, @@ -118,16 +142,26 @@ export function Graph2StepNode({ router.push(linkToTask(task.id)); }; + const showMoveControls = isHovered || isFocused; + return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + onFocusCapture={() => setIsFocused(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setIsFocused(false); + } + }} > - {isHovered && hasPrev && prevStepId && ( + {showMoveControls && hasPrev && prevStepId && ( { e.stopPropagation(); onMoveTask(task, prevStepId); @@ -158,10 +192,12 @@ export function Graph2StepNode({
- {isHovered && hasNext && nextStepId && ( + {showMoveControls && hasNext && nextStepId && ( { e.stopPropagation(); onMoveTask(task, nextStepId); diff --git a/apps/web/components/kanban/graph2-task-pipeline.tsx b/apps/web/components/kanban/graph2-task-pipeline.tsx index 3639b76ff7..9032561801 100644 --- a/apps/web/components/kanban/graph2-task-pipeline.tsx +++ b/apps/web/components/kanban/graph2-task-pipeline.tsx @@ -27,6 +27,7 @@ type ConnectorType = "past" | "transition" | "future"; export type Graph2TaskPipelineProps = { task: Task; steps: WorkflowStep[]; + moveTargetSteps: WorkflowStep[]; onMoveTask: (task: Task, targetStepId: string) => void; onPreviewTask: (task: Task) => void; onOpenTask: (task: Task) => void; @@ -62,6 +63,13 @@ export type StepAdjacency = { nextStepId?: string; }; +export type StepMoveTargets = StepAdjacency & { + prevStepTitle?: string; + prevStepHidden: boolean; + nextStepTitle?: string; + nextStepHidden: boolean; +}; + /** * Computes the prev/next move targets for the node at `index`. The synthetic * "Needs Reassignment" node is display-only: it marks where an orphaned task @@ -80,8 +88,36 @@ export function getStepAdjacency(steps: WorkflowStep[], index: number): StepAdja }; } +export function getStepAdjacencyForStep( + moveTargetSteps: WorkflowStep[], + stepId: string, +): StepAdjacency { + const index = moveTargetSteps.findIndex((step) => step.id === stepId); + if (index < 0) return { hasPrev: false, hasNext: false }; + return getStepAdjacency(moveTargetSteps, index); +} + +export function getStepMoveTargets( + visibleSteps: WorkflowStep[], + moveTargetSteps: WorkflowStep[], + stepId: string, +): StepMoveTargets { + const adjacency = getStepAdjacencyForStep(moveTargetSteps, stepId); + const visibleStepIds = new Set(visibleSteps.map((step) => step.id)); + const prevStep = moveTargetSteps.find((step) => step.id === adjacency.prevStepId); + const nextStep = moveTargetSteps.find((step) => step.id === adjacency.nextStepId); + return { + ...adjacency, + prevStepTitle: prevStep?.title, + prevStepHidden: !!adjacency.prevStepId && !visibleStepIds.has(adjacency.prevStepId), + nextStepTitle: nextStep?.title, + nextStepHidden: !!adjacency.nextStepId && !visibleStepIds.has(adjacency.nextStepId), + }; +} + function PipelineStepNodes({ steps, + moveTargetSteps, currentStepIndex, task, onMoveTask, @@ -89,6 +125,7 @@ function PipelineStepNodes({ isMoving, }: { steps: WorkflowStep[]; + moveTargetSteps: WorkflowStep[]; currentStepIndex: number; task: Task; onMoveTask: (task: Task, targetStepId: string) => void; @@ -104,7 +141,7 @@ function PipelineStepNodes({ ? getConnectorType(phase, getStepPhase(index + 1, currentStepIndex)) : null; - const { hasPrev, prevStepId, hasNext, nextStepId } = getStepAdjacency(steps, index); + const moveTargets = getStepMoveTargets(steps, moveTargetSteps, step.id); return (
@@ -112,10 +149,14 @@ function PipelineStepNodes({ step={step} phase={phase} task={task} - hasPrev={hasPrev} - hasNext={hasNext} - prevStepId={prevStepId} - nextStepId={nextStepId} + hasPrev={moveTargets.hasPrev} + hasNext={moveTargets.hasNext} + prevStepId={moveTargets.prevStepId} + nextStepId={moveTargets.nextStepId} + prevStepTitle={moveTargets.prevStepTitle} + nextStepTitle={moveTargets.nextStepTitle} + prevStepHidden={moveTargets.prevStepHidden} + nextStepHidden={moveTargets.nextStepHidden} onMoveTask={onMoveTask} onPreviewTask={onPreviewTask} isMoving={isMoving} @@ -264,6 +305,7 @@ function useTaskRepoName(task: Task): string | undefined { export function Graph2TaskPipeline({ task, steps, + moveTargetSteps, onMoveTask, onPreviewTask, onOpenTask, @@ -324,6 +366,7 @@ export function Graph2TaskPipeline({ /> vi.fn()); +const pointerWithinMock = vi.hoisted(() => vi.fn()); + +vi.mock("@dnd-kit/core", () => ({ + DndContext: ({ children, ...props }: { children: React.ReactNode }) => { + dndContextProps(props); + return <>{children}; + }, + DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}, + pointerWithin: pointerWithinMock, +})); + +import { KANBAN_AUTO_SCROLL_OPTIONS, KanbanDragSurface } from "./kanban-drag-surface"; + +describe("KanbanDragSurface", () => { + it("leaves horizontal layout compensation to the board anchor", () => { + render( + } + activeTask={null} + boardRef={createRef()} + />, + ); + + expect(KANBAN_AUTO_SCROLL_OPTIONS).toEqual({ + layoutShiftCompensation: { x: false, y: true }, + }); + expect(dndContextProps).toHaveBeenCalledWith( + expect.objectContaining({ + autoScroll: KANBAN_AUTO_SCROLL_OPTIONS, + collisionDetection: pointerWithinMock, + }), + ); + }); +}); diff --git a/apps/web/components/kanban/kanban-drag-surface.tsx b/apps/web/components/kanban/kanban-drag-surface.tsx new file mode 100644 index 0000000000..e82f2e1736 --- /dev/null +++ b/apps/web/components/kanban/kanban-drag-surface.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { ComponentProps, ReactNode, RefObject } from "react"; +import { DndContext, DragOverlay, pointerWithin } from "@dnd-kit/core"; +import { KanbanCardPreview } from "@/components/kanban-card-preview"; +import type { Task } from "@/components/kanban-card"; + +type KanbanDragSurfaceProps = Pick< + ComponentProps, + "sensors" | "onDragStart" | "onDragEnd" | "onDragCancel" +> & { + layoutContent: ReactNode; + activeTask: Task | null; + boardRef: RefObject; +}; + +export const KANBAN_AUTO_SCROLL_OPTIONS = { + layoutShiftCompensation: { x: false, y: true }, +} as const; + +export function KanbanDragSurface({ + layoutContent, + activeTask, + boardRef, + ...dndProps +}: KanbanDragSurfaceProps) { + return ( + +
+ {layoutContent} +
+ + {activeTask ? : null} + +
+ ); +} diff --git a/apps/web/components/kanban/kanban-grid-template.test.ts b/apps/web/components/kanban/kanban-grid-template.test.ts index 75444bbefc..69547e5691 100644 --- a/apps/web/components/kanban/kanban-grid-template.test.ts +++ b/apps/web/components/kanban/kanban-grid-template.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { getKanbanColumnGridTemplate } from "./kanban-grid-template"; +import { KANBAN_DRAG_END_PADDING } from "./adaptive-desktop-kanban"; describe("getKanbanColumnGridTemplate", () => { it("keeps every desktop column readable", () => { @@ -14,3 +15,9 @@ describe("getKanbanColumnGridTemplate", () => { expect(getKanbanColumnGridTemplate(0)).toBe("repeat(0, minmax(280px, 1fr))"); }); }); + +describe("KANBAN_DRAG_END_PADDING", () => { + it("reserves one viewport minus one readable column for drag anchoring", () => { + expect(KANBAN_DRAG_END_PADDING).toBe("max(0px, calc(100% - 280px))"); + }); +}); diff --git a/apps/web/components/kanban/mobile-column-tabs.tsx b/apps/web/components/kanban/mobile-column-tabs.tsx index 6e869aef54..788a23a09f 100644 --- a/apps/web/components/kanban/mobile-column-tabs.tsx +++ b/apps/web/components/kanban/mobile-column-tabs.tsx @@ -30,6 +30,7 @@ type MobileColumnTabsProps = { taskCounts: Record; onColumnChange: (index: number) => void; workflowNavigation?: MobileWorkflowNavigation; + allStepsAutoHidden?: boolean; }; function StepCount({ step, count }: { step: WorkflowStep; count: number }) { @@ -114,12 +115,14 @@ function StepOptions({ taskCounts, onSelect, separated, + allStepsAutoHidden, }: { steps: WorkflowStep[]; activeIndex: number; taskCounts: Record; onSelect: (index: number) => void; separated: boolean; + allStepsAutoHidden?: boolean; }) { const { t } = useTranslation(); return ( @@ -134,7 +137,9 @@ function StepOptions({ {t("kanban:step")} {steps.length === 0 && ( -

{t("kanban:noStepsConfigured")}

+

+ {allStepsAutoHidden ? t("kanban:allEmptyStepsAutoHidden") : t("kanban:noStepsConfigured")} +

)} {steps.map((step, index) => { const isActive = index === activeIndex; @@ -167,6 +172,7 @@ function NavigatorDrawerContent({ activeIndex, taskCounts, workflowNavigation, + allStepsAutoHidden, onSelectStep, onSelectWorkflow, }: Omit & { @@ -192,18 +198,173 @@ function NavigatorDrawerContent({ taskCounts={taskCounts} onSelect={onSelectStep} separated={!!workflowNavigation} + allStepsAutoHidden={allStepsAutoHidden} />
); } +function getEmptyStepsLabel(allStepsAutoHidden: boolean, t: (key: string) => string): string { + return t(allStepsAutoHidden ? "kanban:allEmptyStepsAutoHidden" : "kanban:noStepsConfigured"); +} + +function PreviousStepButton({ + activeStep, + activeIndex, + onColumnChange, + t, +}: { + activeStep?: WorkflowStep; + activeIndex: number; + onColumnChange: (index: number) => void; + t: (key: string) => string; +}) { + return ( + + ); +} + +function NextStepButton({ + activeStep, + activeIndex, + stepCount, + onColumnChange, + t, +}: { + activeStep?: WorkflowStep; + activeIndex: number; + stepCount: number; + onColumnChange: (index: number) => void; + t: (key: string) => string; +}) { + return ( + + ); +} + +function NavigatorTrigger({ + activeStep, + activeWorkflow, + stepLabel, + taskCounts, + t, +}: { + activeStep?: WorkflowStep; + activeWorkflow?: MobileWorkflowNavigation["workflows"][number]; + stepLabel: string; + taskCounts: Record; + t: (key: string, options?: Record) => string; +}) { + const ariaLabel = activeWorkflow + ? t("kanban:chooseWorkflowOrStep", { name: activeWorkflow.name, stepLabel }) + : t("kanban:chooseStep", { stepLabel }); + return ( + + + + ); +} + +function MobileColumnTabsHeader({ + steps, + activeStep, + activeIndex, + activeWorkflow, + stepLabel, + taskCounts, + onColumnChange, + t, +}: { + steps: WorkflowStep[]; + activeStep?: WorkflowStep; + activeIndex: number; + activeWorkflow?: MobileWorkflowNavigation["workflows"][number]; + stepLabel: string; + taskCounts: Record; + onColumnChange: (index: number) => void; + t: (key: string, options?: Record) => string; +}) { + return ( +
+ + + +
+ ); +} + export function MobileColumnTabs({ steps, activeIndex, taskCounts, onColumnChange, workflowNavigation, + allStepsAutoHidden = false, }: MobileColumnTabsProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -212,7 +373,7 @@ export function MobileColumnTabs({ workflowNavigation?.workflows.find( (workflow) => workflow.id === workflowNavigation.activeWorkflowId, ) ?? workflowNavigation?.workflows[0]; - const stepLabel = activeStep?.title ?? t("task:noStepsConfigured"); + const stepLabel = activeStep?.title ?? getEmptyStepsLabel(allStepsAutoHidden, t); const selectStep = (index: number) => { onColumnChange(index); @@ -224,72 +385,23 @@ export function MobileColumnTabs({ return ( -
- - - - - - - -
+ diff --git a/apps/web/components/kanban/mobile-drop-targets.tsx b/apps/web/components/kanban/mobile-drop-targets.tsx index c1eb6962d8..663f8bb5c3 100644 --- a/apps/web/components/kanban/mobile-drop-targets.tsx +++ b/apps/web/components/kanban/mobile-drop-targets.tsx @@ -18,6 +18,7 @@ function MobileDropTarget({ step, isCurrentStep }: MobileDropTargetProps) { return (
{ diff --git a/apps/web/components/kanban/mobile-menu-sheet.test.tsx b/apps/web/components/kanban/mobile-menu-sheet.test.tsx index 1534d319f3..8febc9cad5 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.test.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.test.tsx @@ -76,11 +76,13 @@ function defaultDisplaySettings() { eligibleWorkflows: [], snapshots: {}, hiddenWorkflowStepIds: {}, + workflowIdsWithAutoHideEmptySteps: [], onWorkflowChange: vi.fn(), onRepositoryChange: vi.fn(), onTogglePreviewOnClick: vi.fn(), onToggleTasksListShowDetails: vi.fn(), onToggleStepVisibility: vi.fn(), + onToggleAutoHideEmpty: vi.fn(), effectiveTaskListingView: "kanban", onViewModeChange: vi.fn(), }; diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index 7058e74bfd..90e2f44002 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -72,6 +72,8 @@ export type MobileColumnsSection = { steps: ColumnsMenuStep[]; hiddenStepIds: string[]; onToggle: (workflowId: string, stepId: string) => void; + autoHideEmpty: boolean; + onToggleAutoHide: (workflowId: string) => void; }; function MobileDisplaySelects({ diff --git a/apps/web/components/kanban/orphan-move-target.test.ts b/apps/web/components/kanban/orphan-move-target.test.ts index 96d160e71c..a880c2390c 100644 --- a/apps/web/components/kanban/orphan-move-target.test.ts +++ b/apps/web/components/kanban/orphan-move-target.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { ORPHAN_STEP_ID, isOrphanMoveTarget } from "./swimlane-kanban-content"; -import { getStepAdjacency } from "./graph2-task-pipeline"; +import { + getStepAdjacency, + getStepAdjacencyForStep, + getStepMoveTargets, +} from "./graph2-task-pipeline"; import type { WorkflowStep } from "@/components/kanban-column"; describe("isOrphanMoveTarget", () => { @@ -14,6 +18,45 @@ describe("isOrphanMoveTarget", () => { }); }); +describe("getStepAdjacencyForStep", () => { + it("keeps auto-hidden workflow steps available as adjacent pipeline move targets", () => { + const steps = [{ id: "todo" }, { id: "doing" }, { id: "done" }] as WorkflowStep[]; + + expect(getStepAdjacencyForStep(steps, "doing")).toEqual({ + hasPrev: true, + prevStepId: "todo", + hasNext: true, + nextStepId: "done", + }); + }); + + it("returns no moves for a display-only step", () => { + expect(getStepAdjacencyForStep([{ id: "todo" } as WorkflowStep], "orphan")).toEqual({ + hasPrev: false, + hasNext: false, + }); + }); +}); + +describe("getStepMoveTargets", () => { + const moveTargets = [ + { id: "backlog", title: "Backlog" }, + { id: "review", title: "Review" }, + { id: "done", title: "Done" }, + ] as WorkflowStep[]; + + it("flags only a hidden neighboring destination for tooltip disclosure", () => { + const result = getStepMoveTargets([moveTargets[1], moveTargets[2]], moveTargets, "review"); + + expect(result).toMatchObject({ + prevStepTitle: "Backlog", + prevStepHidden: true, + nextStepTitle: "Done", + nextStepHidden: false, + }); + }); +}); + describe("getStepAdjacency", () => { const realSteps: WorkflowStep[] = [ { id: "todo", title: "Todo", color: "#64748b" }, diff --git a/apps/web/components/kanban/swimlane-container.test.ts b/apps/web/components/kanban/swimlane-container.test.ts index 55741fcc64..0ebabac7ff 100644 --- a/apps/web/components/kanban/swimlane-container.test.ts +++ b/apps/web/components/kanban/swimlane-container.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { Task } from "@/components/kanban-card"; -import { filterTasks } from "./swimlane-container"; +import { filterTasks, projectWorkflowTasks } from "@/lib/kanban/task-projections"; import { mapSelectedRepositoryIds } from "@/lib/kanban/filters"; function makeTask(overrides: Partial & { id: string }): Task { @@ -163,3 +163,29 @@ describe("filterTasks — hiddenStepIds (per-workflow step visibility filter)", expect(resultB.map((t) => t.id)).toEqual(["2"]); }); }); + +describe("projectWorkflowTasks — visible cards vs column occupancy", () => { + const NO_REPO_FILTER = mapSelectedRepositoryIds([], []); + + it("keeps search and manual hiding out of occupancy while applying plugin filters", () => { + const snapshots = { + wf: { + tasks: [ + makeTask({ id: "visible", title: "Needle", workflowStepId: "step-a" }), + makeTask({ id: "hidden", title: "Other", workflowStepId: "step-b" }), + makeTask({ id: "plugin-rejected", title: "Needle", workflowStepId: "step-c" }), + ], + steps: [{ id: "step-a" }, { id: "step-b" }, { id: "step-c" }], + }, + }; + + const projection = projectWorkflowTasks(snapshots, "wf", NO_REPO_FILTER, { + searchQuery: "needle", + hiddenStepIds: new Set(["step-b"]), + matchesPluginTaskFilters: (taskId) => taskId !== "plugin-rejected", + }); + + expect(projection.visibleTasks.map((task) => task.id)).toEqual(["visible"]); + expect(projection.occupancyTasks.map((task) => task.id)).toEqual(["visible", "hidden"]); + }); +}); diff --git a/apps/web/components/kanban/swimlane-container.tsx b/apps/web/components/kanban/swimlane-container.tsx index 6a48e2fac7..59124c8fa7 100644 --- a/apps/web/components/kanban/swimlane-container.tsx +++ b/apps/web/components/kanban/swimlane-container.tsx @@ -19,7 +19,8 @@ import { CSS } from "@dnd-kit/utilities"; import { useAppStore } from "@/components/state-provider"; import { useSwimlaneCollapse } from "@/hooks/domains/kanban/use-swimlane-collapse"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; -import { filterTasksByRepositories, mapSelectedRepositoryIds } from "@/lib/kanban/filters"; +import { mapSelectedRepositoryIds } from "@/lib/kanban/filters"; +import { projectWorkflowTasks } from "@/lib/kanban/task-projections"; import { selectMobileNavigatorWorkflows, selectWorkflowSwimlanes, @@ -28,6 +29,10 @@ import { import { reorderWorkflows } from "@/lib/api"; import { SwimlaneSection } from "./swimlane-section"; import { ColumnsMenu } from "./columns-menu"; +import { + deriveAutoHiddenStepIds, + sortWorkflowStepsByPosition, +} from "@/lib/kanban/auto-hide-empty-columns"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { getEffectiveView, @@ -95,45 +100,6 @@ function renderEmptyState(emptyMessage: string) { ); } -type FilterTasksOptions = { - searchQuery?: string; - matchesPluginTaskFilters?: (taskId: string) => boolean; - hiddenStepIds?: Set; -}; - -/** Exported for unit testing; not part of the module's public component API. */ -export function filterTasks( - snapshots: Record, - workflowId: string, - repoFilter: ReturnType, - options?: FilterTasksOptions, -): Task[] { - const snapshot = snapshots[workflowId]; - if (!snapshot) return []; - let tasks = snapshot.tasks as Task[]; - const { hiddenStepIds, searchQuery, matchesPluginTaskFilters } = options ?? {}; - if (hiddenStepIds && hiddenStepIds.size > 0) { - const liveStepIds = new Set(snapshot.steps.map((s) => s.id)); - const effectiveHidden = new Set([...hiddenStepIds].filter((id) => liveStepIds.has(id))); - if (effectiveHidden.size > 0) { - tasks = tasks.filter((t) => !effectiveHidden.has(t.workflowStepId)); - } - } - tasks = filterTasksByRepositories(tasks, repoFilter); - if (searchQuery) { - const q = searchQuery.toLowerCase(); - tasks = tasks.filter( - (t) => - t.title.toLowerCase().includes(q) || - (t.description && t.description.toLowerCase().includes(q)), - ); - } - if (matchesPluginTaskFilters) { - tasks = tasks.filter((t) => matchesPluginTaskFilters(t.id)); - } - return tasks; -} - /** Stable identity so an unhidden workflow does not remount its menu each render. */ const EMPTY_HIDDEN_IDS: string[] = []; @@ -141,6 +107,7 @@ type WorkflowItemProps = { wf: { id: string; name: string }; snapshot: WorkflowSnapshotData; tasks: Task[]; + occupancyTasks: Task[]; ViewComponent: ComponentType; hideHeader: boolean; isSortable: boolean; @@ -163,6 +130,7 @@ type WorkflowItemProps = { fillHeight?: boolean; mobileWorkflowNavigation?: MobileWorkflowNavigation; onToggleStepVisibility: (workflowId: string, stepId: string) => void; + onToggleAutoHideEmpty: (workflowId: string) => void; }; function SortableWorkflowItem({ @@ -203,6 +171,7 @@ function WorkflowItemContent({ wf, snapshot, tasks, + occupancyTasks, ViewComponent, hideHeader, isCollapsed, @@ -211,23 +180,52 @@ function WorkflowItemContent({ onToggleMultiSelect, fillHeight, onToggleStepVisibility, + onToggleAutoHideEmpty, ...viewProps }: Omit & { dragHandleProps?: HTMLAttributes }) { const hiddenWorkflowStepIds = useAppStore((state) => state.userSettings.hiddenWorkflowStepIds); + const workflowIdsWithAutoHideEmptySteps = useAppStore( + (state) => state.userSettings.workflowIdsWithAutoHideEmptySteps, + ); const hiddenSet = useMemo(() => { const ids = hiddenWorkflowStepIds[wf.id]; if (!ids || ids.length === 0) return new Set(); const liveStepIds = new Set(snapshot.steps.map((s) => s.id)); return new Set(ids.filter((id) => liveStepIds.has(id))); }, [hiddenWorkflowStepIds, wf.id, snapshot.steps]); - const steps = useMemo( + const autoHiddenSet = useMemo( () => - [...snapshot.steps] - .filter((step) => !hiddenSet.has(step.id)) - .sort((a, b) => a.position - b.position), + deriveAutoHiddenStepIds( + snapshot.steps, + occupancyTasks, + workflowIdsWithAutoHideEmptySteps.includes(wf.id), + hiddenWorkflowStepIds[wf.id] ?? EMPTY_HIDDEN_IDS, + ), + [ + snapshot.steps, + occupancyTasks, + workflowIdsWithAutoHideEmptySteps, + wf.id, + hiddenWorkflowStepIds, + ], + ); + const moveTargetSteps = useMemo( + () => sortWorkflowStepsByPosition(snapshot.steps).filter((step) => !hiddenSet.has(step.id)), [snapshot.steps, hiddenSet], ); - const content = ; + const steps = useMemo( + () => moveTargetSteps.filter((step) => !autoHiddenSet.has(step.id)), + [moveTargetSteps, autoHiddenSet], + ); + const content = ( + + ); if (hideHeader) { return
{content}
; @@ -251,6 +249,8 @@ function WorkflowItemContent({ steps={snapshot.steps} hiddenStepIds={hiddenWorkflowStepIds[wf.id] ?? EMPTY_HIDDEN_IDS} onToggle={onToggleStepVisibility} + autoHideEmpty={workflowIdsWithAutoHideEmptySteps.includes(wf.id)} + onToggleAutoHide={onToggleAutoHideEmpty} /> } > @@ -302,6 +302,9 @@ function useSwimlaneData( const workflows = useAppStore((state) => state.workflows.items); const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); const hiddenWorkflowStepIds = useAppStore((state) => state.userSettings.hiddenWorkflowStepIds); + const workflowIdsWithAutoHideEmptySteps = useAppStore( + (state) => state.userSettings.workflowIdsWithAutoHideEmptySteps, + ); const repositories = useMemo( () => Object.values(repositoriesByWorkspace).flat() as Repository[], @@ -320,27 +323,40 @@ function useSwimlaneData( [workflowFilter, workflows, snapshots], ); - const getFilteredTasks = useCallback( - (wfId: string) => { + const getTaskProjection = useMemo(() => { + const cache = new Map>(); + return (wfId: string) => { + const cached = cache.get(wfId); + if (cached) return cached; const hidden = hiddenWorkflowStepIds[wfId]; const hiddenSet = hidden && hidden.length > 0 ? new Set(hidden) : undefined; - return filterTasks(snapshots, wfId, repoFilter, { + const projection = projectWorkflowTasks(snapshots, wfId, repoFilter, { searchQuery, matchesPluginTaskFilters, hiddenStepIds: hiddenSet, }); - }, - [snapshots, repoFilter, searchQuery, matchesPluginTaskFilters, hiddenWorkflowStepIds], + cache.set(wfId, projection); + return projection; + }; + }, [snapshots, repoFilter, searchQuery, matchesPluginTaskFilters, hiddenWorkflowStepIds]); + const getFilteredTasks = useCallback( + (wfId: string) => getTaskProjection(wfId).visibleTasks, + [getTaskProjection], + ); + const getOccupancyTasks = useCallback( + (wfId: string) => getTaskProjection(wfId).occupancyTasks, + [getTaskProjection], ); const hasLiveHiddenSteps = useCallback( (workflowId: string) => { const hidden = hiddenWorkflowStepIds[workflowId]; + if (workflowIdsWithAutoHideEmptySteps.includes(workflowId)) return true; if (!hidden || hidden.length === 0) return false; const liveStepIds = new Set((snapshots[workflowId]?.steps ?? []).map((step) => step.id)); return hidden.some((id) => liveStepIds.has(id)); }, - [hiddenWorkflowStepIds, snapshots], + [hiddenWorkflowStepIds, workflowIdsWithAutoHideEmptySteps, snapshots], ); // The mobile board navigator is the only workflow switcher on the mobile @@ -367,6 +383,7 @@ function useSwimlaneData( allOrderedWorkflows, workflowOptions, getFilteredTasks, + getOccupancyTasks, hasLiveHiddenSteps, }; } @@ -399,11 +416,13 @@ type WorkflowItemsProps = { workflows: { id: string; name: string }[]; snapshots: Record; getFilteredTasks: (workflowId: string) => Task[]; + getOccupancyTasks: (workflowId: string) => Task[]; ViewComponent: ComponentType; hideHeaders: boolean; fillHeight: boolean; isMobileKanban: boolean; onToggleStepVisibility: (workflowId: string, stepId: string) => void; + onToggleAutoHideEmpty: (workflowId: string) => void; canSortWorkflows: boolean; isCollapsed: (workflowId: string) => boolean; toggleCollapse: (workflowId: string) => void; @@ -415,11 +434,13 @@ function WorkflowItems({ workflows, snapshots, getFilteredTasks, + getOccupancyTasks, ViewComponent, hideHeaders, fillHeight, isMobileKanban, onToggleStepVisibility, + onToggleAutoHideEmpty, canSortWorkflows, isCollapsed, toggleCollapse, @@ -436,6 +457,7 @@ function WorkflowItems({ wf={workflow} snapshot={snapshot} tasks={getFilteredTasks(workflow.id)} + occupancyTasks={getOccupancyTasks(workflow.id)} ViewComponent={ViewComponent} hideHeader={hideHeaders} fillHeight={fillHeight && !collapsed} @@ -458,6 +480,7 @@ function WorkflowItems({ onToggleMultiSelect={index === 0 ? containerProps.onToggleMultiSelect : undefined} mobileWorkflowNavigation={mobileWorkflowNavigation} onToggleStepVisibility={onToggleStepVisibility} + onToggleAutoHideEmpty={onToggleAutoHideEmpty} /> ); }); @@ -480,7 +503,7 @@ function usePublishMobileFocus(focusedWorkflowId: string | null) { export function SwimlaneContainer(containerProps: SwimlaneContainerProps) { const { viewMode, workflowFilter, searchQuery, selectedRepositoryIds = [] } = containerProps; const { isMobile } = useResponsiveBreakpoint(); - const { onToggleStepVisibility } = useKanbanDisplaySettings(); + const { onToggleStepVisibility, onToggleAutoHideEmpty } = useKanbanDisplaySettings(); const { isCollapsed, toggleCollapse } = useSwimlaneCollapse(); const { snapshots, @@ -488,6 +511,7 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) { orderedWorkflows, workflowOptions, getFilteredTasks, + getOccupancyTasks, hasLiveHiddenSteps, } = useSwimlaneData( workflowFilter, @@ -559,9 +583,11 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) { workflows={renderedWorkflows} snapshots={snapshots} getFilteredTasks={getFilteredTasks} + getOccupancyTasks={getOccupancyTasks} ViewComponent={ViewComponent} hideHeaders={hideHeaders} onToggleStepVisibility={onToggleStepVisibility} + onToggleAutoHideEmpty={onToggleAutoHideEmpty} fillHeight={view.id === "kanban"} isMobileKanban={isMobileKanban} canSortWorkflows={canSortWorkflows} diff --git a/apps/web/components/kanban/swimlane-graph2-content.tsx b/apps/web/components/kanban/swimlane-graph2-content.tsx index e35d8e66d1..ec72e7f2f6 100644 --- a/apps/web/components/kanban/swimlane-graph2-content.tsx +++ b/apps/web/components/kanban/swimlane-graph2-content.tsx @@ -8,6 +8,7 @@ import type { ViewContentProps } from "@/lib/kanban/view-registry"; import type { Task } from "@/components/kanban-card"; import type { WorkflowStep } from "@/components/kanban-column"; import { useTranslation } from "react-i18next"; +import { areAllEmptyStepsAutoHidden } from "@/lib/kanban/auto-hide-empty-columns"; export function getGraph2DisplayState( tasks: Task[], @@ -25,6 +26,7 @@ export function getGraph2DisplayState( export function SwimlaneGraph2Content({ workflowId, steps, + moveTargetSteps, tasks, onPreviewTask, onOpenTask, @@ -46,6 +48,10 @@ export function SwimlaneGraph2Content({ () => getGraph2DisplayState(tasks, steps, t("kanban:needsReassignment")), [tasks, steps, t], ); + const pipelineMoveTargetSteps = useMemo(() => { + const orphan = displaySteps.find((step) => step.id === ORPHAN_STEP_ID); + return orphan ? [...moveTargetSteps, orphan] : moveTargetSteps; + }, [displaySteps, moveTargetSteps]); const sortedTasks = useMemo( () => @@ -70,7 +76,18 @@ export function SwimlaneGraph2Content({ if (displayTasks.length === 0) { return (
-
{t("kanban:noTasks")}
+
+ {areAllEmptyStepsAutoHidden(steps, moveTargetSteps) + ? t("kanban:allEmptyStepsAutoHidden") + : t("kanban:noTasks")} +
); } @@ -83,6 +100,7 @@ export function SwimlaneGraph2Content({ key={task.id} task={task} steps={displaySteps} + moveTargetSteps={pipelineMoveTargetSteps} onMoveTask={handleMoveTask} onPreviewTask={onPreviewTask} onOpenTask={onOpenTask} diff --git a/apps/web/components/kanban/swimlane-graph2-orphan-display.test.ts b/apps/web/components/kanban/swimlane-graph2-orphan-display.test.ts index 16ba1add81..9496913fa5 100644 --- a/apps/web/components/kanban/swimlane-graph2-orphan-display.test.ts +++ b/apps/web/components/kanban/swimlane-graph2-orphan-display.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { createElement } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { TooltipProvider } from "@kandev/ui/tooltip"; import { ORPHAN_STEP_ID } from "./swimlane-kanban-content"; -import { getGraph2DisplayState } from "./swimlane-graph2-content"; +import { getGraph2DisplayState, SwimlaneGraph2Content } from "./swimlane-graph2-content"; import type { Task } from "@/components/kanban-card"; import type { WorkflowStep } from "@/components/kanban-column"; @@ -18,6 +22,8 @@ const steps: WorkflowStep[] = [ { id: "done", title: "Done", color: "#22c55e" }, ]; +afterEach(cleanup); + describe("getGraph2DisplayState", () => { it("adds a Needs Reassignment pipeline step for tasks on deleted workflow steps", () => { const { displayTasks, displaySteps } = getGraph2DisplayState( @@ -42,3 +48,59 @@ describe("getGraph2DisplayState", () => { expect(displayTasks.map((task) => task.workflowStepId)).toEqual(["todo", "done"]); }); }); + +describe("SwimlaneGraph2Content auto-hidden steps", () => { + function renderPipeline( + visibleSteps: WorkflowStep[], + moveTargetSteps: WorkflowStep[], + tasks: Task[], + ) { + return render( + createElement( + StateProvider, + null, + createElement(TooltipProvider, { + delayDuration: 0, + children: createElement(SwimlaneGraph2Content, { + workflowId: "wf-1", + steps: visibleSteps, + moveTargetSteps, + tasks, + onPreviewTask: () => undefined, + onOpenTask: () => undefined, + onEditTask: () => undefined, + onDeleteTask: () => undefined, + }), + }), + ), + ); + } + + it("keeps auto-hidden stages out of the rendered pipeline", () => { + const doing = { id: "doing", title: "Doing", color: "#3b82f6" }; + renderPipeline( + [doing], + [ + { id: "todo", title: "Todo", color: "#64748b" }, + doing, + { id: "done", title: "Done", color: "#22c55e" }, + ], + [makeTask("task-1", "doing")], + ); + + expect(screen.getByText("Doing")).toBeTruthy(); + expect(screen.queryByText("Todo")).toBeNull(); + expect(screen.queryByText("Done")).toBeNull(); + + fireEvent.mouseEnter(screen.getByRole("button", { name: "Doing" }).parentElement!); + expect(screen.getByRole("button", { name: "Move to Todo" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Move to Done" })).toBeTruthy(); + }); + + it("explains when every empty pipeline stage is auto-hidden", () => { + renderPipeline([], steps, []); + + expect(screen.getByTestId("pipeline-auto-hidden-empty-state")).toBeTruthy(); + expect(screen.queryByText("No tasks")).toBeNull(); + }); +}); diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx index 695e4920e1..701d0f277b 100644 --- a/apps/web/components/kanban/swimlane-kanban-content.tsx +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -2,9 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { - DndContext, DragEndEvent, - DragOverlay, DragStartEvent, PointerSensor, TouchSensor, @@ -13,7 +11,6 @@ import { } from "@dnd-kit/core"; import { KanbanColumn } from "@/components/kanban-column"; import { type Task } from "@/components/kanban-card"; -import { KanbanCardPreview } from "@/components/kanban-card-preview"; import type { WorkflowStep } from "@/components/kanban-column"; import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; import { useTaskActions } from "@/hooks/use-task-actions"; @@ -22,44 +19,40 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { MobileColumnTabs } from "./mobile-column-tabs"; import { SwipeableColumns } from "./swipeable-columns"; import { MobileDropTargets } from "./mobile-drop-targets"; +import { KanbanDragSurface } from "./kanban-drag-surface"; +import { getDesktopEmptyState } from "./desktop-auto-hidden-empty-state"; +import { isOrphanMoveTarget, useOrphanDisplay } from "./swimlane-orphan-display"; +export { + ORPHAN_STEP, + ORPHAN_STEP_ID, + isOrphanMoveTarget, + remapOrphanTasks, +} from "./swimlane-orphan-display"; import { AdaptiveDesktopKanban } from "./adaptive-desktop-kanban"; import type { KanbanState } from "@/lib/state/slices/kanban/types"; import type { MobileWorkflowNavigation } from "@/lib/kanban/view-registry"; import { resolveMobileColumnIndex } from "@/lib/kanban/mobile-column-index"; import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order"; import { countAdmittedTasks } from "@/lib/kanban/wip-limit"; +import { areAllEmptyStepsAutoHidden } from "@/lib/kanban/auto-hide-empty-columns"; import { - type KanbanExternalLinkAvailability, - useKanbanExternalLinkAvailability, -} from "@/components/kanban-external-link-availability"; + getDragDisplaySteps, + getTemporaryStepIds, + useKanbanDragScrollAnchor, +} from "@/hooks/domains/kanban/use-kanban-drag-scroll-anchor"; +import { + type SharedKanbanLayoutProps, + useSharedKanbanLayoutProps, +} from "@/hooks/domains/kanban/use-shared-kanban-layout-props"; +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"; -/** - * Sentinel step ID used to collect tasks whose workflow_step_id no longer - * matches any rendered column. Tasks remapped here are visible as a - * "Needs Reassignment" fallback column so they are never silently hidden. - */ -export const ORPHAN_STEP_ID = "__kandev_orphan__"; - -export const ORPHAN_STEP: WorkflowStep = { - id: ORPHAN_STEP_ID, - title: "", - color: "#f59e0b", -}; - -/** - * The "Needs Reassignment" column is a display-only fallback, not a real - * workflow step — it must never be offered as a manual move destination - * (drag-and-drop, "Move to" menus, or Pipeline navigation). - */ -export function isOrphanMoveTarget(targetStepId: string): boolean { - return targetStepId === ORPHAN_STEP_ID; -} - export type SwimlaneKanbanContentProps = { workflowId: string; steps: WorkflowStep[]; + moveTargetSteps: WorkflowStep[]; tasks: Task[]; onPreviewTask: (task: Task) => void; onOpenTask: (task: Task) => void; @@ -174,6 +167,40 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban }; } +function useSwimlaneKanbanPresentationDnd({ + displaySteps, + moveTargetSteps, + isMobile, + ...dndOptions +}: SwimlaneKanbanDndOptions & { + displaySteps: WorkflowStep[]; + moveTargetSteps: WorkflowStep[]; + isMobile: boolean; +}) { + const dnd = useSwimlaneKanbanDnd(dndOptions); + const renderedSteps = useMemo( + () => getDragDisplaySteps(displaySteps, moveTargetSteps, !!dnd.activeTask, isMobile), + [displaySteps, moveTargetSteps, dnd.activeTask, isMobile], + ); + const { boardRef, handleAnchoredDragStart } = useKanbanDragScrollAnchor({ + tasks: dndOptions.tasks, + activeTask: dnd.activeTask, + renderedSteps, + onDragStart: dnd.handleDragStart, + }); + const temporaryStepIds = useMemo( + () => getTemporaryStepIds(displaySteps, moveTargetSteps), + [displaySteps, moveTargetSteps], + ); + return { + ...dnd, + renderedSteps, + temporaryStepIds, + boardRef, + handleDragStart: handleAnchoredDragStart, + }; +} + function useMobileColumnIndex(workflowId: string, steps: WorkflowStep[], tasks: Task[]) { const storedStepId = useAppStore( (state) => state.mobileKanban.activeStepIdByWorkflowId[workflowId], @@ -203,49 +230,6 @@ function useMobileColumnIndex(workflowId: string, steps: WorkflowStep[], tasks: return { activeIndex, setActiveIndex }; } -/** - * remapOrphanTasks re-keys any task whose workflowStepId matches no step in - * `stepIds` to `orphanStepId`. Returns the remapped task list and whether - * any orphans were found. Pure function — safe to call outside React. - */ -export function remapOrphanTasks( - tasks: Task[], - stepIds: Set, - orphanStepId: string, -): { tasks: Task[]; hasOrphans: boolean } { - let hasOrphans = false; - const remapped = tasks.map((t) => { - if (!t.workflowStepId || stepIds.has(t.workflowStepId)) return t; - hasOrphans = true; - return { ...t, workflowStepId: orphanStepId }; - }); - return { tasks: remapped, hasOrphans }; -} - -/** - * useOrphanDisplay remaps tasks with an unknown workflowStepId to the sentinel - * ORPHAN_STEP so they appear in a visible fallback column instead of being - * silently dropped from the board. - * - * Returns: - * displayTasks – all tasks, with orphaned ones keyed to ORPHAN_STEP_ID - * displaySteps – original steps plus ORPHAN_STEP when orphans are present - */ -function useOrphanDisplay( - tasks: Task[], - steps: WorkflowStep[], -): { displayTasks: Task[]; displaySteps: WorkflowStep[] } { - const { t } = useTranslation("kanban"); - return useMemo(() => { - const stepIds = new Set(steps.map((s) => s.id)); - const { tasks: displayTasks, hasOrphans } = remapOrphanTasks(tasks, stepIds, ORPHAN_STEP_ID); - const displaySteps = hasOrphans - ? [...steps, { ...ORPHAN_STEP, title: t("kanban:needsReassignment") }] - : steps; - return { displayTasks, displaySteps }; - }, [tasks, steps, t]); -} - function useTasksByStep(tasks: Task[]) { return useCallback( (stepId: string) => @@ -292,6 +276,7 @@ function MobileKanbanLayout({ }, [steps, tasks]); const currentStepId = steps[activeIndex]?.id ?? null; + const allStepsAutoHidden = areAllEmptyStepsAutoHidden(steps, moveTargetSteps); return (
)} {steps.length === 0 ? (
- {t("kanban:noStepsConfiguredChooseAnotherWorkflow")} + {allStepsAutoHidden + ? t("kanban:allEmptyStepsAutoHidden") + : t("kanban:noStepsConfiguredChooseAnotherWorkflow")}
) : ( void; - onOpenTask: (task: Task) => void; - onEditTask: (task: Task) => void; - onDeleteTask: (task: Task) => void; - onArchiveTask?: (task: Task) => void; - moveTaskToStep: (task: Task, targetStepId: string) => Promise; - showMaximizeButton?: boolean; - deletingTaskId?: string | null; - archivingTaskId?: string | null; - selectedIds?: Set; - onToggleSelect?: (taskId: string) => void; - onSelectRange?: (taskId: string, orderedIds: string[]) => void; - isMultiSelectMode?: boolean; - externalLinkAvailability: KanbanExternalLinkAvailability; -}; - function TabletKanbanLayout({ steps, moveTargetSteps, @@ -388,6 +355,7 @@ function TabletKanbanLayout({ onSelectRange, isMultiSelectMode, externalLinkAvailability, + temporaryStepIds, }: SharedKanbanLayoutProps) { const getTasksForStep = useTasksByStep(tasks); @@ -397,7 +365,14 @@ function TabletKanbanLayout({ data-testid="tablet-kanban-layout" > {steps.map((step) => ( -
+
( - +
+ +
)} /> ); @@ -515,6 +495,7 @@ function renderKanbanLayout({ export function SwimlaneKanbanContent({ workflowId, steps, + moveTargetSteps, tasks, onPreviewTask, onOpenTask, @@ -535,8 +516,6 @@ export function SwimlaneKanbanContent({ const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); const externalLinkAvailability = useKanbanExternalLinkAvailability(activeWorkspaceId); - // Remap tasks with a dead workflowStepId to the orphan sentinel so they - // always appear in a visible column rather than being silently dropped. const { displayTasks, displaySteps } = useOrphanDisplay(tasks, steps); const { activeIndex, setActiveIndex } = useMobileColumnIndex( @@ -544,54 +523,35 @@ export function SwimlaneKanbanContent({ displaySteps, displayTasks, ); - const { sensors, handleDragStart, handleDragEnd, handleDragCancel, moveTaskToStep, activeTask } = - useSwimlaneKanbanDnd({ tasks: displayTasks, workflowId, onMoveError }); + const drag = useSwimlaneKanbanPresentationDnd({ + tasks: displayTasks, + workflowId, + onMoveError, + displaySteps, + moveTargetSteps, + isMobile, + }); + const sharedProps = useSharedKanbanLayoutProps({ + drag, + moveTargetSteps, + displayTasks, + onPreviewTask, + onOpenTask, + onEditTask, + onDeleteTask, + onArchiveTask, + showMaximizeButton, + deletingTaskId, + archivingTaskId, + selectedIds, + onToggleSelect, + onSelectRange, + isMultiSelectMode, + externalLinkAvailability, + }); - // Memoized so the layout components don't re-render from a fresh props object - // on every parent render. Declared before the early return to keep hook order - // stable. - const sharedProps = useMemo( - () => ({ - steps: displaySteps, - moveTargetSteps: steps, - tasks: displayTasks, - onPreviewTask, - onOpenTask, - onEditTask, - onDeleteTask, - onArchiveTask, - moveTaskToStep, - showMaximizeButton, - deletingTaskId, - archivingTaskId, - selectedIds, - onToggleSelect, - onSelectRange, - isMultiSelectMode, - externalLinkAvailability, - }), - [ - displaySteps, - steps, - displayTasks, - onPreviewTask, - onOpenTask, - onEditTask, - onDeleteTask, - onArchiveTask, - moveTaskToStep, - showMaximizeButton, - deletingTaskId, - archivingTaskId, - selectedIds, - onToggleSelect, - onSelectRange, - isMultiSelectMode, - externalLinkAvailability, - ], - ); - - if (displaySteps.length === 0 && !isMobile) return null; + const desktopEmptyState = getDesktopEmptyState(isMobile, displaySteps, moveTargetSteps); + if (desktopEmptyState !== undefined) return desktopEmptyState; const layoutContent = renderKanbanLayout({ isMobile, @@ -599,21 +559,19 @@ export function SwimlaneKanbanContent({ sharedProps, activeIndex, setActiveIndex, - activeTask, + activeTask: drag.activeTask, mobileWorkflowNavigation, }); return ( - - {layoutContent} - - {activeTask ? : null} - - + ); } diff --git a/apps/web/components/kanban/swimlane-orphan-display.ts b/apps/web/components/kanban/swimlane-orphan-display.ts new file mode 100644 index 0000000000..3bd011a4b8 --- /dev/null +++ b/apps/web/components/kanban/swimlane-orphan-display.ts @@ -0,0 +1,47 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { Task } from "@/components/kanban-card"; +import type { WorkflowStep } from "@/components/kanban-column"; + +export const ORPHAN_STEP_ID = "__kandev_orphan__"; + +export const ORPHAN_STEP: WorkflowStep = { + id: ORPHAN_STEP_ID, + title: "", + color: "#f59e0b", +}; + +export function isOrphanMoveTarget(targetStepId: string): boolean { + return targetStepId === ORPHAN_STEP_ID; +} + +export function remapOrphanTasks( + tasks: Task[], + stepIds: Set, + orphanStepId: string, +): { tasks: Task[]; hasOrphans: boolean } { + let hasOrphans = false; + const remapped = tasks.map((task) => { + if (!task.workflowStepId || stepIds.has(task.workflowStepId)) return task; + hasOrphans = true; + return { ...task, workflowStepId: orphanStepId }; + }); + return { tasks: remapped, hasOrphans }; +} + +export function useOrphanDisplay( + tasks: Task[], + steps: WorkflowStep[], +): { displayTasks: Task[]; displaySteps: WorkflowStep[] } { + const { t } = useTranslation("kanban"); + return useMemo(() => { + const stepIds = new Set(steps.map((step) => step.id)); + const { tasks: displayTasks, hasOrphans } = remapOrphanTasks(tasks, stepIds, ORPHAN_STEP_ID); + const displaySteps = hasOrphans + ? [...steps, { ...ORPHAN_STEP, title: t("kanban:needsReassignment") }] + : steps; + return { displayTasks, displaySteps }; + }, [tasks, steps, t]); +} diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 7e014f487b..2758d1364a 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1103,6 +1103,7 @@ export class ApiClient { tasks_list_group?: string; task_create_last_used?: TaskCreateLastUsedApi; kanban_hidden_step_ids?: Record; + workflow_ids_with_auto_hide_empty_steps?: string[]; }): Promise { await this.request("PATCH", "/api/v1/user/settings", settings); } diff --git a/apps/web/e2e/pages/mobile-kanban-page.ts b/apps/web/e2e/pages/mobile-kanban-page.ts index 7775d3e80c..9525d0b339 100644 --- a/apps/web/e2e/pages/mobile-kanban-page.ts +++ b/apps/web/e2e/pages/mobile-kanban-page.ts @@ -6,6 +6,7 @@ export class MobileKanbanPage { readonly mobileSearchBar: Locator; readonly mobileSearchToggle: Locator; readonly mobileMenuButton: Locator; + readonly menuCard: Locator; readonly swimlaneContainer: Locator; readonly boardNavigator: Locator; @@ -15,6 +16,7 @@ export class MobileKanbanPage { this.mobileSearchBar = page.getByTestId("mobile-search-bar"); this.mobileSearchToggle = page.getByTestId("mobile-search-toggle"); this.mobileMenuButton = page.getByRole("button", { name: "Open menu" }); + this.menuCard = page.getByTestId("mobile-home-menu-card"); this.swimlaneContainer = page.getByTestId("swimlane-container"); this.boardNavigator = page.getByTestId("mobile-board-navigator"); } diff --git a/apps/web/e2e/tests/kanban/auto-hide-empty-columns.spec.ts b/apps/web/e2e/tests/kanban/auto-hide-empty-columns.spec.ts new file mode 100644 index 0000000000..3f063be8eb --- /dev/null +++ b/apps/web/e2e/tests/kanban/auto-hide-empty-columns.spec.ts @@ -0,0 +1,122 @@ +import { type Locator, type Page } from "@playwright/test"; +import { expect, test } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; + +const TASK_TITLE = "Auto-hide drag source"; + +async function openColumnsMenu(page: Page, workflowId: string) { + const trigger = page.getByTestId(`columns-menu-${workflowId}`); + await trigger.click(); + await expect(trigger).toHaveAttribute("data-state", "open"); +} + +async function closeColumnsMenu(page: Page, workflowId: string) { + const trigger = page.getByTestId(`columns-menu-${workflowId}`); + if ((await trigger.getAttribute("data-state")) === "open") { + await page.keyboard.press("Escape"); + } + await expect(trigger).not.toHaveAttribute("data-state", "open"); +} + +async function beginPointerDrag(page: Page, card: Locator) { + const box = await card.boundingBox(); + if (!box) throw new Error("drag source has no layout box"); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 20, y, { steps: 4 }); +} + +test("auto-hides empty columns without changing drag destinations", async ({ + testPage, + apiClient, + seedData, +}) => { + await testPage.setViewportSize({ width: 1440, height: 900 }); + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Auto-hide E2E"); + const sourceStep = await apiClient.createWorkflowStep(workflow.id, "Source", 0, { + is_start_step: true, + }); + const autoHiddenStep = await apiClient.createWorkflowStep(workflow.id, "Auto hidden", 1); + const manuallyHiddenStep = await apiClient.createWorkflowStep(workflow.id, "Manual hidden", 2); + const task = await apiClient.createTask(seedData.workspaceId, TASK_TITLE, { + workflow_id: workflow.id, + workflow_step_id: sourceStep.id, + }); + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + kanban_hidden_step_ids: {}, + workflow_ids_with_auto_hide_empty_steps: [], + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + // Compatibility: the feature is off until this workflow opts in. + await expect(kanban.columnByStepId(sourceStep.id)).toBeVisible(); + await expect(kanban.columnByStepId(autoHiddenStep.id)).toBeVisible(); + await expect(kanban.columnByStepId(manuallyHiddenStep.id)).toBeVisible(); + + await openColumnsMenu(testPage, workflow.id); + const autoHideToggle = testPage.getByTestId(`columns-menu-auto-hide-empty-${workflow.id}`); + await expect(autoHideToggle).toHaveAttribute("aria-checked", "false"); + await autoHideToggle.click(); + await expect(autoHideToggle).toHaveAttribute("aria-checked", "true"); + await testPage.getByTestId(`columns-menu-step-${manuallyHiddenStep.id}`).click(); + await closeColumnsMenu(testPage, workflow.id); + + await expect(kanban.columnByStepId(sourceStep.id)).toBeVisible(); + await expect(kanban.columnByStepId(autoHiddenStep.id)).toHaveCount(0); + await expect(kanban.columnByStepId(manuallyHiddenStep.id)).toHaveCount(0); + + await testPage.reload(); + await expect(kanban.columnByStepId(sourceStep.id)).toBeVisible(); + await expect(kanban.columnByStepId(autoHiddenStep.id)).toHaveCount(0); + await openColumnsMenu(testPage, workflow.id); + await expect(autoHideToggle).toHaveAttribute("aria-checked", "true"); + await closeColumnsMenu(testPage, workflow.id); + + // Occupancy is computed before text search: filtering the card does not + // make its source column disappear. + const search = testPage.getByTestId("kanban-header-search").getByRole("textbox"); + await search.fill("does not match the source task"); + await expect(kanban.taskCard(task.id)).toHaveCount(0); + await expect(kanban.columnByStepId(sourceStep.id)).toBeVisible(); + await search.fill(""); + await expect(kanban.taskCard(task.id)).toBeVisible(); + + await beginPointerDrag(testPage, kanban.taskCard(task.id)); + await expect(kanban.columnByStepId(autoHiddenStep.id)).toBeVisible(); + await expect(kanban.columnByStepId(manuallyHiddenStep.id)).toHaveCount(0); + await testPage.keyboard.press("Escape"); + await testPage.mouse.up(); + await expect(kanban.columnByStepId(autoHiddenStep.id)).toHaveCount(0); + await expect + .poll(async () => (await apiClient.getTask(task.id)).workflow_step_id) + .toBe(sourceStep.id); + + await beginPointerDrag(testPage, kanban.taskCard(task.id)); + const destination = kanban.columnByStepId(autoHiddenStep.id); + await expect(destination).toBeVisible(); + const destinationBox = await destination.boundingBox(); + if (!destinationBox) throw new Error("auto-hidden destination has no layout box"); + await testPage.mouse.move( + destinationBox.x + destinationBox.width / 2, + destinationBox.y + Math.min(160, destinationBox.height / 2), + { steps: 12 }, + ); + await testPage.mouse.up(); + + await expect + .poll(async () => (await apiClient.getTask(task.id)).workflow_step_id) + .toBe(autoHiddenStep.id); + await expect(kanban.taskCardInColumn(TASK_TITLE, autoHiddenStep.id)).toBeVisible(); + const settledDestinationBox = await destination.boundingBox(); + if (!settledDestinationBox) throw new Error("occupied destination has no layout box"); + const viewportWidth = await testPage.evaluate(() => window.innerWidth); + expect(settledDestinationBox.x).toBeLessThan(viewportWidth); + expect(settledDestinationBox.x + settledDestinationBox.width).toBeGreaterThan(0); + await expect(kanban.columnByStepId(manuallyHiddenStep.id)).toHaveCount(0); +}); diff --git a/apps/web/e2e/tests/kanban/mobile-auto-hide-empty-columns.spec.ts b/apps/web/e2e/tests/kanban/mobile-auto-hide-empty-columns.spec.ts new file mode 100644 index 0000000000..454b9a4a88 --- /dev/null +++ b/apps/web/e2e/tests/kanban/mobile-auto-hide-empty-columns.spec.ts @@ -0,0 +1,153 @@ +import { type CDPSession, type Locator, type Page } from "@playwright/test"; +import { expect, test } from "../../fixtures/test-base"; +import { dwell } from "../../helpers/causal-waits"; +import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; + +const TASK_TITLE = "Mobile auto-hide drag source"; + +async function openMobileMenu(mobile: MobileKanbanPage) { + await mobile.mobileMenuButton.click(); + await mobile.menuCard.waitFor({ state: "visible" }); +} + +async function closeMobileMenu(page: Page, mobile: MobileKanbanPage) { + await expect(async () => { + if ((await mobile.menuCard.count()) > 0) { + await page.keyboard.press("Escape"); + } + await expect(mobile.menuCard).toHaveCount(0, { timeout: 1_000 }); + }).toPass({ timeout: 15_000 }); +} + +async function openColumnsMenu( + page: Page, + mobile: MobileKanbanPage, + workflowId: string, +): Promise { + const trigger = mobile.menuCard.getByTestId(`columns-menu-${workflowId}`); + await trigger.click(); + await expect(trigger).toHaveAttribute("data-state", "open"); + const menu = page.locator('[role="menu"]:visible'); + await expect(menu).toBeVisible(); + return menu; +} + +async function touchDragToTarget(page: Page, card: Locator, target: Locator) { + const box = await card.boundingBox(); + if (!box) throw new Error("mobile drag source has no layout box"); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + const cdp: CDPSession = await page.context().newCDPSession(page); + await cdp.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x, y, id: 1 }], + }); + await dwell( + page, + 350, + "library-timer", + "dnd-kit's TouchSensor needs its 250ms activation delay before the move target appears", + ); + await cdp.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: x + 14, y, id: 1 }], + }); + await expect(target).toBeVisible(); + await expectMinTouchTarget(target); + const targetBox = await target.boundingBox(); + if (!targetBox) throw new Error("mobile drop target has no layout box"); + const targetX = targetBox.x + targetBox.width / 2; + const targetY = targetBox.y + targetBox.height / 2; + for (let step = 1; step <= 10; step += 1) { + await cdp.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [ + { + x: x + ((targetX - x) * step) / 10, + y: y + ((targetY - y) * step) / 10, + id: 1, + }, + ], + }); + } + await expect(target).toHaveClass(/border-primary/); + await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); +} + +async function expectMinTouchTarget(locator: Locator) { + await expect(async () => { + const box = await locator.boundingBox(); + if (!box) throw new Error("touch target has no layout box"); + expect(box.height).toBeGreaterThanOrEqual(44); + }).toPass({ timeout: 5_000 }); +} + +test("keeps auto-hide scoped and restores mobile move targets during drag", async ({ + testPage, + apiClient, + seedData, +}) => { + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Mobile auto-hide E2E"); + const sourceStep = await apiClient.createWorkflowStep(workflow.id, "Source", 0, { + is_start_step: true, + }); + const hiddenDestination = await apiClient.createWorkflowStep(workflow.id, "Hidden target", 1); + const otherWorkflow = await apiClient.createWorkflow(seedData.workspaceId, "Unaffected workflow"); + const otherStart = await apiClient.createWorkflowStep(otherWorkflow.id, "Other source", 0, { + is_start_step: true, + }); + const otherEmpty = await apiClient.createWorkflowStep(otherWorkflow.id, "Other empty", 1); + const task = await apiClient.createTask(seedData.workspaceId, TASK_TITLE, { + workflow_id: workflow.id, + workflow_step_id: sourceStep.id, + }); + await apiClient.createTask(seedData.workspaceId, "Unaffected task", { + workflow_id: otherWorkflow.id, + workflow_step_id: otherStart.id, + }); + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + kanban_hidden_step_ids: {}, + workflow_ids_with_auto_hide_empty_steps: [], + }); + + const mobile = new MobileKanbanPage(testPage); + await mobile.goto(); + await openMobileMenu(mobile); + await expect(mobile.menuCard.getByTestId(`columns-menu-${otherWorkflow.id}`)).toHaveCount(0); + const columnsMenu = await openColumnsMenu(testPage, mobile, workflow.id); + + const autoHideToggle = columnsMenu.getByTestId(`columns-menu-auto-hide-empty-${workflow.id}`); + await expectMinTouchTarget(autoHideToggle); + await autoHideToggle.click(); + await expect(autoHideToggle).toHaveAttribute("aria-checked", "true"); + await testPage.keyboard.press("Escape"); + await closeMobileMenu(testPage, mobile); + + await expect(testPage.getByTestId(`kanban-column-${hiddenDestination.id}`)).toHaveCount(0); + const recoveredTarget = testPage.getByTestId(`mobile-drop-target-${hiddenDestination.id}`); + await touchDragToTarget(testPage, mobile.taskCard(task.id), recoveredTarget); + const overflow = await testPage.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth); + await expect(recoveredTarget).toHaveCount(0); + await expect + .poll(async () => (await apiClient.getTask(task.id)).workflow_step_id) + .toBe(hiddenDestination.id); + await expect(testPage.getByTestId(`kanban-column-${hiddenDestination.id}`)).toBeVisible(); + + const persistedSettings = (await apiClient.getUserSettings()).settings; + expect(persistedSettings.workflow_ids_with_auto_hide_empty_steps).toEqual([workflow.id]); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: otherWorkflow.id, + }); + await testPage.reload(); + await mobile.mobileKanbanLayout().waitFor({ state: "visible" }); + await expect(mobile.boardNavigator).toContainText("Unaffected workflow"); + await expect(testPage.getByTestId(`kanban-column-${otherEmpty.id}`)).toHaveCount(1); +}); diff --git a/apps/web/hooks/domains/kanban/use-kanban-drag-scroll-anchor.test.ts b/apps/web/hooks/domains/kanban/use-kanban-drag-scroll-anchor.test.ts new file mode 100644 index 0000000000..98c95bcaab --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-kanban-drag-scroll-anchor.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + getDragDisplaySteps, + getPreservedScrollLeft, + getRenderedStepKey, + getTemporaryStepIds, +} from "./use-kanban-drag-scroll-anchor"; +import type { WorkflowStep } from "@/components/kanban-column"; + +describe("getPreservedScrollLeft", () => { + it("keeps the source at the same viewport position when columns expand", () => { + expect(getPreservedScrollLeft(840, 72, 352)).toBe(1120); + }); + + it("preserves the drag-time scroll when the source column disappears after drop", () => { + expect(getPreservedScrollLeft(1400, 72, undefined)).toBe(1400); + }); +}); + +describe("getRenderedStepKey", () => { + it("is stable when a render recreates the same ordered steps", () => { + const first = [{ id: "todo" }, { id: "done" }] as WorkflowStep[]; + const second = [{ id: "todo" }, { id: "done" }] as WorkflowStep[]; + + expect(getRenderedStepKey(first)).toBe(getRenderedStepKey(second)); + }); +}); + +describe("getDragDisplaySteps", () => { + const visible = [{ id: "review", title: "Review" }] as WorkflowStep[]; + const targets = [ + { id: "backlog", title: "Backlog" }, + { id: "review", title: "Review" }, + ] as WorkflowStep[]; + + it("reveals every real target during desktop drag", () => { + expect(getDragDisplaySteps(visible, targets, true, false)).toBe(targets); + }); + + it("keeps a display-only orphan sentinel after every real drag target", () => { + const orphan = { id: "__orphan__", title: "Needs Reassignment" } as WorkflowStep; + + expect(getDragDisplaySteps([...visible, orphan], targets, true, false)).toEqual([ + ...targets, + orphan, + ]); + }); + + it("keeps mobile columns stable because mobile has separate drop targets", () => { + expect(getDragDisplaySteps(visible, targets, true, true)).toBe(visible); + }); + + it("marks only temporarily revealed real steps", () => { + expect([...getTemporaryStepIds(visible, targets)]).toEqual(["backlog"]); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-kanban-drag-scroll-anchor.ts b/apps/web/hooks/domains/kanban/use-kanban-drag-scroll-anchor.ts new file mode 100644 index 0000000000..d1fc89a0c8 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-kanban-drag-scroll-anchor.ts @@ -0,0 +1,129 @@ +"use client"; + +import { type RefObject, useCallback, useLayoutEffect, useRef } from "react"; +import type { DragStartEvent } from "@dnd-kit/core"; +import type { Task } from "@/components/kanban-card"; +import type { WorkflowStep } from "@/components/kanban-column"; + +type DragScrollAnchor = { + stepId: string; + viewportLeft: number; +}; + +function findStepElement(board: HTMLDivElement, stepId: string): HTMLElement | undefined { + return Array.from(board.querySelectorAll("[data-kanban-step-id]")).find( + (element) => element.dataset.kanbanStepId === stepId, + ); +} + +function findScrollWindow(board: HTMLDivElement): HTMLElement | null { + return board.querySelector( + '[data-testid="desktop-kanban-scroll-window"], [data-testid="tablet-kanban-layout"]', + ); +} + +export function getPreservedScrollLeft( + currentScrollLeft: number, + anchorViewportLeft: number, + currentAnchorLeft: number | undefined, +): number { + if (currentAnchorLeft == null) return currentScrollLeft; + return currentScrollLeft + currentAnchorLeft - anchorViewportLeft; +} + +export function getRenderedStepKey(steps: WorkflowStep[]): string { + return steps.map((step) => step.id).join("\0"); +} + +export function getDragDisplaySteps( + displaySteps: WorkflowStep[], + moveTargetSteps: WorkflowStep[], + isDragging: boolean, + isMobile: boolean, +): WorkflowStep[] { + if (isMobile || !isDragging) return displaySteps; + const moveTargetIds = new Set(moveTargetSteps.map((step) => step.id)); + const displayOnlyStep = displaySteps.find((step) => !moveTargetIds.has(step.id)); + return displayOnlyStep ? [...moveTargetSteps, displayOnlyStep] : moveTargetSteps; +} + +export function getTemporaryStepIds( + displaySteps: WorkflowStep[], + moveTargetSteps: WorkflowStep[], +): Set { + const visibleStepIds = new Set(displaySteps.map((step) => step.id)); + return new Set( + moveTargetSteps.filter((step) => !visibleStepIds.has(step.id)).map((step) => step.id), + ); +} + +export function useKanbanDragScrollAnchor({ + tasks, + activeTask, + renderedSteps, + onDragStart, +}: { + tasks: Task[]; + activeTask: Task | null; + renderedSteps: WorkflowStep[]; + onDragStart: (event: DragStartEvent) => void; +}): { + boardRef: RefObject; + handleAnchoredDragStart: (event: DragStartEvent) => void; +} { + const boardRef = useRef(null); + const anchorRef = useRef(null); + const restoreFrameRef = useRef(null); + + const handleAnchoredDragStart = useCallback( + (event: DragStartEvent) => { + const task = tasks.find((candidate) => candidate.id === event.active.id); + const board = boardRef.current; + const scrollWindow = board ? findScrollWindow(board) : null; + const stepElement = + board && task?.workflowStepId ? findStepElement(board, task.workflowStepId) : undefined; + if (scrollWindow && stepElement && task?.workflowStepId) { + anchorRef.current = { + stepId: task.workflowStepId, + viewportLeft: stepElement.getBoundingClientRect().left, + }; + } + onDragStart(event); + }, + [tasks, onDragStart], + ); + + const renderedStepKey = getRenderedStepKey(renderedSteps); + + useLayoutEffect(() => { + const anchor = anchorRef.current; + const board = boardRef.current; + if (!anchor || !board) return; + const scrollWindow = findScrollWindow(board); + if (!scrollWindow) return; + const stepElement = findStepElement(board, anchor.stepId); + const desiredScrollLeft = getPreservedScrollLeft( + scrollWindow.scrollLeft, + anchor.viewportLeft, + stepElement?.getBoundingClientRect().left, + ); + scrollWindow.scrollLeft = desiredScrollLeft; + + if (!activeTask) { + if (restoreFrameRef.current != null) cancelAnimationFrame(restoreFrameRef.current); + restoreFrameRef.current = requestAnimationFrame(() => { + if (boardRef.current) { + const liveScrollWindow = findScrollWindow(boardRef.current); + if (liveScrollWindow) liveScrollWindow.scrollLeft = desiredScrollLeft; + } + anchorRef.current = null; + }); + } + + return () => { + if (restoreFrameRef.current != null) cancelAnimationFrame(restoreFrameRef.current); + }; + }, [activeTask, renderedStepKey]); + + return { boardRef, handleAnchoredDragStart }; +} diff --git a/apps/web/hooks/domains/kanban/use-shared-kanban-layout-props.ts b/apps/web/hooks/domains/kanban/use-shared-kanban-layout-props.ts new file mode 100644 index 0000000000..4ceaf698e1 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-shared-kanban-layout-props.ts @@ -0,0 +1,91 @@ +import { useMemo } from "react"; +import type { Task } from "@/components/kanban-card"; +import type { WorkflowStep } from "@/components/kanban-column"; +import type { KanbanExternalLinkAvailability } from "@/components/kanban-external-link-availability"; + +export type SharedKanbanLayoutProps = { + steps: WorkflowStep[]; + moveTargetSteps: WorkflowStep[]; + tasks: Task[]; + onPreviewTask: (task: Task) => void; + onOpenTask: (task: Task) => void; + onEditTask: (task: Task) => void; + onDeleteTask: (task: Task) => void; + onArchiveTask?: (task: Task) => void; + moveTaskToStep: (task: Task, targetStepId: string) => Promise; + showMaximizeButton?: boolean; + deletingTaskId?: string | null; + archivingTaskId?: string | null; + selectedIds?: Set; + onToggleSelect?: (taskId: string) => void; + onSelectRange?: (taskId: string, orderedIds: string[]) => void; + isMultiSelectMode?: boolean; + externalLinkAvailability: KanbanExternalLinkAvailability; + temporaryStepIds: Set; + isDragging: boolean; +}; + +type SharedLayoutDragState = { + renderedSteps: WorkflowStep[]; + moveTaskToStep: SharedKanbanLayoutProps["moveTaskToStep"]; + temporaryStepIds: Set; + activeTask: Task | null; +}; + +type SharedLayoutHookOptions = Omit< + SharedKanbanLayoutProps, + "steps" | "tasks" | "moveTaskToStep" | "temporaryStepIds" | "isDragging" +> & { + drag: SharedLayoutDragState; + displayTasks: Task[]; +}; + +export function useSharedKanbanLayoutProps( + options: SharedLayoutHookOptions, +): SharedKanbanLayoutProps { + const { drag, displayTasks, externalLinkAvailability } = options; + return useMemo( + () => ({ + steps: drag.renderedSteps, + moveTargetSteps: options.moveTargetSteps, + tasks: displayTasks, + onPreviewTask: options.onPreviewTask, + onOpenTask: options.onOpenTask, + onEditTask: options.onEditTask, + onDeleteTask: options.onDeleteTask, + onArchiveTask: options.onArchiveTask, + moveTaskToStep: drag.moveTaskToStep, + showMaximizeButton: options.showMaximizeButton, + deletingTaskId: options.deletingTaskId, + archivingTaskId: options.archivingTaskId, + selectedIds: options.selectedIds, + onToggleSelect: options.onToggleSelect, + onSelectRange: options.onSelectRange, + isMultiSelectMode: options.isMultiSelectMode, + externalLinkAvailability, + temporaryStepIds: drag.temporaryStepIds, + isDragging: !!drag.activeTask, + }), + [ + drag.renderedSteps, + drag.moveTaskToStep, + drag.temporaryStepIds, + drag.activeTask, + options.moveTargetSteps, + displayTasks, + options.onPreviewTask, + options.onOpenTask, + options.onEditTask, + options.onDeleteTask, + options.onArchiveTask, + options.showMaximizeButton, + options.deletingTaskId, + options.archivingTaskId, + options.selectedIds, + options.onToggleSelect, + options.onSelectRange, + options.isMultiSelectMode, + externalLinkAvailability, + ], + ); +} diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts index ae510ae860..0e2bbe6d08 100644 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ b/apps/web/hooks/use-ensure-user-settings.test.ts @@ -108,6 +108,7 @@ function makeUnloadedSettings(): UserSettingsState { lspServerConfigs: {}, lspStatusLocation: "toolbar", hiddenWorkflowStepIds: {}, + workflowIdsWithAutoHideEmptySteps: [], loaded: false, }; } diff --git a/apps/web/hooks/use-kanban-display-settings.ts b/apps/web/hooks/use-kanban-display-settings.ts index 99e6924e52..376f5ddbae 100644 --- a/apps/web/hooks/use-kanban-display-settings.ts +++ b/apps/web/hooks/use-kanban-display-settings.ts @@ -14,6 +14,7 @@ type UserSettingsFields = { workflowId: string | null; repositoryIds: string[]; hiddenWorkflowStepIds?: Record; + workflowIdsWithAutoHideEmptySteps?: string[]; }; type CommitSettingsFn = ( @@ -29,6 +30,8 @@ function baseSettingsPayload(settings: UserSettingsFields): UserSettingsFields { workspaceId: settings.workspaceId, workflowId: settings.workflowId, repositoryIds: settings.repositoryIds, + hiddenWorkflowStepIds: settings.hiddenWorkflowStepIds, + workflowIdsWithAutoHideEmptySteps: settings.workflowIdsWithAutoHideEmptySteps, }; } @@ -131,7 +134,20 @@ function useStepVisibilityHandlers( }, [commitSettings, userSettings], ); - return { eligibleWorkflows, onToggleStepVisibility }; + const onToggleAutoHideEmpty = useCallback( + (workflowId: string) => { + const current = userSettings.workflowIdsWithAutoHideEmptySteps ?? []; + const next = current.includes(workflowId) + ? current.filter((id) => id !== workflowId) + : [...current, workflowId]; + commitSettings({ + ...baseSettingsPayload(userSettings), + workflowIdsWithAutoHideEmptySteps: next, + }); + }, + [commitSettings, userSettings], + ); + return { eligibleWorkflows, onToggleStepVisibility, onToggleAutoHideEmpty }; } /** @@ -188,13 +204,8 @@ export function useKanbanDisplaySettings() { [commitSettings, userSettings], ); - const { eligibleWorkflows, onToggleStepVisibility } = useStepVisibilityHandlers( - workflows, - snapshots, - userSettings, - commitSettings, - activeWorkflowId, - ); + const { eligibleWorkflows, onToggleStepVisibility, onToggleAutoHideEmpty } = + useStepVisibilityHandlers(workflows, snapshots, userSettings, commitSettings, activeWorkflowId); const { effectiveView, onViewModeChange } = useViewModeChange(); @@ -213,12 +224,14 @@ export function useKanbanDisplaySettings() { eligibleWorkflows, snapshots, hiddenWorkflowStepIds: userSettings.hiddenWorkflowStepIds ?? {}, + workflowIdsWithAutoHideEmptySteps: userSettings.workflowIdsWithAutoHideEmptySteps ?? [], onWorkspaceChange, onWorkflowChange, onRepositoryChange, onTogglePreviewOnClick, onToggleTasksListShowDetails, onToggleStepVisibility, + onToggleAutoHideEmpty, onViewModeChange, }; } diff --git a/apps/web/hooks/use-mobile-menu-sheet-state.ts b/apps/web/hooks/use-mobile-menu-sheet-state.ts index 069f8aebf7..d2a2059a9f 100644 --- a/apps/web/hooks/use-mobile-menu-sheet-state.ts +++ b/apps/web/hooks/use-mobile-menu-sheet-state.ts @@ -92,6 +92,8 @@ function buildColumnsSection({ snapshots, hiddenWorkflowStepIds, onToggleStepVisibility, + workflowIdsWithAutoHideEmptySteps, + onToggleAutoHideEmpty, }: { isMobile: boolean; currentPage: "kanban" | "tasks"; @@ -100,6 +102,8 @@ function buildColumnsSection({ snapshots: Record }>; hiddenWorkflowStepIds: Record; onToggleStepVisibility: (workflowId: string, stepId: string) => void; + workflowIdsWithAutoHideEmptySteps: string[]; + onToggleAutoHideEmpty: (workflowId: string) => void; }): MobileColumnsSection | null { if (!isMobile || currentPage !== "kanban" || !focusedWorkflowId) return null; const workflow = eligibleWorkflows.find((item) => item.id === focusedWorkflowId); @@ -110,6 +114,8 @@ function buildColumnsSection({ steps: snapshots[workflow.id]?.steps ?? [], hiddenStepIds: hiddenWorkflowStepIds[workflow.id] ?? [], onToggle: onToggleStepVisibility, + autoHideEmpty: workflowIdsWithAutoHideEmptySteps.includes(workflow.id), + onToggleAutoHide: onToggleAutoHideEmpty, }; } @@ -145,6 +151,8 @@ export function useMobileMenuSheetState({ snapshots, hiddenWorkflowStepIds, onToggleStepVisibility, + workflowIdsWithAutoHideEmptySteps, + onToggleAutoHideEmpty, } = useKanbanDisplaySettings(); const focusedWorkflowId = useAppStore((state) => state.mobileKanban.focusedWorkflowId); const columnsSection = buildColumnsSection({ @@ -155,6 +163,8 @@ export function useMobileMenuSheetState({ snapshots, hiddenWorkflowStepIds, onToggleStepVisibility, + workflowIdsWithAutoHideEmptySteps, + onToggleAutoHideEmpty, }); const repositoryValue = allRepositoriesSelected ? "all" : (selectedRepositoryId ?? "all"); const viewValue = currentPage === "tasks" ? "list" : effectiveTaskListingView; diff --git a/apps/web/hooks/use-user-display-settings.test.ts b/apps/web/hooks/use-user-display-settings.test.ts index 18b547f74f..4ea8de86f8 100644 --- a/apps/web/hooks/use-user-display-settings.test.ts +++ b/apps/web/hooks/use-user-display-settings.test.ts @@ -27,6 +27,18 @@ function settingsWithHidden(hiddenWorkflowStepIds: Record): Us } as unknown as UserSettingsState; } +function settingsWithAutoHide(workflowIdsWithAutoHideEmptySteps: string[]): UserSettingsState { + return { + loaded: true, + workspaceId: "workspace-1", + workflowId: null, + repositoryIds: [], + tasksListShowDetails: false, + hiddenWorkflowStepIds: {}, + workflowIdsWithAutoHideEmptySteps, + } as unknown as UserSettingsState; +} + describe("isSettingsUnchanged", () => { it("detects a details-only preference change", () => { expect(isSettingsUnchanged(settings(true), settings(false))).toBe(false); @@ -53,6 +65,21 @@ describe("isSettingsUnchanged", () => { it("treats both empty hidden-step maps as unchanged", () => { expect(isSettingsUnchanged(settingsWithHidden({}), settingsWithHidden({}))).toBe(true); }); + + it("treats reordered auto-hide workflow ids as unchanged", () => { + expect( + isSettingsUnchanged( + settingsWithAutoHide(["wf-b", "wf-a"]), + settingsWithAutoHide(["wf-a", "wf-b"]), + ), + ).toBe(true); + }); + + it("detects an auto-hide workflow id change", () => { + expect( + isSettingsUnchanged(settingsWithAutoHide(["wf-a"]), settingsWithAutoHide(["wf-b"])), + ).toBe(false); + }); }); describe("normalizeHiddenStepIds", () => { @@ -90,4 +117,11 @@ describe("buildSettingsUpdatePayload", () => { kanban_hidden_step_ids: {}, }); }); + + it("sends the per-workflow auto-hide preference under its wire key", () => { + const normalized = settingsWithAutoHide(["wf-b", "wf-a"]); + expect(buildSettingsUpdatePayload(normalized)).toMatchObject({ + workflow_ids_with_auto_hide_empty_steps: ["wf-b", "wf-a"], + }); + }); }); diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts index 4aec815ab8..0a8e6fa4f4 100644 --- a/apps/web/hooks/use-user-display-settings.ts +++ b/apps/web/hooks/use-user-display-settings.ts @@ -27,8 +27,13 @@ type CommitPayload = { tasksListShowDetails?: boolean; kanbanViewMode?: string | null; hiddenWorkflowStepIds?: Record; + workflowIdsWithAutoHideEmptySteps?: string[]; }; +export function normalizeWorkflowIds(ids: string[]): string[] { + return Array.from(new Set(ids)).sort(); +} + export function normalizeHiddenStepIds(raw: Record): Record { const result: Record = {}; for (const [workflowId, ids] of Object.entries(raw)) { @@ -64,6 +69,9 @@ function buildNormalizedSettings(next: CommitPayload, current: DisplaySettings): hiddenWorkflowStepIds: normalizeHiddenStepIds( next.hiddenWorkflowStepIds ?? current.hiddenWorkflowStepIds ?? {}, ), + workflowIdsWithAutoHideEmptySteps: normalizeWorkflowIds( + next.workflowIdsWithAutoHideEmptySteps ?? current.workflowIdsWithAutoHideEmptySteps ?? [], + ), loaded: true, }; } @@ -80,7 +88,12 @@ export function isSettingsUnchanged( normalized.tasksListShowDetails === current.tasksListShowDetails && normalized.repositoryIds.length === current.repositoryIds.length && normalized.repositoryIds.every((id, index) => id === current.repositoryIds[index]) && - hiddenStepIdsEqual(normalized.hiddenWorkflowStepIds ?? {}, current.hiddenWorkflowStepIds ?? {}) + hiddenStepIdsEqual( + normalized.hiddenWorkflowStepIds ?? {}, + current.hiddenWorkflowStepIds ?? {}, + ) && + normalizeWorkflowIds(normalized.workflowIdsWithAutoHideEmptySteps ?? []).join("\0") === + normalizeWorkflowIds(current.workflowIdsWithAutoHideEmptySteps ?? []).join("\0") ); } @@ -92,6 +105,7 @@ export function buildSettingsUpdatePayload(normalized: DisplaySettings): Record< enable_preview_on_click: normalized.enablePreviewOnClick, tasks_list_show_details: normalized.tasksListShowDetails, kanban_hidden_step_ids: normalized.hiddenWorkflowStepIds, + workflow_ids_with_auto_hide_empty_steps: normalized.workflowIdsWithAutoHideEmptySteps, }; } diff --git a/apps/web/lib/kanban/auto-hide-empty-columns.test.ts b/apps/web/lib/kanban/auto-hide-empty-columns.test.ts new file mode 100644 index 0000000000..a8e316306a --- /dev/null +++ b/apps/web/lib/kanban/auto-hide-empty-columns.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + areAllEmptyStepsAutoHidden, + deriveAutoHiddenStepIds, + sortWorkflowStepsByPosition, +} from "./auto-hide-empty-columns"; + +const steps = [{ id: "backlog" }, { id: "doing" }, { id: "done" }]; + +describe("deriveAutoHiddenStepIds", () => { + it("hides only unoccupied live steps when enabled", () => { + const result = deriveAutoHiddenStepIds(steps, [{ workflowStepId: "doing" }], true, []); + expect([...result]).toEqual(["backlog", "done"]); + }); + + it("returns no automatic hidden steps when disabled", () => { + expect([...deriveAutoHiddenStepIds(steps, [], false, [])]).toEqual([]); + }); + + it("leaves manually hidden steps to the manual visibility contract", () => { + const result = deriveAutoHiddenStepIds(steps, [], true, ["backlog"]); + expect([...result]).toEqual(["doing", "done"]); + }); +}); + +describe("areAllEmptyStepsAutoHidden", () => { + it("distinguishes auto-hidden steps from a workflow with no configured steps", () => { + expect(areAllEmptyStepsAutoHidden([], [{ id: "todo" }])).toBe(true); + expect(areAllEmptyStepsAutoHidden([], [])).toBe(false); + expect(areAllEmptyStepsAutoHidden([{ id: "todo" }], [{ id: "todo" }])).toBe(false); + }); +}); + +describe("sortWorkflowStepsByPosition", () => { + it("orders workflow steps deterministically without mutating the snapshot", () => { + const unordered = [ + { id: "step-b", position: 1 }, + { id: "step-c", position: 0 }, + { id: "step-a", position: 1 }, + ]; + + expect(sortWorkflowStepsByPosition(unordered).map((step) => step.id)).toEqual([ + "step-c", + "step-a", + "step-b", + ]); + expect(unordered.map((step) => step.id)).toEqual(["step-b", "step-c", "step-a"]); + }); +}); diff --git a/apps/web/lib/kanban/auto-hide-empty-columns.ts b/apps/web/lib/kanban/auto-hide-empty-columns.ts new file mode 100644 index 0000000000..882d4ef7de --- /dev/null +++ b/apps/web/lib/kanban/auto-hide-empty-columns.ts @@ -0,0 +1,28 @@ +type Step = { id: string }; +type StepTask = { workflowStepId: string | null }; + +export function sortWorkflowStepsByPosition( + steps: T[], +): T[] { + return [...steps].sort((a, b) => a.position - b.position || a.id.localeCompare(b.id)); +} + +export function areAllEmptyStepsAutoHidden(visibleSteps: Step[], moveTargetSteps: Step[]): boolean { + return visibleSteps.length === 0 && moveTargetSteps.length > 0; +} + +export function deriveAutoHiddenStepIds( + steps: Step[], + tasks: StepTask[], + enabled: boolean, + manuallyHiddenStepIds: string[], +): Set { + if (!enabled) return new Set(); + const occupied = new Set(tasks.map((task) => task.workflowStepId).filter(Boolean)); + const manuallyHidden = new Set(manuallyHiddenStepIds); + return new Set( + steps + .filter((step) => !occupied.has(step.id) && !manuallyHidden.has(step.id)) + .map((step) => step.id), + ); +} diff --git a/apps/web/lib/kanban/task-projections.ts b/apps/web/lib/kanban/task-projections.ts new file mode 100644 index 0000000000..be6bf72f25 --- /dev/null +++ b/apps/web/lib/kanban/task-projections.ts @@ -0,0 +1,60 @@ +import type { Task } from "@/components/kanban-card"; +import { filterTasksByRepositories, mapSelectedRepositoryIds } from "@/lib/kanban/filters"; + +type FilterTasksOptions = { + searchQuery?: string; + matchesPluginTaskFilters?: (taskId: string) => boolean; + hiddenStepIds?: Set; +}; + +export function filterTasks( + snapshots: Record, + workflowId: string, + repoFilter: ReturnType, + options?: FilterTasksOptions, +): Task[] { + const snapshot = snapshots[workflowId]; + if (!snapshot) return []; + let tasks = snapshot.tasks; + const { hiddenStepIds, searchQuery, matchesPluginTaskFilters } = options ?? {}; + if (hiddenStepIds && hiddenStepIds.size > 0) { + const liveStepIds = new Set(snapshot.steps.map((step) => step.id)); + const effectiveHidden = new Set([...hiddenStepIds].filter((id) => liveStepIds.has(id))); + if (effectiveHidden.size > 0) { + tasks = tasks.filter((task) => !effectiveHidden.has(task.workflowStepId)); + } + } + tasks = filterTasksByRepositories(tasks, repoFilter); + if (searchQuery) { + const query = searchQuery.toLowerCase(); + tasks = tasks.filter( + (task) => + task.title.toLowerCase().includes(query) || + (task.description && task.description.toLowerCase().includes(query)), + ); + } + if (matchesPluginTaskFilters) { + tasks = tasks.filter((task) => matchesPluginTaskFilters(task.id)); + } + return tasks; +} + +type WorkflowTaskProjectionOptions = { + searchQuery: string; + matchesPluginTaskFilters?: (taskId: string) => boolean; + hiddenStepIds?: Set; +}; + +export function projectWorkflowTasks( + snapshots: Record, + workflowId: string, + repoFilter: ReturnType, + options: WorkflowTaskProjectionOptions, +): { visibleTasks: Task[]; occupancyTasks: Task[] } { + return { + visibleTasks: filterTasks(snapshots, workflowId, repoFilter, options), + occupancyTasks: filterTasks(snapshots, workflowId, repoFilter, { + matchesPluginTaskFilters: options.matchesPluginTaskFilters, + }), + }; +} diff --git a/apps/web/lib/kanban/view-registry.ts b/apps/web/lib/kanban/view-registry.ts index e5ee4e2aeb..8034051816 100644 --- a/apps/web/lib/kanban/view-registry.ts +++ b/apps/web/lib/kanban/view-registry.ts @@ -9,6 +9,7 @@ import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; export type ViewContentProps = { workflowId: string; steps: WorkflowStep[]; + moveTargetSteps: WorkflowStep[]; tasks: Task[]; onPreviewTask: (task: Task) => void; onOpenTask: (task: Task) => void; diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index 4bf8cedc0a..7e6a227857 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -575,6 +575,17 @@ describe("last seen display hydration", () => { }); }); +describe("auto-hide empty steps preference hydration", () => { + it("hydrates the canonical workflow-scoped key", () => { + const mapped = mapUserSettingsData( + { workflow_ids_with_auto_hide_empty_steps: ["wf-a"] }, + createDefaultUserSettings(), + ); + + expect(mapped.workflowIdsWithAutoHideEmptySteps).toEqual(["wf-a"]); + }); +}); + describe("prevent auto-start on open preference", () => { it("defaults the missing preference to false", () => { expect(buildCoreFields({}).preventAutoStartAgentOnOpen).toBe(false); diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index 50a50151a2..908f3ce980 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -84,6 +84,7 @@ export function createDefaultUserSettings(): UserSettingsState { appStatusBarEnabled: false, appStatusBarOrder: { leftItemIds: [], rightItemIds: [] }, hiddenWorkflowStepIds: {}, + workflowIdsWithAutoHideEmptySteps: [], loaded: false, }; } @@ -334,6 +335,8 @@ export function buildCoreFields( ), appStatusBarEnabled: s.app_status_bar_enabled ?? current.appStatusBarEnabled, hiddenWorkflowStepIds: s.kanban_hidden_step_ids ?? current.hiddenWorkflowStepIds, + workflowIdsWithAutoHideEmptySteps: + s.workflow_ids_with_auto_hide_empty_steps ?? current.workflowIdsWithAutoHideEmptySteps, ...buildTerminalFields(s, current), ...buildSystemMetricsDisplayFields(s, current), }; diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 9b1c540ca7..415b7b08a2 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -438,6 +438,7 @@ export type UserSettingsState = { appStatusBarEnabled: boolean; appStatusBarOrder: AppStatusBarOrderState; hiddenWorkflowStepIds: Record; + workflowIdsWithAutoHideEmptySteps: string[]; loaded: boolean; }; diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts index 4d361eccd0..bee12cef3b 100644 --- a/apps/web/lib/types/http-user-settings.ts +++ b/apps/web/lib/types/http-user-settings.ts @@ -106,6 +106,7 @@ export type UserSettings = { app_status_bar_enabled?: boolean; app_status_bar_order?: AppStatusBarOrderApi; kanban_hidden_step_ids?: Record; + workflow_ids_with_auto_hide_empty_steps?: string[]; revision?: number; updated_at: string; }; @@ -171,4 +172,5 @@ export type UserSettingsUpdatePayload = { app_status_bar_enabled?: boolean; app_status_bar_order?: AppStatusBarOrderApi; kanban_hidden_step_ids?: Record; + workflow_ids_with_auto_hide_empty_steps?: string[]; }; diff --git a/apps/web/src/locales/en/kanban.json b/apps/web/src/locales/en/kanban.json index 4df6617965..4126a1edc6 100644 --- a/apps/web/src/locales/en/kanban.json +++ b/apps/web/src/locales/en/kanban.json @@ -53,6 +53,7 @@ "menu": "Menu", "moreOptions": "More options", "moveTo": "Move to", + "moveToStep": "Move to {{step}}", "multiSelect": "Multi-select", "nextCapacity": "next capacity", "nextStep": "Next step", @@ -99,6 +100,8 @@ "workflow": "Workflow", "workflowStepFallback": "workflow step {{stepId}}", "columns": "Columns", + "autoHideEmptyColumns": "Auto-hide empty columns", + "allEmptyStepsAutoHidden": "All empty steps are hidden. Turn off Auto-hide empty columns in Columns to show them again.", "columnsForWorkflow": "Columns for {{name}}", "needsReassignment": "Needs Reassignment", "noTasksYet": "No tasks yet", diff --git a/apps/web/src/locales/pseudo/kanban.json b/apps/web/src/locales/pseudo/kanban.json index f039f8b316..5266dd4497 100644 --- a/apps/web/src/locales/pseudo/kanban.json +++ b/apps/web/src/locales/pseudo/kanban.json @@ -53,6 +53,7 @@ "menu": "Ḿēńũ", "moreOptions": "Ḿōŕē ōƥţĩōńś", "moveTo": "Ḿōvē ţō", + "moveToStep": "Ḿōvē ţō {{step}}", "multiSelect": "Ḿũĺţĩ-śēĺēćţ", "nextCapacity": "ńēxţ ćàƥàćĩţŷ", "nextStep": "Ńēxţ śţēƥ", @@ -99,6 +100,8 @@ "workflow": "Ŵōŕķƒĺōŵ", "workflowStepFallback": "ŵōŕķƒĺōŵ śţēƥ {{stepId}}", "columns": "Ćōĺũḿńś", + "autoHideEmptyColumns": "Àũţō-ĥĩďē ēḿƥţŷ ćōĺũḿńś", + "allEmptyStepsAutoHidden": "Àĺĺ ēḿƥţŷ śţēƥś àŕē ĥĩďďēń. Ţũŕń ōƒƒ Àũţō-ĥĩďē ēḿƥţŷ ćōĺũḿńś ĩń Ćōĺũḿńś ţō śĥōŵ ţĥēḿ àĝàĩń.", "columnsForWorkflow": "Ćōĺũḿńś ƒōŕ {{name}}", "needsReassignment": "Ńēēďś Ŕēàśśĩĝńḿēńţ", "noTasksYet": "Ńō ţàśķś ŷēţ", diff --git a/apps/web/src/locales/pt-pt/kanban.json b/apps/web/src/locales/pt-pt/kanban.json index b7101ee78a..188b1875a7 100644 --- a/apps/web/src/locales/pt-pt/kanban.json +++ b/apps/web/src/locales/pt-pt/kanban.json @@ -48,6 +48,7 @@ "menu": "Menu", "moreOptions": "Mais opções", "moveTo": "Mover para", + "moveToStep": "Mover para {{step}}", "multiSelect": "Seleção múltipla", "nextCapacity": "próxima capacidade", "nextStep": "Passo seguinte", @@ -100,6 +101,8 @@ "blockedFailed": "O bloqueio falhou", "blockedPredecessorFailed": "Uma tarefa de que esta depende falhou: {{tasks}}", "columns": "Colunas", + "autoHideEmptyColumns": "Ocultar colunas vazias automaticamente", + "allEmptyStepsAutoHidden": "Todos os passos vazios estão ocultos. Desative Ocultar colunas vazias automaticamente em Colunas para os mostrar novamente.", "columnsForWorkflow": "Colunas de {{name}}", "noTasksYet": "Ainda sem tarefas", "noWorkflowsAvailableYet": "Ainda não há fluxos de trabalho disponíveis.", diff --git a/apps/web/src/locales/zh-cn/kanban.json b/apps/web/src/locales/zh-cn/kanban.json index 5a5df39c6f..1a0a2121de 100644 --- a/apps/web/src/locales/zh-cn/kanban.json +++ b/apps/web/src/locales/zh-cn/kanban.json @@ -48,6 +48,7 @@ "menu": "菜单", "moreOptions": "更多选项", "moveTo": "移动到", + "moveToStep": "移动到 {{step}}", "multiSelect": "多选", "nextCapacity": "下一容量", "nextStep": "下一步", @@ -94,6 +95,8 @@ "workflow": "工作流", "workflowStepFallback": "工作流步骤 {{stepId}}", "columns": "列", + "autoHideEmptyColumns": "自动隐藏空列", + "allEmptyStepsAutoHidden": "所有空步骤都已隐藏。在“列”中关闭“自动隐藏空列”即可重新显示它们。", "columnsForWorkflow": "{{name}} 的列", "needsReassignment": "需要重新分配", "blockedByCount_one": "被 {{count}} 项阻塞", diff --git a/apps/web/src/locales/zh-hk/kanban.json b/apps/web/src/locales/zh-hk/kanban.json index df22504fe8..243306604e 100644 --- a/apps/web/src/locales/zh-hk/kanban.json +++ b/apps/web/src/locales/zh-hk/kanban.json @@ -48,6 +48,7 @@ "menu": "菜單", "moreOptions": "更多選項", "moveTo": "移動到", + "moveToStep": "移動到 {{step}}", "multiSelect": "多選", "nextCapacity": "下一容量", "nextStep": "下一步", @@ -93,7 +94,9 @@ "wipTaskCount": "{{label}} 個任務", "workflow": "工作流程", "workflowStepFallback": "工作流程步驟 {{stepId}}", - "columns": "列", + "columns": "欄", + "autoHideEmptyColumns": "自動隱藏空白欄", + "allEmptyStepsAutoHidden": "所有空白步驟都已隱藏。在「欄」中關閉「自動隱藏空白欄」即可重新顯示它們。", "columnsForWorkflow": "{{name}} 的列", "needsReassignment": "需要重新分配", "blockedByCount_one": "被 {{count}} 項阻塞", diff --git a/apps/web/src/locales/zh-tw/kanban.json b/apps/web/src/locales/zh-tw/kanban.json index 0c2c7d8b0f..4aeb0dc4d9 100644 --- a/apps/web/src/locales/zh-tw/kanban.json +++ b/apps/web/src/locales/zh-tw/kanban.json @@ -48,6 +48,7 @@ "menu": "選單", "moreOptions": "更多選項", "moveTo": "移動到", + "moveToStep": "移動到 {{step}}", "multiSelect": "多選", "nextCapacity": "下一容量", "nextStep": "下一步", @@ -93,7 +94,9 @@ "wipTaskCount": "{{label}} 個任務", "workflow": "工作流程", "workflowStepFallback": "工作流程步驟 {{stepId}}", - "columns": "列", + "columns": "欄", + "autoHideEmptyColumns": "自動隱藏空白欄", + "allEmptyStepsAutoHidden": "所有空白步驟都已隱藏。在「欄」中關閉「自動隱藏空白欄」即可重新顯示它們。", "columnsForWorkflow": "{{name}} 的列", "needsReassignment": "需要重新分配", "blockedByCount_one": "被 {{count}} 項阻塞", diff --git a/docs/plans/kanban-auto-hide-empty-columns/plan.md b/docs/plans/kanban-auto-hide-empty-columns/plan.md new file mode 100644 index 0000000000..631eb8473b --- /dev/null +++ b/docs/plans/kanban-auto-hide-empty-columns/plan.md @@ -0,0 +1,135 @@ +--- +spec: docs/specs/kanban-auto-hide-empty-columns/spec.md +created: 2026-08-18 +status: complete +--- + +# Implementation Plan: Auto-hide empty workflow steps + +## Overview + +Extend the manual column-visibility feature with a separate, opt-in per-workflow preference. Persist +only the preference. Derive empty steps from the rendered task projection, keep manual hiding +authoritative, and temporarily restore auto-hidden steps as drop targets while a move is active. + +The implementation is sequential because the frontend behavior depends on the persisted settings +contract and drag behavior touches shared Kanban and Pipeline composition. + +## Existing seams to reuse + +- User display settings round-trip in `apps/backend/internal/user/**`, boot state, REST hydration, + WebSocket echo, and `apps/web/hooks/use-user-display-settings.ts`. +- Per-workflow Columns UI in `apps/web/components/kanban/columns-menu.tsx`. +- Workflow-scoped visibility handling in `use-kanban-display-settings.ts` and + `swimlane-container.tsx`. +- Kanban drag state in `swimlane-kanban-content.tsx`, Pipeline drag state in + `swimlane-graph-content.tsx`, and phone drop targets in `mobile-drop-targets.tsx`. +- Existing manual-hidden move-target filtering in `kanban-board.tsx`. + +## Data model and API + +- Add `WorkflowIDsWithAutoHideEmptySteps []string` to the existing Go user-settings model and DTO. +- Add optional update field `workflow_ids_with_auto_hide_empty_steps` and propagate it through controller, + service, storage JSON, boot payload, and user-settings events. +- Add `workflowIdsWithAutoHideEmptySteps: string[]` to frontend settings state and hydrate/persist it through + existing settings helpers. +- Normalize as a sorted, deduplicated list. Default legacy state to `[]`. +- No new endpoint, SQL column, or migration. + +## Frontend behavior + +### Columns control + +- Add a translated display-behavior section at the top of `ColumnsMenu`, separated from the manual + column list, with no secondary description under the switch. +- Add `autoHideEmpty`, `onToggleAutoHide`, and touch-target-safe rendering to the shared component. +- Reuse the same component in the desktop/tablet swimlane header and phone drawer. + +### Occupancy projection + +- Extract a pure helper that returns occupied step ids from tasks after workflow, repository, and + plugin filters but before free-text search. +- Derive auto-hidden live step ids only for workflows whose preference is enabled. +- Keep the manual hidden set separate and authoritative. +- Preserve the lane/header when all real steps are auto-hidden and render translated contextual + guidance. + +### Move presentation + +- Pass all non-manually-hidden live steps as move targets. +- In Kanban DnD, reveal auto-hidden steps as droppable targets while drag state is active, without + duplicating droppable ids. Keep Pipeline compact while its existing move controls retain the full + non-manually-hidden step list. +- Keep Bulk Move behavior unchanged except that auto-hidden steps remain available. +- Reuse the existing phone drop-target surface and preserve mobile navigation. + +## Internationalization + +Add English source keys and complete `pt-pt`, `zh-cn`, `zh-hk`, and `zh-tw` catalogs for: + +- auto-hide toggle label; +- conditional Pipeline destination tooltip copy; +- all-columns-empty contextual state; +- optional drag-target explanation if the existing accessible step title is insufficient. + +No Unicode em dash is permitted in locale values. + +## Public documentation + +Update `docs/public/tasks-and-workflows.md` to distinguish: + +- manually hidden columns, which stay hidden and are not move targets; +- auto-hidden empty columns, which return as destinations during a move. + +Update the feature-spec index with the new spec. + +## Tests + +- Backend model/DTO/controller/service/store/boot tests for persistence and defaults. +- Frontend settings normalization, equality, payload, hydration, and toggle-hook tests. +- Pure occupancy and effective-step helper tests, including search stability and workflow isolation. +- Columns menu tests for default-off, toggling, touch geometry, and manual-state independence. +- Kanban/Pipeline component tests for ordinary hiding and drag-time targets. +- Chromium E2E for enable, persistence, collapse, drag reveal, cancel, successful drop, and manual-hidden + precedence. +- Mobile Chrome E2E for the focused workflow, touch target, move destination, and no overflow. + +## Implementation tasks + +- [x] [Task 01 - Persist the per-workflow preference](task-01-persist-preference.md) +- [x] [Task 02 - Derive and render empty-column visibility](task-02-derived-visibility.md) +- [x] [Task 03 - Restore auto-hidden move targets](task-03-drag-targets.md) +- [x] [Task 04 - Prove end-to-end behavior and document it](task-04-e2e-docs.md) + +All tasks are sequential. Tasks 02-04 depend on the contracts established by the preceding task and +touch overlapping Kanban composition or acceptance tests. + +## Risks + +- Search-driven layout churn if occupancy is derived at the wrong filtering seam. +- Duplicate DnD droppable ids if ghost targets and real columns mount together. +- A lane becoming unreachable when every column is auto-hidden. +- Manual and automatic state accidentally sharing persistence or move-target semantics. +- Incomplete locale updates failing the repo-wide i18n gates. + +## Planned verification + +```bash +make fmt +make typecheck +make test +make lint +(cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet) +(cd apps/web && pnpm e2e:run --host --project chromium tests/kanban/auto-hide-empty-columns.spec.ts) +(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/kanban/mobile-auto-hide-empty-columns.spec.ts) +``` + +## Verification results + +- Targeted Prettier, ESLint, and the E2E sleep ratchet passed for the new coverage and selectors. +- Chromium passed 1/1 test, covering default-off compatibility, persisted enablement, search-stable + occupancy, manual-hidden precedence, drag reveal, cancellation, and successful drop. +- Mobile Chrome passed 1/1 test, covering focused-workflow persistence, the 44 CSS px toggle, + drag-time destination recovery, cancellation cleanup, and document overflow. +- The E2E backend required a dedicated `KANDEV_AGENT_STANDALONE_PORT` locally because the live Kandev + runtime already owned the default port; both successful runs used isolated temporary databases. diff --git a/docs/plans/kanban-auto-hide-empty-columns/task-01-persist-preference.md b/docs/plans/kanban-auto-hide-empty-columns/task-01-persist-preference.md new file mode 100644 index 0000000000..97259dab8d --- /dev/null +++ b/docs/plans/kanban-auto-hide-empty-columns/task-01-persist-preference.md @@ -0,0 +1,49 @@ +--- +id: "01-persist-preference" +title: "Persist the per-workflow auto-hide preference" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/kanban-auto-hide-empty-columns/spec.md" +--- + +# Task 01: Persist the per-workflow auto-hide preference + +## Acceptance + +- User settings persist a sorted, deduplicated `workflow_ids_with_auto_hide_empty_steps` list and + hydrate it as `workflowIdsWithAutoHideEmptySteps`, defaulting missing state to `[]`. +- The existing Columns menu exposes an accessible, translated per-workflow toggle on desktop and + phone without modifying `hiddenWorkflowStepIds`. +- Two workflows can hold different values, and toggling one never changes the other. + +## Likely files + +- `apps/backend/internal/user/models/models.go` +- `apps/backend/internal/user/dto/dto.go` and focused tests +- `apps/backend/internal/user/controller/controller.go` +- `apps/backend/internal/user/service/service.go` +- `apps/backend/internal/user/store/sqlite.go` +- `apps/backend/internal/backendapp/boot_state_routes.go` +- `apps/web/lib/state/slices/settings/types.ts` +- `apps/web/lib/state/default-state.ts` +- `apps/web/lib/ssr/user-settings.ts` +- `apps/web/hooks/use-user-display-settings.ts` and tests +- `apps/web/hooks/use-kanban-display-settings.ts` and tests +- `apps/web/components/kanban/columns-menu.tsx` and tests +- `apps/web/src/locales/*/kanban.json` + +## Verification + +```bash +make -C apps/backend test +(cd apps && pnpm --filter @kandev/web test -- --run hooks/use-user-display-settings.test.ts hooks/use-kanban-display-settings.test.ts components/kanban/columns-menu.test.tsx) +(cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet) +``` + +## Risks + +- The boot-state key must match the Zustand field exactly. +- Empty values must serialize authoritatively so clearing the preference cannot preserve stale state. +- The control needs complete translations in every gated locale. diff --git a/docs/plans/kanban-auto-hide-empty-columns/task-02-derived-visibility.md b/docs/plans/kanban-auto-hide-empty-columns/task-02-derived-visibility.md new file mode 100644 index 0000000000..fefecd294a --- /dev/null +++ b/docs/plans/kanban-auto-hide-empty-columns/task-02-derived-visibility.md @@ -0,0 +1,41 @@ +--- +id: "02-derived-visibility" +title: "Derive and render empty-column visibility" +status: done +wave: 2 +depends_on: ["01-persist-preference"] +plan: "plan.md" +spec: "../../specs/kanban-auto-hide-empty-columns/spec.md" +--- + +# Task 02: Derive and render empty-column visibility + +## Acceptance + +- A pure, tested projection derives occupied and auto-hidden live steps after workflow, repository, + and plugin filters but before free-text search. +- Ordinary Kanban and Pipeline rendering removes auto-hidden empty steps while leaving manual hidden + semantics unchanged. +- A workflow whose every real step is auto-hidden retains its lane, Columns menu, and contextual + translated empty state. + +## Likely files + +- `apps/web/components/kanban/swimlane-container.tsx` and tests +- `apps/web/lib/kanban/workflow-swimlanes.ts` and tests +- `apps/web/components/kanban/swimlane-kanban-content.tsx` +- `apps/web/components/kanban/swimlane-graph-content.tsx` +- `apps/web/src/locales/*/kanban.json` + +## Verification + +```bash +(cd apps && pnpm --filter @kandev/web test -- --run components/kanban/swimlane-container.test.ts lib/kanban/workflow-swimlanes.test.ts) +(cd apps/web && pnpm run typecheck && pnpm run i18n:ratchet) +``` + +## Risks + +- Reusing the fully searched task list would cause layout churn while typing. +- Filtering steps too early can turn valid tasks into orphans. +- All-empty lanes must remain recoverable without restoring full empty columns. diff --git a/docs/plans/kanban-auto-hide-empty-columns/task-03-drag-targets.md b/docs/plans/kanban-auto-hide-empty-columns/task-03-drag-targets.md new file mode 100644 index 0000000000..8b7a5f837e --- /dev/null +++ b/docs/plans/kanban-auto-hide-empty-columns/task-03-drag-targets.md @@ -0,0 +1,44 @@ +--- +id: "03-drag-targets" +title: "Restore auto-hidden steps as move targets" +status: done +wave: 3 +depends_on: ["02-derived-visibility"] +plan: "plan.md" +spec: "../../specs/kanban-auto-hide-empty-columns/spec.md" +--- + +# Task 03: Restore auto-hidden steps as move targets + +## Acceptance + +- Starting a pointer or touch drag reveals every auto-hidden, non-manually-hidden live step as an + accessible droppable target with a unique real step id. +- Cancelling or failing the drag removes temporary targets; a successful authoritative move keeps + the occupied destination visible. +- Bulk Move, Pipeline move controls, and phone move targets include auto-hidden steps and continue + excluding manually hidden steps. +- A Pipeline arrow shows the destination name in a tooltip only when that adjacent destination is + auto-hidden between visible stages. + +## Likely files + +- `apps/web/components/kanban/swimlane-kanban-content.tsx` and focused tests +- `apps/web/components/kanban/swimlane-graph2-content.tsx` and focused tests +- `apps/web/components/kanban/mobile-drop-targets.tsx` and tests +- `apps/web/components/kanban-board.tsx` and tests +- shared Kanban presentation helper extracted during Task 02 + +## Verification + +```bash +(cd apps && pnpm --filter @kandev/web test -- --run components/kanban-board.test.ts components/kanban/mobile-drop-targets.test.tsx components/kanban/swimlane-kanban-content.test.tsx components/kanban/swimlane-graph-content.test.tsx components/kanban/swimlane-graph2-orphan-display.test.ts) +(cd apps/web && pnpm run typecheck && pnpm run i18n:ratchet) +``` + +## Risks + +- Ghost targets and real columns must never mount the same droppable id simultaneously. +- DnD completion is asynchronous; local drag state cannot be treated as authoritative task state. +- Pipeline uses explicit move controls rather than DnD and must stay compact while retaining hidden + destinations in its movement model. diff --git a/docs/plans/kanban-auto-hide-empty-columns/task-04-e2e-docs.md b/docs/plans/kanban-auto-hide-empty-columns/task-04-e2e-docs.md new file mode 100644 index 0000000000..7ac416daea --- /dev/null +++ b/docs/plans/kanban-auto-hide-empty-columns/task-04-e2e-docs.md @@ -0,0 +1,56 @@ +--- +id: "04-e2e-docs" +title: "Prove end-to-end behavior and document it" +status: done +wave: 4 +depends_on: ["03-drag-targets"] +plan: "plan.md" +spec: "../../specs/kanban-auto-hide-empty-columns/spec.md" +--- + +# Task 04: Prove end-to-end behavior and document it + +## Acceptance + +- Desktop E2E covers default-off compatibility, persisted enablement, empty-column collapse, search + stability, drag reveal, cancellation, successful drop, and manual-hidden precedence. +- Mobile E2E covers focused-workflow isolation, a 44 CSS px toggle surface, move-target recovery, and + absence of document overflow. +- Public tasks/workflows documentation and the feature-spec index explain the new preference and its + distinction from manual hiding. + +## Likely files + +- `apps/web/e2e/tests/kanban/auto-hide-empty-columns.spec.ts` +- `apps/web/e2e/tests/kanban/mobile-auto-hide-empty-columns.spec.ts` +- `apps/web/e2e/pages/kanban-page.ts` if a shared locator is justified +- `docs/public/tasks-and-workflows.md` +- `docs/specs/INDEX.md` +- this plan and its task files for final status and command evidence + +## Verification + +```bash +(cd apps/web && pnpm e2e:run --host --project chromium tests/kanban/auto-hide-empty-columns.spec.ts) +(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/kanban/mobile-auto-hide-empty-columns.spec.ts) +make fmt +make typecheck +make test +make lint +``` + +## Risks + +- E2E must wait on causal task/settings updates rather than unconditional sleeps. +- The tests must prove manual and automatic hiding are separate, not merely that a column vanished. +- User-facing documentation must not imply that manually hidden steps become move targets. + +## Results + +- Added `auto-hide-empty-columns.spec.ts`; Chromium passed 1/1 and exercises the complete desktop + acceptance path through the real settings UI and pointer drag surface. +- Added `mobile-auto-hide-empty-columns.spec.ts`; Mobile Chrome passed 1/1 and verifies workflow + isolation, settled 44 CSS px geometry, recovered drop targets, cancellation cleanup, and no + document-level overflow. +- Added a stable mobile drop-target test id and the auto-hide field to the typed E2E settings helper. +- Both runs used the production E2E build, real backend, isolated SQLite database, and no raw sleeps. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 9ede123824..424ff63ede 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -368,7 +368,7 @@ On desktop and tablet, the header switches between **Kanban**, **Pipeline**, and Under **Settings → General → Appearance → Startup Page**, choose **Task overview** (the default) or **Last visited task**. The latter resumes the most recently opened task in the current workspace on that device when Kandev starts or you open bare Home. It does not change an explicit task or workflow link. Home navigation and a task's Back action always return to the task overview; when there is no matching local recent task, Kandev opens the overview instead. - Search matches tasks without changing their state. -- The display menu filters by **Workflow** and **Repository** and can enable **Open preview on click**. In Kanban/Pipeline, each workflow lane has a **Columns** menu to hide individual steps. Unticking a step hides its column and tasks on that board, scoped to its own workflow, until you re-tick it. On phones, open the menu drawer to change columns for the focused workflow. +- The display menu filters by **Workflow** and **Repository** and can enable **Open preview on click**. In Kanban/Pipeline, each workflow lane has a **Columns** menu to hide individual steps. Unticking a step hides its column and tasks on that board, scoped to its own workflow, until you re-tick it. The optional **Auto-hide empty columns** setting collapses unoccupied steps without changing those manual choices; auto-hidden empty steps return as move destinations while a task is being moved, while manually hidden steps remain unavailable for pointer and bulk moves. On phones, open the menu drawer to change columns for the focused workflow. - In **List**, the display menu can enable **Show task details** to include available repository, description, pull-request, session, parent, review, and archive context in each row. This option is off by default and follows the user across devices. - **List** can group by **State**, **Workflow**, **Repository**, or **None**. - **List** can sort by updated time, created time, or title in either direction. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 12b821f944..f63040eaf8 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -186,6 +186,7 @@ Subscription quota tracking and per-agent cheap-model profile routing. | Spec | Status | |---|---| +| [kanban-auto-hide-empty-columns](kanban-auto-hide-empty-columns/spec.md) | shipped | | [workspace-active-first-order](ui/workspace-active-first-order.md) | shipped | | [ci-pr-automation](ui/ci-pr-automation.md) | building | | [github-pr-review-actions](ui/github-pr-review-actions.md) | shipped | diff --git a/docs/specs/kanban-auto-hide-empty-columns/spec.md b/docs/specs/kanban-auto-hide-empty-columns/spec.md new file mode 100644 index 0000000000..688c7a3804 --- /dev/null +++ b/docs/specs/kanban-auto-hide-empty-columns/spec.md @@ -0,0 +1,199 @@ +--- +status: shipped +created: 2026-08-18 +updated: 2026-08-18 +owner: gsimard-nordai +--- + +# Auto-hide empty workflow steps + +## Why + +Long workflows spend most of their lifetime with tasks in only a few steps. The board still renders +every empty step, which consumes horizontal space and makes active work harder to scan. + +The shipped per-workflow Columns menu intentionally models explicit, persistent hiding. It is not an +adaptive layout mode: a user must maintain the hidden set as work moves, and a manually hidden step +is not a move target. Automatic empty-column hiding must therefore remain a separate concept with a +different drag-and-drop contract. + +## Users and outcome + +- An individual developer can opt one workflow into a compact board that shows occupied columns. +- A team sharing a Kandev instance retains user-scoped display preferences; one user's compact board + does not change another user's board. +- Every real workflow step remains reachable as a move destination even while its empty column is + auto-hidden. + +## What + +### Per-workflow preference + +- Each workflow's existing Columns menu SHALL expose an **Auto-hide empty columns** switch. +- The adaptive display switch SHALL be visually separated from the individual manual column list + and SHALL not carry secondary descriptive copy. +- The preference SHALL be user-scoped, persisted per workflow, and disabled by default. +- Enabling it SHALL NOT change `kanban_hidden_step_ids` or the checked state of individual manual + column toggles. +- The same preference SHALL control Kanban and Pipeline surfaces for that workflow on desktop, + tablet, and phone. + +### Occupancy + +- A live step is occupied when at least one task that passes the current workflow, repository, and + plugin filters belongs to that step. +- Free-text search SHALL NOT affect occupancy. Typing in search may filter cards, but SHALL NOT make + the board structure appear and disappear on every keystroke. +- Tasks in a manually hidden step SHALL still count toward occupancy for the automatic calculation. + Manual hiding is authoritative and keeps the step hidden regardless of occupancy. +- Orphaned tasks SHALL continue to use the shipped Needs Reassignment behavior and SHALL NOT make an + unrelated real step occupied. + +### Ordinary rendering + +- With auto-hide disabled, current board behavior SHALL remain unchanged. +- With auto-hide enabled, each unoccupied, non-manually-hidden live step SHALL be removed from normal + column rendering. +- A task created in or moved into an auto-hidden step SHALL make that column appear immediately. +- A task leaving a column SHALL make it collapse after the authoritative move update leaves it empty. +- If every live step is auto-hidden, the workflow lane and its Columns menu SHALL remain reachable. + The lane SHALL show a contextual empty state rather than the generic "No tasks yet" state. + +### Drag and move behavior + +- When a pointer drag starts, every auto-hidden, non-manually-hidden live step SHALL become an + accessible drop target for that workflow. +- Auto-hidden destinations MAY render as compact ghost targets rather than full empty columns, but + they SHALL expose the step title and the same droppable identity as the real step. +- Cancelling a drag SHALL remove ghost targets and restore automatic hiding. +- Dropping into an auto-hidden step SHALL keep that step visible once the task update is authoritative. +- Manually hidden steps SHALL remain hidden and excluded from pointer and bulk-move destinations, as + required by the existing column-visibility contract. +- Bulk Move and other explicit move selectors SHALL include auto-hidden steps and exclude manually + hidden steps. +- Pipeline arrows SHALL name an adjacent destination in a tooltip only when that destination is + auto-hidden; a directly visible neighboring step needs no tooltip. +- Keyboard and screen-reader users SHALL retain access to auto-hidden destinations through the + existing move controls; this feature does not add keyboard drag-and-drop. + +### Mobile and responsive behavior + +- The phone Columns control SHALL expose the same preference for the focused workflow. +- Existing phone focused-column navigation, mobile drop targets, safe-area behavior, and 44 CSS px + touch targets SHALL remain intact. +- Tablet and compact desktop boards SHALL not gain document-level horizontal overflow. +- The preference SHALL not persist responsive state, drag state, or a derived list of empty steps. + +## State and persistence + +### Persisted preference + +The user settings store gains a set of workflow ids whose empty columns are auto-hidden: + +```text +workflowIdsWithAutoHideEmptySteps: string[] +``` + +The REST/WebSocket wire field is: + +```text +workflow_ids_with_auto_hide_empty_steps: string[] +``` + +- Values SHALL be deduplicated and sorted before comparison and persistence. +- Missing or `null` legacy values SHALL hydrate to `[]`. +- Removing a workflow from the set SHALL restore normal rendering without touching manual hidden ids. +- The setting lives in the existing user settings JSON payload; no SQL column or data migration is + required. + +### Derived presentation state + +`autoHiddenStepIds` is ephemeral and SHALL be derived from the current filtered task projection and +live workflow steps. It SHALL NOT be stored in the backend, URL, session, or browser storage. + +`manuallyHiddenStepIds` and `autoHiddenStepIds` remain separate. Ordinary rendered steps are: + +```text +live steps - manually hidden steps - auto-hidden steps +``` + +During an active drag, move destinations are: + +```text +live steps - manually hidden steps +``` + +## API surface + +- Extend the existing user settings GET/update/boot/WS contracts with + `workflow_ids_with_auto_hide_empty_steps`. +- Do not add a dedicated endpoint. +- Do not change task, workflow-step, move, WIP-limit, or drag payloads. + +## Permissions + +- The preference uses the current authenticated user's display settings. +- It does not modify a workflow or require workflow-administration permission. +- It never changes which tasks the user is authorized to read or move. + +## Failure modes + +- A settings persistence failure MAY leave the optimistic local toggle visible until authoritative + settings reconcile, matching existing display-setting behavior. It SHALL NOT mutate manual hidden + ids. +- Stale workflow ids in the preference are inert. They SHALL not hide a newly created workflow that + reuses a title. +- A stale step id is irrelevant because automatic state is derived only from live steps. +- A cancelled or failed move SHALL clear temporary drop-target presentation. +- If a task update arrives after drag state clears, the authoritative task projection determines + whether the destination column remains visible. +- If every column is empty, the lane header and Columns control remain reachable so the preference + can be disabled from the same surface. + +## Scenarios + +- **GIVEN** auto-hide is disabled, **WHEN** a workflow has empty steps, **THEN** every non-manually- + hidden column renders exactly as it does today. +- **GIVEN** auto-hide is enabled, **WHEN** a step has no task after workflow, repository, and plugin + filters, **THEN** its ordinary column is absent. +- **GIVEN** a free-text search matches no task in an occupied step, **WHEN** search results update, + **THEN** the column structure remains stable. +- **GIVEN** an auto-hidden step, **WHEN** pointer drag begins, **THEN** that step becomes an accessible + drop target. +- **GIVEN** an auto-hidden drop target, **WHEN** the drag is cancelled, **THEN** it disappears again. +- **GIVEN** an auto-hidden drop target, **WHEN** a task is dropped into it and the move succeeds, + **THEN** the destination column remains visible with the task. +- **GIVEN** a manually hidden step, **WHEN** pointer drag or Bulk Move begins, **THEN** that step is not + offered as a destination. +- **GIVEN** all live steps are empty, **WHEN** auto-hide is enabled, **THEN** the workflow lane and + Columns menu remain visible with contextual guidance. +- **GIVEN** two workflows share step titles, **WHEN** one enables auto-hide, **THEN** the other + workflow is unaffected. +- **GIVEN** the preference is enabled, **WHEN** the page reloads or another tab hydrates settings, + **THEN** the same workflow resumes automatic empty-column hiding. +- **GIVEN** a phone viewport, **WHEN** the user enables auto-hide for the focused workflow, **THEN** + focused-column navigation and drag targets remain usable without document overflow. + +## Out of scope + +- Automatically or semantically classifying steps as Backlog, Done, Review, or another phase. +- Matching steps by title or `stage_type` across workflows. +- Automatically changing the manually hidden step set. +- Making manually hidden steps available during drag. +- Persisting derived empty-step ids, drag state, search state, or responsive layout state. +- Redesigning the task move API, workflow engine, WIP limits, or task lifecycle. +- A global install-wide auto-hide policy or administrator-enforced preference. + +## References + +- [Per-workflow column visibility spec](../board-step-visibility-filter/spec.md) +- [Merged PR #2467](https://github.com/kdlbs/kandev/pull/2467) +- `apps/web/components/kanban/columns-menu.tsx` +- `apps/web/components/kanban/swimlane-container.tsx` +- `apps/web/components/kanban/swimlane-kanban-content.tsx` +- `apps/web/components/kanban/swimlane-graph-content.tsx` +- `apps/web/components/kanban/mobile-drop-targets.tsx` + +## Implementation plan + +[Auto-hide empty Kanban columns implementation plan](../../plans/kanban-auto-hide-empty-columns/plan.md)