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
67 changes: 67 additions & 0 deletions src/components/ReputationExportButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { exportReputationHistory } from '@/lib/reputationExport';
import type { ReputationEvent } from '@/components/ReputationProfile';

interface ReputationExportButtonProps {
events: ReputationEvent[];
filename?: string;
}

export default function ReputationExportButton({
events,
filename = 'reputation-history',
}: ReputationExportButtonProps) {
const [open, setOpen] = useState(false);
const disabled = events.length === 0;

return (
<div className="relative">
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label="Export reputation history"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
onBlur={() => setOpen(false)}
className="rounded-2xl border border-[var(--border)] bg-[var(--card)] px-3 py-2 text-sm font-semibold text-[var(--foreground)] transition hover:border-[var(--muted-foreground)] disabled:cursor-not-allowed disabled:opacity-50"
>
Export all
</button>
{open && !disabled && (
<ul
role="menu"
aria-label="Export format"
className="absolute left-0 z-10 mt-1 min-w-[8rem] rounded-xl border border-[var(--border)] bg-[var(--card)] py-1 shadow-md"
>
<li role="none">
<button
role="menuitem"
type="button"
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--muted)]"
onMouseDown={() => {
exportReputationHistory(events, 'csv', filename);
setOpen(false);
}}
>
Export as CSV
</button>
</li>
<li role="none">
<button
role="menuitem"
type="button"
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--muted)]"
onMouseDown={() => {
exportReputationHistory(events, 'json', filename);
setOpen(false);
}}
>
Export as JSON
</button>
</li>
</ul>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions src/components/ReputationProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
isReputationUrlInSync,
type ReputationSortDir,
} from '@/lib/reputationUrlState';
import ReputationExportButton from './ReputationExportButton';

/** Number of history events shown per page before "Load more" is needed. */
export const REPUTATION_PAGE_SIZE = 5;
Expand Down Expand Up @@ -268,7 +269,7 @@
// implicit reorder callers didn't ask for. Explicitly choosing a direction
// (including re-selecting the default) always applies a real date sort.
const filteredHistory = useMemo(() => {
const byType =

Check failure on line 272 in src/components/ReputationProfile.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

'clearSelection' is not defined
selectedType === DEFAULT_TYPE
? events
: events.filter((event) => event.type === selectedType);
Expand Down Expand Up @@ -299,7 +300,7 @@
<h2 className="sr-only" id="profile-heading">Reputation profile for {name}</h2>
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--foreground)] text-2xl font-semibold text-[var(--background)]">

Check failure on line 303 in src/components/ReputationProfile.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

'clearSelection' is not defined
{name.slice(0, 1).toUpperCase()}
</div>
<div>
Expand Down Expand Up @@ -564,6 +565,7 @@
>
Clear selection
</button>
<ReputationExportButton events={visibleHistory} />
</div>
</div>
<ol
Expand Down Expand Up @@ -610,7 +612,7 @@
<time
id={dateId}
className="text-sm text-[var(--muted-foreground)] sm:text-right"
{...(isValidDate ? { dateTime: event.date } : {})}

Check failure on line 615 in src/components/ReputationProfile.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

'KbdHint' is not defined
>
{event.date}
</time>
Expand Down
81 changes: 81 additions & 0 deletions src/components/__tests__/ReputationExportButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* ReputationExportButton.test.tsx
*
* Tests for src/components/ReputationExportButton.tsx.
* Covers: CSV activation, JSON activation, empty-view (disabled) behavior,
* and accessibility of the export control.
*/

import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { testA11y, renderWithA11y } from '@/test-utils/a11y';
import ReputationExportButton from '../ReputationExportButton';
import type { ReputationEvent } from '@/components/ReputationProfile';

// Mock the export adapter so we assert the control wires up the right call
// without triggering an actual browser download.
jest.mock('@/lib/reputationExport', () => ({
exportReputationHistory: jest.fn(),
}));

import { exportReputationHistory } from '@/lib/reputationExport';

const mockExport = exportReputationHistory as jest.MockedFunction<
typeof exportReputationHistory
>;

const sampleEvents: ReputationEvent[] = [
{ id: 'rep-1', type: 'endorsement', summary: 'Great work', date: '2026-05-15', version: 1 },
{ id: 'rep-2', type: 'review', summary: 'On time', date: '2026-06-01', version: 2 },
];

afterEach(() => {
jest.clearAllMocks();
});

