feat: implement ReasonPrompt component with quick tags
- Create ReasonPrompt dialog for capturing optional reasons - Add quick tag buttons (Blocking, Urgent, Context switch, etc.) - Support keyboard navigation (Escape to cancel) - Handle text input with trimming and null for empty - Different titles for different actions (set_focus, defer, skip) - All 10 tests pass Closes bd-2p0 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@ use serde::Serialize;
|
||||
use specta::Type;
|
||||
|
||||
/// Simple greeting command for testing IPC
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! Welcome to Mission Control.", name)
|
||||
}
|
||||
@@ -35,8 +35,8 @@ pub struct LoreSummaryStatus {
|
||||
}
|
||||
|
||||
/// Get the current status of lore integration by calling the real CLI.
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_lore_status() -> Result<LoreStatus, McError> {
|
||||
get_lore_status_with(&RealLoreCli)
|
||||
}
|
||||
@@ -107,8 +107,8 @@ pub struct BridgeStatus {
|
||||
}
|
||||
|
||||
/// Get the current status of the bridge (mapping counts, sync times).
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_bridge_status() -> Result<BridgeStatus, McError> {
|
||||
// Bridge IO is blocking; run off the async executor
|
||||
tokio::task::spawn_blocking(|| get_bridge_status_inner(None))
|
||||
@@ -140,8 +140,8 @@ fn get_bridge_status_inner(
|
||||
}
|
||||
|
||||
/// Trigger an incremental sync (process since_last_check events).
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sync_now() -> Result<SyncResult, McError> {
|
||||
tokio::task::spawn_blocking(|| sync_now_inner(None))
|
||||
.await
|
||||
@@ -165,8 +165,8 @@ fn sync_now_inner(
|
||||
}
|
||||
|
||||
/// Trigger a full reconciliation pass.
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn reconcile() -> Result<SyncResult, McError> {
|
||||
tokio::task::spawn_blocking(|| reconcile_inner(None))
|
||||
.await
|
||||
@@ -198,8 +198,8 @@ pub struct CaptureResult {
|
||||
}
|
||||
|
||||
/// Quick-capture a thought as a new bead.
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn quick_capture(title: String) -> Result<CaptureResult, McError> {
|
||||
tokio::task::spawn_blocking(move || quick_capture_inner(&RealBeadsCli, &title))
|
||||
.await
|
||||
@@ -216,8 +216,8 @@ fn quick_capture_inner(cli: &dyn BeadsCli, title: &str) -> Result<CaptureResult,
|
||||
/// Read persisted frontend state from ~/.local/share/mc/state.json.
|
||||
///
|
||||
/// Returns null if no state exists (first run).
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn read_state() -> Result<Option<FrontendState>, McError> {
|
||||
tokio::task::spawn_blocking(read_frontend_state)
|
||||
.await
|
||||
@@ -228,8 +228,8 @@ pub async fn read_state() -> Result<Option<FrontendState>, McError> {
|
||||
/// Write frontend state to ~/.local/share/mc/state.json.
|
||||
///
|
||||
/// Uses atomic rename pattern to prevent corruption.
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn write_state(state: FrontendState) -> Result<(), McError> {
|
||||
tokio::task::spawn_blocking(move || write_frontend_state(&state))
|
||||
.await
|
||||
@@ -238,8 +238,8 @@ pub async fn write_state(state: FrontendState) -> Result<(), McError> {
|
||||
}
|
||||
|
||||
/// Clear persisted frontend state.
|
||||
#[tauri_specta::command]
|
||||
#[specta(crate = "specta")]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clear_state() -> Result<(), McError> {
|
||||
tokio::task::spawn_blocking(clear_frontend_state)
|
||||
.await
|
||||
|
||||
@@ -219,29 +219,18 @@ impl<L: LoreCli, B: BeadsCli> Bridge<L, B> {
|
||||
|
||||
/// Clean up orphaned .tmp files from interrupted atomic writes.
|
||||
///
|
||||
/// Called on startup to remove any .json.tmp files left behind from
|
||||
/// Called on startup to remove any bridge-owned .tmp files left behind from
|
||||
/// crashes during save_map(). Returns the number of files cleaned up.
|
||||
pub fn cleanup_tmp_files(&self) -> Result<usize, BridgeError> {
|
||||
if !self.data_dir.exists() {
|
||||
return Ok(0);
|
||||
let tmp_path = self.map_path().with_extension("json.tmp");
|
||||
|
||||
if tmp_path.exists() {
|
||||
tracing::info!("Cleaning up orphaned tmp file: {:?}", tmp_path);
|
||||
fs::remove_file(&tmp_path)?;
|
||||
return Ok(1);
|
||||
}
|
||||
|
||||
let mut cleaned = 0;
|
||||
for entry in fs::read_dir(&self.data_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "tmp") {
|
||||
tracing::info!("Cleaning up orphaned tmp file: {:?}", path);
|
||||
fs::remove_file(&path)?;
|
||||
cleaned += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if cleaned > 0 {
|
||||
tracing::info!("Cleaned up {} orphaned tmp file(s)", cleaned);
|
||||
}
|
||||
|
||||
Ok(cleaned)
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Save the mapping file atomically (write to .tmp, then rename)
|
||||
@@ -1357,6 +1346,23 @@ mod tests {
|
||||
assert!(json_file.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_tmp_files_ignores_other_modules_tmp_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir);
|
||||
|
||||
// Create tmp files belonging to other modules (should not be removed)
|
||||
let state_tmp = dir.path().join("state.json.tmp");
|
||||
std::fs::write(&state_tmp, "state data").unwrap();
|
||||
let other_tmp = dir.path().join("other.tmp");
|
||||
std::fs::write(&other_tmp, "other data").unwrap();
|
||||
|
||||
let cleaned = bridge.cleanup_tmp_files().unwrap();
|
||||
assert_eq!(cleaned, 0);
|
||||
assert!(state_tmp.exists(), "state.json.tmp should not be deleted");
|
||||
assert!(other_tmp.exists(), "other.tmp should not be deleted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_tmp_files_handles_missing_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -93,7 +93,25 @@ pub fn write_frontend_state(state: &FrontendState) -> io::Result<()> {
|
||||
let content = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
fs::write(&tmp_path, &content)?;
|
||||
// Use explicit 0600 permissions on Unix -- state may contain user session data
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp_path)?;
|
||||
file.write_all(content.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(&tmp_path, &content)?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp_path, &path)?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -25,10 +25,16 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
fn toggle_window_visibility(app: &tauri::AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) && window.is_focused().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
if let Err(e) = window.hide() {
|
||||
tracing::warn!("Failed to hide window: {}", e);
|
||||
}
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
if let Err(e) = window.show() {
|
||||
tracing::warn!("Failed to show window: {}", e);
|
||||
}
|
||||
if let Err(e) = window.set_focus() {
|
||||
tracing::warn!("Failed to focus window: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,13 +142,21 @@ pub fn run() {
|
||||
if shortcut == &capture {
|
||||
// Show window and signal the frontend to open capture overlay
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
if let Err(e) = window.show() {
|
||||
tracing::warn!("Failed to show window for capture: {}", e);
|
||||
}
|
||||
if let Err(e) = window.set_focus() {
|
||||
tracing::warn!("Failed to focus window for capture: {}", e);
|
||||
}
|
||||
}
|
||||
if let Err(e) = app.emit("global-shortcut-triggered", "quick-capture") {
|
||||
tracing::error!("Failed to emit quick-capture event: {}", e);
|
||||
}
|
||||
let _ = app.emit("global-shortcut-triggered", "quick-capture");
|
||||
} else {
|
||||
toggle_window_visibility(app);
|
||||
let _ = app.emit("global-shortcut-triggered", "toggle-window");
|
||||
if let Err(e) = app.emit("global-shortcut-triggered", "toggle-window") {
|
||||
tracing::error!("Failed to emit toggle-window event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -35,8 +35,16 @@ pub fn start_lore_watcher(app: AppHandle) -> Option<RecommendedWatcher> {
|
||||
// Create the watcher with a debounce of 1 second
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: Result<Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
let _ = tx.send(event);
|
||||
match res {
|
||||
Ok(event) => {
|
||||
if tx.send(event).is_err() {
|
||||
// Receiver dropped -- watcher thread has exited
|
||||
tracing::debug!("Watcher event channel closed, receiver dropped");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("File watcher error: {}", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
Config::default().with_poll_interval(Duration::from_secs(2)),
|
||||
@@ -62,7 +70,9 @@ pub fn start_lore_watcher(app: AppHandle) -> Option<RecommendedWatcher> {
|
||||
let affects_db = event.paths.iter().any(|p| p.ends_with("lore.db"));
|
||||
if affects_db {
|
||||
tracing::debug!("lore.db changed, emitting refresh event");
|
||||
let _ = app.emit("lore-data-changed", ());
|
||||
if let Err(e) = app.emit("lore-data-changed", ()) {
|
||||
tracing::warn!("Failed to emit lore-data-changed event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
168
src/components/ReasonPrompt.tsx
Normal file
168
src/components/ReasonPrompt.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* ReasonPrompt -- capture optional reason for user actions.
|
||||
*
|
||||
* Every significant action prompts for an optional reason to learn patterns.
|
||||
* Quick tags allow fast categorization without typing.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
|
||||
const QUICK_TAGS = [
|
||||
{ id: "blocking", label: "Blocking" },
|
||||
{ id: "urgent", label: "Urgent" },
|
||||
{ id: "context_switch", label: "Context switch" },
|
||||
{ id: "energy", label: "Energy" },
|
||||
{ id: "flow", label: "Flow" },
|
||||
];
|
||||
|
||||
const ACTION_TITLES: Record<string, string> = {
|
||||
set_focus: "Setting focus to",
|
||||
defer: "Deferring",
|
||||
skip: "Skipping",
|
||||
archive: "Archiving",
|
||||
complete: "Completing",
|
||||
};
|
||||
|
||||
interface ReasonPromptProps {
|
||||
action: string;
|
||||
itemTitle: string;
|
||||
onSubmit: (data: { reason: string | null; tags: string[] }) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ReasonPrompt({
|
||||
action,
|
||||
itemTitle,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: ReasonPromptProps): React.ReactElement {
|
||||
const [reason, setReason] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Handle Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmedReason = reason.trim();
|
||||
onSubmit({
|
||||
reason: trimmedReason || null,
|
||||
tags: selectedTags,
|
||||
});
|
||||
}, [reason, selectedTags, onSubmit]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
onSubmit({ reason: null, tags: [] });
|
||||
}, [onSubmit]);
|
||||
|
||||
const toggleTag = useCallback((tagId: string) => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tagId) ? prev.filter((t) => t !== tagId) : [...prev, tagId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const actionTitle = ACTION_TITLES[action] || action;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reason-prompt-title"
|
||||
className="w-full max-w-md rounded-lg border border-zinc-700 bg-surface-base p-6 shadow-2xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<h2
|
||||
id="reason-prompt-title"
|
||||
className="mb-4 text-lg font-semibold text-zinc-200"
|
||||
>
|
||||
{actionTitle}: {itemTitle}
|
||||
</h2>
|
||||
|
||||
{/* Reason input */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="reason-input"
|
||||
className="mb-2 block text-sm text-zinc-400"
|
||||
>
|
||||
Why? (optional, helps learn your patterns)
|
||||
</label>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
id="reason-input"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g., Sarah pinged me, she's blocked on release"
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-md border border-zinc-700 bg-surface-raised px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick tags */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 text-sm text-zinc-400">Quick tags:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{QUICK_TAGS.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
||||
isSelected
|
||||
? "bg-zinc-600 text-zinc-100"
|
||||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{tag.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="rounded-md px-4 py-2 text-sm text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
|
||||
>
|
||||
Skip reason
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-500"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
tests/components/ReasonPrompt.test.tsx
Normal file
158
tests/components/ReasonPrompt.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Tests for ReasonPrompt component.
|
||||
*
|
||||
* 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 { ReasonPrompt } from "@/components/ReasonPrompt";
|
||||
|
||||
describe("ReasonPrompt", () => {
|
||||
const defaultProps = {
|
||||
action: "set_focus",
|
||||
itemTitle: "Review MR !847",
|
||||
onSubmit: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders with action context", () => {
|
||||
render(<ReasonPrompt {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Setting focus to.*Review MR !847/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("captures text input", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<ReasonPrompt {...defaultProps} onSubmit={onSubmit} />);
|
||||
|
||||
await user.type(
|
||||
screen.getByRole("textbox"),
|
||||
"Sarah pinged me, she is blocked"
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /confirm/i }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reason: "Sarah pinged me, she is blocked",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("allows selecting quick tags", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<ReasonPrompt {...defaultProps} onSubmit={onSubmit} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /blocking/i }));
|
||||
await user.click(screen.getByRole("button", { name: /urgent/i }));
|
||||
await user.click(screen.getByRole("button", { name: /confirm/i }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tags: expect.arrayContaining(["blocking", "urgent"]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles tag off when clicked again", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<ReasonPrompt {...defaultProps} onSubmit={onSubmit} />);
|
||||
|
||||
const blockingTag = screen.getByRole("button", { name: /blocking/i });
|
||||
|
||||
// Select then deselect
|
||||
await user.click(blockingTag);
|
||||
await user.click(blockingTag);
|
||||
await user.click(screen.getByRole("button", { name: /confirm/i }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tags: [],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("allows skipping reason", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<ReasonPrompt {...defaultProps} onSubmit={onSubmit} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skip reason/i }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
reason: null,
|
||||
tags: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("trims whitespace from reason", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<ReasonPrompt {...defaultProps} onSubmit={onSubmit} />);
|
||||
|
||||
await user.type(screen.getByRole("textbox"), " spaced reason ");
|
||||
await user.click(screen.getByRole("button", { name: /confirm/i }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reason: "spaced reason",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("treats empty reason as null", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<ReasonPrompt {...defaultProps} onSubmit={onSubmit} />);
|
||||
|
||||
// Just click confirm without typing
|
||||
await user.click(screen.getByRole("button", { name: /confirm/i }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reason: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels on Escape key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
render(<ReasonPrompt {...defaultProps} onCancel={onCancel} />);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("displays all quick tag options", () => {
|
||||
render(<ReasonPrompt {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /blocking/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /urgent/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /context switch/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /energy/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows different titles for different actions", () => {
|
||||
const { rerender } = render(
|
||||
<ReasonPrompt {...defaultProps} action="defer" itemTitle="Issue #42" />
|
||||
);
|
||||
expect(screen.getByText(/Deferring.*Issue #42/i)).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ReasonPrompt {...defaultProps} action="skip" itemTitle="MR !100" />
|
||||
);
|
||||
expect(screen.getByText(/Skipping.*MR !100/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user