From 9954717d69114d626d6acf754ff3fcac35633804 Mon Sep 17 00:00:00 2001 From: Iye Date: Wed, 24 Jun 2026 15:10:35 +0000 Subject: [PATCH] feat(dashboard): create export history page with pagination, status, and download actions --- dashboard/package-lock.json | 45 --- dashboard/src/App.tsx | 26 +- dashboard/src/index.css | 236 ++++++++++++++ .../src/pages/ExportHistoryPage.test.tsx | 65 ++++ dashboard/src/pages/ExportHistoryPage.tsx | 296 ++++++++++++++++++ dashboard/src/utils/exportData.ts | 65 ++++ 6 files changed, 687 insertions(+), 46 deletions(-) create mode 100644 dashboard/src/pages/ExportHistoryPage.test.tsx create mode 100644 dashboard/src/pages/ExportHistoryPage.tsx create mode 100644 dashboard/src/utils/exportData.ts diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index cf25acd..c69f122 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -860,21 +860,6 @@ } } }, - "node_modules/@creit.tech/stellar-wallets-kit/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/@creit.tech/xbull-wallet-connect": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@creit.tech/xbull-wallet-connect/-/xbull-wallet-connect-0.4.0.tgz", @@ -7772,21 +7757,6 @@ "ws": "^7.5.1" } }, - "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", @@ -12904,21 +12874,6 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 2443663..896b2f4 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,9 +1,33 @@ +import { useState } from 'react'; import { EventExplorerPage } from './pages/EventExplorerPage'; +import { ExportHistoryPage } from './pages/ExportHistoryPage'; export function App() { + const [activeTab, setActiveTab] = useState<'explorer' | 'exports'>('explorer'); + return (
- + + + {activeTab === 'explorer' ? : }
); } diff --git a/dashboard/src/index.css b/dashboard/src/index.css index f3b7ac7..a234a19 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -758,3 +758,239 @@ body { display: none; } } + +/* ─── Navigation & Export Center Styles ────────────────────────── */ + +.nav-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding-bottom: 12px; +} + +.nav-brand { + font-weight: 700; + font-size: 1.25rem; + color: #38bdf8; +} + +.nav-tabs { + display: flex; + gap: 8px; +} + +.nav-tab-btn { + background: transparent; + border: none; + color: #9aa0a6; + padding: 8px 16px; + font-size: 0.9rem; + font-weight: 500; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.nav-tab-btn:hover { + color: #e8eaed; + background: rgba(255, 255, 255, 0.04); +} + +.nav-tab-btn--active { + color: #0b0d12; + background: #38bdf8; +} + +.nav-tab-btn--active:hover { + color: #0b0d12; + background: #7dd3fc; +} + +.export-history-page { + padding: 8px 0; +} + +.export-history__header { + display: flex; + justify-content: space-between; + margin-bottom: 32px; + gap: 20px; +} + +.export-history__eyebrow { + color: #38bdf8; + text-transform: uppercase; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.1em; + margin: 0 0 8px; +} + +.export-history__lead { + color: #9aa0a6; + max-width: 600px; + margin: 8px 0 0; +} + +.export-table-container { + overflow-x: auto; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.01); + margin-bottom: 24px; +} + +.export-table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +.export-table th { + padding: 16px; + font-size: 0.85rem; + color: #9aa0a6; + font-weight: 500; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.02); +} + +.export-table td { + padding: 16px; + font-size: 0.9rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.export-table__row:hover { + background: rgba(255, 255, 255, 0.02); +} + +.export-table__cell-id { + font-family: monospace; + color: #9aa0a6; +} + +.export-table__cell-name { + font-weight: 500; + color: #e8eaed; +} + +.export-table__cell-date { + color: #9aa0a6; +} + +.export-table__cell-numeric { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.format-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; +} + +.format-badge--csv { + background: rgba(16, 185, 129, 0.1); + color: #10b981; +} + +.format-badge--json { + background: rgba(245, 158, 11, 0.1); + color: #f59e0b; +} + +.format-badge--pdf { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; +} + +.status-badge__dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.status-badge--completed { + background: rgba(16, 185, 129, 0.1); + color: #10b981; +} + +.status-badge--completed .status-badge__dot { + background: #10b981; + box-shadow: 0 0 8px #10b981; +} + +.status-badge--processing { + background: rgba(59, 130, 246, 0.1); + color: #3b82f6; +} + +.status-badge--processing .status-badge__dot { + background: #3b82f6; + animation: pulse-badge-animation 1.5s infinite alternate; +} + +.status-badge--failed { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +.status-badge--failed .status-badge__dot { + background: #ef4444; +} + +@keyframes pulse-badge-animation { + 0% { + transform: scale(0.8); + opacity: 0.5; + } + 100% { + transform: scale(1.2); + opacity: 1; + box-shadow: 0 0 8px #3b82f6; + } +} + +.export-action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: #1e293b; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + padding: 6px 12px; + color: #e8eaed; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.export-action-btn:hover:not(:disabled) { + background: #334155; + border-color: rgba(255, 255, 255, 0.16); +} + +.export-action-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.download-icon { + width: 14px; + height: 14px; +} diff --git a/dashboard/src/pages/ExportHistoryPage.test.tsx b/dashboard/src/pages/ExportHistoryPage.test.tsx new file mode 100644 index 0000000..e110257 --- /dev/null +++ b/dashboard/src/pages/ExportHistoryPage.test.tsx @@ -0,0 +1,65 @@ +import '@testing-library/jest-dom'; +import { render, fireEvent } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import { ExportHistoryPage } from './ExportHistoryPage'; + +expect.extend(toHaveNoViolations); + +test('ExportHistoryPage has no accessibility violations', async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); + +test('ExportHistoryPage renders correctly and lists mock exports', () => { + const { getByText, getByRole, getAllByRole } = render(); + + // Header text + expect(getByText('Notification Export History')).toBeInTheDocument(); + expect(getByText(/Manage, filter, and download/)).toBeInTheDocument(); + + // Table + expect(getByRole('table')).toBeInTheDocument(); + + // First page mock rows (limit is 5) + const rows = getAllByRole('row'); + // 1 header row + 5 data rows = 6 rows total + expect(rows).toHaveLength(6); +}); + +test('ExportHistoryPage search and filtering works', () => { + const { getByLabelText, queryByText, getByText } = render(); + + // Search for "System Alert" which exists in the mock list + const searchInput = getByLabelText('Search Exports'); + fireEvent.change(searchInput, { target: { value: 'System Alert' } }); + + // Should see it + expect(getByText('System Alert Notification logs')).toBeInTheDocument(); + + // Should NOT see other items + expect(queryByText('Monthly billing export')).not.toBeInTheDocument(); +}); + +test('ExportHistoryPage pagination limit and page switching works', () => { + const { getByLabelText, getByText, queryByText } = render(); + + // Initially we are on page 1 of 3 (15 items total, limit 5) + expect(getByText('Page 1 of 3')).toBeInTheDocument(); + expect(getByText('15 total export records')).toBeInTheDocument(); + expect(getByText('System Alert Notification logs')).toBeInTheDocument(); + + // Click Next + const nextBtn = getByText('Next'); + fireEvent.click(nextBtn); + + expect(getByText('Page 2 of 3')).toBeInTheDocument(); + expect(queryByText('System Alert Notification logs')).not.toBeInTheDocument(); + + // Change limit to 10 + const selectLimit = getByLabelText('Show'); + fireEvent.change(selectLimit, { target: { value: '10' } }); + + // Pages should recalculate to page 1 of 2 + expect(getByText('Page 1 of 2')).toBeInTheDocument(); +}); diff --git a/dashboard/src/pages/ExportHistoryPage.tsx b/dashboard/src/pages/ExportHistoryPage.tsx new file mode 100644 index 0000000..2c722e8 --- /dev/null +++ b/dashboard/src/pages/ExportHistoryPage.tsx @@ -0,0 +1,296 @@ +import { useState, useMemo, useEffect } from 'react'; +import { generateMockExports, type NotificationExport } from '../utils/exportData'; +import { WalletConnectButton } from '../components/WalletConnectButton'; + +export function ExportHistoryPage() { + const [exports, setExports] = useState([]); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(5); + + useEffect(() => { + setExports(generateMockExports()); + }, []); + + // Filter exports + const filteredExports = useMemo(() => { + return exports.filter((item) => { + const matchesSearch = item.name.toLowerCase().includes(search.toLowerCase()) || + item.id.toLowerCase().includes(search.toLowerCase()) || + item.format.toLowerCase().includes(search.toLowerCase()); + + const matchesStatus = statusFilter === 'all' || item.status.toLowerCase() === statusFilter.toLowerCase(); + + return matchesSearch && matchesStatus; + }); + }, [exports, search, statusFilter]); + + // Reset page when filters change + useEffect(() => { + setPage(1); + }, [search, statusFilter]); + + // Calculate pagination + const totalCount = filteredExports.length; + const pageCount = Math.max(1, Math.ceil(totalCount / limit)); + const displayedExports = useMemo(() => { + const startIndex = (page - 1) * limit; + return filteredExports.slice(startIndex, startIndex + limit); + }, [filteredExports, page, limit]); + + // Adjust page if it exceeds pageCount + useEffect(() => { + if (page > pageCount) { + setPage(pageCount); + } + }, [pageCount, page]); + + // Format date helper + const formatDate = (timestamp: number) => { + return new Date(timestamp).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true, + }); + }; + + // Trigger download action + const handleDownload = (item: NotificationExport) => { + if (item.status !== 'Completed') return; + + let fileContent = ''; + let mimeType = 'text/plain'; + let fileExtension = 'txt'; + + if (item.format === 'JSON') { + fileContent = JSON.stringify( + { + export_id: item.id, + export_name: item.name, + format: item.format, + created_at: new Date(item.createdAt).toISOString(), + record_count: item.recordCount, + records: Array.from({ length: 5 }, (_, i) => ({ + id: `notif-${2000 + i}`, + contract: 'CCEMX6Q5V5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5', + event_name: i % 2 === 0 ? 'NotificationScheduled' : 'NotificationExpired', + timestamp: new Date(item.createdAt - i * 15 * 60 * 1000).toISOString(), + status: 'Delivered', + })), + }, + null, + 2 + ); + mimeType = 'application/json'; + fileExtension = 'json'; + } else if (item.format === 'CSV') { + fileContent = + 'ID,Contract Address,Event Name,Timestamp,Status\n' + + Array.from({ length: 5 }, (_, i) => + `notif-${2000 + i},CCEMX6Q5V5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5,${ + i % 2 === 0 ? 'NotificationScheduled' : 'NotificationExpired' + },${new Date(item.createdAt - i * 15 * 60 * 1000).toISOString()},Delivered` + ).join('\n'); + mimeType = 'text/csv'; + fileExtension = 'csv'; + } else { + fileContent = `Notify-Chain Notification Export Report\n` + + `========================================\n` + + `Export ID: ${item.id}\n` + + `Export Name: ${item.name}\n` + + `Date Generated: ${formatDate(item.createdAt)}\n` + + `Total Records: ${item.recordCount}\n` + + `File Size: ${item.fileSize}\n` + + `========================================\n` + + `* This is a mock PDF export file representation *`; + mimeType = 'text/plain'; + fileExtension = 'txt'; + } + + const blob = new Blob([fileContent], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${item.name.toLowerCase().replace(/[^a-z0-9]+/g, '_')}_${item.id}.${fileExtension}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + return ( +
+
+
+

Export Center

+

Notification Export History

+

+ Manage, filter, and download your previously generated notification and smart contract event export records. +

+
+ +
+ + {/* Filters Section */} +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ + +
+
+ + {/* Table Section */} +
+ {displayedExports.length > 0 ? ( + + + + + + + + + + + + + + + {displayedExports.map((item) => ( + + + + + + + + + + + ))} + +
IDNameFormatCreated AtRecordsSizeStatusActions
{item.id}{item.name} + + {item.format} + + {formatDate(item.createdAt)}{item.recordCount.toLocaleString()}{item.fileSize} + + + {item.status} + + + +
+ ) : ( +
+

No export records found

+

Try modifying your search query or status filter to locate matching exports.

+
+ )} +
+ + {/* Custom Pagination */} +
+
+ + Page {page} of {pageCount} + + {totalCount.toLocaleString()} total export records +
+ +
+ + + + + + +
+
+
+ ); +} diff --git a/dashboard/src/utils/exportData.ts b/dashboard/src/utils/exportData.ts new file mode 100644 index 0000000..00ea045 --- /dev/null +++ b/dashboard/src/utils/exportData.ts @@ -0,0 +1,65 @@ +export interface NotificationExport { + id: string; + name: string; + format: 'CSV' | 'JSON' | 'PDF'; + status: 'Completed' | 'Processing' | 'Failed'; + createdAt: number; + recordCount: number; + fileSize: string; +} + +export function generateMockExports(): NotificationExport[] { + const baseTime = 1782399000000; // Represents roughly mid 2026 + const formats: ('CSV' | 'JSON' | 'PDF')[] = ['CSV', 'JSON', 'PDF']; + + const names = [ + 'System Alert Notification logs', + 'Monthly billing export', + 'Contract event dispatch history', + 'Urgent error broadcast records', + 'Stellar network sync logs', + 'User activity digest export', + 'AutoShare usage tracking audit', + 'Revocation history summary', + 'Priority dispatch queue telemetry', + 'Security auditing report', + 'Deduplication database logs', + 'Webhook delivery metrics', + 'Client preferences dump', + 'API access token usage report', + 'Failure recovery logs' + ]; + + return names.map((name, index) => { + // Determine status (mostly completed, some processing/failed for realism) + let status: 'Completed' | 'Processing' | 'Failed' = 'Completed'; + if (index === 1) { + status = 'Processing'; + } else if (index === 5) { + status = 'Failed'; + } else if (index === 8) { + status = 'Processing'; + } + + // Determine format + const format = formats[index % formats.length]; + + // Determine size and count + const recordCount = (index + 1) * 384 + (index % 3) * 12; + const fileSize = status === 'Failed' + ? '0 KB' + : status === 'Processing' + ? '--' + : `${((recordCount * 0.15) + (index % 5)).toFixed(1)} KB`; + + return { + id: `exp-${1000 + index}`, + name, + format, + status, + createdAt: baseTime - index * 3 * 3600 * 1000 - (index % 5) * 15 * 60 * 1000, + recordCount: status === 'Failed' ? 0 : recordCount, + fileSize + }; + }); +}