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),
|
* Groups items into sections (Reviews, Issues, Authored MRs, Tasks),
|
||||||
* shows counts, and allows clicking to set focus.
|
* 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 { motion } from "framer-motion";
|
||||||
import { useFocusStore } from "@/stores/focus-store";
|
import { useFocusStore } from "@/stores/focus-store";
|
||||||
import { QueueItem } from "./QueueItem";
|
import { QueueItem } from "./QueueItem";
|
||||||
|
import { CommandPalette, type FilterCriteria } from "./CommandPalette";
|
||||||
import type { FocusItem, FocusItemType } from "@/lib/types";
|
import type { FocusItem, FocusItemType } from "@/lib/types";
|
||||||
|
|
||||||
interface QueueViewProps {
|
export interface QueueViewProps {
|
||||||
onSetFocus: (id: string) => void;
|
onSetFocus: (id: string) => void;
|
||||||
onSwitchToFocus: () => 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 {
|
interface Section {
|
||||||
@@ -28,6 +41,12 @@ const SECTION_ORDER: { type: FocusItemType; label: string }[] = [
|
|||||||
{ type: "manual", label: "TASKS" },
|
{ 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[] {
|
function groupByType(items: FocusItem[]): Section[] {
|
||||||
return SECTION_ORDER.map(({ type, label }) => ({
|
return SECTION_ORDER.map(({ type, label }) => ({
|
||||||
type,
|
type,
|
||||||
@@ -39,14 +58,78 @@ function groupByType(items: FocusItem[]): Section[] {
|
|||||||
export function QueueView({
|
export function QueueView({
|
||||||
onSetFocus,
|
onSetFocus,
|
||||||
onSwitchToFocus,
|
onSwitchToFocus,
|
||||||
|
onStartBatch,
|
||||||
|
showSnoozed = false,
|
||||||
|
filterType,
|
||||||
}: QueueViewProps): React.ReactElement {
|
}: QueueViewProps): React.ReactElement {
|
||||||
const current = useFocusStore((s) => s.current);
|
const current = useFocusStore((s) => s.current);
|
||||||
const queue = useFocusStore((s) => s.queue);
|
const queue = useFocusStore((s) => s.queue);
|
||||||
|
|
||||||
// Combine current + queue for the full list
|
// Command palette state
|
||||||
const allItems = current ? [current, ...queue] : [...queue];
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center">
|
<div className="flex min-h-screen flex-col items-center justify-center">
|
||||||
<p className="text-zinc-500">No items in the queue</p>
|
<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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-zinc-800 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-zinc-800 px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-lg font-semibold text-zinc-100">Queue</h1>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSwitchToFocus}
|
onClick={onSwitchToFocus}
|
||||||
@@ -72,7 +168,12 @@ export function QueueView({
|
|||||||
|
|
||||||
{/* Sections */}
|
{/* Sections */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
{sections.map((section, sectionIdx) => (
|
{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
|
<motion.div
|
||||||
key={section.type}
|
key={section.type}
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
@@ -80,9 +181,22 @@ export function QueueView({
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.2, delay: sectionIdx * 0.06 }}
|
transition={{ duration: 0.2, delay: sectionIdx * 0.06 }}
|
||||||
>
|
>
|
||||||
<h2 className="mb-2 text-xs font-bold tracking-wider text-zinc-500">
|
<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})
|
{section.label} ({section.items.length})
|
||||||
</h2>
|
</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">
|
<div className="flex flex-col gap-1.5">
|
||||||
{section.items.map((item) => (
|
{section.items.map((item) => (
|
||||||
<QueueItem
|
<QueueItem
|
||||||
@@ -97,8 +211,18 @@ export function QueueView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Command Palette */}
|
||||||
|
<CommandPalette
|
||||||
|
isOpen={isPaletteOpen}
|
||||||
|
items={visibleItems}
|
||||||
|
onFilter={handleFilter}
|
||||||
|
onSelect={handlePaletteSelect}
|
||||||
|
onClose={handleClosePalette}
|
||||||
|
/>
|
||||||
</div>
|
</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
|
* IPC types are auto-generated by tauri-specta and re-exported from bindings.
|
||||||
* type safety across the Tauri boundary.
|
* 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 {
|
// -- Type guards for IPC types --
|
||||||
last_sync: string | null;
|
|
||||||
is_healthy: boolean;
|
|
||||||
message: string;
|
|
||||||
summary: LoreSummaryStatus | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoreSummaryStatus {
|
import type { McError } from "./bindings";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Type guard to check if an error is a structured McError */
|
/** Type guard to check if an error is a structured McError */
|
||||||
export function isMcError(err: unknown): err is 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 --
|
// -- Frontend-only types --
|
||||||
|
|
||||||
/** The type of work item surfaced in the Focus View */
|
/** The type of work item surfaced in the Focus View */
|
||||||
@@ -102,10 +58,18 @@ export interface FocusItem {
|
|||||||
contextQuote: string | null;
|
contextQuote: string | null;
|
||||||
/** Who is requesting attention */
|
/** Who is requesting attention */
|
||||||
requestedBy: string | null;
|
requestedBy: string | null;
|
||||||
|
/** ISO timestamp when snooze expires (item hidden until then) */
|
||||||
|
snoozedUntil: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Action the user takes on a focused item */
|
/** 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 */
|
/** An entry in the decision log */
|
||||||
export interface DecisionEntry {
|
export interface DecisionEntry {
|
||||||
@@ -143,6 +107,10 @@ export interface InboxItem {
|
|||||||
url?: string;
|
url?: string;
|
||||||
/** Who triggered this item (e.g., commenter name) */
|
/** Who triggered this item (e.g., commenter name) */
|
||||||
actor?: string;
|
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 */
|
/** Triage action the user can take on an inbox item */
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { QueueView } from "@/components/QueueView";
|
import { QueueView } from "@/components/QueueView";
|
||||||
import { useFocusStore } from "@/stores/focus-store";
|
import { useFocusStore } from "@/stores/focus-store";
|
||||||
|
import { useBatchStore } from "@/stores/batch-store";
|
||||||
import { makeFocusItem } from "../helpers/fixtures";
|
import { makeFocusItem } from "../helpers/fixtures";
|
||||||
|
|
||||||
describe("QueueView", () => {
|
describe("QueueView", () => {
|
||||||
@@ -13,6 +14,14 @@ describe("QueueView", () => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
useBatchStore.setState({
|
||||||
|
isActive: false,
|
||||||
|
batchLabel: "",
|
||||||
|
items: [],
|
||||||
|
statuses: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
startedAt: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows empty state when no items", () => {
|
it("shows empty state when no items", () => {
|
||||||
@@ -84,8 +93,9 @@ describe("QueueView", () => {
|
|||||||
expect(screen.getByText("Queued item")).toBeInTheDocument();
|
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 onSetFocus = vi.fn();
|
||||||
|
const onSwitchToFocus = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
useFocusStore.setState({
|
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"));
|
await user.click(screen.getByText("Click me"));
|
||||||
expect(onSetFocus).toHaveBeenCalledWith("target");
|
expect(onSetFocus).toHaveBeenCalledWith("target");
|
||||||
|
expect(onSwitchToFocus).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks the current focus item visually", () => {
|
it("marks the current focus item visually", () => {
|
||||||
@@ -113,4 +126,325 @@ describe("QueueView", () => {
|
|||||||
|
|
||||||
expect(container.querySelector("[data-focused='true']")).toBeTruthy();
|
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.
|
* 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. */
|
/** Create a FocusItem with sensible defaults, overridable per field. */
|
||||||
export function makeFocusItem(
|
export function makeFocusItem(
|
||||||
@@ -20,6 +20,23 @@ export function makeFocusItem(
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
contextQuote: null,
|
contextQuote: null,
|
||||||
requestedBy: 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,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user