describe('ReputationExportButton', () => {
it('opens the menu and exports as CSV with the visible events', () => {
renderWithA11y(<ReputationExportButton events={sampleEvents} />);

fireEvent.click(screen.getByRole('button', { name: /export reputation history/i }));

// Menu items fire on mousedown (before the toggle button's blur closes the menu).
fireEvent.mouseDown(screen.getByRole('menuitem', { name: /export as csv/i }));

expect(mockExport).toHaveBeenCalledTimes(1);
expect(mockExport).toHaveBeenCalledWith(sampleEvents, 'csv', 'reputation-history');
});

it('exports as JSON when the JSON menu item is activated', () => {
renderWithA11y(<ReputationExportButton events={sampleEvents} filename="my-rep" />);

fireEvent.click(screen.getByRole('button', { name: /export reputation history/i }));
fireEvent.mouseDown(screen.getByRole('menuitem', { name: /export as json/i }));

expect(mockExport).toHaveBeenCalledTimes(1);
expect(mockExport).toHaveBeenCalledWith(sampleEvents, 'json', 'my-rep');
});

it('is disabled and opens no menu when there are no events', () => {
renderWithA11y(<ReputationExportButton events={[]} />);

const toggle = screen.getByRole('button', { name: /export reputation history/i });
expect(toggle).toBeDisabled();

fireEvent.click(toggle);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(mockExport).not.toHaveBeenCalled();
});

it('has no accessibility violations when closed', async () => {
await testA11y(<ReputationExportButton events={sampleEvents} />);
});

it('has no accessibility violations when the menu is open', async () => {
const { container } = renderWithA11y(<ReputationExportButton events={sampleEvents} />);
fireEvent.click(screen.getByRole('button', { name: /export reputation history/i }));
expect(screen.getByRole('menu')).toBeInTheDocument();
const { assertNoA11yViolations } = await import('@/test-utils/a11y');
await assertNoA11yViolations(container);
});
});
197 changes: 197 additions & 0 deletions src/lib/__tests__/reputationExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* reputationExport.test.ts
*
* Tests for src/lib/reputationExport.ts.
* Covers: CSV escaping edge cases, exporting the current visible/filtered
* data (not the whole dataset), empty-view behavior, JSON export, and the
* client-side download trigger.
*/

import { exportReputationHistory } from '../reputationExport';
import type { ReputationEvent } from '@/components/ReputationProfile';

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

const makeEvent = (overrides: Partial<ReputationEvent> = {}): ReputationEvent => ({
id: 'rep-001',
type: 'endorsement',
summary: 'Delivered milestone on time',
date: '2026-05-15',
version: 1,
...overrides,
});

// ---------------------------------------------------------------------------
// Download-trigger harness (mirrors icsExport.test.ts)
// ---------------------------------------------------------------------------

describe('exportReputationHistory', () => {
let createObjectURLMock: jest.Mock;
let revokeObjectURLMock: jest.Mock;
let appendChildSpy: jest.SpyInstance;
let removeChildSpy: jest.SpyInstance;
let clickSpy: jest.Mock;
// Captured string content of each Blob created during a test. jsdom's Blob
// does not implement async .text(), so we capture the parts synchronously.
let blobContents: string[];

beforeEach(() => {
createObjectURLMock = jest.fn().mockReturnValue('blob:fake-rep-url');
revokeObjectURLMock = jest.fn();
clickSpy = jest.fn();
blobContents = [];

const OriginalBlob = global.Blob;
jest
.spyOn(global, 'Blob')
.mockImplementation(
(...args: ConstructorParameters<typeof Blob>) => {
const [parts, options] = args;
blobContents.push((parts ?? []).map(String).join(''));
return new OriginalBlob(parts, options);
}
);

global.URL.createObjectURL = createObjectURLMock;
global.URL.revokeObjectURL = revokeObjectURLMock;

const originalCreateElement = document.createElement.bind(document);
jest.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = originalCreateElement(tag);
if (tag === 'a') {
Object.defineProperty(el, 'click', { value: clickSpy, writable: true });
}
return el;
});

appendChildSpy = jest.spyOn(document.body, 'appendChild');
removeChildSpy = jest.spyOn(document.body, 'removeChild');
});

afterEach(() => {
jest.restoreAllMocks();
});

// Helper: read back the string content written into the first Blob.
const readBlobText = (): string => blobContents[0];

// -------------------------------------------------------------------------
// CSV escaping edge cases
// -------------------------------------------------------------------------

