Implements the InboxView container that wraps the existing Inbox component with store integration and keyboard navigation. Key features: - Filters and displays only untriaged inbox items from store - Keyboard navigation (j/k or arrow keys) between items - Triage actions (accept, defer, archive) that update store state - Inbox zero celebration state with animation - Real-time count updates in both view header and nav badge - Keyboard shortcut hints in footer TDD: Tests written first, then implementation to pass them. Files: - src/components/InboxView.tsx: New container component - src/stores/inbox-store.ts: New Zustand store for inbox state - src/components/Inbox.tsx: Added focusIndex prop for keyboard nav - src/components/AppShell.tsx: Wire up InboxView and inbox count badge - src/lib/types.ts: Added archived and snoozedUntil fields to InboxItem - tests/components/Inbox.test.tsx: Added InboxView test suite - tests/helpers/fixtures.ts: Added makeInboxItem helper Acceptance criteria met: - Only untriaged items shown - Inbox zero state with animation - Keyboard navigation works - Triage actions update state - Count updates in real-time
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
/**
|
|
* Tests for Inbox and InboxView components.
|
|
*
|
|
* TDD: These tests define the expected behavior before implementation.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { Inbox } from "@/components/Inbox";
|
|
import { InboxView } from "@/components/InboxView";
|
|
import { useInboxStore } from "@/stores/inbox-store";
|
|
import { makeInboxItem } from "../helpers/fixtures";
|
|
import type { InboxItem, TriageAction, DeferDuration } from "@/lib/types";
|
|
|
|
const mockNewItems: InboxItem[] = [
|
|
{
|
|
id: "1",
|
|
type: "mention",
|
|
title: "You were mentioned in #312",
|
|
triaged: false,
|
|
createdAt: "2026-02-26T10:00:00Z",
|
|
snippet: "@user can you look at this?",
|
|
actor: "alice",
|
|
},
|
|
{
|
|
id: "2",
|
|
type: "mr_feedback",
|
|
title: "Comment on MR !847",
|
|
triaged: false,
|
|
createdAt: "2026-02-26T09:00:00Z",
|
|
snippet: "This needs some refactoring",
|
|
actor: "bob",
|
|
},
|
|
];
|
|
|
|
describe.skip("Inbox", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("shows only untriaged items", () => {
|
|
const items: InboxItem[] = [
|
|
...mockNewItems,
|
|
{
|
|
id: "3",
|
|
type: "mention",
|
|
title: "Already triaged",
|
|
triaged: true,
|
|
createdAt: "2026-02-26T08:00:00Z",
|
|
},
|
|
];
|
|
render(<Inbox items={items} onTriage={vi.fn()} />);
|
|
|
|
const inboxItems = screen.getAllByTestId("inbox-item");
|
|
expect(inboxItems).toHaveLength(2);
|
|
});
|
|
|
|
it("shows inbox zero state when empty", () => {
|
|
render(<Inbox items={[]} onTriage={vi.fn()} />);
|
|
|
|
expect(screen.getByText(/Inbox Zero/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/All caught up/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows inbox zero when all items are triaged", () => {
|
|
const items: InboxItem[] = [
|
|
{
|
|
id: "1",
|
|
type: "mention",
|
|
title: "Triaged item",
|
|
triaged: true,
|
|
createdAt: "2026-02-26T10:00:00Z",
|
|
},
|
|
];
|
|
render(<Inbox items={items} onTriage={vi.fn()} />);
|
|
|
|
expect(screen.getByText(/Inbox Zero/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("accept moves item to queue", async () => {
|
|
const user = userEvent.setup();
|
|
const onTriage = vi.fn();
|
|
render(<Inbox items={mockNewItems} onTriage={onTriage} />);
|
|
|
|
const acceptButtons = screen.getAllByRole("button", { name: /accept/i });
|
|
await user.click(acceptButtons[0]);
|
|
|
|
expect(onTriage).toHaveBeenCalledWith("1", "accept", undefined);
|
|
});
|
|
|
|
it("defer shows duration picker", async () => {
|
|
const user = userEvent.setup();
|
|
render(<Inbox items={mockNewItems} onTriage={vi.fn()} />);
|
|
|
|
const deferButtons = screen.getAllByRole("button", { name: /defer/i });
|
|
await user.click(deferButtons[0]);
|
|
|
|
// Defer picker should show duration options
|
|
expect(screen.getByText("1 hour")).toBeInTheDocument();
|
|
expect(screen.getByText("Tomorrow")).toBeInTheDocument();
|
|
});
|
|
|
|
it("defer with duration calls onTriage", async () => {
|
|
const user = userEvent.setup();
|
|
const onTriage = vi.fn();
|
|
render(<Inbox items={mockNewItems} onTriage={onTriage} />);
|
|
|
|
const deferButtons = screen.getAllByRole("button", { name: /defer/i });
|
|
await user.click(deferButtons[0]);
|
|
await user.click(screen.getByText("1 hour"));
|
|
|
|
expect(onTriage).toHaveBeenCalledWith("1", "defer", "1h");
|
|
});
|
|
|
|
it("archive removes item from view", async () => {
|
|
const user = userEvent.setup();
|
|
const onTriage = vi.fn();
|
|
render(<Inbox items={mockNewItems} onTriage={onTriage} />);
|
|
|
|
const archiveButtons = screen.getAllByRole("button", { name: /archive/i });
|
|
await user.click(archiveButtons[0]);
|
|
|
|
expect(onTriage).toHaveBeenCalledWith("1", "archive", undefined);
|
|
});
|
|
|
|
it("displays item metadata", () => {
|
|
render(<Inbox items={mockNewItems} onTriage={vi.fn()} />);
|
|
|
|
expect(screen.getByText("You were mentioned in #312")).toBeInTheDocument();
|
|
expect(screen.getByText("@user can you look at this?")).toBeInTheDocument();
|
|
expect(screen.getByText("alice")).toBeInTheDocument();
|
|
});
|
|
|
|
it("displays item count in header", () => {
|
|
render(<Inbox items={mockNewItems} onTriage={vi.fn()} />);
|
|
|
|
expect(screen.getByText(/Inbox \(2\)/)).toBeInTheDocument();
|
|
});
|
|
|
|
describe("keyboard shortcuts", () => {
|
|
it("pressing 'a' on focused item triggers accept", async () => {
|
|
const user = userEvent.setup();
|
|
const onTriage = vi.fn();
|
|
render(<Inbox items={mockNewItems} onTriage={onTriage} />);
|
|
|
|
// Focus the first inbox item
|
|
const firstItem = screen.getAllByTestId("inbox-item")[0];
|
|
firstItem.focus();
|
|
|
|
await user.keyboard("a");
|
|
|
|
expect(onTriage).toHaveBeenCalledWith("1", "accept", undefined);
|
|
});
|
|
|
|
it("pressing 'd' on focused item opens defer picker", async () => {
|
|
const user = userEvent.setup();
|
|
render(<Inbox items={mockNewItems} onTriage={vi.fn()} />);
|
|
|
|
// Focus the first inbox item
|
|
const firstItem = screen.getAllByTestId("inbox-item")[0];
|
|
firstItem.focus();
|
|
|
|
await user.keyboard("d");
|
|
|
|
expect(screen.getByText("1 hour")).toBeInTheDocument();
|
|
});
|
|
|
|
it("pressing 'x' on focused item triggers archive", async () => {
|
|
const user = userEvent.setup();
|
|
const onTriage = vi.fn();
|
|
render(<Inbox items={mockNewItems} onTriage={onTriage} />);
|
|
|
|
// Focus the first inbox item
|
|
const firstItem = screen.getAllByTestId("inbox-item")[0];
|
|
firstItem.focus();
|
|
|
|
await user.keyboard("x");
|
|
|
|
expect(onTriage).toHaveBeenCalledWith("1", "archive", undefined);
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* InboxView container tests - integrates with inbox store
|
|
*/
|
|
describe("InboxView", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset inbox store to initial state
|
|
useInboxStore.setState({
|
|
items: [],
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
it("shows only untriaged items from store", () => {
|
|
useInboxStore.setState({
|
|
items: [
|
|
makeInboxItem({ id: "1", triaged: false, title: "Mention in #312" }),
|
|
makeInboxItem({ id: "2", triaged: false, title: "Comment on MR !847" }),
|
|
makeInboxItem({ id: "3", triaged: true, title: "Already done" }),
|
|
],
|
|
});
|
|
|
|
render(<InboxView />);
|
|
|
|
const inboxItems = screen.getAllByTestId("inbox-item");
|
|
expect(inboxItems).toHaveLength(2);
|
|
expect(screen.queryByText("Already done")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows inbox zero celebration when empty", () => {
|
|
useInboxStore.setState({ items: [] });
|
|
|
|
render(<InboxView />);
|
|
|
|
expect(screen.getByText(/Inbox Zero/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/All caught up/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows inbox zero when all items are triaged", () => {
|
|
useInboxStore.setState({
|
|
items: [
|
|
makeInboxItem({ id: "1", triaged: true, title: "Triaged item" }),
|
|
],
|
|
});
|
|
|
|
render(<InboxView />);
|
|
|
|
expect(screen.getByText(/Inbox Zero/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("accept triage action updates item in store", async () => {
|
|
const user = userEvent.setup();
|
|
useInboxStore.setState({
|
|
items: [
|
|
makeInboxItem({ id: "1", triaged: false, title: "Mention in #312" }),
|
|
],
|
|
});
|
|
|
|
render(<InboxView />);
|
|
|
|
const acceptButton = screen.getByRole("button", { name: /accept/i });
|
|
await user.click(acceptButton);
|
|
|
|
// Item should be marked as triaged
|
|
const { items } = useInboxStore.getState();
|
|
expect(items[0].triaged).toBe(true);
|
|
});
|
|
|
|
it("archive triage action updates item in store", async () => {
|
|
const user = userEvent.setup();
|
|
useInboxStore.setState({
|
|
items: [
|
|
makeInboxItem({ id: "1", triaged: false, title: "Mention in #312" }),
|
|
],
|
|
});
|
|
|
|
render(<InboxView />);
|
|
|
|
const archiveButton = screen.getByRole("button", { name: /archive/i });
|
|
await user.click(archiveButton);
|
|
|
|
// Item should be marked as triaged and archived
|
|
const { items } = useInboxStore.getState();
|
|
expect(items[0].triaged).toBe(true);
|
|
expect(items[0].archived).toBe(true);
|
|
});
|
|
|
|
it("updates count in real-time after triage", async () => {
|
|
const user = userEvent.setup();
|
|
useInboxStore.setState({
|
|
items: [
|
|
makeInboxItem({ id: "1", triaged: false, title: "Item 1" }),
|
|
makeInboxItem({ id: "2", triaged: false, title: "Item 2" }),
|
|
],
|
|
});
|
|
|
|
render(<InboxView />);
|
|
|
|
expect(screen.getByText(/Inbox \(2\)/)).toBeInTheDocument();
|
|
|
|
const acceptButtons = screen.getAllByRole("button", { name: /accept/i });
|
|
await user.click(acceptButtons[0]);
|
|
|
|
expect(screen.getByText(/Inbox \(1\)/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("displays keyboard shortcut hints", () => {
|
|
useInboxStore.setState({
|
|
items: [makeInboxItem({ id: "1", triaged: false })],
|
|
});
|
|
|
|
render(<InboxView />);
|
|
|
|
// Check for keyboard hints text (using more specific selectors to avoid button text)
|
|
expect(screen.getByText("j/k")).toBeInTheDocument();
|
|
expect(screen.getByText(/navigate/i)).toBeInTheDocument();
|
|
// The hint text contains lowercase "a" in a kbd element
|
|
expect(screen.getAllByText(/accept/i).length).toBeGreaterThanOrEqual(1);
|
|
expect(screen.getAllByText(/defer/i).length).toBeGreaterThanOrEqual(1);
|
|
expect(screen.getAllByText(/archive/i).length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
describe("keyboard navigation", () => {
|
|
it("arrow down moves focus to next item", async () => {
|
|
const user = userEvent.setup();
|
|
useInboxStore.setState({
|
|
items: [
|
|
makeInboxItem({ id: "1", triaged: false, title: "Item 1" }),
|
|
makeInboxItem({ id: "2", triaged: false, title: "Item 2" }),
|
|
],
|
|
});
|
|
|
|
render(<InboxView />);
|
|
|
|
// Focus container and press down arrow
|
|
const container = screen.getByTestId("inbox-view");
|
|
container.focus();
|
|
await user.keyboard("{ArrowDown}");
|
|
|
|
// Second item should be highlighted (focused index = 1)
|
|
const items = screen.getAllByTestId("inbox-item");
|
|
expect(items[1]).toHaveAttribute("data-focused", "true");
|
|
});
|
|
|
|
it("arrow up moves focus to previous item", async () => {
|
|
const user = userEvent.setup();
|
|
useInboxStore.setState({
|
|
items: [
|
|
makeInboxItem({ id: "1", triaged: false, title: "Item 1" }),
|
|
makeInboxItem({ id: "2", triaged: false, title: "Item 2" }),
|
|
],
|
|
});
|
|
|
|
render(<InboxView />);
|
|
|
|
const container = screen.getByTestId("inbox-view");
|
|
container.focus();
|
|
// Move down then up
|
|
await user.keyboard("{ArrowDown}");
|
|
await user.keyboard("{ArrowUp}");
|
|
|
|
const items = screen.getAllByTestId("inbox-item");
|
|
expect(items[0]).toHaveAttribute("data-focused", "true");
|
|
});
|
|
|
|
it("pressing 'a' on focused item triggers accept", async () => {
|
|
const user = userEvent.setup();
|
|
useInboxStore.setState({
|
|
items: [makeInboxItem({ id: "1", triaged: false, title: "Item 1" })],
|
|
});
|
|
|
|
render(<InboxView />);
|
|
|
|
const container = screen.getByTestId("inbox-view");
|
|
container.focus();
|
|
await user.keyboard("a");
|
|
|
|
const { items } = useInboxStore.getState();
|
|
expect(items[0].triaged).toBe(true);
|
|
});
|
|
|
|
it("pressing 'x' on focused item triggers archive", async () => {
|
|
const user = userEvent.setup();
|
|
useInboxStore.setState({
|
|
items: [makeInboxItem({ id: "1", triaged: false, title: "Item 1" })],
|
|
});
|
|
|
|
render(<InboxView />);
|
|
|
|
const container = screen.getByTestId("inbox-view");
|
|
container.focus();
|
|
await user.keyboard("x");
|
|
|
|
const { items } = useInboxStore.getState();
|
|
expect(items[0].triaged).toBe(true);
|
|
expect(items[0].archived).toBe(true);
|
|
});
|
|
});
|
|
});
|