feat(bd-318): implement QueueView container with filtering and batch support
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>
This commit is contained in:
@@ -3,16 +3,29 @@
|
||||
*
|
||||
* Groups items into sections (Reviews, Issues, Authored MRs, Tasks),
|
||||
* shows counts, and allows clicking to set focus.
|
||||
*
|
||||
* Features:
|
||||
* - Filter items via CommandPalette (Cmd+K)
|
||||
* - Hide snoozed items by default
|
||||
* - Support batch mode entry for sections with 2+ items
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useFocusStore } from "@/stores/focus-store";
|
||||
import { QueueItem } from "./QueueItem";
|
||||
import { CommandPalette, type FilterCriteria } from "./CommandPalette";
|
||||
import type { FocusItem, FocusItemType } from "@/lib/types";
|
||||
|
||||
interface QueueViewProps {
|
||||
export interface QueueViewProps {
|
||||
onSetFocus: (id: string) => void;
|
||||
onSwitchToFocus: () => void;
|
||||
/** Callback to start batch mode with the given items and label */
|
||||
onStartBatch?: (items: FocusItem[], label: string) => void;
|
||||
/** Show snoozed items (default: false) */
|
||||
showSnoozed?: boolean;
|
||||
/** Filter to a specific type */
|
||||
filterType?: FocusItemType;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
@@ -28,6 +41,12 @@ const SECTION_ORDER: { type: FocusItemType; label: string }[] = [
|
||||
{ type: "manual", label: "TASKS" },
|
||||
];
|
||||
|
||||
/** Check if an item is currently snoozed (snooze time in the future) */
|
||||
function isSnoozed(item: FocusItem): boolean {
|
||||
if (!item.snoozedUntil) return false;
|
||||
return new Date(item.snoozedUntil).getTime() > Date.now();
|
||||
}
|
||||
|
||||
function groupByType(items: FocusItem[]): Section[] {
|
||||
return SECTION_ORDER.map(({ type, label }) => ({
|
||||
type,
|
||||
@@ -39,14 +58,78 @@ function groupByType(items: FocusItem[]): Section[] {
|
||||
export function QueueView({
|
||||
onSetFocus,
|
||||
onSwitchToFocus,
|
||||
onStartBatch,
|
||||
showSnoozed = false,
|
||||
filterType,
|
||||
}: QueueViewProps): React.ReactElement {
|
||||
const current = useFocusStore((s) => s.current);
|
||||
const queue = useFocusStore((s) => s.queue);
|
||||
|
||||
// Combine current + queue for the full list
|
||||
const allItems = current ? [current, ...queue] : [...queue];
|
||||
// Command palette state
|
||||
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
|
||||
const [activeFilter, setActiveFilter] = useState<FilterCriteria>({});
|
||||
|
||||
if (allItems.length === 0) {
|
||||
// Combine current + queue for the full list
|
||||
const allItems = useMemo(() => {
|
||||
return current ? [current, ...queue] : [...queue];
|
||||
}, [current, queue]);
|
||||
|
||||
// Apply snooze filtering
|
||||
const visibleItems = useMemo(() => {
|
||||
return allItems.filter((item) => showSnoozed || !isSnoozed(item));
|
||||
}, [allItems, showSnoozed]);
|
||||
|
||||
// Count snoozed items for the indicator
|
||||
const snoozedCount = useMemo(() => {
|
||||
return allItems.filter(isSnoozed).length;
|
||||
}, [allItems]);
|
||||
|
||||
// Apply type filter (from props or command palette)
|
||||
const effectiveFilterType = filterType ?? activeFilter.type;
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!effectiveFilterType) return visibleItems;
|
||||
return visibleItems.filter((item) => item.type === effectiveFilterType);
|
||||
}, [visibleItems, effectiveFilterType]);
|
||||
|
||||
// Handle Cmd+K to open palette
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleFilter = useCallback((criteria: FilterCriteria) => {
|
||||
setActiveFilter(criteria);
|
||||
}, []);
|
||||
|
||||
const handlePaletteSelect = useCallback(
|
||||
(itemId: string) => {
|
||||
onSetFocus(itemId);
|
||||
onSwitchToFocus();
|
||||
},
|
||||
[onSetFocus, onSwitchToFocus]
|
||||
);
|
||||
|
||||
const handleClosePalette = useCallback(() => {
|
||||
setIsPaletteOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleStartBatch = useCallback(
|
||||
(items: FocusItem[], label: string) => {
|
||||
if (onStartBatch) {
|
||||
onStartBatch(items, label);
|
||||
}
|
||||
},
|
||||
[onStartBatch]
|
||||
);
|
||||
|
||||
if (filteredItems.length === 0 && allItems.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center">
|
||||
<p className="text-zinc-500">No items in the queue</p>
|
||||
@@ -54,13 +137,26 @@ export function QueueView({
|
||||
);
|
||||
}
|
||||
|
||||
const sections = groupByType(allItems);
|
||||
const sections = groupByType(filteredItems);
|
||||
const isFiltered = effectiveFilterType !== undefined;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 px-6 py-4">
|
||||
<h1 className="text-lg font-semibold text-zinc-100">Queue</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold text-zinc-100">Queue</h1>
|
||||
{isFiltered && (
|
||||
<span className="rounded bg-zinc-700 px-2 py-0.5 text-[10px] font-medium text-zinc-300">
|
||||
Filtered
|
||||
</span>
|
||||
)}
|
||||
{!showSnoozed && snoozedCount > 0 && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
{snoozedCount} snoozed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToFocus}
|
||||
@@ -72,33 +168,61 @@ export function QueueView({
|
||||
|
||||
{/* Sections */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<motion.div
|
||||
key={section.type}
|
||||
className="mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: sectionIdx * 0.06 }}
|
||||
>
|
||||
<h2 className="mb-2 text-xs font-bold tracking-wider text-zinc-500">
|
||||
{section.label} ({section.items.length})
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{section.items.map((item) => (
|
||||
<QueueItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={(id) => {
|
||||
onSetFocus(id);
|
||||
onSwitchToFocus();
|
||||
}}
|
||||
isFocused={current?.id === item.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-zinc-500">No items match the filter</p>
|
||||
</div>
|
||||
) : (
|
||||
sections.map((section, sectionIdx) => (
|
||||
<motion.div
|
||||
key={section.type}
|
||||
className="mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: sectionIdx * 0.06 }}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-xs font-bold tracking-wider text-zinc-500">
|
||||
{section.label} ({section.items.length})
|
||||
</h2>
|
||||
{section.items.length >= 2 && onStartBatch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleStartBatch(section.items, section.label)
|
||||
}
|
||||
className="rounded border border-zinc-700 px-2 py-0.5 text-[10px] font-medium text-zinc-400 transition-colors hover:border-zinc-600 hover:text-zinc-300"
|
||||
>
|
||||
Batch
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{section.items.map((item) => (
|
||||
<QueueItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={(id) => {
|
||||
onSetFocus(id);
|
||||
onSwitchToFocus();
|
||||
}}
|
||||
isFocused={current?.id === item.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandPalette
|
||||
isOpen={isPaletteOpen}
|
||||
items={visibleItems}
|
||||
onFilter={handleFilter}
|
||||
onSelect={handlePaletteSelect}
|
||||
onClose={handleClosePalette}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,26 @@
|
||||
/**
|
||||
* TypeScript types mirroring the Rust backend data structures.
|
||||
* TypeScript types for Mission Control.
|
||||
*
|
||||
* These are used by the IPC layer and components to maintain
|
||||
* type safety across the Tauri boundary.
|
||||
* IPC types are auto-generated by tauri-specta and re-exported from bindings.
|
||||
* Frontend-only types are defined here.
|
||||
*/
|
||||
|
||||
// -- Backend response types (match Rust structs in commands/mod.rs) --
|
||||
// -- Re-export IPC types from generated bindings --
|
||||
export type {
|
||||
BridgeStatus,
|
||||
CaptureResult,
|
||||
JsonValue,
|
||||
LoreStatus,
|
||||
LoreSummaryStatus,
|
||||
McError,
|
||||
McErrorCode,
|
||||
SyncResult,
|
||||
Result,
|
||||
} from "./bindings";
|
||||
|
||||
export interface LoreStatus {
|
||||
last_sync: string | null;
|
||||
is_healthy: boolean;
|
||||
message: string;
|
||||
summary: LoreSummaryStatus | null;
|
||||
}
|
||||
// -- Type guards for IPC types --
|
||||
|
||||
export interface LoreSummaryStatus {
|
||||
open_issues: number;
|
||||
authored_mrs: number;
|
||||
reviewing_mrs: number;
|
||||
}
|
||||
|
||||
export interface BridgeStatus {
|
||||
mapping_count: number;
|
||||
pending_count: number;
|
||||
suspect_count: number;
|
||||
last_sync: string | null;
|
||||
last_reconciliation: string | null;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
created: number;
|
||||
closed: number;
|
||||
skipped: number;
|
||||
/** Number of suspect_orphan flags cleared (item reappeared) */
|
||||
healed: number;
|
||||
/** Error messages from non-fatal errors during sync */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// -- Structured error types (match Rust error.rs) --
|
||||
|
||||
/** Error codes for programmatic handling */
|
||||
export type McErrorCode =
|
||||
| "LORE_UNAVAILABLE"
|
||||
| "LORE_UNHEALTHY"
|
||||
| "LORE_FETCH_FAILED"
|
||||
| "BRIDGE_LOCKED"
|
||||
| "BRIDGE_MAP_CORRUPTED"
|
||||
| "BRIDGE_SYNC_FAILED"
|
||||
| "BEADS_UNAVAILABLE"
|
||||
| "BEADS_CREATE_FAILED"
|
||||
| "BEADS_CLOSE_FAILED"
|
||||
| "IO_ERROR"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
/** Structured error from Tauri IPC commands */
|
||||
export interface McError {
|
||||
code: McErrorCode;
|
||||
message: string;
|
||||
recoverable: boolean;
|
||||
}
|
||||
import type { McError } from "./bindings";
|
||||
|
||||
/** Type guard to check if an error is a structured McError */
|
||||
export function isMcError(err: unknown): err is McError {
|
||||
@@ -72,11 +33,6 @@ export function isMcError(err: unknown): err is McError {
|
||||
);
|
||||
}
|
||||
|
||||
/** Result from the quick_capture command */
|
||||
export interface CaptureResult {
|
||||
bead_id: string;
|
||||
}
|
||||
|
||||
// -- Frontend-only types --
|
||||
|
||||
/** The type of work item surfaced in the Focus View */
|
||||
@@ -102,10 +58,18 @@ export interface FocusItem {
|
||||
contextQuote: string | null;
|
||||
/** Who is requesting attention */
|
||||
requestedBy: string | null;
|
||||
/** ISO timestamp when snooze expires (item hidden until then) */
|
||||
snoozedUntil: string | null;
|
||||
}
|
||||
|
||||
/** Action the user takes on a focused item */
|
||||
export type FocusAction = "start" | "defer_1h" | "defer_tomorrow" | "skip";
|
||||
export type FocusAction =
|
||||
| "start"
|
||||
| "defer_1h"
|
||||
| "defer_3h"
|
||||
| "defer_tomorrow"
|
||||
| "defer_next_week"
|
||||
| "skip";
|
||||
|
||||
/** An entry in the decision log */
|
||||
export interface DecisionEntry {
|
||||
@@ -143,6 +107,10 @@ export interface InboxItem {
|
||||
url?: string;
|
||||
/** Who triggered this item (e.g., commenter name) */
|
||||
actor?: string;
|
||||
/** Whether this item has been archived */
|
||||
archived?: boolean;
|
||||
/** ISO timestamp when snooze expires (item hidden until then) */
|
||||
snoozedUntil?: string;
|
||||
}
|
||||
|
||||
/** Triage action the user can take on an inbox item */
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
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", () => {
|
||||
@@ -13,6 +14,14 @@ describe("QueueView", () => {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
useBatchStore.setState({
|
||||
isActive: false,
|
||||
batchLabel: "",
|
||||
items: [],
|
||||
statuses: [],
|
||||
currentIndex: 0,
|
||||
startedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when no items", () => {
|
||||
@@ -84,8 +93,9 @@ describe("QueueView", () => {
|
||||
expect(screen.getByText("Queued item")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSetFocus when an item is clicked", async () => {
|
||||
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({
|
||||
@@ -95,10 +105,13 @@ describe("QueueView", () => {
|
||||
],
|
||||
});
|
||||
|
||||
render(<QueueView onSetFocus={onSetFocus} onSwitchToFocus={vi.fn()} />);
|
||||
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", () => {
|
||||
@@ -113,4 +126,325 @@ describe("QueueView", () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Centralized here to avoid duplication across test files.
|
||||
*/
|
||||
|
||||
import type { FocusItem } from "@/lib/types";
|
||||
import type { FocusItem, InboxItem } from "@/lib/types";
|
||||
|
||||
/** Create a FocusItem with sensible defaults, overridable per field. */
|
||||
export function makeFocusItem(
|
||||
@@ -20,6 +20,23 @@ export function makeFocusItem(
|
||||
updatedAt: new Date().toISOString(),
|
||||
contextQuote: null,
|
||||
requestedBy: null,
|
||||
snoozedUntil: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create an InboxItem with sensible defaults, overridable per field. */
|
||||
export function makeInboxItem(
|
||||
overrides: Partial<InboxItem> = {}
|
||||
): InboxItem {
|
||||
return {
|
||||
id: "inbox-item-1",
|
||||
title: "You were mentioned in #312",
|
||||
type: "mention",
|
||||
triaged: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
snippet: "@user can you look at this?",
|
||||
actor: "alice",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user