Files
kandev/apps/web/e2e/tests/task/sidebar-layout.spec.ts
T
José Almeida f6f45b5777 feat: add filter/group/sort and saved views to task sidebar (#644)
* feat(web): add sidebar view types and apply-view engine

Introduces the typed representation of a sidebar view (filter clauses,
sort spec, group key) and the pure engine that applies a view to a list
of tasks to produce a filtered, sorted, grouped result.

* feat(web): add built-in sidebar view presets

Provides the four shipped presets (All tasks, No PR reviews, Active,
Archived). The 'No PR reviews' preset filters out PR-watcher tasks that
would otherwise crowd the sidebar.

* feat(web): persist sidebar views in ui store

Adds sidebar view state to the ui slice with draft semantics for saved
views, localStorage persistence of active view + user-created views, and
root store wiring.

* feat(web): add filter dimension registry and clause editor

Registry of filterable task dimensions (status, workspace, workflow, task
type, etc.) plus the primitive UI pieces — clause editor, sort picker,
group picker, and a hook that supplies operator and value options for a
selected dimension.

* feat(web): add sidebar filter bar, popover, and view manager

Composes the filter primitives into the user-facing surfaces: the top
filter bar, the popover that hosts clause editing and sort/group
selection, the chips that switch between views, and the manager for
saving, renaming, and deleting user views.

* refactor(web): extract NewTaskButton component

Pulls the Create Task button and its command-palette registration into a
standalone memoized component in preparation for reuse across sidebar
surfaces.

* feat(web): integrate filter/view system into task sidebar

Wires the filter bar, view chips, and apply-view engine into the task
sidebar (desktop and mobile sheet). Updates task-switcher to consume the
grouped list from the engine and adds mock data for tests and previews.

* test(web): add e2e coverage for sidebar filter/view system

Page object for the filter popover plus Playwright specs covering built-in
presets, clause editing, sort/group, saved-view draft semantics, and
persistence across reloads.

* refactor(web): trim sidebar built-in views to a single seeded default

Drop the "No PR reviews", "Active", and "Archived" built-in view presets
and the isBuiltIn protection flag. The sidebar now seeds a single "All
tasks" view on first load; users can rename, overwrite, or delete it like
any other view. Deletion is guarded against removing the last remaining
view so the user can never lock themselves out.

* fix(web): humanize executor type labels in sidebar filter and grouping

The executor-type filter dropdown and group-by label were rendering raw
backend identifiers like "worktree" and "local_docker". Route both
through the existing getExecutorLabel() helper so the sidebar shows
"Worktree" and "Local Docker" like the rest of the app.

* feat(web): replace "Has PR" filter with "Is PR review"

The old dimension matched any task with an associated PR, including
tasks a user manually linked. The new dimension isolates tasks created
by the GitHub PR-review watcher specifically, which is what the filter
is useful for — hiding a wall of auto-generated review tasks.

The flag is derived from the review_watch_id metadata key the backend
already stamps onto watcher-created tasks, threaded through the kanban
snapshot and into the sidebar TaskSwitcherItem. A small migration drops
unknown dimensions (including the old "hasPR") from stored views so the
popover does not crash when reading legacy data.

* fix(web): tighten sidebar filter popover layout

Reduce the popover width (w-96 → w-[22rem]), trim section padding
(p-3 → p-2.5) and label margins (mb-2 → mb-1.5), and drop the
"No filters applied." placeholder. Extract a shared SectionLabel to
keep the three section headers visually aligned.

* feat(web): show workflow step color dot in sidebar filter dropdown

Surface the step color on the workflow-step filter's value dropdown so
each option renders with the same coloured circle used elsewhere (task
switcher, kanban column, command panel). Adds an optional color field
on filter value options; only workflow-step options populate it today.

* feat(web): replace cramped in/not-in filter editor with a multiselect dropdown

The previous "in/not in" value input rendered selected values as tiny
10px pills inside a fixed-height row that overflowed and truncated as
the list of options grew. Replace it with a combobox-style multiselect
(Popover + Command) that shows a compact summary ("N selected" or the
single label with its color dot) and opens a searchable, scrollable
list with check indicators on click.

* fix(web): show workflow and step names (not UUIDs) in sidebar grouping

Group-by Workflow and Workflow Step rendered raw UUID strings as group
headers because `TaskSwitcherItem` did not carry the resolved names.
Pass `workflowName` and `workflowStepTitle` through `toSidebarItem` and
prefer them in the group extractors.

* fix(web): drop redundant true/false selector on boolean sidebar filters

Boolean dimensions (PR review, Archived, Has diff) previously exposed
both an operator (is / is not) and a value selector (true / false),
which gives four user inputs for two real states. Hide the value input
for boolean clauses and always store `true`; negation is expressed via
the operator. Rename "Is PR review" to "PR review" since the dimension
label no longer needs to read as a question.

* refactor(web): drop Sort by Repository and relabel State to Status

Sorting by repository duplicates what Group by Repository already
offers, and "State" leaks a backend term into the picker. Remove the
repository sort key (comparator, type, picker option) and rename the
state option to "Status". Stored views referencing the removed key are
migrated back to `state` at load time so the popover does not crash.

* fix(web): tighten sidebar filter popover section spacing

The FILTERS, SORT, and GROUP BY section labels each sat above their
controls with both padded containers (p-2.5) and a 6px bottom margin,
leaving visible dead space above each header. Reduce container padding
to p-2 and label margin to mb-1 so sections read as a compact stack.

* chore(web): apply prettier formatting drift in sidebar-filter area

* revert(web): undo store.ts prettier reformat to stay under max-lines cap

The packed single-line UIA declaration in store.ts is load-bearing for
the 600-line ESLint cap: the previous chore commit unpacked it to 9
lines and pushed the file to 609 lines, turning the max-lines warning
into a lint failure. Restore the packed layout (the /* prettier-ignore */
comment is there for exactly this reason).

* refactor(web): label boolean filter ops as Show/Hide instead of is/is not

* fix(web): pull section labels flush to sidebar filter popover borders

* refactor(web): inline rename and save-as into sidebar view header row

* refactor(web): split ViewHeaderRow into mode, display, and action subcomponents

* feat(backend): persist sidebar views in user settings

Adds a SidebarViews field to UserSettings (stored in the existing
users.settings JSON blob) plus validation in the service layer:
views are capped at 50, ids and names must be non-empty and unique.

* feat(web): hydrate and sync sidebar views to backend

Moves the source of truth for saved sidebar views from localStorage
to the user settings endpoint. Views hydrate from SSR on first load;
create/rename/overwrite/delete/duplicate/toggle-collapsed all PATCH
the backend with optimistic updates and revert-on-failure. The
active view id and draft remain per-device in localStorage. A
sync bridge surfaces PATCH errors as toasts and runs a one-time
migration to upload any pre-existing local views.

As a side effect, localStorage writes and PATCH side effects are
moved out of immer producers to avoid proxy-serialization risk.

* chore(web): apply prettier to store.ts sidebar actions block

* fix(web): run migrateView on SSR-hydrated sidebar views

Defensively drop clauses with unknown dimensions and reset stale sort
keys when views come from the backend, matching the localStorage load
path. Also notes the default-state module and sidebar views in the
store structure docs.

* docs: note default-state module and sidebar views in store structure

* fix(web): type sidebar view storage wrappers properly

Drop the as-unknown-as-SidebarView and as-never casts at call sites by
making the local-storage getters/setters accept the domain type and do
the JsonValue cast once inside the wrapper.

* refactor(web): stop PATCHing on sidebar group collapse toggles

collapsedGroups is per-device visual state; routing it through
mutateViews made every expand/collapse click fire a user-settings
PATCH. Keep it local (in-memory + localStorage cache); the next real
view mutation picks up whatever state is current.

* fix(web): skip sidebar view migration when settings hydrated from server

After SSR hydration the in-memory views are already the backend's
truth, so migrating re-POSTs the same data every page load. Gate the
one-shot migration on userSettings.loaded — only run if the server
didn't answer and the slice fell back to localStorage.
2026-04-20 13:44:24 +01:00

338 lines
13 KiB
TypeScript

/**
* E2E tests for the repo-grouped task sidebar layout.
*
* Covers:
* - Repo group headers: letter avatar, task count, collapsible
* - Subtasks (parent_id): nested under parent with arrow indicator
* - Unassigned group: tasks with no repo
* - Context menu: Rename, Archive, Delete options via right-click
* - Active task highlight: selected task has aria/visual selection state
*/
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
test.describe("Sidebar layout — repo groups", () => {
test("tasks grouped by repository with header showing label and count", async ({
testPage,
apiClient,
seedData,
}) => {
// Create two tasks in the seeded repo so there is one group
await apiClient.createTask(seedData.workspaceId, "Sidebar Group Task A", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await apiClient.createTask(seedData.workspaceId, "Sidebar Group Task B", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
// Navigate to any task to open the session page which shows the sidebar
const task = await apiClient.createTask(seedData.workspaceId, "Sidebar Nav Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// Sidebar is visible
await expect(session.sidebar).toBeVisible({ timeout: 10_000 });
// At least one repo group header should be visible.
// The group label for a local path repo will be its local_path — we can
// match without knowing the exact slug by checking for any group header.
const anyGroupHeader = session.sidebar.locator("[data-testid='sidebar-group-header']");
await expect(anyGroupHeader.first()).toBeVisible({ timeout: 10_000 });
// A group header should contain a task count greater than zero
const countText = anyGroupHeader.first().locator("span").nth(1);
const count = await countText.innerText();
expect(Number(count)).toBeGreaterThan(0);
});
test("repo group header is collapsible — clicking hides and restores tasks", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.createTask(seedData.workspaceId, "Collapsible Task Alpha", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const task = await apiClient.createTask(seedData.workspaceId, "Collapsible Nav Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// In single-repo workspaces, unassigned tasks merge into the repo group,
// so both tasks should be in the same group.
const groupHeader = session.sidebar.locator("[data-testid='sidebar-group-header']").first();
await expect(groupHeader).toBeVisible({ timeout: 10_000 });
// Both tasks should be visible before collapsing
await expect(session.sidebar.getByText("Collapsible Task Alpha")).toBeVisible({
timeout: 10_000,
});
await expect(session.sidebar.getByText("Collapsible Nav Task")).toBeVisible({
timeout: 10_000,
});
// Collapse the group
await groupHeader.click();
// After collapsing, both tasks should be hidden
await expect(session.sidebar.getByText("Collapsible Task Alpha")).not.toBeVisible({
timeout: 5_000,
});
await expect(session.sidebar.getByText("Collapsible Nav Task")).not.toBeVisible({
timeout: 5_000,
});
// Expand again
await groupHeader.click();
await expect(session.sidebar.getByText("Collapsible Task Alpha")).toBeVisible({
timeout: 5_000,
});
});
test("tasks without repository merge into single repo group instead of Unassigned", async ({
testPage,
apiClient,
seedData,
}) => {
// Create a task with the repo and one without
await apiClient.createTask(seedData.workspaceId, "Repo Task One", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await apiClient.createTask(seedData.workspaceId, "No Repo Task One", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
// no repository_ids
});
const task = await apiClient.createTask(seedData.workspaceId, "No Repo Nav Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// In a single-repo workspace, no "Unassigned" group should appear
const unassignedGroup = session.sidebar.locator(
"[data-testid='sidebar-group-header'][data-group-label='Unassigned']",
);
await expect(unassignedGroup).not.toBeVisible({ timeout: 5_000 });
// Both tasks should be visible in the sidebar (under the repo group)
await expect(session.sidebar.getByText("Repo Task One", { exact: true })).toBeVisible({
timeout: 10_000,
});
await expect(session.sidebar.getByText("No Repo Task One", { exact: true })).toBeVisible({
timeout: 5_000,
});
});
});
test.describe("Sidebar layout — subtasks", () => {
test("subtask appears nested under parent with arrow indicator", async ({
testPage,
apiClient,
seedData,
}) => {
// Create parent task
const parent = await apiClient.createTask(seedData.workspaceId, "Parent Task Sidebar", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
// Create subtask with parent_id
await apiClient.createTask(seedData.workspaceId, "Child Subtask Sidebar", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
parent_id: parent.id,
});
await testPage.goto(`/t/${parent.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// Both titles are visible in the sidebar
await expect(session.sidebar.getByText("Parent Task Sidebar")).toBeVisible({ timeout: 10_000 });
await expect(session.sidebar.getByText("Child Subtask Sidebar")).toBeVisible({
timeout: 10_000,
});
// The subtask row has the isSubTask styling — it uses pl-8 (subtask indent)
// and renders the ↳ arrow indicator
const subtaskRow = session.sidebar
.locator("[data-testid='sidebar-task-item']")
.filter({ hasText: "Child Subtask Sidebar" });
await expect(subtaskRow).toBeVisible({ timeout: 5_000 });
// Arrow indicator ↳ is present inside the subtask row
await expect(subtaskRow.getByText("↳")).toBeVisible({ timeout: 5_000 });
});
});
test.describe("Sidebar layout — context menu", () => {
test("right-clicking a task shows Rename, Archive, and Delete options", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.createTask(seedData.workspaceId, "Context Menu Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const task = await apiClient.createTask(seedData.workspaceId, "Context Menu Nav Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// Right-click on the task row
const taskRow = session.sidebar
.locator("[data-testid='sidebar-task-item']")
.filter({ hasText: "Context Menu Task" });
await expect(taskRow).toBeVisible({ timeout: 10_000 });
await taskRow.click({ button: "right" });
// Context menu items should be visible
await expect(testPage.getByRole("menuitem", { name: "Rename" })).toBeVisible({
timeout: 5_000,
});
await expect(testPage.getByRole("menuitem", { name: "Archive" })).toBeVisible({
timeout: 5_000,
});
await expect(testPage.getByRole("menuitem", { name: "Delete" })).toBeVisible({
timeout: 5_000,
});
// Dismiss
await testPage.keyboard.press("Escape");
});
});
test.describe("Sidebar layout — task timestamps and sorting", () => {
test("every task row shows a relative timestamp on second line", async ({
testPage,
apiClient,
seedData,
}) => {
// Create three tasks
await apiClient.createTask(seedData.workspaceId, "Timestamp Task A", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await apiClient.createTask(seedData.workspaceId, "Timestamp Task B", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const navTask = await apiClient.createTask(seedData.workspaceId, "Timestamp Task C", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await testPage.goto(`/t/${navTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// Every sidebar task row should contain a relative time string ("now", "ago", etc.)
const taskRows = session.sidebar.locator("[data-testid='sidebar-task-item']");
await expect(taskRows).toHaveCount(3, { timeout: 10_000 });
for (let i = 0; i < 3; i++) {
const text = await taskRows.nth(i).innerText();
// Should contain "now" or "ago" — formatRelativeTime always produces one of these
expect(text, `task row ${i} text=${JSON.stringify(text)}`).toMatch(/now|ago/);
}
});
test("tasks are sorted by createdAt descending (newest first) within state bucket", async ({
testPage,
apiClient,
seedData,
}) => {
// Create three tasks in order; each should appear above the previous
const taskOldest = await apiClient.createTask(seedData.workspaceId, "Sort Task Oldest", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
// Small delay so timestamps differ
await new Promise((r) => setTimeout(r, 50));
await apiClient.createTask(seedData.workspaceId, "Sort Task Middle", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await new Promise((r) => setTimeout(r, 50));
await apiClient.createTask(seedData.workspaceId, "Sort Task Newest", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await testPage.goto(`/t/${taskOldest.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const taskRows = session.sidebar.locator("[data-testid='sidebar-task-item']");
await expect(taskRows).toHaveCount(3, { timeout: 10_000 });
// Within the same state bucket (all backlog), newest should be first
const titles = await Promise.all(
[0, 1, 2].map(async (i) => (await taskRows.nth(i).innerText()).split("\n")[0]),
);
const newestIdx = titles.findIndex((t) => t.includes("Newest"));
const middleIdx = titles.findIndex((t) => t.includes("Middle"));
const oldestIdx = titles.findIndex((t) => t.includes("Oldest"));
expect(newestIdx).toBeLessThan(middleIdx);
expect(middleIdx).toBeLessThan(oldestIdx);
});
});
test.describe("Sidebar layout — active task highlight", () => {
test("selected task has visual selection state", async ({ testPage, apiClient, seedData }) => {
const taskA = await apiClient.createTask(seedData.workspaceId, "Highlight Task A", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await testPage.goto(`/t/${taskA.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// The task row for the active task should have the selected styling
// TaskItem applies bg-primary/10 and a left border when isSelected=true
const activeRow = session.sidebar
.locator("[data-testid='sidebar-task-item']")
.filter({ hasText: "Highlight Task A" });
await expect(activeRow).toBeVisible({ timeout: 10_000 });
// The selected row has a specific class pattern from isSelected — check for it
// isSelected applies `bg-primary/10` which becomes part of the element's class
await expect(activeRow).toHaveClass(/bg-primary/, { timeout: 5_000 });
});
});