Skip to content

Commit 5f7b218

Browse files
Merge pull request #822 from jSydorowicz21/dedup/phase-08-shared-ui-components
refactor: extract shared UI components (Phase 08)
2 parents d4bc26b + d96adf6 commit 5f7b218

73 files changed

Lines changed: 1466 additions & 539 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Tests for EmptyStatePlaceholder component
3+
*/
4+
5+
import { describe, it, expect } from 'vitest';
6+
import { render, screen } from '@testing-library/react';
7+
import { EmptyStatePlaceholder } from '../../../../renderer/components/ui/EmptyStatePlaceholder';
8+
import type { Theme } from '../../../../renderer/types';
9+
10+
const mockTheme: Theme = {
11+
id: 'test-theme',
12+
name: 'Test Theme',
13+
mode: 'dark',
14+
colors: {
15+
bgMain: '#1a1a1a',
16+
bgSidebar: '#242424',
17+
bgActivity: '#2a2a2a',
18+
textMain: '#ffffff',
19+
textDim: '#888888',
20+
accent: '#3b82f6',
21+
accentForeground: '#ffffff',
22+
border: '#333333',
23+
error: '#ef4444',
24+
success: '#22c55e',
25+
warning: '#f59e0b',
26+
cursor: '#ffffff',
27+
terminalBg: '#1a1a1a',
28+
},
29+
};
30+
31+
describe('EmptyStatePlaceholder', () => {
32+
it('renders title only', () => {
33+
render(<EmptyStatePlaceholder theme={mockTheme} title="No items" />);
34+
expect(screen.getByText('No items')).toBeInTheDocument();
35+
});
36+
37+
it('renders icon when provided', () => {
38+
render(
39+
<EmptyStatePlaceholder theme={mockTheme} title="No items" icon={<svg data-testid="icon" />} />
40+
);
41+
expect(screen.getByTestId('icon')).toBeInTheDocument();
42+
});
43+
44+
it('renders description when provided', () => {
45+
render(
46+
<EmptyStatePlaceholder
47+
theme={mockTheme}
48+
title="Empty"
49+
description="Try adjusting your filters"
50+
/>
51+
);
52+
expect(screen.getByText('Try adjusting your filters')).toBeInTheDocument();
53+
});
54+
55+
it('renders action when provided', () => {
56+
render(
57+
<EmptyStatePlaceholder theme={mockTheme} title="Empty" action={<button>Clear</button>} />
58+
);
59+
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
60+
});
61+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Tests for GhostIconButton component
3+
*/
4+
5+
import { describe, it, expect, vi } from 'vitest';
6+
import { render, screen, fireEvent } from '@testing-library/react';
7+
import { GhostIconButton } from '../../../../renderer/components/ui/GhostIconButton';
8+
9+
describe('GhostIconButton', () => {
10+
it('renders children and default classes', () => {
11+
render(
12+
<GhostIconButton ariaLabel="Close">
13+
<span data-testid="icon">x</span>
14+
</GhostIconButton>
15+
);
16+
const btn = screen.getByRole('button', { name: 'Close' });
17+
expect(btn).toBeInTheDocument();
18+
expect(btn).toHaveClass('rounded');
19+
expect(btn).toHaveClass('hover:bg-white/10');
20+
expect(btn).toHaveClass('p-1');
21+
expect(screen.getByTestId('icon')).toBeInTheDocument();
22+
});
23+
24+
it('calls onClick when clicked', () => {
25+
const onClick = vi.fn();
26+
render(
27+
<GhostIconButton onClick={onClick} ariaLabel="Do it">
28+
<span>x</span>
29+
</GhostIconButton>
30+
);
31+
fireEvent.click(screen.getByRole('button', { name: 'Do it' }));
32+
expect(onClick).toHaveBeenCalledTimes(1);
33+
});
34+
35+
it('respects disabled prop', () => {
36+
const onClick = vi.fn();
37+
render(
38+
<GhostIconButton onClick={onClick} disabled ariaLabel="Disabled">
39+
<span>x</span>
40+
</GhostIconButton>
41+
);
42+
const btn = screen.getByRole('button', { name: 'Disabled' });
43+
expect(btn).toBeDisabled();
44+
fireEvent.click(btn);
45+
expect(onClick).not.toHaveBeenCalled();
46+
});
47+
48+
it('applies custom padding', () => {
49+
render(
50+
<GhostIconButton padding="p-2" ariaLabel="Pad">
51+
<span>x</span>
52+
</GhostIconButton>
53+
);
54+
expect(screen.getByRole('button', { name: 'Pad' })).toHaveClass('p-2');
55+
});
56+
57+
it('stops propagation when stopPropagation is true', () => {
58+
const parentClick = vi.fn();
59+
const onClick = vi.fn();
60+
render(
61+
<div onClick={parentClick}>
62+
<GhostIconButton onClick={onClick} stopPropagation ariaLabel="Stop">
63+
<span>x</span>
64+
</GhostIconButton>
65+
</div>
66+
);
67+
fireEvent.click(screen.getByRole('button', { name: 'Stop' }));
68+
expect(onClick).toHaveBeenCalledTimes(1);
69+
expect(parentClick).not.toHaveBeenCalled();
70+
});
71+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Tests for Spinner component
3+
*/
4+
5+
import { describe, it, expect } from 'vitest';
6+
import { render, screen } from '@testing-library/react';
7+
import { Spinner } from '../../../../renderer/components/ui/Spinner';
8+
9+
describe('Spinner', () => {
10+
it('renders with default size', () => {
11+
render(<Spinner />);
12+
const icon = screen.getByTestId('loader2-icon');
13+
expect(icon).toBeInTheDocument();
14+
expect(icon).toHaveClass('animate-spin');
15+
expect(icon).toHaveStyle({ width: '16px', height: '16px' });
16+
});
17+
18+
it('applies custom size', () => {
19+
render(<Spinner size={32} />);
20+
const icon = screen.getByTestId('loader2-icon');
21+
expect(icon).toHaveStyle({ width: '32px', height: '32px' });
22+
});
23+
24+
it('applies custom color', () => {
25+
render(<Spinner color="rgb(255, 0, 0)" />);
26+
const icon = screen.getByTestId('loader2-icon');
27+
expect(icon).toHaveStyle({ color: 'rgb(255, 0, 0)' });
28+
});
29+
30+
it('merges custom className', () => {
31+
render(<Spinner className="text-blue-500" />);
32+
const icon = screen.getByTestId('loader2-icon');
33+
expect(icon).toHaveClass('animate-spin');
34+
expect(icon).toHaveClass('text-blue-500');
35+
});
36+
});

src/renderer/components/AboutModal.tsx

Lines changed: 18 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import {
55
ExternalLink,
66
FileCode,
77
BarChart3,
8-
Loader2,
98
Trophy,
109
Globe,
1110
Check,
1211
BookOpen,
1312
} from 'lucide-react';
13+
import { Spinner } from './ui/Spinner';
14+
import { GhostIconButton } from './ui/GhostIconButton';
1415
import type { Theme, AutoRunStats, MaestroUsageStats, LeaderboardRegistration } from '../types';
1516
import type { GlobalAgentStats } from '../../shared/types';
1617
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
@@ -114,48 +115,36 @@ export function AboutModal({
114115
<h2 className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
115116
About Maestro
116117
</h2>
117-
<button
118-
type="button"
118+
<GhostIconButton
119119
onClick={() => openUrl(buildMaestroUrl('https://runmaestro.ai'))}
120-
className="p-1 rounded hover:bg-white/10 transition-colors"
121120
title="Visit runmaestro.ai"
122-
aria-label="Visit runmaestro.ai"
123-
style={{ color: theme.colors.accent }}
121+
ariaLabel="Visit runmaestro.ai"
122+
color={theme.colors.accent}
124123
>
125124
<Globe className="w-4 h-4" />
126-
</button>
127-
<button
128-
type="button"
125+
</GhostIconButton>
126+
<GhostIconButton
129127
onClick={() => openUrl(buildMaestroUrl('https://runmaestro.ai/discord'))}
130-
className="p-1 rounded hover:bg-white/10 transition-colors"
131128
title="Join our Discord"
132-
aria-label="Join our Discord"
133-
style={{ color: theme.colors.accent }}
129+
ariaLabel="Join our Discord"
130+
color={theme.colors.accent}
134131
>
135132
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
136133
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
137134
</svg>
138-
</button>
139-
<button
140-
type="button"
135+
</GhostIconButton>
136+
<GhostIconButton
141137
onClick={() => openUrl(buildMaestroUrl('https://docs.runmaestro.ai/'))}
142-
className="p-1 rounded hover:bg-white/10 transition-colors"
143138
title="Documentation"
144-
aria-label="Documentation"
145-
style={{ color: theme.colors.accent }}
139+
ariaLabel="Documentation"
140+
color={theme.colors.accent}
146141
>
147142
<BookOpen className="w-4 h-4" />
148-
</button>
143+
</GhostIconButton>
149144
</div>
150-
<button
151-
type="button"
152-
onClick={onClose}
153-
className="p-1 rounded hover:bg-white/10 transition-colors"
154-
style={{ color: theme.colors.textDim }}
155-
aria-label="Close modal"
156-
>
145+
<GhostIconButton onClick={onClose} color={theme.colors.textDim} ariaLabel="Close modal">
157146
<X className="w-4 h-4" />
158-
</button>
147+
</GhostIconButton>
159148
</div>
160149
);
161150

@@ -215,13 +204,11 @@ export function AboutModal({
215204
<span className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
216205
Global Statistics
217206
</span>
218-
{!isStatsComplete && (
219-
<Loader2 className="w-3 h-3 animate-spin" style={{ color: theme.colors.textDim }} />
220-
)}
207+
{!isStatsComplete && <Spinner size={12} color={theme.colors.textDim} />}
221208
</div>
222209
{loading ? (
223210
<div className="flex items-center justify-center py-4 gap-2">
224-
<Loader2 className="w-4 h-4 animate-spin" style={{ color: theme.colors.textDim }} />
211+
<Spinner size={16} color={theme.colors.textDim} />
225212
<span className="text-xs" style={{ color: theme.colors.textDim }}>
226213
Loading stats...
227214
</span>

src/renderer/components/AgentCreationDialog.tsx

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,9 @@
1313

1414
import { useState, useEffect, useRef, useCallback } from 'react';
1515
import { createPortal } from 'react-dom';
16-
import {
17-
Music,
18-
X,
19-
Loader2,
20-
Bot,
21-
Settings,
22-
FolderOpen,
23-
ChevronRight,
24-
RefreshCw,
25-
} from 'lucide-react';
16+
import { Music, X, Bot, Settings, FolderOpen, ChevronRight, RefreshCw } from 'lucide-react';
17+
import { GhostIconButton } from './ui/GhostIconButton';
18+
import { Spinner } from './ui/Spinner';
2619
import type { Theme, AgentConfig } from '../types';
2720
import type { RegisteredRepository, SymphonyIssue } from '../../shared/symphony-types';
2821
import { useLayerStack } from '../contexts/LayerStackContext';
@@ -337,13 +330,9 @@ export function AgentCreationDialog({
337330
Create Symphony Agent
338331
</h2>
339332
</div>
340-
<button
341-
onClick={onClose}
342-
className="p-1.5 rounded hover:bg-white/10 transition-colors"
343-
title="Close (Esc)"
344-
>
333+
<GhostIconButton onClick={onClose} padding="p-1.5" title="Close (Esc)">
345334
<X className="w-4 h-4" style={{ color: theme.colors.textDim }} />
346-
</button>
335+
</GhostIconButton>
347336
</div>
348337

349338
{/* Content - scrollable */}
@@ -377,7 +366,7 @@ export function AgentCreationDialog({
377366

378367
{ac.isDetecting ? (
379368
<div className="flex items-center justify-center py-8">
380-
<Loader2 className="w-6 h-6 animate-spin" style={{ color: theme.colors.accent }} />
369+
<Spinner size={24} color={theme.colors.accent} />
381370
</div>
382371
) : ac.detectedAgents.length === 0 ? (
383372
<div className="text-center py-4" style={{ color: theme.colors.textDim }}>
@@ -446,19 +435,18 @@ export function AgentCreationDialog({
446435
>
447436
Available
448437
</span>
449-
<button
438+
<GhostIconButton
450439
onClick={(e) => {
451440
e.stopPropagation();
452441
handleRefreshAgent(agent.id);
453442
}}
454-
className="p-1 rounded hover:bg-white/10 transition-colors"
455443
title="Refresh detection"
456-
style={{ color: theme.colors.textDim }}
444+
color={theme.colors.textDim}
457445
>
458446
<RefreshCw
459447
className={`w-3 h-3 ${refreshingAgent === agent.id ? 'animate-spin' : ''}`}
460448
/>
461-
</button>
449+
</GhostIconButton>
462450
</div>
463451
</div>
464452

@@ -647,7 +635,7 @@ export function AgentCreationDialog({
647635
>
648636
{isCreating ? (
649637
<>
650-
<Loader2 className="w-4 h-4 animate-spin" />
638+
<Spinner size={16} />
651639
Creating...
652640
</>
653641
) : (

src/renderer/components/AgentPromptComposerModal.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useEffect, useRef, useState } from 'react';
22
import { X, FileText, Variable, ChevronDown, ChevronRight } from 'lucide-react';
3+
import { GhostIconButton } from './ui/GhostIconButton';
34
import type { Theme } from '../types';
45
import { useLayerStack } from '../contexts/LayerStackContext';
56
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
@@ -151,13 +152,9 @@ export function AgentPromptComposerModal({
151152
</span>
152153
</div>
153154
<div className="flex items-center gap-3">
154-
<button
155-
onClick={handleDone}
156-
className="p-1.5 rounded hover:bg-white/10 transition-colors"
157-
title="Close (Escape)"
158-
>
155+
<GhostIconButton onClick={handleDone} padding="p-1.5" title="Close (Escape)">
159156
<X className="w-5 h-5" style={{ color: theme.colors.textDim }} />
160-
</button>
157+
</GhostIconButton>
161158
</div>
162159
</div>
163160

0 commit comments

Comments
 (0)