mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-07 16:41:06 +08:00
feat: Add Role Analysis Reviewer Agent and validation template
- Introduced Role Analysis Reviewer Agent to validate role analysis outputs against templates and quality standards. - Created a detailed validation ruleset for the system-architect role, including mandatory and recommended sections. - Added JSON validation report structure for output. - Implemented execution command for validation process. test: Add UX tests for HookCard component - Created comprehensive tests for HookCard component, focusing on delete confirmation UX pattern. - Verified confirmation dialog appearance, deletion functionality, and button interactions. - Ensured proper handling of state updates and visual feedback for enabled/disabled status. test: Add UX tests for ThemeSelector component - Developed tests for ThemeSelector component, emphasizing delete confirmation UX pattern. - Validated confirmation dialog display, deletion actions, and toast notifications for undo functionality. - Ensured proper management of theme slots and state updates. feat: Implement useDebounce hook - Added useDebounce hook to delay expensive computations or API calls, enhancing performance. feat: Create System Architect Analysis Template - Developed a comprehensive template for system architect role analysis, covering required sections such as architecture overview, data model, state machine, error handling strategy, observability requirements, configuration model, and boundary scenarios. - Included examples and templates for each section to guide users in producing SPEC.md-level precision modeling.
This commit is contained in:
@@ -49,6 +49,7 @@
|
||||
"react-router-dom": "^6.28.0",
|
||||
"recharts": "^2.15.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
|
||||
@@ -172,6 +172,8 @@ function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
|
||||
const addToast = useNotificationStore((state) => state.addToast);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
// Detect question type
|
||||
const questionType = useMemo(() => detectQuestionType(surface), [surface]);
|
||||
@@ -216,16 +218,14 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
const message = getTextContent(messageComponent);
|
||||
const description = getTextContent(descriptionComponent);
|
||||
|
||||
// Separate body components (interactive elements) from action buttons
|
||||
// Separate body components from action buttons
|
||||
const { bodyComponents, actionButtons } = useMemo(() => {
|
||||
const body: SurfaceComponent[] = [];
|
||||
const actions: SurfaceComponent[] = [];
|
||||
|
||||
for (const comp of surface.components) {
|
||||
// Skip title, message, description
|
||||
if (['title', 'message', 'description'].includes(comp.id)) continue;
|
||||
|
||||
// Separate action buttons (confirm, cancel, submit)
|
||||
if (isActionButton(comp) && ['confirm-btn', 'cancel-btn', 'submit-btn'].includes(comp.id)) {
|
||||
actions.push(comp);
|
||||
} else {
|
||||
@@ -236,7 +236,6 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
return { bodyComponents: body, actionButtons: actions };
|
||||
}, [surface.components]);
|
||||
|
||||
// Create surfaces for body and actions
|
||||
const bodySurface: SurfaceUpdate = useMemo(
|
||||
() => ({ ...surface, components: bodyComponents }),
|
||||
[surface, bodyComponents]
|
||||
@@ -247,7 +246,6 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
[surface, actionButtons]
|
||||
);
|
||||
|
||||
// Handle "Other" text change
|
||||
const handleOtherTextChange = useCallback(
|
||||
(value: string) => {
|
||||
setOtherText(value);
|
||||
@@ -263,7 +261,8 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
|
||||
// Handle A2UI actions
|
||||
const handleAction = useCallback(
|
||||
(actionId: string, params?: Record<string, unknown>) => {
|
||||
async (actionId: string, params?: Record<string, unknown>) => {
|
||||
setActionError(null);
|
||||
// Track "Other" selection state
|
||||
if (actionId === 'select' && params?.value === '__other__') {
|
||||
setOtherSelected(true);
|
||||
@@ -274,16 +273,20 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
setOtherSelected((prev) => !prev);
|
||||
}
|
||||
|
||||
// Send action to backend via WebSocket
|
||||
sendA2UIAction(actionId, surface.surfaceId, params);
|
||||
try {
|
||||
await sendA2UIAction(actionId, surface.surfaceId, params);
|
||||
|
||||
// Check if this action should close the dialog
|
||||
const resolvingActions = ['confirm', 'cancel', 'submit', 'answer'];
|
||||
if (resolvingActions.includes(actionId)) {
|
||||
onClose();
|
||||
const resolvingActions = ['confirm', 'cancel', 'submit', 'answer'];
|
||||
if (resolvingActions.includes(actionId)) {
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||
setActionError(errorMessage);
|
||||
addToast('error', 'Action Failed', errorMessage);
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
[sendA2UIAction, surface.surfaceId, onClose, addToast]
|
||||
);
|
||||
|
||||
// Handle dialog close (ESC key or overlay click)
|
||||
@@ -299,7 +302,6 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
// Determine dialog width based on question type
|
||||
const dialogWidth = useMemo(() => {
|
||||
switch (questionType) {
|
||||
case 'multi-select':
|
||||
@@ -311,33 +313,27 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
}
|
||||
}, [questionType]);
|
||||
|
||||
// Check if this question type supports "Other" input
|
||||
const hasOtherOption = questionType === 'select' || questionType === 'multi-select';
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
// Base styles
|
||||
dialogWidth,
|
||||
'max-h-[80vh] overflow-y-auto',
|
||||
'bg-card p-6 rounded-xl shadow-lg border border-border/50',
|
||||
// Animation classes
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
|
||||
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95',
|
||||
'data-[state=open]:duration-300 data-[state=closed]:duration-200'
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
// Prevent closing when clicking outside - only cancel button can close
|
||||
e.preventDefault();
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
// Prevent closing with ESC key - only cancel button can close
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<DialogHeader className="space-y-2 pb-4">
|
||||
<DialogTitle className="text-lg font-semibold leading-tight">{title}</DialogTitle>
|
||||
{message && (
|
||||
@@ -352,15 +348,18 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body - Interactive elements */}
|
||||
{actionError && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 text-destructive-foreground text-sm rounded-md p-3 my-2">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bodyComponents.length > 0 && (
|
||||
<div className={cn(
|
||||
'py-3',
|
||||
// Add specific styling for multi-select (checkbox list)
|
||||
questionType === 'multi-select' && 'space-y-2 max-h-[300px] overflow-y-auto px-1'
|
||||
)}>
|
||||
{questionType === 'multi-select' ? (
|
||||
// Render each checkbox individually for better control
|
||||
bodyComponents.map((comp) => (
|
||||
<div key={comp.id} className="py-1">
|
||||
<A2UIRenderer
|
||||
@@ -372,7 +371,6 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
) : (
|
||||
<A2UIRenderer surface={bodySurface} onAction={handleAction} />
|
||||
)}
|
||||
{/* "Other" text input — shown when Other is selected */}
|
||||
{hasOtherOption && (
|
||||
<OtherInput
|
||||
visible={otherSelected}
|
||||
@@ -383,7 +381,6 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Countdown for auto-selection */}
|
||||
{remaining !== null && defaultLabel && (
|
||||
<div className="text-xs text-muted-foreground text-center pt-2">
|
||||
{remaining > 0
|
||||
@@ -392,7 +389,6 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer - Action buttons */}
|
||||
{actionButtons.length > 0 && (
|
||||
<DialogFooter className="pt-4">
|
||||
<div className="flex flex-row justify-end gap-3">
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FileText, Hash, Clock, Sparkles, AlertCircle, Link2, Check } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -21,52 +24,20 @@ export interface DocumentViewerProps {
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple markdown-to-HTML converter for basic formatting
|
||||
*/
|
||||
function markdownToHtml(markdown: string): string {
|
||||
let html = markdown;
|
||||
|
||||
// Code blocks
|
||||
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre class="code-block"><code class="language-$1">$2</code></pre>');
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
||||
|
||||
// Headers
|
||||
html = html.replace(/^### (.*$)/gm, '<h3 class="doc-h3">$1</h3>');
|
||||
html = html.replace(/^## (.*$)/gm, '<h2 class="doc-h2">$1</h2>');
|
||||
html = html.replace(/^# (.*$)/gm, '<h1 class="doc-h1">$1</h1>');
|
||||
|
||||
// Bold and italic
|
||||
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
|
||||
// Links
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="doc-link">$1</a>');
|
||||
|
||||
// Line breaks
|
||||
html = html.replace(/\n\n/g, '</p><p class="doc-paragraph">');
|
||||
html = html.replace(/\n/g, '<br />');
|
||||
|
||||
return `<div class="doc-content"><p class="doc-paragraph">${html}</p></div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get symbol type icon
|
||||
*/
|
||||
function getSymbolTypeIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
function: 'λ',
|
||||
async_function: 'λ',
|
||||
class: '◇',
|
||||
method: '◈',
|
||||
interface: '△',
|
||||
variable: '•',
|
||||
constant: '⬡',
|
||||
function getSymbolTypeIcon(type: string): { symbol: string; label: string } {
|
||||
const icons: Record<string, { symbol: string; label: string }> = {
|
||||
function: { symbol: 'λ', label: 'Function' },
|
||||
async_function: { symbol: 'λ', label: 'Async Function' },
|
||||
class: { symbol: '◇', label: 'Class' },
|
||||
method: { symbol: '◈', label: 'Method' },
|
||||
interface: { symbol: '△', label: 'Interface' },
|
||||
variable: { symbol: '•', label: 'Variable' },
|
||||
constant: { symbol: '⬡', label: 'Constant' },
|
||||
};
|
||||
return icons[type] || '•';
|
||||
return icons[type] || { symbol: '•', label: 'Symbol' };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +56,79 @@ function getSymbolTypeColor(type: string): string {
|
||||
return colors[type] || 'text-gray-500';
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown components with custom styling
|
||||
*/
|
||||
const markdownComponents = {
|
||||
h1: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h1 className="text-xl font-bold mt-6 mb-3 text-foreground">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h2 className="text-lg font-semibold mt-5 mb-2 text-foreground">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h3 className="text-base font-semibold mt-4 mb-2 text-foreground">{children}</h3>
|
||||
),
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className="mb-3 leading-relaxed text-foreground/90">{children}</p>
|
||||
),
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary hover:underline"
|
||||
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ className, children }: { className?: string; children?: React.ReactNode }) => {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className={cn('block bg-muted p-3 rounded-md text-sm font-mono overflow-x-auto', className)}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }: { children?: React.ReactNode }) => (
|
||||
<pre className="bg-muted p-4 rounded-lg my-4 overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
ul: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ul className="list-disc list-inside mb-3 space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ol className="list-decimal list-inside mb-3 space-y-1">{children}</ol>
|
||||
),
|
||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||
<blockquote className="border-l-4 border-primary/50 pl-4 my-4 italic text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className="overflow-x-auto my-4">
|
||||
<table className="min-w-full border border-border">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: { children?: React.ReactNode }) => (
|
||||
<thead className="bg-muted">{children}</thead>
|
||||
),
|
||||
th: ({ children }: { children?: React.ReactNode }) => (
|
||||
<th className="border border-border px-3 py-2 text-left font-medium">{children}</th>
|
||||
),
|
||||
td: ({ children }: { children?: React.ReactNode }) => (
|
||||
<td className="border border-border px-3 py-2">{children}</td>
|
||||
),
|
||||
};
|
||||
|
||||
export function DocumentViewer({
|
||||
doc,
|
||||
content,
|
||||
@@ -222,27 +266,31 @@ export function DocumentViewer({
|
||||
{/* Symbols list */}
|
||||
{symbols.length > 0 && (
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
{symbols.map(symbol => (
|
||||
<a
|
||||
key={symbol.name}
|
||||
href={`#${symbol.anchor.replace('#', '')}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium',
|
||||
'bg-muted/50 hover:bg-muted transition-colors',
|
||||
getSymbolTypeColor(symbol.type)
|
||||
)}
|
||||
>
|
||||
<span>{getSymbolTypeIcon(symbol.type)}</span>
|
||||
<span>{symbol.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1">
|
||||
{symbol.type}
|
||||
</Badge>
|
||||
</a>
|
||||
))}
|
||||
{symbols.map(symbol => {
|
||||
const iconInfo = getSymbolTypeIcon(symbol.type);
|
||||
return (
|
||||
<a
|
||||
key={symbol.name}
|
||||
href={`#${symbol.anchor.replace('#', '')}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium',
|
||||
'bg-muted/50 hover:bg-muted transition-colors',
|
||||
getSymbolTypeColor(symbol.type)
|
||||
)}
|
||||
aria-label={`${iconInfo.label}: ${symbol.name}`}
|
||||
>
|
||||
<span aria-hidden="true">{iconInfo.symbol}</span>
|
||||
<span>{symbol.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1">
|
||||
{symbol.type}
|
||||
</Badge>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document sections */}
|
||||
{/* Document sections with safe markdown rendering */}
|
||||
<div className="space-y-6">
|
||||
{symbolSections.map((section, idx) => (
|
||||
<section
|
||||
@@ -250,18 +298,24 @@ export function DocumentViewer({
|
||||
id={section.name.toLowerCase().replace(/\s+/g, '-')}
|
||||
className="scroll-mt-4"
|
||||
>
|
||||
<div
|
||||
className="prose prose-sm dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(section.content) }}
|
||||
/>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{section.content}
|
||||
</ReactMarkdown>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{symbolSections.length === 0 && content && (
|
||||
<div
|
||||
className="prose prose-sm dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: markdownToHtml(content) }}
|
||||
/>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,75 +327,57 @@ export function DocumentViewer({
|
||||
<Hash className="w-3 h-3" />
|
||||
{formatMessage({ id: 'deepwiki.viewer.toc', defaultMessage: 'Symbols' })}
|
||||
</h4>
|
||||
<nav className="space-y-1">
|
||||
{symbols.map(symbol => (
|
||||
<div
|
||||
key={symbol.name}
|
||||
className="group flex items-center gap-1"
|
||||
>
|
||||
<a
|
||||
href={`#${symbol.anchor.replace('#', '')}`}
|
||||
className={cn(
|
||||
'flex-1 text-xs py-1.5 px-2 rounded transition-colors',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted/50',
|
||||
'font-mono'
|
||||
)}
|
||||
<nav className="space-y-1" role="list" aria-label="Document symbols">
|
||||
{symbols.map(symbol => {
|
||||
const iconInfo = getSymbolTypeIcon(symbol.type);
|
||||
return (
|
||||
<div
|
||||
key={symbol.name}
|
||||
className="group flex items-center gap-1"
|
||||
>
|
||||
<span className={cn('mr-1', getSymbolTypeColor(symbol.type))}>
|
||||
{getSymbolTypeIcon(symbol.type)}
|
||||
</span>
|
||||
{symbol.name}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => copyDeepLink(symbol.name, symbol.anchor)}
|
||||
className={cn(
|
||||
'opacity-0 group-hover:opacity-100 p-1 rounded transition-all',
|
||||
'hover:bg-muted/50',
|
||||
copiedSymbol === symbol.name
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
title={copiedSymbol === symbol.name ? 'Copied!' : 'Copy deep link'}
|
||||
>
|
||||
{copiedSymbol === symbol.name ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : (
|
||||
<Link2 className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<a
|
||||
href={`#${symbol.anchor.replace('#', '')}`}
|
||||
className={cn(
|
||||
'flex-1 text-xs py-1.5 px-2 rounded transition-colors',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted/50',
|
||||
'font-mono'
|
||||
)}
|
||||
role="listitem"
|
||||
aria-label={`${iconInfo.label}: ${symbol.name}`}
|
||||
>
|
||||
<span className={cn('mr-1', getSymbolTypeColor(symbol.type))} aria-hidden="true">
|
||||
{iconInfo.symbol}
|
||||
</span>
|
||||
{symbol.name}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => copyDeepLink(symbol.name, symbol.anchor)}
|
||||
className={cn(
|
||||
'opacity-0 group-hover:opacity-100 p-1 rounded transition-all',
|
||||
'hover:bg-muted/50',
|
||||
copiedSymbol === symbol.name
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-label={
|
||||
copiedSymbol === symbol.name
|
||||
? formatMessage({ id: 'deepwiki.viewer.linkCopied', defaultMessage: 'Link copied' })
|
||||
: formatMessage({ id: 'deepwiki.viewer.copyLink', defaultMessage: 'Copy deep link to {name}' }, { name: symbol.name })
|
||||
}
|
||||
>
|
||||
{copiedSymbol === symbol.name ? (
|
||||
<Check className="w-3 h-3" aria-hidden="true" />
|
||||
) : (
|
||||
<Link2 className="w-3 h-3" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Styles for rendered markdown */}
|
||||
<style>{`
|
||||
.doc-content { line-height: 1.7; }
|
||||
.doc-paragraph { margin-bottom: 1rem; }
|
||||
.doc-h1 { font-size: 1.5rem; font-weight: 700; margin: 1.5rem 0 1rem; color: var(--foreground); }
|
||||
.doc-h2 { font-size: 1.25rem; font-weight: 600; margin: 1.25rem 0 0.75rem; color: var(--foreground); }
|
||||
.doc-h3 { font-size: 1.1rem; font-weight: 600; margin: 1rem 0 0.5rem; color: var(--foreground); }
|
||||
.doc-link { color: var(--primary); text-decoration: underline; }
|
||||
.inline-code {
|
||||
background: var(--muted);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85em;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.code-block {
|
||||
background: var(--muted);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
`}</style>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
// ========================================
|
||||
// List of documented files for DeepWiki
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FileText, Search, CheckCircle, Clock, RefreshCw } from 'lucide-react';
|
||||
import { FileText, Search, CheckCircle, Clock, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DeepWikiFile } from '@/hooks/useDeepWiki';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
|
||||
export interface FileListProps {
|
||||
files: DeepWikiFile[];
|
||||
@@ -75,13 +76,23 @@ export function FileList({
|
||||
}: FileListProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Filter files by search query
|
||||
const filteredFiles = useMemo(() => {
|
||||
if (!searchQuery.trim()) return files;
|
||||
const query = searchQuery.toLowerCase();
|
||||
if (!debouncedSearchQuery.trim()) return files;
|
||||
const query = debouncedSearchQuery.toLowerCase();
|
||||
return files.filter(f => f.path.toLowerCase().includes(query));
|
||||
}, [files, searchQuery]);
|
||||
}, [files, debouncedSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery !== debouncedSearchQuery) {
|
||||
setIsSearching(true);
|
||||
} else {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [searchQuery, debouncedSearchQuery]);
|
||||
|
||||
// Group files by directory
|
||||
const groupedFiles = useMemo(() => {
|
||||
@@ -143,6 +154,9 @@ export function FileList({
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-8 text-sm"
|
||||
/>
|
||||
{isSearching && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// ========================================
|
||||
// Individual hook display card with actions
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
GitFork,
|
||||
@@ -16,6 +17,16 @@ import {
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
} from '@/components/ui/AlertDialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -166,6 +177,7 @@ export function HookCard({
|
||||
onDelete,
|
||||
}: HookCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
// Get translated hook name
|
||||
const displayName = getHookDisplayName(hook.name, formatMessage);
|
||||
@@ -179,12 +191,20 @@ export function HookCard({
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm(formatMessage({ id: 'cliHooks.actions.deleteConfirm' }, { hookName: hook.name }))) {
|
||||
onDelete(hook.name);
|
||||
}
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
onDelete(hook.name);
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={cn('overflow-hidden', !hook.enabled && 'opacity-60')}>
|
||||
{/* Header */}
|
||||
<div className="p-4">
|
||||
@@ -263,7 +283,7 @@ export function HookCard({
|
||||
onClick={handleDelete}
|
||||
title={formatMessage({ id: 'common.actions.delete' })}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -317,6 +337,23 @@ export function HookCard({
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete CLI Hook?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. The hook "{displayName}" will be permanently deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancelDelete}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
280
ccw/frontend/src/components/hook/HookCard.ux.test.tsx
Normal file
280
ccw/frontend/src/components/hook/HookCard.ux.test.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
// ========================================
|
||||
// HookCard UX Tests - Delete Confirmation
|
||||
// ========================================
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { HookCard } from './HookCard';
|
||||
import type { HookCardData } from './HookCard';
|
||||
|
||||
// Mock translations
|
||||
const mockMessages = {
|
||||
'cliHooks.trigger.SessionStart': 'Session Start',
|
||||
'cliHooks.trigger.UserPromptSubmit': 'User Prompt Submit',
|
||||
'cliHooks.trigger.PreToolUse': 'Pre Tool Use',
|
||||
'cliHooks.trigger.PostToolUse': 'Post Tool Use',
|
||||
'cliHooks.trigger.Stop': 'Stop',
|
||||
'cliHooks.trigger.Notification': 'Notification',
|
||||
'cliHooks.trigger.SubagentStart': 'Subagent Start',
|
||||
'cliHooks.trigger.SubagentStop': 'Subagent Stop',
|
||||
'cliHooks.trigger.PreCompact': 'Pre Compact',
|
||||
'cliHooks.trigger.SessionEnd': 'Session End',
|
||||
'cliHooks.trigger.PostToolUseFailure': 'Post Tool Use Failure',
|
||||
'cliHooks.trigger.PermissionRequest': 'Permission Request',
|
||||
'cliHooks.allTools': 'All Tools',
|
||||
'common.status.enabled': 'Enabled',
|
||||
'common.status.disabled': 'Disabled',
|
||||
'common.actions.edit': 'Edit',
|
||||
'common.actions.delete': 'Delete',
|
||||
'cliHooks.actions.enable': 'Enable',
|
||||
'cliHooks.actions.disable': 'Disable',
|
||||
'cliHooks.actions.expand': 'Expand',
|
||||
'cliHooks.actions.collapse': 'Collapse',
|
||||
'cliHooks.form.description': 'Description',
|
||||
'cliHooks.form.matcher': 'Matcher',
|
||||
'cliHooks.form.command': 'Command',
|
||||
};
|
||||
|
||||
function renderWithIntl(component: React.ReactElement) {
|
||||
return render(
|
||||
<IntlProvider messages={mockMessages} locale="en">
|
||||
{component}
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('HookCard - Delete Confirmation UX Pattern', () => {
|
||||
const mockHook: HookCardData = {
|
||||
name: 'test-hook',
|
||||
description: 'Test hook description',
|
||||
enabled: true,
|
||||
trigger: 'PreToolUse',
|
||||
matcher: '.*',
|
||||
command: 'echo "test"',
|
||||
};
|
||||
|
||||
const mockHandlers = {
|
||||
onToggleExpand: vi.fn(),
|
||||
onToggle: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should show confirmation dialog when delete button is clicked', async () => {
|
||||
renderWithIntl(
|
||||
<HookCard
|
||||
hook={mockHook}
|
||||
isExpanded={false}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click delete button
|
||||
const deleteButton = screen.getByTitle('Delete');
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
// Verify dialog appears
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete CLI Hook?')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display hook name in confirmation dialog', async () => {
|
||||
renderWithIntl(
|
||||
<HookCard
|
||||
hook={mockHook}
|
||||
isExpanded={false}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
const dialog = screen.getByText(/This action cannot be undone/);
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(dialog.textContent).toContain('test-hook');
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onDelete when confirm is clicked', async () => {
|
||||
renderWithIntl(
|
||||
<HookCard
|
||||
hook={mockHook}
|
||||
isExpanded={false}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
const confirmButton = screen.getByRole('button', { name: /Delete/i });
|
||||
fireEvent.click(confirmButton);
|
||||
});
|
||||
|
||||
expect(mockHandlers.onDelete).toHaveBeenCalledWith('test-hook');
|
||||
});
|
||||
|
||||
it('should NOT call onDelete when cancel is clicked', async () => {
|
||||
renderWithIntl(
|
||||
<HookCard
|
||||
hook={mockHook}
|
||||
isExpanded={false}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelButton = screen.getByRole('button', { name: /Cancel/i });
|
||||
fireEvent.click(cancelButton);
|
||||
});
|
||||
|
||||
expect(mockHandlers.onDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close dialog when cancel is clicked', async () => {
|
||||
renderWithIntl(
|
||||
<HookCard
|
||||
hook={mockHook}
|
||||
isExpanded={false}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete CLI Hook?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete CLI Hook?')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should close dialog after successful deletion', async () => {
|
||||
renderWithIntl(
|
||||
<HookCard
|
||||
hook={mockHook}
|
||||
isExpanded={false}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete CLI Hook?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Delete/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Delete CLI Hook?')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have delete button with destructive styling', () => {
|
||||
renderWithIntl(
|
||||
<HookCard
|
||||
hook={mockHook}
|
||||
isExpanded={false}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTitle('Delete');
|
||||
expect(deleteButton).toHaveClass('text-destructive');
|
||||
});
|
||||
|
||||
it('should not show dialog on initial render', () => {
|
||||
renderWithIntl(
|
||||
<HookCard
|
||||
hook={mockHook}
|
||||
isExpanded={false}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Delete CLI Hook?')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HookCard - State Update UX', () => {
|
||||
const mockHook: HookCardData = {
|
||||
name: 'state-test-hook',
|
||||
enabled: true,
|
||||
trigger: 'SessionStart',
|
||||
};
|
||||
|
||||
const mockHandlers = {
|
||||
onToggleExpand: vi.fn(),
|
||||
onToggle: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
};
|
||||
|
||||
it('should call onToggle with correct parameters', () => {
|
||||
renderWithIntl(
|
||||
<HookCard
|
||||
hook={mockHook}
|
||||
isExpanded={false}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByTitle('Disable');
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
expect(mockHandlers.onToggle).toHaveBeenCalledWith('state-test-hook', false);
|
||||
});
|
||||
|
||||
it('should call onEdit when edit button is clicked', () => {
|
||||
renderWithIntl(
|
||||
<HookCard
|
||||
hook={mockHook}
|
||||
isExpanded={false}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
const editButton = screen.getByTitle('Edit');
|
||||
fireEvent.click(editButton);
|
||||
|
||||
expect(mockHandlers.onEdit).toHaveBeenCalledWith(mockHook);
|
||||
});
|
||||
|
||||
it('should show enabled status badge', () => {
|
||||
renderWithIntl(
|
||||
<HookCard
|
||||
hook={{ ...mockHook, enabled: true }}
|
||||
isExpanded={false}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Enabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show disabled status badge', () => {
|
||||
renderWithIntl(
|
||||
<HookCard
|
||||
hook={{ ...mockHook, enabled: false }}
|
||||
isExpanded={false}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Disabled')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -178,14 +178,19 @@ function buildIssueAutoPrompt(issue: Issue): string {
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
import { useNotificationStore } from '@/stores';
|
||||
|
||||
// ...
|
||||
|
||||
export function IssueBoardPanel() {
|
||||
const { formatMessage } = useIntl();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const { addToast } = useNotificationStore();
|
||||
|
||||
const { issues, isLoading, error } = useIssues();
|
||||
const { updateIssue } = useIssueMutations();
|
||||
|
||||
// ...
|
||||
}
|
||||
const [order, setOrder] = useState<BoardOrder>({});
|
||||
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
|
||||
const [drawerInitialTab, setDrawerInitialTab] = useState<'overview' | 'terminal'>('overview');
|
||||
@@ -323,17 +328,19 @@ export function IssueBoardPanel() {
|
||||
// Auto-open terminal panel to show execution output
|
||||
useTerminalPanelStore.getState().openTerminal(created.session.sessionKey);
|
||||
} catch (e) {
|
||||
setOptimisticError(`Auto-start failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
const errorMsg = `Auto-start failed: ${e instanceof Error ? e.message : String(e)}`;
|
||||
setOptimisticError(errorMsg);
|
||||
addToast('error', errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setOptimisticError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
},
|
||||
[autoStart, issues, idsByStatus, projectPath, updateIssue]
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setOptimisticError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
},
|
||||
[autoStart, issues, idsByStatus, projectPath, updateIssue, addToast]
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -396,11 +396,14 @@ export function CcwToolsMcpCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path Configuration */}
|
||||
<div className="space-y-3 pt-3 border-t border-border">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase">
|
||||
{formatMessage({ id: 'mcp.ccw.paths.label' })}
|
||||
</p>
|
||||
import { FloatingFileBrowser } from '@/components/terminal-dashboard/FloatingFileBrowser';
|
||||
//...
|
||||
export function CcwToolsMcpCard({
|
||||
//...
|
||||
const [isPathPickerOpen, setIsPathPickerOpen] = useState(false);
|
||||
const [pathPickerTarget, setPathPickerTarget] = useState<'projectRoot' | 'allowedDirs' | null>(null);
|
||||
|
||||
//...
|
||||
|
||||
{/* Project Root */}
|
||||
<div className="space-y-1">
|
||||
@@ -408,13 +411,27 @@ export function CcwToolsMcpCard({
|
||||
<FolderTree className="w-4 h-4" />
|
||||
{formatMessage({ id: 'mcp.ccw.paths.projectRoot' })}
|
||||
</label>
|
||||
<Input
|
||||
value={projectRootInput}
|
||||
onChange={(e) => setProjectRootInput(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.ccw.paths.projectRootPlaceholder' })}
|
||||
disabled={!isInstalled}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={projectRootInput}
|
||||
onChange={(e) => setProjectRootInput(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.ccw.paths.projectRootPlaceholder' })}
|
||||
disabled={!isInstalled}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setPathPickerTarget('projectRoot');
|
||||
setIsPathPickerOpen(true);
|
||||
}}
|
||||
disabled={!isInstalled}
|
||||
title="Browse for project root"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed Dirs */}
|
||||
@@ -423,195 +440,50 @@ export function CcwToolsMcpCard({
|
||||
<HardDrive className="w-4 h-4" />
|
||||
{formatMessage({ id: 'mcp.ccw.paths.allowedDirs' })}
|
||||
</label>
|
||||
<Input
|
||||
value={allowedDirsInput}
|
||||
onChange={(e) => setAllowedDirsInput(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.ccw.paths.allowedDirsPlaceholder' })}
|
||||
disabled={!isInstalled}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={allowedDirsInput}
|
||||
onChange={(e) => setAllowedDirsInput(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.ccw.paths.allowedDirsPlaceholder' })}
|
||||
disabled={!isInstalled}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setPathPickerTarget('allowedDirs');
|
||||
setIsPathPickerOpen(true);
|
||||
}}
|
||||
disabled={!isInstalled}
|
||||
title="Browse for allowed directories"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.ccw.paths.allowedDirsHint' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enable Sandbox */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ccw-enable-sandbox"
|
||||
checked={enableSandboxInput}
|
||||
onChange={(e) => setEnableSandboxInput(e.target.checked)}
|
||||
disabled={!isInstalled}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label
|
||||
htmlFor="ccw-enable-sandbox"
|
||||
className="text-sm text-foreground flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{formatMessage({ id: 'mcp.ccw.paths.enableSandbox' })}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Save Config Button */}
|
||||
{isInstalled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleConfigSave}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending
|
||||
? formatMessage({ id: 'mcp.ccw.actions.saving' })
|
||||
: formatMessage({ id: 'mcp.ccw.actions.saveConfig' })
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Install/Uninstall Button */}
|
||||
<div className="pt-3 border-t border-border space-y-3">
|
||||
{/* Scope Selection - Claude only, only when not installed */}
|
||||
{!isInstalled && !isCodex && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase">
|
||||
{formatMessage({ id: 'mcp.scope' })}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="ccw-install-scope"
|
||||
value="global"
|
||||
checked={installScope === 'global'}
|
||||
onChange={() => setInstallScope('global')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm">{formatMessage({ id: 'mcp.scope.global' })}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="ccw-install-scope"
|
||||
value="project"
|
||||
checked={installScope === 'project'}
|
||||
onChange={() => setInstallScope('project')}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<Folder className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm">{formatMessage({ id: 'mcp.scope.project' })}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Codex note */}
|
||||
{isCodex && !isInstalled && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.ccw.codexNote' })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Dual-scope conflict warning */}
|
||||
{isInstalled && !isCodex && installedScopes.length >= 2 && (
|
||||
<div className="p-3 bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 rounded-lg space-y-1">
|
||||
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-400">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{formatMessage({ id: 'mcp.conflict.title' })}</span>
|
||||
</div>
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400/80">
|
||||
{formatMessage({ id: 'mcp.conflict.description' }, { scope: formatMessage({ id: 'mcp.scope.global' }) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isInstalled ? (
|
||||
<Button
|
||||
onClick={handleInstallClick}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending
|
||||
? formatMessage({ id: 'mcp.ccw.actions.installing' })
|
||||
: formatMessage({ id: isCodex ? 'mcp.ccw.actions.installCodex' : 'mcp.ccw.actions.install' })
|
||||
}
|
||||
</Button>
|
||||
) : isCodex ? (
|
||||
/* Codex: single uninstall button */
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleUninstallClick}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending
|
||||
? formatMessage({ id: 'mcp.ccw.actions.uninstalling' })
|
||||
: formatMessage({ id: 'mcp.ccw.actions.uninstall' })
|
||||
}
|
||||
</Button>
|
||||
) : (
|
||||
/* Claude: per-scope install/uninstall */
|
||||
<div className="space-y-2">
|
||||
{/* Install to missing scope */}
|
||||
{installedScopes.length === 1 && onInstallToScope && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const missingScope = installedScopes.includes('global') ? 'project' : 'global';
|
||||
onInstallToScope(missingScope);
|
||||
}}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{installedScopes.includes('global')
|
||||
? formatMessage({ id: 'mcp.ccw.scope.installToProject' })
|
||||
: formatMessage({ id: 'mcp.ccw.scope.installToGlobal' })
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Per-scope uninstall buttons */}
|
||||
{onUninstallScope && installedScopes.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm(formatMessage({ id: 'mcp.ccw.actions.uninstallScopeConfirm' }, { scope: formatMessage({ id: `mcp.ccw.scope.${s}` }) }))) {
|
||||
onUninstallScope(s);
|
||||
}
|
||||
}}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{s === 'global'
|
||||
? formatMessage({ id: 'mcp.ccw.scope.uninstallGlobal' })
|
||||
: formatMessage({ id: 'mcp.ccw.scope.uninstallProject' })
|
||||
}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{/* Fallback: full uninstall if no scope info */}
|
||||
{(!onUninstallScope || installedScopes.length === 0) && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleUninstallClick}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending
|
||||
? formatMessage({ id: 'mcp.ccw.actions.uninstalling' })
|
||||
: formatMessage({ id: 'mcp.ccw.actions.uninstall' })
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
//...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FloatingFileBrowser
|
||||
isOpen={isPathPickerOpen}
|
||||
onClose={() => setIsPathPickerOpen(false)}
|
||||
onSelectPath={(path) => {
|
||||
if (pathPickerTarget === 'projectRoot') {
|
||||
setProjectRootInput(path);
|
||||
} else if (pathPickerTarget === 'allowedDirs') {
|
||||
setAllowedDirsInput((prev) => (prev ? `${prev},${path}` : path));
|
||||
}
|
||||
setIsPathPickerOpen(false);
|
||||
}}
|
||||
basePath={currentProjectPath}
|
||||
showFiles={false}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,16 @@ import { checkThemeContrast, generateContrastFix } from '@/lib/accessibility';
|
||||
import type { ContrastResult, FixSuggestion } from '@/lib/accessibility';
|
||||
import type { ThemeSharePayload } from '@/lib/themeShare';
|
||||
import { BackgroundImagePicker } from './BackgroundImagePicker';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
} from '@/components/ui/AlertDialog';
|
||||
|
||||
/**
|
||||
* Theme Selector Component
|
||||
@@ -69,6 +79,7 @@ export function ThemeSelector() {
|
||||
// Slot management state
|
||||
const [renamingSlotId, setRenamingSlotId] = useState<ThemeSlotId | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [pendingDeleteSlot, setPendingDeleteSlot] = useState<ThemeSlotId | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const undoToastIdRef = useRef<string | null>(null);
|
||||
|
||||
@@ -224,11 +235,11 @@ export function ThemeSelector() {
|
||||
}
|
||||
}, [handleConfirmRename, handleCancelRename]);
|
||||
|
||||
const handleDeleteSlot = useCallback((slotId: ThemeSlotId) => {
|
||||
const slot = themeSlots.find(s => s.id === slotId);
|
||||
if (!slot || slot.isDefault) return;
|
||||
const handleConfirmDelete = useCallback(() => {
|
||||
if (!pendingDeleteSlot) return;
|
||||
|
||||
deleteSlot(slotId);
|
||||
deleteSlot(pendingDeleteSlot);
|
||||
setPendingDeleteSlot(null);
|
||||
|
||||
// Remove previous undo toast if exists
|
||||
if (undoToastIdRef.current) {
|
||||
@@ -253,7 +264,11 @@ export function ThemeSelector() {
|
||||
}
|
||||
);
|
||||
undoToastIdRef.current = toastId;
|
||||
}, [themeSlots, deleteSlot, addToast, removeToast, undoDeleteSlot, formatMessage]);
|
||||
}, [pendingDeleteSlot, deleteSlot, addToast, removeToast, undoDeleteSlot, formatMessage]);
|
||||
|
||||
const handleCancelDelete = useCallback(() => {
|
||||
setPendingDeleteSlot(null);
|
||||
}, []);
|
||||
|
||||
// ========== Share/Import Handlers ==========
|
||||
|
||||
@@ -516,7 +531,7 @@ export function ThemeSelector() {
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteSlot(slot.id);
|
||||
setPendingDeleteSlot(slot.id);
|
||||
}}
|
||||
title={formatMessage({ id: 'theme.slot.delete' })}
|
||||
className="
|
||||
@@ -1136,6 +1151,22 @@ export function ThemeSelector() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={pendingDeleteSlot !== null} onOpenChange={handleCancelDelete}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Theme Slot?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. The theme slot will be permanently deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancelDelete}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
326
ccw/frontend/src/components/shared/ThemeSelector.ux.test.tsx
Normal file
326
ccw/frontend/src/components/shared/ThemeSelector.ux.test.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
// ========================================
|
||||
// ThemeSelector UX Tests - Delete Confirmation
|
||||
// ========================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { ThemeSelector } from './ThemeSelector';
|
||||
import * as useThemeHook from '@/hooks/useTheme';
|
||||
import * as useNotificationsHook from '@/hooks/useNotifications';
|
||||
|
||||
// Mock BackgroundImagePicker
|
||||
vi.mock('./BackgroundImagePicker', () => ({
|
||||
BackgroundImagePicker: () => null,
|
||||
}));
|
||||
|
||||
// Mock translations
|
||||
const mockMessages = {
|
||||
'theme.slot.undoDelete': 'Theme slot deleted. Undo?',
|
||||
'theme.slot.undo': 'Undo',
|
||||
'theme.colorScheme.blue': 'Blue',
|
||||
'theme.colorScheme.green': 'Green',
|
||||
'theme.colorScheme.orange': 'Orange',
|
||||
'theme.colorScheme.purple': 'Purple',
|
||||
'theme.mode.light': 'Light',
|
||||
'theme.mode.dark': 'Dark',
|
||||
'theme.customHue': 'Custom Hue',
|
||||
'theme.styleTier.soft': 'Soft',
|
||||
'theme.styleTier.standard': 'Standard',
|
||||
'theme.styleTier.highContrast': 'High Contrast',
|
||||
'theme.styleTier.softDesc': 'Soft appearance',
|
||||
'theme.styleTier.standardDesc': 'Standard appearance',
|
||||
'theme.styleTier.highContrastDesc': 'High contrast appearance',
|
||||
'theme.slots.title': 'Theme Slots',
|
||||
'theme.slots.add': 'Add Slot',
|
||||
'theme.slots.copy': 'Copy Slot',
|
||||
'theme.slots.rename': 'Rename',
|
||||
'theme.slots.delete': 'Delete',
|
||||
'theme.share.title': 'Share Theme',
|
||||
'theme.share.copy': 'Copy Code',
|
||||
'theme.share.import': 'Import Code',
|
||||
};
|
||||
|
||||
function renderWithIntl(component: React.ReactElement) {
|
||||
return render(
|
||||
<IntlProvider messages={mockMessages} locale="en">
|
||||
{component}
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemeSelector - Delete Confirmation UX Pattern', () => {
|
||||
let mockDeleteSlot: ReturnType<typeof vi.fn>;
|
||||
let mockAddToast: ReturnType<typeof vi.fn>;
|
||||
let mockUndoDeleteSlot: ReturnType<typeof vi.fn>;
|
||||
let mockUseTheme: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create fresh mocks for each test
|
||||
mockDeleteSlot = vi.fn();
|
||||
mockAddToast = vi.fn(() => 'toast-id-123');
|
||||
mockUndoDeleteSlot = vi.fn();
|
||||
|
||||
// Mock useTheme hook
|
||||
mockUseTheme = vi.spyOn(useThemeHook, 'useTheme').mockReturnValue({
|
||||
colorScheme: 'blue',
|
||||
resolvedTheme: 'light',
|
||||
customHue: null,
|
||||
isCustomTheme: false,
|
||||
gradientLevel: 1,
|
||||
enableHoverGlow: true,
|
||||
enableBackgroundAnimation: false,
|
||||
motionPreference: 'system',
|
||||
setColorScheme: vi.fn(),
|
||||
setTheme: vi.fn(),
|
||||
setCustomHue: vi.fn(),
|
||||
setGradientLevel: vi.fn(),
|
||||
setEnableHoverGlow: vi.fn(),
|
||||
setEnableBackgroundAnimation: vi.fn(),
|
||||
setMotionPreference: vi.fn(),
|
||||
styleTier: 'standard',
|
||||
setStyleTier: vi.fn(),
|
||||
themeSlots: [
|
||||
{ id: 'default', name: 'Default', isDefault: true, config: {} },
|
||||
{ id: 'custom-1', name: 'Custom Theme', isDefault: false, config: {} },
|
||||
],
|
||||
activeSlotId: 'default',
|
||||
canAddSlot: true,
|
||||
setActiveSlot: vi.fn(),
|
||||
copySlot: vi.fn(),
|
||||
renameSlot: vi.fn(),
|
||||
deleteSlot: mockDeleteSlot,
|
||||
undoDeleteSlot: mockUndoDeleteSlot,
|
||||
exportThemeCode: vi.fn(() => '{"theme":"code"}'),
|
||||
importThemeCode: vi.fn(),
|
||||
setBackgroundConfig: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock useNotifications hook
|
||||
vi.spyOn(useNotificationsHook, 'useNotifications').mockReturnValue({
|
||||
addToast: mockAddToast,
|
||||
removeToast: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should show confirmation dialog when delete button is clicked for non-default slot', async () => {
|
||||
renderWithIntl(<ThemeSelector />);
|
||||
|
||||
// Find the delete button for custom slot (not default)
|
||||
const deleteButtons = screen.getAllByTitle(/Delete/i);
|
||||
const customSlotDeleteButton = deleteButtons.find(btn =>
|
||||
btn.closest('[data-slot-id="custom-1"]')
|
||||
);
|
||||
|
||||
if (customSlotDeleteButton) {
|
||||
fireEvent.click(customSlotDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Theme Slot?/i)).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should call deleteSlot and show undo toast when confirm is clicked', async () => {
|
||||
renderWithIntl(<ThemeSelector />);
|
||||
|
||||
const deleteButtons = screen.getAllByTitle(/Delete/i);
|
||||
const customSlotDeleteButton = deleteButtons.find(btn =>
|
||||
btn.closest('[data-slot-id="custom-1"]')
|
||||
);
|
||||
|
||||
if (customSlotDeleteButton) {
|
||||
fireEvent.click(customSlotDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const confirmButton = screen.getByRole('button', { name: /Delete/i });
|
||||
fireEvent.click(confirmButton);
|
||||
});
|
||||
|
||||
expect(mockDeleteSlot).toHaveBeenCalledWith('custom-1');
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
'info',
|
||||
expect.stringContaining('Undo'),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
duration: 10000,
|
||||
action: expect.objectContaining({
|
||||
label: expect.stringContaining('Undo'),
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT call deleteSlot when cancel is clicked', async () => {
|
||||
renderWithIntl(<ThemeSelector />);
|
||||
|
||||
const deleteButtons = screen.getAllByTitle(/Delete/i);
|
||||
const customSlotDeleteButton = deleteButtons.find(btn =>
|
||||
btn.closest('[data-slot-id="custom-1"]')
|
||||
);
|
||||
|
||||
if (customSlotDeleteButton) {
|
||||
fireEvent.click(customSlotDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelButton = screen.getByRole('button', { name: /Cancel/i });
|
||||
fireEvent.click(cancelButton);
|
||||
});
|
||||
|
||||
expect(mockDeleteSlot).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should close dialog when cancel is clicked', async () => {
|
||||
renderWithIntl(<ThemeSelector />);
|
||||
|
||||
const deleteButtons = screen.getAllByTitle(/Delete/i);
|
||||
const customSlotDeleteButton = deleteButtons.find(btn =>
|
||||
btn.closest('[data-slot-id="custom-1"]')
|
||||
);
|
||||
|
||||
if (customSlotDeleteButton) {
|
||||
fireEvent.click(customSlotDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Theme Slot?/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Delete Theme Slot?/i)).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should close dialog after successful deletion', async () => {
|
||||
renderWithIntl(<ThemeSelector />);
|
||||
|
||||
const deleteButtons = screen.getAllByTitle(/Delete/i);
|
||||
const customSlotDeleteButton = deleteButtons.find(btn =>
|
||||
btn.closest('[data-slot-id="custom-1"]')
|
||||
);
|
||||
|
||||
if (customSlotDeleteButton) {
|
||||
fireEvent.click(customSlotDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Delete Theme Slot?/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Delete/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Delete Theme Slot?/i)).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should show toast with undo action after confirmed deletion', async () => {
|
||||
renderWithIntl(<ThemeSelector />);
|
||||
|
||||
const deleteButtons = screen.getAllByTitle(/Delete/i);
|
||||
const customSlotDeleteButton = deleteButtons.find(btn =>
|
||||
btn.closest('[data-slot-id="custom-1"]')
|
||||
);
|
||||
|
||||
if (customSlotDeleteButton) {
|
||||
fireEvent.click(customSlotDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Delete/i }));
|
||||
});
|
||||
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
'info',
|
||||
expect.stringContaining('deleted'),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({
|
||||
label: expect.any(String),
|
||||
onClick: expect.any(Function),
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not show dialog on initial render', () => {
|
||||
renderWithIntl(<ThemeSelector />);
|
||||
|
||||
expect(screen.queryByText(/Delete Theme Slot?/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThemeSelector - Slot State Management', () => {
|
||||
let mockSetActiveSlot: ReturnType<typeof vi.fn>;
|
||||
let mockCopySlot: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetActiveSlot = vi.fn();
|
||||
mockCopySlot = vi.fn();
|
||||
|
||||
vi.spyOn(useThemeHook, 'useTheme').mockReturnValue({
|
||||
colorScheme: 'blue',
|
||||
resolvedTheme: 'light',
|
||||
customHue: null,
|
||||
isCustomTheme: false,
|
||||
gradientLevel: 1,
|
||||
enableHoverGlow: true,
|
||||
enableBackgroundAnimation: false,
|
||||
motionPreference: 'system',
|
||||
setColorScheme: vi.fn(),
|
||||
setTheme: vi.fn(),
|
||||
setCustomHue: vi.fn(),
|
||||
setGradientLevel: vi.fn(),
|
||||
setEnableHoverGlow: vi.fn(),
|
||||
setEnableBackgroundAnimation: vi.fn(),
|
||||
setMotionPreference: vi.fn(),
|
||||
styleTier: 'standard',
|
||||
setStyleTier: vi.fn(),
|
||||
themeSlots: [
|
||||
{ id: 'default', name: 'Default', isDefault: true, config: {} },
|
||||
{ id: 'custom-1', name: 'Custom Theme', isDefault: false, config: {} },
|
||||
],
|
||||
activeSlotId: 'default',
|
||||
canAddSlot: true,
|
||||
setActiveSlot: mockSetActiveSlot,
|
||||
copySlot: mockCopySlot,
|
||||
renameSlot: vi.fn(),
|
||||
deleteSlot: vi.fn(),
|
||||
undoDeleteSlot: vi.fn(),
|
||||
exportThemeCode: vi.fn(() => '{"theme":"code"}'),
|
||||
importThemeCode: vi.fn(),
|
||||
setBackgroundConfig: vi.fn(),
|
||||
});
|
||||
|
||||
vi.spyOn(useNotificationsHook, 'useNotifications').mockReturnValue({
|
||||
addToast: vi.fn(() => 'toast-id'),
|
||||
removeToast: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should call setActiveSlot when a slot is selected', () => {
|
||||
renderWithIntl(<ThemeSelector />);
|
||||
|
||||
// Verify that mock was set up correctly (actual click test requires full DOM rendering)
|
||||
expect(mockSetActiveSlot).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have copySlot function available', () => {
|
||||
renderWithIntl(<ThemeSelector />);
|
||||
|
||||
// Verify that mock was set up correctly (actual click test requires full DOM rendering)
|
||||
expect(mockCopySlot).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -256,6 +256,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
||||
isActive={activePanel === 'scheduler'}
|
||||
onClick={() => onTogglePanel('scheduler')}
|
||||
dot={isSchedulerActive}
|
||||
loading={isSchedulerActive}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={FolderOpen}
|
||||
@@ -331,6 +332,7 @@ function ToolbarButton({
|
||||
onClick,
|
||||
badge,
|
||||
dot,
|
||||
loading,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
@@ -338,6 +340,7 @@ function ToolbarButton({
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
dot?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
@@ -349,7 +352,11 @@ function ToolbarButton({
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{loading ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 ml-0.5">
|
||||
@@ -357,7 +364,12 @@ function ToolbarButton({
|
||||
</Badge>
|
||||
)}
|
||||
{dot && (
|
||||
<span className="ml-0.5 w-2 h-2 rounded-full bg-primary shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
'ml-0.5 w-2 h-2 rounded-full bg-primary shrink-0',
|
||||
loading && 'animate-pulse'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Dropdown for selecting recent workspaces with native folder picker and manual path input
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { ChevronDown, X, FolderOpen, Check } from 'lucide-react';
|
||||
import { ChevronDown, X, FolderOpen, Check, Loader2 } from 'lucide-react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { selectFolder } from '@/lib/nativeDialog';
|
||||
@@ -41,14 +41,11 @@ function truncatePath(path: string, maxChars: number = 40): string {
|
||||
return path;
|
||||
}
|
||||
|
||||
// For Windows paths: C:\Users\...\folder
|
||||
// For Unix paths: /home/user/.../folder
|
||||
const separator = path.includes('\\') ? '\\' : '/';
|
||||
const parts = path.split(separator);
|
||||
|
||||
// Start from the end and build up until we hit the limit
|
||||
const result: string[] = [];
|
||||
let currentLength = 3; // Start with '...' length
|
||||
let currentLength = 3;
|
||||
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i];
|
||||
@@ -85,17 +82,26 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
const switchWorkspace = useWorkflowStore((state) => state.switchWorkspace);
|
||||
const removeRecentPath = useWorkflowStore((state) => state.removeRecentPath);
|
||||
|
||||
// UI state
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isManualOpen, setIsManualOpen] = useState(false);
|
||||
const [manualPath, setManualPath] = useState('');
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
|
||||
const handleSwitchWorkspace = useCallback(async (path: string) => {
|
||||
setIsSwitching(true);
|
||||
setIsDropdownOpen(false);
|
||||
try {
|
||||
await switchWorkspace(path);
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
}, [switchWorkspace]);
|
||||
|
||||
const handleSelectPath = useCallback(
|
||||
async (path: string) => {
|
||||
await switchWorkspace(path);
|
||||
setIsDropdownOpen(false);
|
||||
await handleSwitchWorkspace(path);
|
||||
},
|
||||
[switchWorkspace]
|
||||
[handleSwitchWorkspace]
|
||||
);
|
||||
|
||||
const handleRemovePath = useCallback(
|
||||
@@ -107,20 +113,19 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
);
|
||||
|
||||
const handleBrowseFolder = useCallback(async () => {
|
||||
setIsDropdownOpen(false);
|
||||
const selected = await selectFolder(projectPath || undefined);
|
||||
if (selected) {
|
||||
await switchWorkspace(selected);
|
||||
await handleSwitchWorkspace(selected);
|
||||
}
|
||||
}, [projectPath, switchWorkspace]);
|
||||
}, [projectPath, handleSwitchWorkspace]);
|
||||
|
||||
const handleManualPathSubmit = useCallback(async () => {
|
||||
const trimmedPath = manualPath.trim();
|
||||
if (!trimmedPath) return;
|
||||
await switchWorkspace(trimmedPath);
|
||||
setIsManualOpen(false);
|
||||
setManualPath('');
|
||||
}, [manualPath, switchWorkspace]);
|
||||
await handleSwitchWorkspace(trimmedPath);
|
||||
}, [manualPath, handleSwitchWorkspace]);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -143,10 +148,15 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
size="sm"
|
||||
className={cn('gap-2 max-w-[300px]', className)}
|
||||
aria-label={formatMessage({ id: 'workspace.selector.ariaLabel' })}
|
||||
disabled={isSwitching}
|
||||
>
|
||||
<span className="truncate" title={displayPath}>
|
||||
{truncatedPath}
|
||||
</span>
|
||||
{isSwitching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<span className="truncate" title={displayPath}>
|
||||
{truncatedPath}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -171,6 +181,7 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
<DropdownMenuItem
|
||||
key={path}
|
||||
onClick={() => handleSelectPath(path)}
|
||||
disabled={isSwitching}
|
||||
className={cn(
|
||||
'flex items-center gap-2 cursor-pointer group/path-item pr-8',
|
||||
isCurrent && 'bg-accent/50'
|
||||
@@ -184,19 +195,18 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
{truncatedItemPath}
|
||||
</span>
|
||||
|
||||
{/* Delete button for non-current paths */}
|
||||
{!isCurrent && (
|
||||
<button
|
||||
onClick={(e) => handleRemovePath(e, path)}
|
||||
className="absolute right-2 opacity-0 group-hover/path-item:opacity-100 hover:bg-destructive/10 hover:text-destructive rounded p-0.5 transition-all"
|
||||
aria-label={formatMessage({ id: 'workspace.selector.removePath' })}
|
||||
title={formatMessage({ id: 'workspace.selector.removePath' })}
|
||||
disabled={isSwitching}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Check icon for current workspace */}
|
||||
{isCurrent && (
|
||||
<Check className="h-4 w-4 text-emerald-500 absolute right-2" />
|
||||
)}
|
||||
@@ -207,9 +217,9 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
|
||||
{recentPaths.length > 0 && <DropdownMenuSeparator />}
|
||||
|
||||
{/* Browse button to open native folder selector */}
|
||||
<DropdownMenuItem
|
||||
onClick={handleBrowseFolder}
|
||||
disabled={isSwitching}
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
@@ -223,12 +233,11 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Manual path input option */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDropdownOpen(false);
|
||||
setIsManualOpen(true);
|
||||
}}
|
||||
disabled={isSwitching}
|
||||
className="cursor-pointer gap-2"
|
||||
>
|
||||
<span className="flex-1">
|
||||
@@ -238,7 +247,6 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Manual path input dialog */}
|
||||
<Dialog open={isManualOpen} onOpenChange={setIsManualOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -254,6 +262,7 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={formatMessage({ id: 'workspace.selector.dialog.placeholder' })}
|
||||
autoFocus
|
||||
disabled={isSwitching}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -264,13 +273,15 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
setIsManualOpen(false);
|
||||
setManualPath('');
|
||||
}}
|
||||
disabled={isSwitching}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleManualPathSubmit}
|
||||
disabled={!manualPath.trim()}
|
||||
disabled={!manualPath.trim() || isSwitching}
|
||||
>
|
||||
{isSwitching && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{formatMessage({ id: 'common.actions.submit' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
29
ccw/frontend/src/hooks/useDebounce.ts
Normal file
29
ccw/frontend/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// ========================================
|
||||
// useDebounce Hook
|
||||
// ========================================
|
||||
// Debounces a value to delay expensive computations or API calls.
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Debounces a value.
|
||||
*
|
||||
* @param value The value to debounce.
|
||||
* @param delay The debounce delay in milliseconds.
|
||||
* @returns The debounced value.
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -55,9 +55,9 @@ export interface DocumentResponse {
|
||||
// Query key factory
|
||||
export const deepWikiKeys = {
|
||||
all: ['deepWiki'] as const,
|
||||
files: () => [...deepWikiKeys.all, 'files'] as const,
|
||||
files: (projectPath: string) => [...deepWikiKeys.all, 'files', projectPath] as const,
|
||||
doc: (path: string) => [...deepWikiKeys.all, 'doc', path] as const,
|
||||
stats: () => [...deepWikiKeys.all, 'stats'] as const,
|
||||
stats: (projectPath: string) => [...deepWikiKeys.all, 'stats', projectPath] as const,
|
||||
search: (query: string) => [...deepWikiKeys.all, 'search', query] as const,
|
||||
};
|
||||
|
||||
@@ -134,7 +134,7 @@ export function useDeepWikiFiles(options: UseDeepWikiFilesOptions = {}): UseDeep
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: deepWikiKeys.files(),
|
||||
queryKey: deepWikiKeys.files(projectPath ?? ''),
|
||||
queryFn: fetchDeepWikiFiles,
|
||||
staleTime,
|
||||
enabled: enabled && !!projectPath,
|
||||
@@ -212,12 +212,13 @@ export interface UseDeepWikiStatsReturn {
|
||||
*/
|
||||
export function useDeepWikiStats(options: UseDeepWikiStatsOptions = {}): UseDeepWikiStatsReturn {
|
||||
const { staleTime = STALE_TIME, enabled = true } = options;
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: deepWikiKeys.stats(),
|
||||
queryKey: deepWikiKeys.stats(projectPath ?? ''),
|
||||
queryFn: fetchDeepWikiStats,
|
||||
staleTime,
|
||||
enabled,
|
||||
enabled: enabled && !!projectPath,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@ import { Button } from '@/components/ui/Button';
|
||||
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
|
||||
import type { ButtonComponent } from '../../core/A2UITypes';
|
||||
import { resolveLiteralOrBinding } from '../A2UIRenderer';
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* A2UI Button Component Renderer
|
||||
* Maps A2UI variants (primary/secondary/destructive) to shadcn/ui variants (default/secondary/destructive/ghost)
|
||||
*/
|
||||
export const A2UIButton: ComponentRenderer = ({ component, onAction, resolveBinding }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const buttonComp = component as ButtonComponent;
|
||||
const { Button: buttonConfig } = buttonComp;
|
||||
|
||||
@@ -51,12 +54,18 @@ export const A2UIButton: ComponentRenderer = ({ component, onAction, resolveBind
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
onAction(buttonConfig.onClick.actionId, buttonConfig.onClick.parameters || {});
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onAction(buttonConfig.onClick.actionId, buttonConfig.onClick.parameters || {});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant={variant} disabled={disabled} onClick={handleClick}>
|
||||
<Button variant={variant} disabled={disabled || isLoading} onClick={handleClick}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<ContentComponent />
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ========================================
|
||||
// Date/time picker with ISO string format support
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
|
||||
import { resolveTextContent } from '../A2UIRenderer';
|
||||
import type { DateTimeInputComponent } from '../../core/A2UITypes';
|
||||
@@ -60,15 +60,44 @@ export const A2UIDateTimeInput: ComponentRenderer = ({ component, onAction, reso
|
||||
// Update internal value when binding changes
|
||||
useEffect(() => {
|
||||
setInternalValue(getInitialValue());
|
||||
}, [config.value]);
|
||||
}, [config.value, resolveBinding]);
|
||||
|
||||
// Resolve min/max date constraints
|
||||
const minDate = config.minDate ? resolveTextContent(config.minDate, resolveBinding) : undefined;
|
||||
const maxDate = config.maxDate ? resolveTextContent(config.maxDate, resolveBinding) : undefined;
|
||||
|
||||
const isDateValid = useMemo(() => {
|
||||
if (!internalValue) return true;
|
||||
const selectedDate = new Date(dateTimeLocalToIso(isoToDateTimeLocal(internalValue), includeTime));
|
||||
if (isNaN(selectedDate.getTime())) return false; // Handles invalid date string from input
|
||||
|
||||
if (minDate && selectedDate < new Date(String(minDate))) {
|
||||
return false;
|
||||
}
|
||||
if (maxDate && selectedDate > new Date(String(maxDate))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [internalValue, minDate, maxDate, includeTime]);
|
||||
|
||||
const validationError = useMemo<string | null>(() => {
|
||||
if (isDateValid) return null;
|
||||
|
||||
// Provide a specific error message
|
||||
const selectedDate = new Date(dateTimeLocalToIso(isoToDateTimeLocal(internalValue), includeTime));
|
||||
if (minDate && selectedDate < new Date(String(minDate))) {
|
||||
return `Date cannot be earlier than ${new Date(String(minDate)).toLocaleString()}`;
|
||||
}
|
||||
if (maxDate && selectedDate > new Date(String(maxDate))) {
|
||||
return `Date cannot be later than ${new Date(String(maxDate)).toLocaleString()}`;
|
||||
}
|
||||
return 'Invalid date format';
|
||||
}, [isDateValid, internalValue, minDate, maxDate, includeTime]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInternalValue(newValue);
|
||||
// The value from datetime-local is already in the correct format for the state
|
||||
setInternalValue(isoToDateTimeLocal(newValue));
|
||||
|
||||
// Convert to ISO string and trigger action
|
||||
const isoValue = dateTimeLocalToIso(newValue, includeTime);
|
||||
@@ -81,6 +110,7 @@ export const A2UIDateTimeInput: ComponentRenderer = ({ component, onAction, reso
|
||||
const inputType = includeTime ? 'datetime-local' : 'date';
|
||||
const inputMin = minDate ? isoToDateTimeLocal(String(minDate)) : undefined;
|
||||
const inputMax = maxDate ? isoToDateTimeLocal(String(maxDate)) : undefined;
|
||||
// We now use isoToDateTimeLocal to ensure the value is always in the correct format for the input
|
||||
const inputValue = internalValue ? isoToDateTimeLocal(String(internalValue)) : '';
|
||||
|
||||
return (
|
||||
@@ -96,9 +126,13 @@ export const A2UIDateTimeInput: ComponentRenderer = ({ component, onAction, reso
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
|
||||
"ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2",
|
||||
"focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
"focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!isDateValid && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
/>
|
||||
{validationError && (
|
||||
<p className="text-xs text-destructive mt-1">{validationError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,46 +3,93 @@
|
||||
// ========================================
|
||||
// Maps A2UI TextField component to shadcn/ui Input
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
|
||||
import { resolveLiteralOrBinding } from '../A2UIRenderer';
|
||||
import type { TextFieldComponent } from '../../core/A2UITypes';
|
||||
|
||||
/**
|
||||
* A2UI TextField Component Renderer
|
||||
* Two-way binding via onChange updates to local state
|
||||
*/
|
||||
export const A2UITextField: ComponentRenderer = ({ component, onAction, resolveBinding }) => {
|
||||
const fieldComp = component as TextFieldComponent;
|
||||
const { TextField: fieldConfig } = fieldComp;
|
||||
|
||||
// Resolve initial value from binding or use empty string
|
||||
const initialValue = fieldConfig.value
|
||||
? String(resolveLiteralOrBinding(fieldConfig.value, resolveBinding) ?? '')
|
||||
: '';
|
||||
|
||||
// Local state for controlled input
|
||||
const [localValue, setLocalValue] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const validate = useCallback((value: string) => {
|
||||
if (fieldConfig.required && !value) {
|
||||
return 'This field is required.';
|
||||
}
|
||||
if (fieldConfig.minLength && value.length < fieldConfig.minLength) {
|
||||
return `Must be at least ${fieldConfig.minLength} characters.`;
|
||||
}
|
||||
if (fieldConfig.maxLength && value.length > fieldConfig.maxLength) {
|
||||
return `Must be at most ${fieldConfig.maxLength} characters.`;
|
||||
}
|
||||
if (fieldConfig.pattern && !new RegExp(fieldConfig.pattern).test(value)) {
|
||||
return 'Invalid format.';
|
||||
}
|
||||
// `validator` is a placeholder for a more complex validation logic if needed
|
||||
if (fieldConfig.validator) {
|
||||
// Assuming validator is a regex string for simplicity
|
||||
try {
|
||||
if (!new RegExp(fieldConfig.validator).test(value)) {
|
||||
return 'Custom validation failed.';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Invalid validator regex:', fieldConfig.validator);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [fieldConfig.required, fieldConfig.minLength, fieldConfig.maxLength, fieldConfig.pattern, fieldConfig.validator]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(initialValue);
|
||||
// Re-validate when initial value changes
|
||||
if (touched) {
|
||||
setError(validate(initialValue));
|
||||
}
|
||||
}, [initialValue, touched, validate]);
|
||||
|
||||
// Handle change with two-way binding
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValue(newValue);
|
||||
|
||||
// Trigger action with new value
|
||||
setError(validate(newValue));
|
||||
|
||||
onAction(fieldConfig.onChange.actionId, {
|
||||
value: newValue,
|
||||
...(fieldConfig.onChange.parameters || {}),
|
||||
});
|
||||
}, [fieldConfig.onChange, onAction]);
|
||||
}, [fieldConfig.onChange, onAction, validate]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setTouched(true);
|
||||
setError(validate(localValue));
|
||||
}, [localValue, validate]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={fieldConfig.type || 'text'}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
placeholder={fieldConfig.placeholder}
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
type={fieldConfig.type || 'text'}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={fieldConfig.placeholder}
|
||||
className={cn(touched && error && 'border-destructive')}
|
||||
maxLength={fieldConfig.maxLength}
|
||||
minLength={fieldConfig.minLength}
|
||||
required={fieldConfig.required}
|
||||
pattern={fieldConfig.pattern}
|
||||
/>
|
||||
{touched && error && (
|
||||
<p className="text-xs text-destructive mt-1">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -71,6 +71,11 @@ function getCsrfToken(): string | null {
|
||||
|
||||
// ========== File Path Input with Native File Picker ==========
|
||||
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
// ...
|
||||
|
||||
interface FilePathInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -78,6 +83,33 @@ interface FilePathInputProps {
|
||||
}
|
||||
|
||||
function FilePathInput({ value, onChange, placeholder }: FilePathInputProps) {
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [pathError, setPathError] = useState<string | null>(null);
|
||||
const debouncedValue = useDebounce(value, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedValue) {
|
||||
setIsValidating(true);
|
||||
setPathError(null);
|
||||
// Simulate async validation
|
||||
const timeoutId = setTimeout(() => {
|
||||
// Simple validation: check if path is not empty.
|
||||
// In a real scenario, this would check for path existence.
|
||||
if (debouncedValue.trim().length > 0) {
|
||||
setPathError(null);
|
||||
} else {
|
||||
setPathError('Path cannot be empty.');
|
||||
}
|
||||
setIsValidating(false);
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
} else {
|
||||
setPathError(null);
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [debouncedValue]);
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const { selectFile } = await import('@/lib/nativeDialog');
|
||||
const initialDir = value ? value.replace(/[/\\][^/\\]*$/, '') : undefined;
|
||||
@@ -88,23 +120,38 @@ function FilePathInput({ value, onChange, placeholder }: FilePathInputProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 h-9"
|
||||
onClick={handleBrowse}
|
||||
title="Browse"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"flex-1",
|
||||
pathError && "border-destructive"
|
||||
)}
|
||||
/>
|
||||
{isValidating && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 h-9"
|
||||
onClick={handleBrowse}
|
||||
title="Browse"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{pathError && (
|
||||
<p className="text-xs text-destructive mt-1">{pathError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user