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;
|
use specta::Type;
|
||||||
|
|
||||||
/// Simple greeting command for testing IPC
|
/// Simple greeting command for testing IPC
|
||||||
#[tauri_specta::command]
|
#[tauri::command]
|
||||||
#[specta(crate = "specta")]
|
#[specta::specta]
|
||||||
pub fn greet(name: &str) -> String {
|
pub fn greet(name: &str) -> String {
|
||||||
format!("Hello, {}! Welcome to Mission Control.", name)
|
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.
|
/// Get the current status of lore integration by calling the real CLI.
|
||||||
#[tauri_specta::command]
|
#[tauri::command]
|
||||||
#[specta(crate = "specta")]
|
#[specta::specta]
|
||||||
pub async fn get_lore_status() -> Result<LoreStatus, McError> {
|
pub async fn get_lore_status() -> Result<LoreStatus, McError> {
|
||||||
get_lore_status_with(&RealLoreCli)
|
get_lore_status_with(&RealLoreCli)
|
||||||
}
|
}
|
||||||
@@ -107,8 +107,8 @@ pub struct BridgeStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current status of the bridge (mapping counts, sync times).
|
/// Get the current status of the bridge (mapping counts, sync times).
|
||||||
#[tauri_specta::command]
|
#[tauri::command]
|
||||||
#[specta(crate = "specta")]
|
#[specta::specta]
|
||||||
pub async fn get_bridge_status() -> Result<BridgeStatus, McError> {
|
pub async fn get_bridge_status() -> Result<BridgeStatus, McError> {
|
||||||
// Bridge IO is blocking; run off the async executor
|
// Bridge IO is blocking; run off the async executor
|
||||||
tokio::task::spawn_blocking(|| get_bridge_status_inner(None))
|
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).
|
/// Trigger an incremental sync (process since_last_check events).
|
||||||
#[tauri_specta::command]
|
#[tauri::command]
|
||||||
#[specta(crate = "specta")]
|
#[specta::specta]
|
||||||
pub async fn sync_now() -> Result<SyncResult, McError> {
|
pub async fn sync_now() -> Result<SyncResult, McError> {
|
||||||
tokio::task::spawn_blocking(|| sync_now_inner(None))
|
tokio::task::spawn_blocking(|| sync_now_inner(None))
|
||||||
.await
|
.await
|
||||||
@@ -165,8 +165,8 @@ fn sync_now_inner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Trigger a full reconciliation pass.
|
/// Trigger a full reconciliation pass.
|
||||||
#[tauri_specta::command]
|
#[tauri::command]
|
||||||
#[specta(crate = "specta")]
|
#[specta::specta]
|
||||||
pub async fn reconcile() -> Result<SyncResult, McError> {
|
pub async fn reconcile() -> Result<SyncResult, McError> {
|
||||||
tokio::task::spawn_blocking(|| reconcile_inner(None))
|
tokio::task::spawn_blocking(|| reconcile_inner(None))
|
||||||
.await
|
.await
|
||||||
@@ -198,8 +198,8 @@ pub struct CaptureResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Quick-capture a thought as a new bead.
|
/// Quick-capture a thought as a new bead.
|
||||||
#[tauri_specta::command]
|
#[tauri::command]
|
||||||
#[specta(crate = "specta")]
|
#[specta::specta]
|
||||||
pub async fn quick_capture(title: String) -> Result<CaptureResult, McError> {
|
pub async fn quick_capture(title: String) -> Result<CaptureResult, McError> {
|
||||||
tokio::task::spawn_blocking(move || quick_capture_inner(&RealBeadsCli, &title))
|
tokio::task::spawn_blocking(move || quick_capture_inner(&RealBeadsCli, &title))
|
||||||
.await
|
.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.
|
/// Read persisted frontend state from ~/.local/share/mc/state.json.
|
||||||
///
|
///
|
||||||
/// Returns null if no state exists (first run).
|
/// Returns null if no state exists (first run).
|
||||||
#[tauri_specta::command]
|
#[tauri::command]
|
||||||
#[specta(crate = "specta")]
|
#[specta::specta]
|
||||||
pub async fn read_state() -> Result<Option<FrontendState>, McError> {
|
pub async fn read_state() -> Result<Option<FrontendState>, McError> {
|
||||||
tokio::task::spawn_blocking(read_frontend_state)
|
tokio::task::spawn_blocking(read_frontend_state)
|
||||||
.await
|
.await
|
||||||
@@ -228,8 +228,8 @@ pub async fn read_state() -> Result<Option<FrontendState>, McError> {
|
|||||||
/// Write frontend state to ~/.local/share/mc/state.json.
|
/// Write frontend state to ~/.local/share/mc/state.json.
|
||||||
///
|
///
|
||||||
/// Uses atomic rename pattern to prevent corruption.
|
/// Uses atomic rename pattern to prevent corruption.
|
||||||
#[tauri_specta::command]
|
#[tauri::command]
|
||||||
#[specta(crate = "specta")]
|
#[specta::specta]
|
||||||
pub async fn write_state(state: FrontendState) -> Result<(), McError> {
|
pub async fn write_state(state: FrontendState) -> Result<(), McError> {
|
||||||
tokio::task::spawn_blocking(move || write_frontend_state(&state))
|
tokio::task::spawn_blocking(move || write_frontend_state(&state))
|
||||||
.await
|
.await
|
||||||
@@ -238,8 +238,8 @@ pub async fn write_state(state: FrontendState) -> Result<(), McError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clear persisted frontend state.
|
/// Clear persisted frontend state.
|
||||||
#[tauri_specta::command]
|
#[tauri::command]
|
||||||
#[specta(crate = "specta")]
|
#[specta::specta]
|
||||||
pub async fn clear_state() -> Result<(), McError> {
|
pub async fn clear_state() -> Result<(), McError> {
|
||||||
tokio::task::spawn_blocking(clear_frontend_state)
|
tokio::task::spawn_blocking(clear_frontend_state)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -219,29 +219,18 @@ impl<L: LoreCli, B: BeadsCli> Bridge<L, B> {
|
|||||||
|
|
||||||
/// Clean up orphaned .tmp files from interrupted atomic writes.
|
/// 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.
|
/// crashes during save_map(). Returns the number of files cleaned up.
|
||||||
pub fn cleanup_tmp_files(&self) -> Result<usize, BridgeError> {
|
pub fn cleanup_tmp_files(&self) -> Result<usize, BridgeError> {
|
||||||
if !self.data_dir.exists() {
|
let tmp_path = self.map_path().with_extension("json.tmp");
|
||||||
return Ok(0);
|
|
||||||
|
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;
|
Ok(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save the mapping file atomically (write to .tmp, then rename)
|
/// Save the mapping file atomically (write to .tmp, then rename)
|
||||||
@@ -1357,6 +1346,23 @@ mod tests {
|
|||||||
assert!(json_file.exists());
|
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]
|
#[test]
|
||||||
fn test_cleanup_tmp_files_handles_missing_dir() {
|
fn test_cleanup_tmp_files_handles_missing_dir() {
|
||||||
let dir = TempDir::new().unwrap();
|
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)
|
let content = serde_json::to_string_pretty(state)
|
||||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
.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)?;
|
fs::rename(&tmp_path, &path)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -25,10 +25,16 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
|||||||
fn toggle_window_visibility(app: &tauri::AppHandle) {
|
fn toggle_window_visibility(app: &tauri::AppHandle) {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
if window.is_visible().unwrap_or(false) && window.is_focused().unwrap_or(false) {
|
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 {
|
} else {
|
||||||
let _ = window.show();
|
if let Err(e) = window.show() {
|
||||||
let _ = window.set_focus();
|
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 {
|
if shortcut == &capture {
|
||||||
// Show window and signal the frontend to open capture overlay
|
// Show window and signal the frontend to open capture overlay
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.show();
|
if let Err(e) = window.show() {
|
||||||
let _ = window.set_focus();
|
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 {
|
} else {
|
||||||
toggle_window_visibility(app);
|
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
|
// Create the watcher with a debounce of 1 second
|
||||||
let mut watcher = RecommendedWatcher::new(
|
let mut watcher = RecommendedWatcher::new(
|
||||||
move |res: Result<Event, notify::Error>| {
|
move |res: Result<Event, notify::Error>| {
|
||||||
if let Ok(event) = res {
|
match res {
|
||||||
let _ = tx.send(event);
|
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)),
|
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"));
|
let affects_db = event.paths.iter().any(|p| p.ends_with("lore.db"));
|
||||||
if affects_db {
|
if affects_db {
|
||||||
tracing::debug!("lore.db changed, emitting refresh event");
|
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