Skip to content

Commit 10ba3a8

Browse files
committed
Build complete Matrix page UI with drawer and cell details
Features implemented: - Matrix grid with sticky header showing PHP versions as columns - Extension names in first column (clickable to navigate to detail page) - Virtualized rows using react-window for 200+ extensions performance - Color-coded cells: green (success), red (failure), gray (skipped/unknown) - Accessible cells with aria-labels and keyboard navigation - Hover tooltips showing status, category, and extension version - Right-side drawer (modal on mobile) on cell click with: - Extension name and version - Build status badge with color - Category information - Started/finished timestamps with duration calculation - Links to workflow run and artifact (external links) - Copy permalink button (copies URL with anchor to cell) - Search and sort from existing FilterBar - Smooth slide-in animation for drawer - Escape key to close drawer - Sample data updated with started_at timestamps CSS updates: - .cell-success, .cell-failure, .cell-skipped classes - .drawer-right with slide-in animation - Detail section styles (detail-grid, detail-item, etc.) - Status badge, links, and permalink button styles - Mobile responsive drawer (full width on mobile) Type updates: - Added startedAt and finishedAt to MatrixCell - Added started_at to BuildResult interface
1 parent ff8ddba commit 10ba3a8

7 files changed

Lines changed: 453 additions & 36 deletions

File tree

