QueueView now supports: - Filtering items via CommandPalette (Cmd+K) - Hide snoozed items by default (showSnoozed prop) - Show snooze count indicator when items are hidden - Support batch mode entry for sections with 2+ items - Filter by type prop for programmatic filtering Added snoozedUntil field to FocusItem type and updated fixtures. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
451 lines
13 KiB
TypeScript
451 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { QueueView } from "@/components/QueueView";
|
|
import { useFocusStore } from "@/stores/focus-store";
|
|
import { useBatchStore } from "@/stores/batch-store";
|
|
import { makeFocusItem } from "../helpers/fixtures";
|
|
|
|
describe("QueueView", () => {
|
|
beforeEach(() => {
|
|
useFocusStore.setState({
|
|
current: null,
|
|
queue: [],
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
useBatchStore.setState({
|
|
isActive: false,
|
|
batchLabel: "",
|
|
items: [],
|
|
statuses: [],
|
|
currentIndex: 0,
|
|
startedAt: null,
|
|
});
|
|
});
|
|
|
|
it("shows empty state when no items", () => {
|
|
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
|
expect(screen.getByText(/no items/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("groups items by type with section headers", () => {
|
|
useFocusStore.setState({
|
|
current: makeFocusItem({ id: "current" }),
|
|
queue: [
|
|
makeFocusItem({ id: "r1", type: "mr_review", title: "Review A" }),
|
|
makeFocusItem({ id: "r2", type: "mr_review", title: "Review B" }),
|
|
makeFocusItem({ id: "i1", type: "issue", title: "Issue A" }),
|
|
makeFocusItem({
|
|
id: "m1",
|
|
type: "mr_authored",
|
|
title: "My MR",
|
|
}),
|
|
],
|
|
});
|
|
|
|
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
|
|
|
expect(screen.getByText(/REVIEWS/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/ISSUES/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/AUTHORED MRS/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows item count in section headers", () => {
|
|
useFocusStore.setState({
|
|
current: makeFocusItem({ id: "current", type: "issue" }),
|
|
queue: [
|
|
makeFocusItem({ id: "r1", type: "mr_review" }),
|
|
makeFocusItem({ id: "r2", type: "mr_review" }),
|
|
makeFocusItem({ id: "i1", type: "issue" }),
|
|
],
|
|
});
|
|
|
|
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
|
|
|
// Text is split across elements, so use a function matcher
|
|
const reviewsHeader = screen.getByText((_content, element) => {
|
|
return element?.tagName === "H2" && element.textContent === "REVIEWS (2)";
|
|
});
|
|
expect(reviewsHeader).toBeInTheDocument();
|
|
|
|
const issuesHeader = screen.getByText((_content, element) => {
|
|
return element?.tagName === "H2" && element.textContent === "ISSUES (2)";
|
|
});
|
|
expect(issuesHeader).toBeInTheDocument();
|
|
});
|
|
|
|
it("includes current focus item in the list", () => {
|
|
useFocusStore.setState({
|
|
current: makeFocusItem({
|
|
id: "focused",
|
|
type: "mr_review",
|
|
title: "Focused item",
|
|
}),
|
|
queue: [
|
|
makeFocusItem({ id: "q1", type: "issue", title: "Queued item" }),
|
|
],
|
|
});
|
|
|
|
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
|
|
|
expect(screen.getByText("Focused item")).toBeInTheDocument();
|
|
expect(screen.getByText("Queued item")).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onSetFocus and switches to focus when an item is clicked", async () => {
|
|
const onSetFocus = vi.fn();
|
|
const onSwitchToFocus = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
useFocusStore.setState({
|
|
current: makeFocusItem({ id: "current" }),
|
|
queue: [
|
|
makeFocusItem({ id: "target", type: "issue", title: "Click me" }),
|
|
],
|
|
});
|
|
|
|
render(
|
|
<QueueView onSetFocus={onSetFocus} onSwitchToFocus={onSwitchToFocus} />
|
|
);
|
|
|
|
await user.click(screen.getByText("Click me"));
|
|
expect(onSetFocus).toHaveBeenCalledWith("target");
|
|
expect(onSwitchToFocus).toHaveBeenCalled();
|
|
});
|
|
|
|
it("marks the current focus item visually", () => {
|
|
useFocusStore.setState({
|
|
current: makeFocusItem({ id: "focused", title: "Current focus" }),
|
|
queue: [],
|
|
});
|
|
|
|
const { container } = render(
|
|
<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />
|
|
);
|
|
|
|
expect(container.querySelector("[data-focused='true']")).toBeTruthy();
|
|
});
|
|
|
|
// -- Snoozed items filtering --
|
|
|
|
describe("snoozed items", () => {
|
|
it("hides snoozed items by default", () => {
|
|
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
|
|
useFocusStore.setState({
|
|
current: makeFocusItem({ id: "active", title: "Active item" }),
|
|
queue: [
|
|
makeFocusItem({
|
|
id: "snoozed",
|
|
type: "issue",
|
|
title: "Snoozed item",
|
|
snoozedUntil: future,
|
|
}),
|
|
makeFocusItem({
|
|
id: "visible",
|
|
type: "issue",
|
|
title: "Visible item",
|
|
snoozedUntil: null,
|
|
}),
|
|
],
|
|
});
|
|
|
|
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
|
|
|
expect(screen.queryByText("Snoozed item")).not.toBeInTheDocument();
|
|
expect(screen.getByText("Visible item")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows snoozed items when showSnoozed is true", () => {
|
|
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
|
|
useFocusStore.setState({
|
|
current: makeFocusItem({ id: "active", title: "Active item" }),
|
|
queue: [
|
|
makeFocusItem({
|
|
id: "snoozed",
|
|
type: "issue",
|
|
title: "Snoozed item",
|
|
snoozedUntil: future,
|
|
}),
|
|
],
|
|
});
|
|
|
|
render(
|
|
<QueueView
|
|
onSetFocus={vi.fn()}
|
|
onSwitchToFocus={vi.fn()}
|
|
showSnoozed={true}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText("Snoozed item")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows items with expired snooze time", () => {
|
|
const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
|
|
useFocusStore.setState({
|
|
current: null,
|
|
queue: [
|
|
makeFocusItem({
|
|
id: "expired",
|
|
type: "issue",
|
|
title: "Expired snooze",
|
|
snoozedUntil: past,
|
|
}),
|
|
],
|
|
});
|
|
|
|
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
|
|
|
expect(screen.getByText("Expired snooze")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows snooze count indicator when items are hidden", () => {
|
|
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
|
|
useFocusStore.setState({
|
|
current: makeFocusItem({ id: "active", title: "Active item" }),
|
|
queue: [
|
|
makeFocusItem({
|
|
id: "snoozed1",
|
|
type: "issue",
|
|
title: "Snoozed 1",
|
|
snoozedUntil: future,
|
|
}),
|
|
makeFocusItem({
|
|
id: "snoozed2",
|
|
type: "mr_review",
|
|
title: "Snoozed 2",
|
|
snoozedUntil: future,
|
|
}),
|
|
],
|
|
});
|
|
|
|
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
|
|
|
expect(screen.getByText(/2 snoozed/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// -- Filtering via type --
|
|
|
|
describe("filtering", () => {
|
|
it("filters items by type when filter is applied", () => {
|
|
useFocusStore.setState({
|
|
current: makeFocusItem({ id: "current", type: "mr_review" }),
|
|
queue: [
|
|
makeFocusItem({ id: "r1", type: "mr_review", title: "Review 1" }),
|
|
makeFocusItem({ id: "i1", type: "issue", title: "Issue 1" }),
|
|
makeFocusItem({ id: "m1", type: "manual", title: "Task 1" }),
|
|
],
|
|
});
|
|
|
|
render(
|
|
<QueueView
|
|
onSetFocus={vi.fn()}
|
|
onSwitchToFocus={vi.fn()}
|
|
filterType="issue"
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText("Review 1")).not.toBeInTheDocument();
|
|
expect(screen.getByText("Issue 1")).toBeInTheDocument();
|
|
expect(screen.queryByText("Task 1")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows all types when no filter is applied", () => {
|
|
useFocusStore.setState({
|
|
current: null,
|
|
queue: [
|
|
makeFocusItem({ id: "r1", type: "mr_review", title: "Review 1" }),
|
|
makeFocusItem({ id: "i1", type: "issue", title: "Issue 1" }),
|
|
],
|
|
});
|
|
|
|
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
|
|
|
expect(screen.getByText("Review 1")).toBeInTheDocument();
|
|
expect(screen.getByText("Issue 1")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// -- Batch mode entry --
|
|
|
|
describe("batch mode", () => {
|
|
it("shows batch button for sections with multiple items", () => {
|
|
useFocusStore.setState({
|
|
current: null,
|
|
queue: [
|
|
makeFocusItem({ id: "r1", type: "mr_review", title: "Review 1" }),
|
|
makeFocusItem({ id: "r2", type: "mr_review", title: "Review 2" }),
|
|
makeFocusItem({ id: "r3", type: "mr_review", title: "Review 3" }),
|
|
],
|
|
});
|
|
|
|
render(
|
|
<QueueView
|
|
onSetFocus={vi.fn()}
|
|
onSwitchToFocus={vi.fn()}
|
|
onStartBatch={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
const batchButton = screen.getByRole("button", { name: /batch/i });
|
|
expect(batchButton).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not show batch button for sections with single item", () => {
|
|
useFocusStore.setState({
|
|
current: null,
|
|
queue: [
|
|
makeFocusItem({ id: "r1", type: "mr_review", title: "Review 1" }),
|
|
makeFocusItem({ id: "i1", type: "issue", title: "Issue 1" }),
|
|
],
|
|
});
|
|
|
|
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
|
|
|
// Neither section has multiple items
|
|
expect(
|
|
screen.queryByRole("button", { name: /batch/i })
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onStartBatch with section items when batch button clicked", async () => {
|
|
const onStartBatch = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
useFocusStore.setState({
|
|
current: null,
|
|
queue: [
|
|
makeFocusItem({ id: "r1", type: "mr_review", title: "Review 1" }),
|
|
makeFocusItem({ id: "r2", type: "mr_review", title: "Review 2" }),
|
|
],
|
|
});
|
|
|
|
render(
|
|
<QueueView
|
|
onSetFocus={vi.fn()}
|
|
onSwitchToFocus={vi.fn()}
|
|
onStartBatch={onStartBatch}
|
|
/>
|
|
);
|
|
|
|
const batchButton = screen.getByRole("button", { name: /batch/i });
|
|
await user.click(batchButton);
|
|
|
|
expect(onStartBatch).toHaveBeenCalledWith(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: "r1" }),
|
|
expect.objectContaining({ id: "r2" }),
|
|
]),
|
|
"REVIEWS"
|
|
);
|
|
});
|
|
});
|
|
|
|
// -- Command palette integration --
|
|
|
|
describe("command palette", () => {
|
|
it("opens command palette on Cmd+K", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
useFocusStore.setState({
|
|
current: makeFocusItem({ id: "current" }),
|
|
queue: [],
|
|
});
|
|
|
|
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
|
|
|
await user.keyboard("{Meta>}k{/Meta}");
|
|
|
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
});
|
|
|
|
it("filters items when command is selected", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
useFocusStore.setState({
|
|
current: null,
|
|
queue: [
|
|
makeFocusItem({ id: "r1", type: "mr_review", title: "Review 1" }),
|
|
makeFocusItem({ id: "i1", type: "issue", title: "Issue 1" }),
|
|
],
|
|
});
|
|
|
|
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
|
|
|
// Open palette
|
|
await user.keyboard("{Meta>}k{/Meta}");
|
|
|
|
// Type filter command prefix to enter command mode
|
|
const input = screen.getByRole("textbox");
|
|
await user.type(input, "type:");
|
|
|
|
// Click the type:issue option directly
|
|
const issueOption = screen.getByRole("option", { name: /type:issue/i });
|
|
await user.click(issueOption);
|
|
|
|
// Should only show issues
|
|
expect(screen.queryByText("Review 1")).not.toBeInTheDocument();
|
|
expect(screen.getByText("Issue 1")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// -- Header actions --
|
|
|
|
describe("header", () => {
|
|
it("shows Back to Focus button", () => {
|
|
useFocusStore.setState({
|
|
current: makeFocusItem({ id: "current" }),
|
|
queue: [],
|
|
});
|
|
|
|
render(<QueueView onSetFocus={vi.fn()} onSwitchToFocus={vi.fn()} />);
|
|
|
|
expect(
|
|
screen.getByRole("button", { name: /back to focus/i })
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onSwitchToFocus when Back to Focus clicked", async () => {
|
|
const onSwitchToFocus = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
useFocusStore.setState({
|
|
current: makeFocusItem({ id: "current" }),
|
|
queue: [],
|
|
});
|
|
|
|
render(
|
|
<QueueView onSetFocus={vi.fn()} onSwitchToFocus={onSwitchToFocus} />
|
|
);
|
|
|
|
await user.click(screen.getByRole("button", { name: /back to focus/i }));
|
|
expect(onSwitchToFocus).toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows filter indicator when filter is active", () => {
|
|
useFocusStore.setState({
|
|
current: null,
|
|
queue: [
|
|
makeFocusItem({ id: "i1", type: "issue", title: "Issue 1" }),
|
|
],
|
|
});
|
|
|
|
render(
|
|
<QueueView
|
|
onSetFocus={vi.fn()}
|
|
onSwitchToFocus={vi.fn()}
|
|
filterType="issue"
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText(/filtered/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|