Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
74 changes: 74 additions & 0 deletions src/app/[locale]/history/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { getAuthenticatedUser } from '@/utils/auth/get-authenticated-user';
import { getRequestHistory } from '@/utils/history/get-request-history';

import HistoryPage from './page';

vi.mock('next-intl/server', () => ({
getTranslations: async () => (key: string) => key,
}));

vi.mock('@/utils/auth/get-authenticated-user', () => ({
getAuthenticatedUser: vi.fn(),
}));

vi.mock('@/utils/history/get-request-history', () => ({
getRequestHistory: vi.fn(),
}));

vi.mock('@/components/history-empty-state/history-empty-state', () => ({
HistoryEmptyState: () => <div data-testid="empty-state-stub" />,
}));

vi.mock('@/components/history-list/history-list', () => ({
HistoryList: ({ entries }: { entries: unknown[] }) => (
<div data-testid="history-list-stub">{entries.length}</div>
),
}));

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

describe('HistoryPage', () => {
it('calls getAuthenticatedUser to protect the route', async () => {
vi.mocked(getRequestHistory).mockResolvedValue({ entries: [], hasError: false });

const jsx = await HistoryPage();
render(jsx);

expect(getAuthenticatedUser).toHaveBeenCalled();
});

it('shows an error alert when the history fails to load', async () => {
vi.mocked(getRequestHistory).mockResolvedValue({ entries: [], hasError: true });

const jsx = await HistoryPage();
render(jsx);

expect(screen.getByText('historyLoadError')).toBeInTheDocument();
});

it('shows the empty state when there are no entries', async () => {
vi.mocked(getRequestHistory).mockResolvedValue({ entries: [], hasError: false });

const jsx = await HistoryPage();
render(jsx);

expect(screen.getByTestId('empty-state-stub')).toBeInTheDocument();
});

it('shows the history list when there are entries', async () => {
vi.mocked(getRequestHistory).mockResolvedValue({
entries: [{ id: '1' }] as never,
hasError: false,
});

const jsx = await HistoryPage();
render(jsx);

expect(screen.getByTestId('history-list-stub')).toHaveTextContent('1');
});
});
24 changes: 23 additions & 1 deletion src/app/[locale]/history/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
import { Alert } from '@mui/material';
import { getTranslations } from 'next-intl/server';

import { HistoryEmptyState } from '@/components/history-empty-state/history-empty-state';
import { HistoryList } from '@/components/history-list/history-list';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## File map\n'
git ls-files 'src/app/[locale]/history/page.tsx' 'src/components/history-list/history-list.*' 'src/app/**/page.tsx' | sed 's#^`#-` #'

printf '\n## Outline: history page\n'
ast-grep outline src/app/[locale]/history/page.tsx --view expanded || true

printf '\n## Outline: history list component\n'
ast-grep outline src/components/history-list/history-list.tsx --view expanded || true

printf '\n## Relevant route imports/usages\n'
rg -n --glob 'src/app/**/page.tsx' "HistoryList|dynamic\\(|next/dynamic|history-list" src/app || true

printf '\n## History page contents (numbered)\n'
wc -l src/app/[locale]/history/page.tsx
cat -n src/app/[locale]/history/page.tsx | sed -n '1,220p'

printf '\n## History list contents (numbered, first 260 lines)\n'
wc -l src/components/history-list/history-list.tsx
cat -n src/components/history-list/history-list.tsx | sed -n '1,260p'

Repository: AlyaEngineer/swagger-editor-app

Length of output: 4494


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Helper outlines\n'
ast-grep outline src/utils/auth/get-authenticated-user.ts --view expanded || true
ast-grep outline src/utils/history/get-request-history.ts --view expanded || true

printf '\n## Auth helper\n'
wc -l src/utils/auth/get-authenticated-user.ts
cat -n src/utils/auth/get-authenticated-user.ts | sed -n '1,220p'

printf '\n## History helper\n'
wc -l src/utils/history/get-request-history.ts
cat -n src/utils/history/get-request-history.ts | sed -n '1,260p'

Repository: AlyaEngineer/swagger-editor-app

Length of output: 2671