public/sample-data/snapshots/latest.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"platform_version": "alpine3.19",
1212
"arch": "amd64",
1313
"status": "success",
14+
"started_at": "2024-01-01T09:55:00Z",
1415
"finished_at": "2024-01-01T10:00:00Z",
1516
"run": {
1617
"url": "https://example.com/runs/1"
@@ -28,6 +29,7 @@
2829
"platform_version": "alpine3.19",
2930
"arch": "amd64",
3031
"status": "success",
32+
"started_at": "2024-01-01T10:00:00Z",
3133
"finished_at": "2024-01-01T10:05:00Z",
3234
"run": {
3335
"url": "https://example.com/runs/2"
@@ -42,6 +44,7 @@
4244
"platform_version": "debian12",
4345
"arch": "amd64",
4446
"status": "success",
47+
"started_at": "2024-01-01T10:05:00Z",
4548
"finished_at": "2024-01-01T10:10:00Z"
4649
},
4750
{
@@ -53,6 +56,7 @@
5356
"platform_version": "debian12",
5457
"arch": "amd64",
5558
"status": "success",
59+
"started_at": "2024-01-01T10:10:00Z",
5660
"finished_at": "2024-01-01T10:15:00Z"
5761
},
5862
{
@@ -64,6 +68,7 @@
6468
"platform_version": "alpine3.19",
6569
"arch": "amd64",
6670
"status": "success",
71+
"started_at": "2024-01-01T10:55:00Z",
6772
"finished_at": "2024-01-01T11:00:00Z"
6873
},
6974
{
@@ -76,6 +81,7 @@
7681
"arch": "amd64",
7782
"status": "failure",
7883
"category": "build_error",
84+
"started_at": "2024-01-01T11:00:00Z",
7985
"finished_at": "2024-01-01T11:05:00Z",
8086
"run": {
8187
"url": "https://example.com/runs/5"
@@ -90,6 +96,7 @@
9096
"platform_version": "debian12",
9197
"arch": "amd64",
9298
"status": "success",
99+
"started_at": "2024-01-01T11:05:00Z",
93100
"finished_at": "2024-01-01T11:10:00Z"
94101
},
95102
{
@@ -101,6 +108,7 @@
101108
"platform_version": "debian12",
102109
"arch": "amd64",
103110
"status": "success",
111+
"started_at": "2024-01-01T11:10:00Z",
104112
"finished_at": "2024-01-01T11:15:00Z"
105113
},
106114
{
@@ -112,6 +120,7 @@
112120
"platform_version": "alpine3.19",
113121
"arch": "amd64",
114122
"status": "success",
123+
"started_at": "2024-01-01T11:55:00Z",
115124
"finished_at": "2024-01-01T12:00:00Z"
116125
},
117126
{
@@ -124,6 +133,7 @@
124133
"arch": "amd64",
125134
"status": "skipped",
126135
"category": "incompatible",
136+
"started_at": "2024-01-01T12:00:00Z",
127137
"finished_at": "2024-01-01T12:05:00Z"
128138
},
129139
{
@@ -135,6 +145,7 @@
135145
"platform_version": "debian12",
136146
"arch": "amd64",
137147
"status": "success",
148+
"started_at": "2024-01-01T12:05:00Z",
138149
"finished_at": "2024-01-01T12:10:00Z"
139150
},
140151
{
@@ -146,6 +157,7 @@
146157
"platform_version": "debian12",
147158
"arch": "amd64",
148159
"status": "success",
160+
"started_at": "2024-01-01T12:10:00Z",
149161
"finished_at": "2024-01-01T12:15:00Z"
150162
}
151163
]

src/components/CellDrawer.tsx

Lines changed: 213 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,235 @@
1-
import { Extension, FilterState } from '../types';
1+
import { useState, useEffect } from 'react';
2+
import { MatrixCell } from '../types';
23

34
interface CellDrawerProps {
45
extensionName: string;
56
phpVersion: string;
6-
extension: Extension | undefined;
7-
filters: FilterState;
7+
cell: MatrixCell | null;
88
onClose: () => void;
99
}
1010

11-
export function CellDrawer({ extensionName, phpVersion, extension, filters, onClose }: CellDrawerProps) {
12-
if (!extension) return null;
11+
function formatDuration(startedAt?: string, finishedAt?: string): string | null {
12+
if (!startedAt || !finishedAt) return null;
13+
14+
const start = new Date(startedAt).getTime();
15+
const finish = new Date(finishedAt).getTime();
16+
const durationMs = finish - start;
17+
18+
if (durationMs < 0) return null;
19+
20+
const seconds = Math.floor(durationMs / 1000);
21+
const minutes = Math.floor(seconds / 60);
22+
const hours = Math.floor(minutes / 60);
23+
24+
if (hours > 0) {
25+
return `${hours}h ${minutes % 60}m`;
26+
} else if (minutes > 0) {
27+
return `${minutes}m ${seconds % 60}s`;
28+
} else {
29+
return `${seconds}s`;
30+
}
31+
}
32+
33+
function formatTimestamp(timestamp?: string): string {
34+
if (!timestamp) return 'N/A';
35+
const date = new Date(timestamp);
36+
return date.toLocaleString();
37+
}
1338

14-
const relevantAvailability = extension.availability.filter(avail => {
15-
const matchesFilters =
16-
(!filters.channel || avail.channel === filters.channel) &&
17-
(!filters.platform || avail.platform === filters.platform) &&
18-
(!filters.platformVersion || avail.platform_version === filters.platformVersion) &&
19-
(!filters.arch || avail.arch === filters.arch);
39+
function getStatusColor(status: string): string {
40+
switch (status) {
41+
case 'success':
42+
return 'var(--success)';
43+
case 'failure':
44+
return 'var(--danger)';
45+
case 'skipped':
46+
case 'unknown':
47+
return 'var(--gray-500)';
48+
default:
49+
return 'var(--gray-400)';
50+
}
51+
}
52+
53+
function copyToClipboard(text: string): Promise<boolean> {
54+
if (navigator.clipboard && window.isSecureContext) {
55+
return navigator.clipboard.writeText(text).then(() => true).catch(() => false);
56+
} else {
57+
// Fallback for older browsers
58+
const textArea = document.createElement('textarea');
59+
textArea.value = text;
60+
textArea.style.position = 'fixed';
61+
textArea.style.left = '-999999px';
62+
document.body.appendChild(textArea);
63+
textArea.focus();
64+
textArea.select();
65+
try {
66+
document.execCommand('copy');
67+
textArea.remove();
68+
return Promise.resolve(true);
69+
} catch (error) {
70+
textArea.remove();
71+
return Promise.resolve(false);
72+
}
73+
}
74+
}
75+
76+
export function CellDrawer({ extensionName, phpVersion, cell, onClose }: CellDrawerProps) {
77+
const [copySuccess, setCopySuccess] = useState(false);
78+
79+
useEffect(() => {
80+
const handleEscape = (e: KeyboardEvent) => {
81+
if (e.key === 'Escape') {
82+
onClose();
83+
}
84+
};
2085

21-
return matchesFilters && avail.php_versions.includes(phpVersion);
22-
});
86+
window.addEventListener('keydown', handleEscape);
87+
return () => window.removeEventListener('keydown', handleEscape);
88+
}, [onClose]);
89+
90+
if (!cell) {
91+
return (
92+
<div className="drawer-overlay" onClick={onClose}>
93+
<div className="drawer drawer-right" onClick={(e) => e.stopPropagation()}>
94+
<div className="drawer-header">
95+
<h2>{extensionName} - PHP {phpVersion}</h2>
96+
<button onClick={onClose} className="close-button" aria-label="Close drawer">×</button>
97+
</div>
98+
<div className="drawer-content">
99+
<p className="no-data-message">No build data available for this configuration.</p>
100+
</div>
101+
</div>
102+
</div>
103+
);
104+
}
105+
106+
const duration = formatDuration(cell.startedAt, cell.finishedAt);
107+
108+
const handleCopyPermalink = async () => {
109+
const url = new URL(window.location.href);
110+
url.hash = `${extensionName}-${phpVersion}`;
111+
const success = await copyToClipboard(url.toString());
112+
if (success) {
113+
setCopySuccess(true);
114+
setTimeout(() => setCopySuccess(false), 2000);
115+
}
116+
};
23117

24118
return (
25119
<div className="drawer-overlay" onClick={onClose}>
26-
<div className="drawer" onClick={(e) => e.stopPropagation()}>
120+
<div className="drawer drawer-right" onClick={(e) => e.stopPropagation()}>
27121
<div className="drawer-header">
28122
<h2>{extensionName} - PHP {phpVersion}</h2>
29-
<button onClick={onClose} className="close-button">×</button>
123+
<button onClick={onClose} className="close-button" aria-label="Close drawer">×</button>
30124
</div>
31125
<div className="drawer-content">
32-
{relevantAvailability.length === 0 ? (
33-
<p>Not available with current filters for PHP {phpVersion}</p>
34-
) : (
35-
<div className="availability-list">
36-
<h3>Available on:</h3>
37-
{relevantAvailability.map((avail, idx) => (
38-
<div key={idx} className="availability-item">
39-
<div><strong>Channel:</strong> {avail.channel}</div>
40-
<div><strong>Platform:</strong> {avail.platform}</div>
41-
<div><strong>Version:</strong> {avail.platform_version}</div>
42-
<div><strong>Architecture:</strong> {avail.arch}</div>
43-
<div><strong>PHP Versions:</strong> {avail.php_versions.join(', ')}</div>
126+
{/* Extension info */}
127+
<div className="detail-section">
128+
<h3>Extension</h3>
129+
<div className="detail-grid">
130+
<div className="detail-item">
131+
<span className="detail-label">Name</span>
132+
<span className="detail-value">{extensionName}</span>
133+
</div>
134+
{cell.extensionVersion && (
135+
<div className="detail-item">
136+
<span className="detail-label">Version</span>
137+
<span className="detail-value">{cell.extensionVersion}</span>
44138
</div>
45-
))}
139+
)}
140+
</div>
141+
</div>
142+
143+
{/* Build status */}
144+
<div className="detail-section">
145+
<h3>Build Status</h3>
146+
<div className="detail-grid">
147+
<div className="detail-item">
148+
<span className="detail-label">Status</span>
149+
<span
150+
className="detail-value status-badge"
151+
style={{ backgroundColor: getStatusColor(cell.status), color: 'white' }}
152+
>
153+
{cell.status}
154+
</span>
155+
</div>
156+
{cell.category && (
157+
<div className="detail-item">
158+
<span className="detail-label">Category</span>
159+
<span className="detail-value">{cell.category}</span>
160+
</div>
161+
)}
162+
</div>
163+
</div>
164+
165+
{/* Timestamps */}
166+
{(cell.startedAt || cell.finishedAt) && (
167+
<div className="detail-section">
168+
<h3>Timing</h3>
169+
<div className="detail-grid">
170+
{cell.startedAt && (
171+
<div className="detail-item">
172+
<span className="detail-label">Started</span>
173+
<span className="detail-value">{formatTimestamp(cell.startedAt)}</span>
174+
</div>
175+
)}
176+
{cell.finishedAt && (
177+
<div className="detail-item">
178+
<span className="detail-label">Finished</span>
179+
<span className="detail-value">{formatTimestamp(cell.finishedAt)}</span>
180+
</div>
181+
)}
182+
{duration && (
183+
<div className="detail-item">
184+
<span className="detail-label">Duration</span>
185+
<span className="detail-value">{duration}</span>
186+
</div>
187+
)}
188+
</div>
46189
</div>
47190
)}
191+
192+
{/* Links */}
193+
{(cell.runUrl || cell.artifactUrl) && (
194+
<div className="detail-section">
195+
<h3>Links</h3>
196+
<div className="detail-links">
197+
{cell.runUrl && (
198+
<a
199+
href={cell.runUrl}
200+
target="_blank"
201+
rel="noopener noreferrer"
202+
className="detail-link"
203+
>
204+
<span className="link-icon">🔗</span>
205+
View Workflow Run
206+
</a>
207+
)}
208+
{cell.artifactUrl && (
209+
<a
210+
href={cell.artifactUrl}
211+
target="_blank"
212+
rel="noopener noreferrer"
213+
className="detail-link"
214+
>
215+
<span className="link-icon">📦</span>
216+
Download Artifact
217+
</a>
218+
)}
219+
</div>
220+
</div>
221+
)}
222+
223+
{/* Copy permalink */}
224+
<div className="detail-section">
225+
<button
226+
onClick={handleCopyPermalink}
227+
className="copy-permalink-button"
228+
aria-label="Copy permalink to this cell"
229+
>
230+
{copySuccess ? '✓ Copied!' : '🔗 Copy Permalink'}
231+
</button>
232+
</div>
48233
</div>
49234
</div>
50235
</div>

0 commit comments

Comments
 (0)