-
Notifications
You must be signed in to change notification settings - Fork 0
feat: history and analytics #88
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 7 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
df76404
feat: add History page with empty state and paginated table view
AlyaEngineer fde4f59
test: cover History page, history list, history empty state, and getR…
AlyaEngineer 15098c5
feat: add server-side request history persistence
AlyaEngineer b88a2b4
test: add tests for saveRequestHistory
AlyaEngineer 5e33551
fix: handle unexpected errors when loading request history
AlyaEngineer dc76e5d
fix: prevent rendering history list on load failure
AlyaEngineer dee4727
fix: improve handle request history save failures
AlyaEngineer 7771ddc
refactor: simplify history page conditional rendering
AlyaEngineer 9918108
refactor: improve pagination code readability
AlyaEngineer ba96563
refactor: simplify history table header rendering
AlyaEngineer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
| 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'); | ||
|
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
29
src/components/history-empty-state/history-empty-state.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
22
src/components/history-empty-state/history-empty-state.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| </Stack> | ||
| </Stack> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
Repository: AlyaEngineer/swagger-editor-app
Length of output: 4494
🏁 Script executed:
Repository: AlyaEngineer/swagger-editor-app
Length of output: 2671
Lazy-load
HistoryListon the history route.src/app/[locale]/history/page.tsxstill statically imports the table component, which violates the History & Analytics lazy-loading requirement. Switch this tonext/dynamichere; keep SSR on if you still want the initial rows in the server-rendered HTML.🤖 Prompt for AI Agents
Source: Path instructions