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
826 changes: 717 additions & 109 deletions src/features/demo-admin-dashboard/DemoAdminDashboard.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/features/demo-admin-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ npx vitest run src/features/demo-admin-dashboard/__tests__/layout.test.ts
```

The fixture data in `fixtures/demoData.ts` is deterministic, fake, and safe for public repository review.
=======

This folder is the implementation boundary for the admin dashboard used to populate and manage demo data in the Stealth demo inbox UI.

Contributors working on demo-admin issues should keep new dashboard code, local state helpers, fixtures, validators, UI components, test utilities, and documentation inside:
Expand Down
67 changes: 67 additions & 0 deletions src/features/demo-admin-dashboard/__tests__/AdminDataTable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { sortData } from "../components/AdminDataTable";

interface TestItem {
id: number;
name: string;
count: number;
optional?: string | null;
}

describe("AdminDataTable sortData helper", () => {
const items: TestItem[] = [
{ id: 1, name: "Charlie", count: 15, optional: "Yes" },
{ id: 2, name: "Alice", count: 42, optional: null },
{ id: 3, name: "Bob", count: 7, optional: "No" },
];

it("returns original data if sortKey is null", () => {
const result = sortData(items, null, "asc");
expect(result).toEqual(items);
// Should be a reference match (or at least return the exact same array/elements)
expect(result).toBe(items);
});

it("sorts string fields in ascending order", () => {
const result = sortData(items, "name", "asc");
expect(result.map((item) => item.name)).toEqual(["Alice", "Bob", "Charlie"]);
});

it("sorts string fields in descending order", () => {
const result = sortData(items, "name", "desc");
expect(result.map((item) => item.name)).toEqual(["Charlie", "Bob", "Alice"]);
});

it("sorts numeric fields in ascending order", () => {
const result = sortData(items, "count", "asc");
expect(result.map((item) => item.count)).toEqual([7, 15, 42]);
});

it("sorts numeric fields in descending order", () => {
const result = sortData(items, "count", "desc");
expect(result.map((item) => item.count)).toEqual([42, 15, 7]);
});

it("handles null/undefined values by treating them as empty strings", () => {
const result = sortData(items, "optional", "asc");
// null optional value (Alice) should sort first in ascending
expect(result[0].name).toBe("Alice");
});

it("uses custom sortValue function if provided", () => {
const column = {
key: "name",
header: "Name",
sortValue: (row: TestItem) => row.name.length,
};
// Charlie (7), Alice (5), Bob (3)
const result = sortData(items, "name", "asc", column);
expect(result.map((item) => item.name)).toEqual(["Bob", "Alice", "Charlie"]);
});

it("does not mutate the original array", () => {
const itemsCopy = [...items];
sortData(items, "name", "asc");
expect(items).toEqual(itemsCopy);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,19 @@ const mockStorage = (() => {
};
})();

// Helper to attach mock to global window
// Helper to attach mock to global window and globalThis
function setWindowStorage() {
// @ts-ignore
global.window = { localStorage: mockStorage } as any;
// @ts-ignore
global.localStorage = mockStorage;
}

function clearWindowStorage() {
// @ts-ignore
delete (global as any).window;
// @ts-ignore
delete (global as any).localStorage;
}

describe('localStorageAdapter', () => {
Expand Down
126 changes: 126 additions & 0 deletions src/features/demo-admin-dashboard/__tests__/presets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { PRESET_SCENARIOS } from "../fixtures/presets";

describe("demo admin dashboard presets", () => {
it("defines the three required scenario flows", () => {
const ids = PRESET_SCENARIOS.map((p) => p.id);
expect(ids).toContain("relay-verification");
expect(ids).toContain("proof-pending");
expect(ids).toContain("receipt-settlement");
expect(PRESET_SCENARIOS.length).toBe(3);
});

it("contains deterministic and valid stats, accounts, mail, and audit logs for each scenario", () => {
for (const scenario of PRESET_SCENARIOS) {
expect(scenario.name.trim()).not.toBe("");
expect(scenario.description.trim()).not.toBe("");
expect(scenario.stats.length).toBeGreaterThan(0);
expect(scenario.accounts.length).toBeGreaterThan(0);
expect(scenario.mail.length).toBeGreaterThan(0);
expect(scenario.attachments.length).toBeGreaterThan(0);
expect(scenario.events.length).toBeGreaterThan(0);
expect(scenario.auditEvents.length).toBeGreaterThan(0);

// Verify stats shape
for (const stat of scenario.stats) {
expect(stat.label.trim()).not.toBe("");
expect(stat.value.trim()).not.toBe("");
}

// Verify accounts shape
for (const acct of scenario.accounts) {
expect(acct.name.trim()).not.toBe("");
expect(acct.address.trim()).not.toBe("");
expect(acct.balance.trim()).not.toBe("");
expect(acct.type.trim()).not.toBe("");
}

// Verify attachments shape
for (const att of scenario.attachments) {
expect(att.id.trim()).not.toBe("");
expect(att.fileName.trim()).not.toBe("");
expect(att.fileSize.trim()).not.toBe("");
expect(att.fileType.trim()).not.toBe("");
expect(att.messageSubject.trim()).not.toBe("");
expect(att.sender.trim()).not.toBe("");
}

// Verify events shape
for (const evt of scenario.events) {
expect(evt.id.trim()).not.toBe("");
expect(evt.title.trim()).not.toBe("");
expect(evt.date.trim()).not.toBe("");
expect(evt.time.trim()).not.toBe("");
expect(evt.location.trim()).not.toBe("");
expect(evt.organizer.trim()).not.toBe("");
expect(["confirmed", "tentative", "cancelled"]).toContain(evt.status);
}

// Verify audit events shape
for (const event of scenario.auditEvents) {
expect(event.action.trim()).not.toBe("");
expect(event.actor.trim()).not.toBe("");
expect(event.timestamp.trim()).not.toBe("");
}
}
});

it("uses only safe, fake demo emails", () => {
for (const scenario of PRESET_SCENARIOS) {
for (const item of scenario.mail) {
expect(item.email).toMatch(/(\*stealth\.demo|@example\.(com|org))$/);
}
for (const item of scenario.attachments) {
if (item.sender.includes("@") || item.sender.includes("*")) {
expect(item.sender).toMatch(/(\*stealth\.demo|@example\.(com|org))$/);
}
}
for (const item of scenario.events) {
if (item.organizer.includes("@") || item.organizer.includes("*")) {
expect(item.organizer).toMatch(/(\*stealth\.demo|@example\.(com|org))$/);
}
}
}
});

it("attaches required relay verification metadata in relay verification scenario", () => {
const relayVerification = PRESET_SCENARIOS.find((p) => p.id === "relay-verification");
expect(relayVerification).toBeDefined();

const pendingRelay = relayVerification?.accounts.find((a) => a.name === "Relay Node 07");
expect(pendingRelay).toBeDefined();
expect(pendingRelay?.relayMetadata).toBeDefined();
expect(pendingRelay?.relayMetadata?.status).toBe("pending");
expect(pendingRelay?.relayMetadata?.nodeUri).toBe("relay07*stealth.demo");

const verificationMail = relayVerification?.mail.find((m) => m.subject === "Your relay verification code");
expect(verificationMail).toBeDefined();
expect(verificationMail?.status).toBe("pending");
expect(verificationMail?.folder).toBe("pending");
expect(verificationMail?.proofMetadata).toBeDefined();
});

it("attaches required proof pending metadata in proof pending scenario", () => {
const proofPending = PRESET_SCENARIOS.find((p) => p.id === "proof-pending");
expect(proofPending).toBeDefined();

const pendingMail = proofPending?.mail.find((m) => m.subject === "Soroban proof generation pending");
expect(pendingMail).toBeDefined();
expect(pendingMail?.status).toBe("pending");
expect(pendingMail?.folder).toBe("pending");
expect(pendingMail?.proofMetadata).toBeDefined();
expect(pendingMail?.proofMetadata?.postageStatus).toBe("pending");
});

it("attaches required receipt settlement metadata in receipt settlement scenario", () => {
const receiptSettlement = PRESET_SCENARIOS.find((p) => p.id === "receipt-settlement");
expect(receiptSettlement).toBeDefined();

const deliveryReceiptMail = receiptSettlement?.mail.find((m) => m.subject === "Delivery receipt settled");
expect(deliveryReceiptMail).toBeDefined();
expect(deliveryReceiptMail?.status).toBe("delivered");
expect(deliveryReceiptMail?.folder).toBe("receipts");
expect(deliveryReceiptMail?.proofMetadata).toBeDefined();
expect(deliveryReceiptMail?.proofMetadata?.postageStatus).toBe("settled");
});
});
154 changes: 154 additions & 0 deletions src/features/demo-admin-dashboard/components/AdminDataTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import React, { useState, useMemo } from "react";
import { ArrowUpDown, ChevronUp, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";

export interface Column<T> {
key: string;
header: string;
sortable?: boolean;
sortValue?: (row: T) => string | number | boolean;
render?: (row: T) => React.ReactNode;
}

interface AdminDataTableProps<T> {
data: T[];
columns: Column<T>[];
onRowClick?: (row: T) => void;
selectedRowKey?: (row: T) => boolean;
defaultSortKey?: string;
defaultSortDirection?: "asc" | "desc";
emptyMessage?: string;
className?: string;
}

/**
* Pure helper function to sort data based on a key and direction.
*/
export function sortData<T>(
data: T[],
sortKey: string | null,
sortDirection: "asc" | "desc",
column?: Column<T>
): T[] {
if (!sortKey) return data;

return [...data].sort((a, b) => {
let valA: any = column?.sortValue ? column.sortValue(a) : (a as any)[sortKey];
let valB: any = column?.sortValue ? column.sortValue(b) : (b as any)[sortKey];

if (valA === undefined || valA === null) valA = "";
if (valB === undefined || valB === null) valB = "";

if (typeof valA === "string" && typeof valB === "string") {
return sortDirection === "asc"
? valA.localeCompare(valB)
: valB.localeCompare(valA);
}

if (valA < valB) return sortDirection === "asc" ? -1 : 1;
if (valA > valB) return sortDirection === "asc" ? 1 : -1;
return 0;
});
}

/**
* Reusable, sortable table component for the Demo Admin Dashboard.
* Confined to displaying messages, senders, attachments, events, and audit entries.
*/
export function AdminDataTable<T>({
data,
columns,
onRowClick,
selectedRowKey,
defaultSortKey,
defaultSortDirection = "asc",
emptyMessage = "No records found.",
className,
}: AdminDataTableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(defaultSortKey || null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">(defaultSortDirection);

const handleSort = (key: string) => {
if (sortKey === key) {
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDirection("asc");
}
};

const sortedData = useMemo(() => {
const col = columns.find((c) => c.key === sortKey);
return sortData(data, sortKey, sortDirection, col);
}, [data, sortKey, sortDirection, columns]);

return (
<div className={cn("overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.01]", className)}>
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="border-b border-white/[0.06] bg-white/[0.02]">
{columns.map((col) => {
const isSorted = sortKey === col.key;
return (
<th
key={col.key}
onClick={() => col.sortable && handleSort(col.key)}
className={cn(
"px-4 py-3 font-medium text-muted-foreground select-none",
col.sortable ? "cursor-pointer hover:text-foreground transition-colors" : ""
)}
>
<div className="flex items-center gap-1">
<span>{col.header}</span>
{col.sortable && (
<span className="inline-flex text-muted-foreground/60">
{!isSorted ? (
<ArrowUpDown className="h-3 w-3" />
) : sortDirection === "asc" ? (
<ChevronUp className="h-3.5 w-3.5 text-amber-400" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-amber-400" />
)}
</span>
)}
</div>
</th>
);
})}
</tr>
</thead>
<tbody>
{sortedData.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-8 text-center text-muted-foreground">
{emptyMessage}
</td>
</tr>
) : (
sortedData.map((row, i) => {
const isSelected = selectedRowKey ? selectedRowKey(row) : false;
const isClickable = !!onRowClick;
return (
<tr
key={i}
onClick={() => onRowClick && onRowClick(row)}
className={cn(
"border-b border-white/[0.04] last:border-0 transition-colors",
isClickable ? "cursor-pointer hover:bg-white/[0.02]" : "",
isSelected ? "bg-white/[0.04]" : ""
)}
>
{columns.map((col) => (
<td key={col.key} className="px-4 py-3 text-foreground align-middle">
{col.render ? col.render(row) : String((row as any)[col.key] ?? "")}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
);
}
Loading