Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion frontend/src/components/EmployeeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,57 @@ interface Employee {

interface EmployeeListProps {
employees: Employee[];
isLoading?: boolean;
onEmployeeClick?: (employee: Employee) => void;
onAddEmployee: (employee: Employee) => void;
onEditEmployee?: (employee: Employee) => void;
onRemoveEmployee?: (id: string) => void;
onUpdateEmployeeImage?: (id: string, imageUrl: string) => void;
}

const SKELETON_ROW_COUNT = 5;

const EmployeeSkeletonRow: React.FC = () => (
<tr className="animate-pulse border-b border-gray-200/20">
{/* Name column */}
<td className="p-6">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-300/30 shrink-0" />
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
<div className="h-2.5 rounded bg-gray-300/30 w-3/4" />
<div className="h-2 rounded bg-gray-300/20 w-1/2" />
</div>
</div>
</td>
{/* Role */}
<td className="p-6">
<div className="h-2.5 rounded bg-gray-300/30 w-2/3" />
</td>
{/* Wallet */}
<td className="p-6">
<div className="h-2.5 rounded bg-gray-300/20 w-3/4 font-mono" />
</td>
{/* Salary */}
<td className="p-6">
<div className="h-2.5 rounded bg-gray-300/30 w-1/2" />
</td>
{/* Status */}
<td className="p-6">
<div className="h-5 rounded-full bg-gray-300/20 w-16" />
</td>
{/* Actions */}
<td className="p-6">
<div className="flex gap-2">
<div className="w-5 h-5 rounded bg-gray-300/20" />
<div className="w-5 h-5 rounded bg-gray-300/20" />
</div>
</td>
</tr>
);

export const EmployeeList: React.FC<EmployeeListProps> = ({
employees,
isLoading = false,
onAddEmployee,
onEditEmployee,
onRemoveEmployee,
Expand Down Expand Up @@ -212,7 +254,11 @@ export const EmployeeList: React.FC<EmployeeListProps> = ({
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{sortedEmployees.length === 0 ? (
{isLoading ? (
Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => (
<EmployeeSkeletonRow key={i} />
))
) : sortedEmployees.length === 0 ? (
<tr>
<td colSpan={6} className="p-6 text-center text-gray-500">
{debouncedSearch ? `No employees match "${debouncedSearch}"` : 'No employees found'}
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/components/FeeEstimationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -141,7 +142,13 @@ export const FeeEstimationPanel: React.FC = () => {
</div>

<div className={styles.statRow}>
<span className={styles.statLabel}>{t('feeEstimation.lastLedger')}</span>
<span className={styles.statLabel}>
{t('feeEstimation.lastLedger')}
<InfoTooltip
label="What is a Ledger Sequence?"
content="The Ledger Sequence (or ledger number) identifies the latest confirmed block on the Stellar network. Each ledger closes roughly every 5 seconds and bundles a set of transactions. Higher numbers mean a more recent ledger."
/>
</span>
<span className={styles.statValue}>
#{feeRecommendation.lastLedger.toLocaleString()}
</span>
Expand Down
68 changes: 68 additions & 0 deletions frontend/src/components/InfoTooltip.tsx
Original file line number Diff line number Diff line change
@@ -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<InfoTooltipProps> = ({
content,
label = 'More information',
}) => {
const [visible, setVisible] = useState(false);
const tooltipRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(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 (
<span className="relative inline-flex items-center">
<button
ref={triggerRef}
type="button"
aria-label={label}
aria-expanded={visible}
aria-haspopup="true"
onClick={() => setVisible((v) => !v)}
onFocus={() => setVisible(true)}
onBlur={() => setVisible(false)}
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
className="ml-1 rounded-full text-(--muted) hover:text-(--accent) focus:outline-none focus:ring-2 focus:ring-(--accent) transition"
>
<Info className="w-3.5 h-3.5" aria-hidden="true" />
</button>

{visible && (
<div
ref={tooltipRef}
role="tooltip"
className="absolute left-5 top-0 z-50 w-64 rounded-lg border border-(--border-hi) bg-(--surface) p-3 text-xs text-(--text) shadow-lg"
>
{content}
</div>
)}
</span>
);
};
20 changes: 20 additions & 0 deletions frontend/src/components/__tests__/EmployeeList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<EmployeeList employees={[employee]} isLoading onAddEmployee={vi.fn()} />);

// 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(<EmployeeList employees={[]} isLoading={false} onAddEmployee={vi.fn()} />);
expect(screen.getByText('No employees found')).toBeTruthy();
});
});
40 changes: 40 additions & 0 deletions frontend/src/components/__tests__/InfoTooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<InfoTooltip content="Test explanation" />);
expect(screen.queryByRole('tooltip')).toBeNull();
});

test('shows tooltip content on mouse enter', () => {
render(<InfoTooltip content="ORGUSD is the org asset" label="What is ORGUSD?" />);
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(<InfoTooltip content="ORGUSD is the org asset" label="What is ORGUSD?" />);
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(<InfoTooltip content="Ledger explanation" label="What is a Ledger?" />);
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(<InfoTooltip content="Some info" />);
expect(screen.getByRole('button', { name: 'More information' })).toBeTruthy();
});
});
29 changes: 26 additions & 3 deletions frontend/src/pages/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -293,7 +294,13 @@ export default function AdminPanel() {

<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS}>Asset Code</label>
<label className={LABEL_CLASS}>
Asset Code
<InfoTooltip
label="What is ORGUSD?"
content="ORGUSD is the organisation's custom Stellar asset used for payroll. It is pegged to USD and issued by the organisation's issuer account on the Stellar network."
/>
</label>
<input
type="text"
value={accountAsset}
Expand Down Expand Up @@ -361,7 +368,13 @@ export default function AdminPanel() {
<div className="grid gap-4 mt-2">
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS}>Asset Code</label>
<label className={LABEL_CLASS}>
Asset Code
<InfoTooltip
label="What is ORGUSD?"
content="ORGUSD is the organisation's custom Stellar asset used for payroll. It is pegged to USD and issued by the organisation's issuer account on the Stellar network."
/>
</label>
<input
type="text"
value={globalAsset}
Expand Down Expand Up @@ -420,6 +433,10 @@ export default function AdminPanel() {
<div className="flex flex-col gap-6 max-w-2xl">
<h2 className="text-xl font-bold flex items-center gap-2">
<Search className="w-5 h-5 text-accent" /> Trustline Status
<InfoTooltip
label="What is a Trustline?"
content="A trustline is a permission on your Stellar account that allows it to hold a specific asset (e.g. ORGUSD). Employees must establish a trustline before they can receive payments. Each trustline reserves 0.5 XLM."
/>
</h2>
<p className="text-sm text-muted">
Verify whether an account's trustline is currently frozen for a given asset.
Expand All @@ -440,7 +457,13 @@ export default function AdminPanel() {

<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS}>Asset Code</label>
<label className={LABEL_CLASS}>
Asset Code
<InfoTooltip
label="What is ORGUSD?"
content="ORGUSD is the organisation's custom Stellar asset used for payroll. It is pegged to USD and issued by the organisation's issuer account on the Stellar network."
/>
</label>
<input
type="text"
value={statusAsset}
Expand Down
Loading