describe('CSV escaping', () => {
it('quotes and escapes commas, quotes, and newlines in values', () => {
const events = [
makeEvent({
id: 'rep-esc',
summary: 'Said "great", then\nmoved on, quickly',
}),
];
exportReputationHistory(events, 'csv');

const text = readBlobText();
// Header row is quoted.
expect(text).toContain('"id","type","summary","date","version"');
// Embedded double-quotes are doubled; the whole field stays wrapped in quotes.
expect(text).toContain('"Said ""great"", then\nmoved on, quickly"');
});

it('renders empty/null-ish values as empty quoted fields', () => {
// version is optional; the adapter maps a missing version to ''.
const events = [makeEvent({ id: 'rep-empty', summary: '', version: undefined })];
exportReputationHistory(events, 'csv');

const text = readBlobText();
const dataRow = text.split('\n').slice(1).join('\n');
// Empty summary and empty version both serialize as "".
expect(dataRow).toContain('""');
expect(dataRow.startsWith('"rep-empty"')).toBe(true);
});
});

// -------------------------------------------------------------------------
// Respects the provided (filtered/visible) data set
// -------------------------------------------------------------------------

it('exports exactly the events passed in, not more', () => {
const visible = [
makeEvent({ id: 'rep-a', summary: 'A' }),
makeEvent({ id: 'rep-b', summary: 'B' }),
];
exportReputationHistory(visible, 'json');

const text = readBlobText();
const parsed = JSON.parse(text) as Array<{ id: string }>;
expect(parsed).toHaveLength(2);
expect(parsed.map((r) => r.id)).toEqual(['rep-a', 'rep-b']);
});

// -------------------------------------------------------------------------
// Empty view behavior — no download, no throw
// -------------------------------------------------------------------------

it('does not trigger a download when there are no events', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
expect(() => exportReputationHistory([], 'csv')).not.toThrow();
expect(createObjectURLMock).not.toHaveBeenCalled();
expect(clickSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});

// -------------------------------------------------------------------------
// JSON export
// -------------------------------------------------------------------------

it('produces pretty-printed JSON with the mapped fields', () => {
const events = [makeEvent({ id: 'rep-json', version: 3 })];
exportReputationHistory(events, 'json');

const blob = createObjectURLMock.mock.calls[0][0] as Blob;
expect(blob.type).toContain('application/json');

const text = readBlobText();
expect(text).toContain('\n'); // pretty-printed (indented)
const parsed = JSON.parse(text) as Array<Record<string, unknown>>;
expect(parsed[0]).toEqual({
id: 'rep-json',
type: 'endorsement',
summary: 'Delivered milestone on time',
date: '2026-05-15',
version: 3,
});
});

// -------------------------------------------------------------------------
// Download trigger (CSV activation)
// -------------------------------------------------------------------------

describe('download trigger', () => {
it('creates a CSV Blob and clicks an anchor with a .csv filename', () => {
exportReputationHistory([makeEvent()], 'csv', 'my-reputation');

expect(createObjectURLMock).toHaveBeenCalledTimes(1);
const blob = createObjectURLMock.mock.calls[0][0] as Blob;
expect(blob.type).toContain('text/csv');

const anchor = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement;
expect(anchor.download).toBe('my-reputation.csv');
expect(clickSpy).toHaveBeenCalledTimes(1);
});

it('creates a JSON Blob and clicks an anchor with a .json filename', () => {
exportReputationHistory([makeEvent()], 'json', 'my-reputation');

const anchor = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement;
expect(anchor.download).toBe('my-reputation.json');
expect(clickSpy).toHaveBeenCalledTimes(1);
});

it('revokes the object URL after triggering the download', () => {
exportReputationHistory([makeEvent()], 'csv');
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:fake-rep-url');
expect(removeChildSpy).toHaveBeenCalledTimes(1);
});
});
});
19 changes: 19 additions & 0 deletions src/lib/reputationExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { exportData } from '@/utils/export';
import type { ReputationEvent } from '@/components/ReputationProfile';

type ExportFormat = 'csv' | 'json';

export function exportReputationHistory(
events: ReputationEvent[],
format: ExportFormat,
filename = 'reputation-history'
): void {
const rows = events.map((e) => ({
id: e.id,
type: e.type,
summary: e.summary,
date: e.date,
version: e.version ?? '',
}));
exportData(rows, filename, format);
}
Loading