Skip to content

Commit 5c0d64b

Browse files
google-labs-jules[bot]TytaniumDevclaude
authored
🎨 Palette: Add loading spinners to async buttons (#88)
* fix: add approval filter to automerge-label caller workflow Added if condition so the reusable workflow is only called on TytaniumDev's approvals, not every review submission. Removed redundant top-level permissions (reusable workflow declares its own). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ui): add loading spinners to async buttons Rebase spinner changes onto current main (post-PR #90 Firestore migration). Adds a reusable Spinner component with size variants and integrates it into async action buttons (Run Again, Cancel Job, Delete Job, bulk delete) and the Browse page loading state. Removes .jules/ metadata file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review comments - Restore explicit permissions block in automerge-label workflow (contents: read, pull-requests: write, issues: write) to enforce least-privilege on the GITHUB_TOKEN - Fix Spinner className concatenation to avoid trailing whitespace when no custom className is provided Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Tyler Holland <tytaniumdev@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7479f43 commit 5c0d64b

5 files changed

Lines changed: 75 additions & 9 deletions

File tree

.jules/palette.md

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Spinner } from './Spinner';
2+
import { render } from '@testing-library/react';
3+
import { describe, it, expect } from 'vitest';
4+
5+
describe('Spinner', () => {
6+
it('renders correctly', () => {
7+
const { container } = render(<Spinner />);
8+
const svg = container.querySelector('svg');
9+
expect(svg).toBeInTheDocument();
10+
expect(svg).toHaveClass('animate-spin');
11+
expect(svg).toHaveClass('w-4'); // Default size sm
12+
});
13+
14+
it('applies custom className', () => {
15+
const { container } = render(<Spinner className="text-red-500" />);
16+
const svg = container.querySelector('svg');
17+
expect(svg).toHaveClass('text-red-500');
18+
});
19+
20+
it('applies size classes', () => {
21+
const { container } = render(<Spinner size="lg" />);
22+
const svg = container.querySelector('svg');
23+
expect(svg).toHaveClass('w-8');
24+
});
25+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
interface SpinnerProps {
2+
className?: string;
3+
size?: 'sm' | 'md' | 'lg';
4+
}
5+
6+
export function Spinner({ className = '', size = 'sm' }: SpinnerProps) {
7+
const sizeClasses = {
8+
sm: 'w-4 h-4',
9+
md: 'w-6 h-6',
10+
lg: 'w-8 h-8',
11+
};
12+
13+
return (
14+
<svg
15+
className={`animate-spin text-current ${sizeClasses[size]}${className ? ` ${className}` : ''}`}
16+
xmlns="http://www.w3.org/2000/svg"
17+
fill="none"
18+
viewBox="0 0 24 24"
19+
aria-hidden="true"
20+
>
21+
<circle
22+
className="opacity-25"
23+
cx="12"
24+
cy="12"
25+
r="10"
26+
stroke="currentColor"
27+
strokeWidth="4"
28+
></circle>
29+
<path
30+
className="opacity-75"
31+
fill="currentColor"
32+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
33+
></path>
34+
</svg>
35+
);
36+
}

frontend/src/pages/Browse.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
33
import { getApiBase, fetchWithAuth, deleteJobs } from '../api';
44
import { useAuth } from '../contexts/AuthContext';
55
import { WorkerStatusBanner } from '../components/WorkerStatusBanner';
6+
import { Spinner } from '../components/Spinner';
67
import { useWorkerStatus } from '../hooks/useWorkerStatus';
78
import type { JobStatus, JobSummary } from '@shared/types/job';
89

@@ -186,7 +187,8 @@ export default function Browse() {
186187

187188
{/* Jobs List */}
188189
{isLoading && (
189-
<div className="bg-gray-800 rounded-lg p-6 text-gray-400 text-center">
190+
<div className="bg-gray-800 rounded-lg p-6 text-gray-400 text-center flex justify-center items-center gap-2">
191+
<Spinner />
190192
Loading simulations...
191193
</div>
192194
)}
@@ -220,8 +222,9 @@ export default function Browse() {
220222
type="button"
221223
onClick={handleBulkDelete}
222224
disabled={isDeleting}
223-
className="ml-auto px-3 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
225+
className="ml-auto px-3 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
224226
>
227+
{isDeleting && <Spinner size="sm" />}
225228
{isDeleting ? 'Deleting...' : `Delete Selected (${selectedJobs.size})`}
226229
</button>
227230
</>

frontend/src/pages/JobStatus.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getApiBase, fetchWithAuth, deleteJob } from '../api';
77
import { ColorIdentity } from '../components/ColorIdentity';
88
import { DeckShowcase } from '../components/DeckShowcase';
99
import { SimulationGrid } from '../components/SimulationGrid';
10+
import { Spinner } from '../components/Spinner';
1011
import { useJobStream } from '../hooks/useJobStream';
1112
import { useWinData } from '../hooks/useWinData';
1213
import { useJobLogs } from '../hooks/useJobLogs';
@@ -184,8 +185,9 @@ export default function JobStatusPage() {
184185
type="button"
185186
onClick={handleRunAgain}
186187
disabled={isResubmitting}
187-
className="bg-blue-600 hover:bg-blue-700 text-white text-sm rounded px-3 py-1 disabled:opacity-50 disabled:cursor-not-allowed"
188+
className="bg-blue-600 hover:bg-blue-700 text-white text-sm rounded px-3 py-1 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
188189
>
190+
{isResubmitting && <Spinner size="sm" />}
189191
{isResubmitting ? 'Submitting...' : 'Run Again'}
190192
</button>
191193
)}
@@ -237,8 +239,9 @@ export default function JobStatusPage() {
237239
type="button"
238240
onClick={handleCancel}
239241
disabled={isCancelling}
240-
className="ml-auto px-3 py-1 text-xs rounded bg-orange-600 text-white hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed"
242+
className="ml-auto px-3 py-1 text-xs rounded bg-orange-600 text-white hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
241243
>
244+
{isCancelling && <Spinner size="sm" />}
242245
{isCancelling ? 'Cancelling...' : 'Cancel Job'}
243246
</button>
244247
</div>
@@ -324,8 +327,9 @@ export default function JobStatusPage() {
324327
type="button"
325328
onClick={handleCancel}
326329
disabled={isCancelling}
327-
className="ml-4 px-3 py-1 text-xs rounded bg-orange-600 text-white hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed"
330+
className="ml-4 px-3 py-1 text-xs rounded bg-orange-600 text-white hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
328331
>
332+
{isCancelling && <Spinner size="sm" />}
329333
{isCancelling ? 'Cancelling...' : 'Cancel Job'}
330334
</button>
331335
)}
@@ -780,8 +784,9 @@ export default function JobStatusPage() {
780784
type="button"
781785
onClick={handleDelete}
782786
disabled={isDeletingJob}
783-
className="ml-auto px-3 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
787+
className="ml-auto px-3 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
784788
>
789+
{isDeletingJob && <Spinner size="sm" />}
785790
{isDeletingJob ? 'Deleting...' : 'Delete Job'}
786791
</button>
787792
)}

0 commit comments

Comments
 (0)