diff --git a/frontend/src/components/EmployeeList.tsx b/frontend/src/components/EmployeeList.tsx index 9167749..2b92767 100644 --- a/frontend/src/components/EmployeeList.tsx +++ b/frontend/src/components/EmployeeList.tsx @@ -20,6 +20,7 @@ interface Employee { interface EmployeeListProps { employees: Employee[]; + isLoading?: boolean; onEmployeeClick?: (employee: Employee) => void; onAddEmployee: (employee: Employee) => void; onEditEmployee?: (employee: Employee) => void; @@ -27,8 +28,49 @@ interface EmployeeListProps { onUpdateEmployeeImage?: (id: string, imageUrl: string) => void; } +const SKELETON_ROW_COUNT = 5; + +const EmployeeSkeletonRow: React.FC = () => ( + + {/* Name column */} + +
+
+
+
+
+
+
+ + {/* Role */} + +
+ + {/* Wallet */} + +
+ + {/* Salary */} + +
+ + {/* Status */} + +
+ + {/* Actions */} + +
+
+
+
+ + +); + export const EmployeeList: React.FC = ({ employees, + isLoading = false, onAddEmployee, onEditEmployee, onRemoveEmployee, @@ -212,7 +254,11 @@ export const EmployeeList: React.FC = ({ - {sortedEmployees.length === 0 ? ( + {isLoading ? ( + Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => ( + + )) + ) : sortedEmployees.length === 0 ? ( {debouncedSearch ? `No employees match "${debouncedSearch}"` : 'No employees found'} diff --git a/frontend/src/components/FeeEstimationPanel.tsx b/frontend/src/components/FeeEstimationPanel.tsx index 94ceb6f..aad42b3 100644 --- a/frontend/src/components/FeeEstimationPanel.tsx +++ b/frontend/src/components/FeeEstimationPanel.tsx @@ -12,6 +12,7 @@ import { useFeeEstimation } from '../hooks/useFeeEstimation'; import type { BatchBudgetEstimate } from '../services/feeEstimation'; import styles from './FeeEstimationPanel.module.css'; import { useTranslation } from 'react-i18next'; +import { InfoTooltip } from './InfoTooltip'; // --------------------------------------------------------------------------- // Sub‑components @@ -141,7 +142,13 @@ export const FeeEstimationPanel: React.FC = () => {
- {t('feeEstimation.lastLedger')} + + {t('feeEstimation.lastLedger')} + + #{feeRecommendation.lastLedger.toLocaleString()} diff --git a/frontend/src/components/InfoTooltip.tsx b/frontend/src/components/InfoTooltip.tsx new file mode 100644 index 0000000..2f7c85e --- /dev/null +++ b/frontend/src/components/InfoTooltip.tsx @@ -0,0 +1,68 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Info } from 'lucide-react'; + +interface InfoTooltipProps { + /** The explanation text shown in the tooltip. */ + content: string; + /** Optional accessible label for the trigger button. Defaults to "More information". */ + label?: string; +} + +/** + * A small ⓘ button that shows a descriptive tooltip when focused or hovered. + * Keyboard-accessible and screen-reader friendly. + */ +export const InfoTooltip: React.FC = ({ + content, + label = 'More information', +}) => { + const [visible, setVisible] = useState(false); + const tooltipRef = useRef(null); + const triggerRef = useRef(null); + + // Close tooltip on outside click + useEffect(() => { + if (!visible) return; + const handleClick = (e: MouseEvent) => { + if ( + tooltipRef.current && + !tooltipRef.current.contains(e.target as Node) && + !triggerRef.current?.contains(e.target as Node) + ) { + setVisible(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [visible]); + + return ( + + + + {visible && ( +
+ {content} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/__tests__/EmployeeList.test.tsx b/frontend/src/components/__tests__/EmployeeList.test.tsx index 022464a..ad7a294 100644 --- a/frontend/src/components/__tests__/EmployeeList.test.tsx +++ b/frontend/src/components/__tests__/EmployeeList.test.tsx @@ -40,4 +40,24 @@ describe('EmployeeList', () => { expect(email).toHaveAttribute('title', employee.email); expect(email.className).toContain('truncate'); }); + + test('renders skeleton rows and hides employee data while loading', () => { + render(); + + // Employee data must not be visible during loading + expect(screen.queryByLabelText(`Employee name: ${employee.name}`)).toBeNull(); + expect(screen.queryByLabelText(`Employee email: ${employee.email}`)).toBeNull(); + + // Skeleton rows are rendered with pulse animation + const rows = document.querySelectorAll('tbody tr'); + expect(rows.length).toBe(5); + rows.forEach((row) => { + expect(row.className).toContain('animate-pulse'); + }); + }); + + test('renders empty state message when not loading and no employees exist', () => { + render(); + expect(screen.getByText('No employees found')).toBeTruthy(); + }); }); diff --git a/frontend/src/components/__tests__/InfoTooltip.test.tsx b/frontend/src/components/__tests__/InfoTooltip.test.tsx new file mode 100644 index 0000000..4ac580d --- /dev/null +++ b/frontend/src/components/__tests__/InfoTooltip.test.tsx @@ -0,0 +1,40 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { InfoTooltip } from '../InfoTooltip'; + +describe('InfoTooltip', () => { + test('does not show tooltip content initially', () => { + render(); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); + + test('shows tooltip content on mouse enter', () => { + render(); + const button = screen.getByRole('button', { name: 'What is ORGUSD?' }); + fireEvent.mouseEnter(button); + expect(screen.getByRole('tooltip')).toBeTruthy(); + expect(screen.getByText('ORGUSD is the org asset')).toBeTruthy(); + }); + + test('hides tooltip content on mouse leave', () => { + render(); + const button = screen.getByRole('button', { name: 'What is ORGUSD?' }); + fireEvent.mouseEnter(button); + fireEvent.mouseLeave(button); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); + + test('shows tooltip on focus and hides on blur', () => { + render(); + const button = screen.getByRole('button', { name: 'What is a Ledger?' }); + fireEvent.focus(button); + expect(screen.getByRole('tooltip')).toBeTruthy(); + fireEvent.blur(button); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); + + test('uses default label when none provided', () => { + render(); + expect(screen.getByRole('button', { name: 'More information' })).toBeTruthy(); + }); +}); diff --git a/frontend/src/pages/AdminPanel.tsx b/frontend/src/pages/AdminPanel.tsx index 07ca481..c643b20 100644 --- a/frontend/src/pages/AdminPanel.tsx +++ b/frontend/src/pages/AdminPanel.tsx @@ -13,6 +13,7 @@ import { useNotification } from '../hooks/useNotification'; import { useWallet } from '../hooks/useWallet'; import ContractUpgradeTab from '../components/ContractUpgradeTab'; import MultisigDetector from '../components/MultisigDetector'; +import { InfoTooltip } from '../components/InfoTooltip'; /** Centralized API base so URL changes happen in one place. */ const API_BASE = '/api/v1'; @@ -293,7 +294,13 @@ export default function AdminPanel() {
- +
- +

Trustline Status +

Verify whether an account's trustline is currently frozen for a given asset. @@ -440,7 +457,13 @@ export default function AdminPanel() {

- +