Lazy-load HistoryList on the history route.
src/app/[locale]/history/page.tsx still statically imports the table component, which violates the History & Analytics lazy-loading requirement. Switch this to next/dynamic here; keep SSR on if you still want the initial rows in the server-rendered HTML.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`[locale]/history/page.tsx at line 5, Replace the static HistoryList
import in the history page with a next/dynamic lazy import, preserving the
component’s existing usage and enabling SSR so initial rows can remain
server-rendered.

Source: Path instructions

import { getAuthenticatedUser } from '@/utils/auth/get-authenticated-user';
import { getRequestHistory } from '@/utils/history/get-request-history';

export default async function HistoryPage() {
await getAuthenticatedUser();

return <div>History</div>;
const { entries, hasError } = await getRequestHistory();
const t = await getTranslations('HistoryPage');
const tToast = await getTranslations('toaster');
Comment thread
AlyaEngineer marked this conversation as resolved.

return (
<div>
<h1>{t('title')}</h1>

{hasError ? (
<Alert severity="error">{tToast('historyLoadError')}</Alert>
) : entries.length === 0 ? (
<HistoryEmptyState />
) : (
<HistoryList entries={entries} />
)}
</div>
);
}
29 changes: 29 additions & 0 deletions src/components/history-empty-state/history-empty-state.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react';

import { render, screen } from '@testing-library/react';

import { HistoryEmptyState } from './history-empty-state';

vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));

vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href, ...props }: { children: ReactNode; href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
}));

describe('HistoryEmptyState', () => {
it('renders the informational message, hint, and link to the editor', () => {
render(<HistoryEmptyState />);

expect(screen.getByText('emptyMessage')).toBeInTheDocument();
expect(screen.getByText('emptyLinksHint')).toBeInTheDocument();

const link = screen.getByRole('link', { name: 'goToEditor' });
expect(link).toHaveAttribute('href', '/');
});
});
22 changes: 22 additions & 0 deletions src/components/history-empty-state/history-empty-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Alert, Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';

import { AppLinkButton } from '@/components/app-link/app-link';
import { ROUTES } from '@/constants/routes';

export function HistoryEmptyState() {
const t = useTranslations('HistoryPage');

return (
<Stack spacing={6}>
<Alert severity="info">{t('emptyMessage')}</Alert>

<Stack direction="column" spacing={2}>
<Typography color="text.secondary">{t('emptyLinksHint')}</Typography>
<AppLinkButton href={ROUTES.home} sx={{ alignSelf: 'flex-start' }} variant="outlined">
{t('goToEditor')}
</AppLinkButton>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</Stack>
</Stack>
);
}
106 changes: 106 additions & 0 deletions src/components/history-list/history-list.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';

import type { RequestHistoryEntry } from '@/utils/history/history-types';

import { HistoryList } from './history-list';

vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));

function createEntry(overrides: Partial<RequestHistoryEntry>): RequestHistoryEntry {
return {
createdAt: '2026-07-12T09:46:10.444583+00',
durationMs: 245,
endpoint: 'https://api.example.com/users',
errorDetails: null,
id: '1',
method: 'GET',
requestSize: 0,
responseSize: 1024,
statusCode: 200,
...overrides,
};
}

describe('HistoryList', () => {
it('renders column headers', () => {
render(<HistoryList entries={[createEntry({})]} />);

expect(screen.getByText('methodColumn')).toBeInTheDocument();
expect(screen.getByText('endpointColumn')).toBeInTheDocument();
expect(screen.getByText('statusColumn')).toBeInTheDocument();
expect(screen.getByText('durationColumn')).toBeInTheDocument();
expect(screen.getByText('requestSizeColumn')).toBeInTheDocument();
expect(screen.getByText('responseSizeColumn')).toBeInTheDocument();
expect(screen.getByText('timestampColumn')).toBeInTheDocument();
expect(screen.getByText('errorColumn')).toBeInTheDocument();
});

it('renders an entry with method, endpoint, status, duration, and sizes', () => {
render(<HistoryList entries={[createEntry({})]} />);

expect(screen.getByText('GET')).toBeInTheDocument();
expect(screen.getByText('https://api.example.com/users')).toBeInTheDocument();
expect(screen.getByText('200')).toBeInTheDocument();
expect(screen.getByText('245ms')).toBeInTheDocument();
expect(screen.getByText('0B')).toBeInTheDocument();
expect(screen.getByText('1024B')).toBeInTheDocument();
});

it('shows a dash when there are no error details', () => {
render(<HistoryList entries={[createEntry({ errorDetails: null })]} />);

expect(screen.getByText('—')).toBeInTheDocument();
});

it('shows the error details text when present', () => {
render(<HistoryList entries={[createEntry({ errorDetails: 'Not Found' })]} />);

expect(screen.getByText('Not Found')).toBeInTheDocument();
});

it('truncates long error details and shows the full text in a tooltip', async () => {
const longError = 'A'.repeat(60);

render(<HistoryList entries={[createEntry({ errorDetails: longError })]} />);

const truncated = screen.getByText(`${'A'.repeat(40)}…`);
expect(truncated).toBeInTheDocument();

await userEvent.hover(truncated);

expect(await screen.findByText(longError)).toBeInTheDocument();
});

it('paginates entries, showing only the first page by default', async () => {
const entries = Array.from({ length: 15 }, (_, index) =>
createEntry({ endpoint: `https://api.example.com/item-${index}`, id: String(index) }),
);

