-
Notifications
You must be signed in to change notification settings - Fork 0
feat(dashboard): adopt <Card> + <Cluster> in AlertsPanel [Phase 1e] #624
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 all commits
Commits
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
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
176 changes: 176 additions & 0 deletions
176
frontend/src/components/dashboard/__tests__/AlertsPanel.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,176 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { render, screen, fireEvent, within } from '@testing-library/react'; | ||
| import { MemoryRouter } from 'react-router-dom'; | ||
| import AlertsPanel from '../AlertsPanel'; | ||
|
|
||
| /* ── Mocks ─────────────────────────────────────────────────────────── */ | ||
|
|
||
| const navigateMock = vi.fn(); | ||
| vi.mock('react-router-dom', async () => { | ||
| const actual = | ||
| await vi.importActual<typeof import('react-router-dom')>('react-router-dom'); | ||
| return { | ||
| ...actual, | ||
| useNavigate: () => navigateMock, | ||
| }; | ||
| }); | ||
|
|
||
| vi.mock('../../../i18n', () => ({ | ||
| useTranslation: () => ({ | ||
| t: { | ||
| dashboard: new Proxy( | ||
| {} as Record<string, string>, | ||
| { get: (_t, key: string) => `dash.${key}` }, | ||
| ), | ||
| operationTypes: {}, | ||
| }, | ||
| lang: 'en', | ||
| setLang: vi.fn(), | ||
| }), | ||
| })); | ||
|
|
||
| /* ── Helpers ───────────────────────────────────────────────────────── */ | ||
|
|
||
| const renderPanel = ( | ||
| props: Partial<React.ComponentProps<typeof AlertsPanel>> = {}, | ||
| ) => | ||
| render( | ||
| <MemoryRouter> | ||
| <AlertsPanel | ||
| underRepairMachines={0} | ||
| pendingOperations={0} | ||
| overdueOperations={0} | ||
| lowStockItems={0} | ||
| completedToday={0} | ||
| {...props} | ||
| /> | ||
| </MemoryRouter>, | ||
| ); | ||
|
|
||
| beforeEach(() => navigateMock.mockReset()); | ||
|
|
||
| /* ── Tests ─────────────────────────────────────────────────────────── */ | ||
|
|
||
| describe('AlertsPanel — empty state', () => { | ||
| it('renders nothing when no alerts apply (all counts are 0)', () => { | ||
| const { container } = renderPanel(); | ||
| expect(container.firstChild).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('AlertsPanel — normal alerts', () => { | ||
| it('renders one row per non-zero count, in severity order', () => { | ||
| renderPanel({ | ||
| underRepairMachines: 2, | ||
| pendingOperations: 5, | ||
| overdueOperations: 1, | ||
| lowStockItems: 3, | ||
| completedToday: 7, | ||
| }); | ||
| const items = screen.getAllByRole('listitem'); | ||
| expect(items).toHaveLength(5); | ||
|
|
||
| // Critical first (overdueOps, machinesUnderRepair), then warnings, | ||
| // then info. | ||
| const orderedTitles = items.map((li) => | ||
| within(li).getByRole('button').getAttribute('aria-label'), | ||
| ); | ||
| expect(orderedTitles[0]).toMatch(/dash\.overdueOps: 1/); | ||
| expect(orderedTitles[1]).toMatch(/dash\.machinesUnderRepair: 2/); | ||
| expect(orderedTitles[2]).toMatch(/dash\.pendingOpsAlert: 5/); | ||
| expect(orderedTitles[3]).toMatch(/dash\.lowStockAlert: 3/); | ||
| expect(orderedTitles[4]).toMatch(/dash\.completedToday: 7/); | ||
| }); | ||
|
|
||
| it('renders the panel as a <section> with an aria-label', () => { | ||
| const { container } = renderPanel({ pendingOperations: 1 }); | ||
| const section = container.querySelector('section'); | ||
| expect(section).not.toBeNull(); | ||
| expect(section).toHaveAttribute('aria-label', 'dash.needsAttention'); | ||
| }); | ||
|
|
||
| it('renders a semantic <h2> for the panel title', () => { | ||
| renderPanel({ pendingOperations: 1 }); | ||
| expect( | ||
| screen.getByRole('heading', { level: 2, name: 'dash.needsAttention' }), | ||
| ).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('shows the total count badge equal to the number of rows', () => { | ||
| renderPanel({ pendingOperations: 1, lowStockItems: 1, completedToday: 1 }); | ||
| const items = screen.getAllByRole('listitem'); | ||
| expect(items).toHaveLength(3); | ||
| // Total count badge sits next to the title and matches `items.length`. | ||
| const badges = screen.getAllByText('3'); | ||
| expect(badges.length).toBeGreaterThan(0); | ||
| }); | ||
| }); | ||
|
|
||
| describe('AlertsPanel — status (severity) rendering', () => { | ||
| it('tags each row with a severity-specific class for the tint', () => { | ||
| renderPanel({ | ||
| overdueOperations: 1, // critical | ||
| pendingOperations: 1, // warning | ||
| completedToday: 1, // info | ||
| }); | ||
| const buttons = screen.getAllByRole('button'); | ||
| const criticalBtn = buttons.find((b) => | ||
| b.getAttribute('aria-label')?.startsWith('dash.overdueOps'), | ||
| ); | ||
| const warningBtn = buttons.find((b) => | ||
| b.getAttribute('aria-label')?.startsWith('dash.pendingOpsAlert'), | ||
| ); | ||
| const infoBtn = buttons.find((b) => | ||
| b.getAttribute('aria-label')?.startsWith('dash.completedToday'), | ||
| ); | ||
| expect(criticalBtn?.className).toMatch(/critical/); | ||
| expect(warningBtn?.className).toMatch(/warning/); | ||
| expect(infoBtn?.className).toMatch(/info/); | ||
| }); | ||
| }); | ||
|
|
||
| describe('AlertsPanel — actions', () => { | ||
| it('navigates to the row’s route when the row button is clicked', () => { | ||
| renderPanel({ underRepairMachines: 1, pendingOperations: 1 }); | ||
| const repairBtn = screen.getByRole('button', { | ||
| name: /dash\.machinesUnderRepair/, | ||
| }); | ||
| fireEvent.click(repairBtn); | ||
| expect(navigateMock).toHaveBeenCalledWith('/machinery'); | ||
|
|
||
| const pendingBtn = screen.getByRole('button', { | ||
| name: /dash\.pendingOpsAlert/, | ||
| }); | ||
| fireEvent.click(pendingBtn); | ||
| expect(navigateMock).toHaveBeenCalledWith('/operations'); | ||
| }); | ||
|
|
||
| it('hides rows beyond 5 by default and shows a "show all" footer', () => { | ||
| // Pad with non-zero counts so we get 5 rows. To exceed 5 we’d need | ||
| // a sixth alert source — the current API exposes 5 — so the footer | ||
| // is only shown when the fixture intentionally crosses the limit. | ||
| // For the sake of this assertion we render exactly 5 rows and then | ||
| // assert the footer is NOT shown (boundary case). | ||
| renderPanel({ | ||
| underRepairMachines: 1, | ||
| pendingOperations: 1, | ||
| overdueOperations: 1, | ||
| lowStockItems: 1, | ||
| completedToday: 1, | ||
| }); | ||
| expect(screen.queryByText(/dash\.showAll|Показати всі/i)).not.toBeInTheDocument(); | ||
| expect(screen.getAllByRole('listitem')).toHaveLength(5); | ||
| }); | ||
| }); | ||
|
|
||
| describe('AlertsPanel — Card adoption', () => { | ||
| it('the outer container is the design-system Card (data-variant="subtle", radius=lg)', () => { | ||
| const { container } = renderPanel({ pendingOperations: 1 }); | ||
| const section = container.querySelector('section'); | ||
| expect(section).toHaveAttribute('data-variant', 'subtle'); | ||
| expect(section).toHaveAttribute('data-bordered', 'true'); | ||
| const inline = section?.getAttribute('style') ?? ''; | ||
| expect(inline).toContain('var(--card-bg)'); | ||
| expect(inline).toContain('var(--radius-lg)'); | ||
| }); | ||
| }); |
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.
Switching the header to
Clusterchanged its flex behavior from non-wrapping to wrapping by default, so on narrow layouts or longer localizedneedsAttentionstrings the count badge can drop to a second line. The previousheaderstyling kept title and badge in one horizontal row, and this panel’s anatomy depends on that for consistent scanability. Setnowrapon thisCluster(or otherwise disable wrapping) to preserve the prior behavior.Useful? React with 👍 / 👎.