render(<HistoryList entries={entries} />);

expect(screen.getByText('https://api.example.com/item-0')).toBeInTheDocument();
expect(screen.queryByText('https://api.example.com/item-10')).not.toBeInTheDocument();

await userEvent.click(screen.getByRole('button', { name: /next page/i }));

expect(screen.getByText('https://api.example.com/item-10')).toBeInTheDocument();
expect(screen.queryByText('https://api.example.com/item-0')).not.toBeInTheDocument();
});

it('changes rows per page and resets to the first page', async () => {
const entries = Array.from({ length: 15 }, (_, index) =>
createEntry({ endpoint: `https://api.example.com/item-${index}`, id: String(index) }),
);

render(<HistoryList entries={entries} />);

await userEvent.click(screen.getByRole('combobox', { name: /rows per page/i }));
await userEvent.click(await screen.findByRole('option', { name: '25' }));

expect(screen.getByText('https://api.example.com/item-10')).toBeInTheDocument();
});
});
59 changes: 59 additions & 0 deletions src/components/history-list/history-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client';

import { Paper, Table, TableBody, TableContainer, TablePagination } from '@mui/material';
import { useTranslations } from 'next-intl';
import { useState } from 'react';

import type { RequestHistoryEntry } from '@/utils/history/history-types';

import { HistoryTableHeader } from './history-table-header';
import { HistoryTableRow } from './history-table-row';

type HistoryListProps = {
entries: RequestHistoryEntry[];
};

const ROWS_PER_PAGE = [10, 25, 100];

export function HistoryList({ entries }: HistoryListProps) {
const t = useTranslations('HistoryPage');
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(ROWS_PER_PAGE[0]);

function handleChangePage(_event: unknown, newPage: number) {
setPage(newPage);
}

function handleChangeRowsPerPage(event: React.ChangeEvent<HTMLInputElement>) {
setRowsPerPage(Number(event.target.value));
setPage(0);
}

const visibleEntries = entries.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);

return (
<Paper sx={{ borderRadius: '8px', overflow: 'hidden', width: '100%' }} variant="outlined">
<TableContainer sx={{ maxHeight: 520 }}>
<Table aria-label={t('title')} stickyHeader>
<HistoryTableHeader />

<TableBody>
{visibleEntries.map((entry) => (
<HistoryTableRow entry={entry} key={entry.id} />
))}
</TableBody>
</Table>
</TableContainer>

<TablePagination
component="div"
count={entries.length}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
page={page}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={ROWS_PER_PAGE}
/>
</Paper>
);
}
42 changes: 42 additions & 0 deletions src/components/history-list/history-table-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { TableCell, TableHead, TableRow } from '@mui/material';
import { useTranslations } from 'next-intl';

const headerCellSx = {
bgcolor: 'background.paper',
whiteSpace: 'pre-line',
};

export function HistoryTableHeader() {
const t = useTranslations('HistoryPage');

return (
<TableHead>
<TableRow>
<TableCell align="center" sx={headerCellSx}>
{t('methodColumn')}
</TableCell>
<TableCell align="center" sx={headerCellSx}>
{t('endpointColumn')}
</TableCell>
<TableCell align="center" sx={headerCellSx}>
{t('statusColumn')}
</TableCell>
<TableCell align="center" sx={headerCellSx}>
{t('durationColumn')}
</TableCell>
<TableCell align="center" sx={headerCellSx}>
{t('requestSizeColumn')}
</TableCell>
<TableCell align="center" sx={headerCellSx}>
{t('responseSizeColumn')}
</TableCell>
<TableCell align="center" sx={headerCellSx}>
{t('timestampColumn')}
</TableCell>
<TableCell align="center" sx={headerCellSx}>
{t('errorColumn')}
</TableCell>
</TableRow>
</TableHead>
);
}
Loading