Skip to content

Commit 7eef735

Browse files
committed
feat: add loading overlay to DataView and refactor lists to use usePagination
- Add isFetching prop to DataView for loading state - Add visual dimming overlay during pagination - Add isFetching prop to PaginationControl - Refactor LocationsList to use usePagination hook - Refactor ContractorList to use usePagination hook - Refactor EmployeeList to use usePagination hook - Add PaginationControl tests and stories
1 parent 761adba commit 7eef735

File tree

14 files changed

+339
-49
lines changed

14 files changed

+339
-49
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
11
.dataViewContainer {
22
width: 100%;
33
}
4+
5+
.contentWrapper {
6+
position: relative;
7+
transition: opacity 0.2s ease-in-out;
8+
}
9+
10+
.contentWrapper[data-fetching='true'] {
11+
opacity: 0.5;
12+
pointer-events: none;
13+
}

src/components/Common/DataView/DataView.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,11 @@ export const DataView = <T,>({
5555
containerRef.current = ref
5656
}}
5757
>
58-
{isBreakpointsDetected && (
59-
<Component {...dataViewProps} footer={footer} variant={variant} emptyState={emptyState} />
60-
)}
58+
<div className={styles.contentWrapper} data-fetching={isFetching || undefined}>
59+
{isBreakpointsDetected && (
60+
<Component {...dataViewProps} footer={footer} variant={variant} emptyState={emptyState} />
61+
)}
62+
</div>
6163
{pagination && <PaginationControl {...pagination} isFetching={isFetching} />}
6264
</div>
6365
)

src/components/Common/PaginationControl/PaginationControl.stories.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,87 @@ export const MiddlePage: Story = () => {
105105
/>
106106
)
107107
}
108+
109+
export const EmptyState: Story = () => {
110+
return (
111+
<div>
112+
<p style={{ marginBottom: '1rem', color: '#666' }}>
113+
When totalItems is 0, pagination is hidden:
114+
</p>
115+
<div style={{ border: '1px dashed #ccc', padding: '1rem', minHeight: '50px' }}>
116+
<PaginationControl
117+
currentPage={1}
118+
totalPages={1}
119+
totalItems={0}
120+
itemsPerPage={5}
121+
handleFirstPage={() => {}}
122+
handlePreviousPage={() => {}}
123+
handleNextPage={() => {}}
124+
handleLastPage={() => {}}
125+
handleItemsPerPageChange={() => {}}
126+
/>
127+
<p style={{ color: '#999', fontStyle: 'italic' }}>
128+
(Pagination control is not rendered - this is expected)
129+
</p>
130+
</div>
131+
</div>
132+
)
133+
}
134+
135+
export const SinglePageWithItems: Story = () => {
136+
const { value: perPage, handleChange: setItemsPerPage } = useLadleState<number>(
137+
'SinglePageItemsPerPage',
138+
50,
139+
)
140+
const itemsPerPage = (perPage ?? 50) as 5 | 10 | 50
141+
142+
return (
143+
<div>
144+
<p style={{ marginBottom: '1rem', color: '#666' }}>
145+
3 items with page size {itemsPerPage} = 1 page. Pagination is still visible so users can
146+
adjust page size:
147+
</p>
148+
<PaginationControl
149+
currentPage={1}
150+
totalPages={1}
151+
totalItems={3}
152+
itemsPerPage={itemsPerPage}
153+
handleFirstPage={() => {}}
154+
handlePreviousPage={() => {}}
155+
handleNextPage={() => {}}
156+
handleLastPage={() => {}}
157+
handleItemsPerPageChange={n => setItemsPerPage(n)}
158+
/>
159+
<p style={{ marginTop: '1rem', color: '#666' }}>
160+
Notice: All navigation buttons are disabled, but page size selector is available.
161+
</p>
162+
</div>
163+
)
164+
}
165+
166+
export const LegacyWithoutTotalItems: Story = () => {
167+
const { value: page, handleChange: setCurrentPage } = useLadleState<number>(
168+
'LegacyPaginationPage',
169+
1,
170+
)
171+
const currentPage = page ?? 1
172+
const totalPages = 5
173+
174+
return (
175+
<div>
176+
<p style={{ marginBottom: '1rem', color: '#666' }}>
177+
Legacy usage without totalItems prop (fallback behavior - always shows):
178+
</p>
179+
<PaginationControl
180+
currentPage={currentPage}
181+
totalPages={totalPages}
182+
itemsPerPage={10}
183+
handleFirstPage={() => setCurrentPage(1)}
184+
handlePreviousPage={() => setCurrentPage(Math.max(1, currentPage - 1))}
185+
handleNextPage={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
186+
handleLastPage={() => setCurrentPage(totalPages)}
187+
handleItemsPerPageChange={() => setCurrentPage(1)}
188+
/>
189+
</div>
190+
)
191+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { render, screen } from '@testing-library/react'
2+
import { describe, test, expect, vi } from 'vitest'
3+
import { userEvent } from '@testing-library/user-event'
4+
import { PaginationControl } from './PaginationControl'
5+
import type { PaginationControlProps } from './PaginationControlTypes'
6+
import { ThemeProvider } from '@/contexts/ThemeProvider'
7+
import { ComponentsProvider } from '@/contexts/ComponentAdapter/ComponentsProvider'
8+
import { defaultComponents } from '@/contexts/ComponentAdapter/adapters/defaultComponentAdapter'
9+
10+
const createMockPaginationProps = (
11+
overrides: Partial<PaginationControlProps> = {},
12+
): PaginationControlProps => ({
13+
currentPage: 1,
14+
totalPages: 5,
15+
totalItems: 25,
16+
itemsPerPage: 5,
17+
handleFirstPage: vi.fn(),
18+
handlePreviousPage: vi.fn(),
19+
handleNextPage: vi.fn(),
20+
handleLastPage: vi.fn(),
21+
handleItemsPerPageChange: vi.fn(),
22+
...overrides,
23+
})
24+
25+
const renderPaginationControl = (props: PaginationControlProps) => {
26+
return render(
27+
<ThemeProvider>
28+
<ComponentsProvider value={defaultComponents}>
29+
<PaginationControl {...props} />
30+
</ComponentsProvider>
31+
</ThemeProvider>,
32+
)
33+
}
34+
35+
describe('PaginationControl', () => {
36+
describe('visibility logic', () => {
37+
test('hides when totalItems is 0 (empty state)', () => {
38+
const props = createMockPaginationProps({ totalItems: 0 })
39+
const { container } = renderPaginationControl(props)
40+
expect(container.querySelector('[data-testid="pagination-control"]')).not.toBeInTheDocument()
41+
})
42+
43+
test('shows when totalItems > 0 and totalPages === 1 (single page with items)', () => {
44+
const props = createMockPaginationProps({ totalItems: 3, totalPages: 1 })
45+
renderPaginationControl(props)
46+
expect(screen.getByTestId('pagination-control')).toBeInTheDocument()
47+
})
48+
49+
test('shows when totalItems is undefined (fallback for legacy usage)', () => {
50+
const props = createMockPaginationProps({ totalItems: undefined, totalPages: 2 })
51+
renderPaginationControl(props)
52+
expect(screen.getByTestId('pagination-control')).toBeInTheDocument()
53+
})
54+
55+
test('shows when totalItems > 0 and totalPages > 1 (multi-page)', () => {
56+
const props = createMockPaginationProps({ totalItems: 50, totalPages: 5 })
57+
renderPaginationControl(props)
58+
expect(screen.getByTestId('pagination-control')).toBeInTheDocument()
59+
})
60+
})
61+
62+
describe('button disabled states', () => {
63+
test('first and previous buttons are disabled when currentPage === 1', () => {
64+
const props = createMockPaginationProps({ currentPage: 1, totalPages: 5 })
65+
renderPaginationControl(props)
66+
67+
const firstButton = screen.getByRole('button', { name: /paginationFirst/i })
68+
const previousButton = screen.getByTestId('pagination-previous')
69+
70+
expect(firstButton).toBeDisabled()
71+
expect(previousButton).toBeDisabled()
72+
})
73+
74+
test('next and last buttons are disabled when currentPage === totalPages', () => {
75+
const props = createMockPaginationProps({ currentPage: 5, totalPages: 5 })
76+
renderPaginationControl(props)
77+
78+
const nextButton = screen.getByTestId('pagination-next')
79+
const lastButton = screen.getByRole('button', { name: /paginationLast/i })
80+
81+
expect(nextButton).toBeDisabled()
82+
expect(lastButton).toBeDisabled()
83+
})
84+
85+
test('all nav buttons are disabled when totalPages === 1', () => {
86+
const props = createMockPaginationProps({
87+
currentPage: 1,
88+
totalPages: 1,
89+
totalItems: 3,
90+
})
91+
renderPaginationControl(props)
92+
93+
const firstButton = screen.getByRole('button', { name: /paginationFirst/i })
94+
const previousButton = screen.getByTestId('pagination-previous')
95+
const nextButton = screen.getByTestId('pagination-next')
96+
const lastButton = screen.getByRole('button', { name: /paginationLast/i })
97+
98+
expect(firstButton).toBeDisabled()
99+
expect(previousButton).toBeDisabled()
100+
expect(nextButton).toBeDisabled()
101+
expect(lastButton).toBeDisabled()
102+
})
103+
})
104+
105+
describe('page size selector', () => {
106+
test('is visible when pagination is shown', () => {
107+
const props = createMockPaginationProps({ totalItems: 25 })
108+
renderPaginationControl(props)
109+
110+
const selectButton = screen.getByRole('button', { name: /paginationControlCountLabel/i })
111+
expect(selectButton).toBeInTheDocument()
112+
})
113+
})
114+
115+
describe('accessibility', () => {
116+
test('all nav buttons have aria-labels', () => {
117+
const props = createMockPaginationProps()
118+
renderPaginationControl(props)
119+
120+
expect(screen.getByRole('button', { name: /paginationFirst/i })).toBeInTheDocument()
121+
expect(screen.getByRole('button', { name: /paginationPrev/i })).toBeInTheDocument()
122+
expect(screen.getByRole('button', { name: /paginationNext/i })).toBeInTheDocument()
123+
expect(screen.getByRole('button', { name: /paginationLast/i })).toBeInTheDocument()
124+
})
125+
126+
test('select button has an accessible label', () => {
127+
const props = createMockPaginationProps()
128+
renderPaginationControl(props)
129+
130+
const selectButton = screen.getByRole('button', { name: /paginationControlCountLabel/i })
131+
expect(selectButton).toHaveAttribute('aria-label')
132+
})
133+
})
134+
135+
describe('navigation handlers', () => {
136+
test('handleNextPage is called when next button is clicked', async () => {
137+
const handleNextPage = vi.fn()
138+
const props = createMockPaginationProps({ currentPage: 2, handleNextPage })
139+
renderPaginationControl(props)
140+
141+
await userEvent.click(screen.getByTestId('pagination-next'))
142+
expect(handleNextPage).toHaveBeenCalledTimes(1)
143+
})
144+
145+
test('handlePreviousPage is called when previous button is clicked', async () => {
146+
const handlePreviousPage = vi.fn()
147+
const props = createMockPaginationProps({ currentPage: 2, handlePreviousPage })
148+
renderPaginationControl(props)
149+
150+
await userEvent.click(screen.getByTestId('pagination-previous'))
151+
expect(handlePreviousPage).toHaveBeenCalledTimes(1)
152+
})
153+
154+
test('handleFirstPage is called when first button is clicked', async () => {
155+
const handleFirstPage = vi.fn()
156+
const props = createMockPaginationProps({ currentPage: 3, handleFirstPage })
157+
renderPaginationControl(props)
158+
159+
await userEvent.click(screen.getByRole('button', { name: /paginationFirst/i }))
160+
expect(handleFirstPage).toHaveBeenCalledTimes(1)
161+
})
162+
163+
test('handleLastPage is called when last button is clicked', async () => {
164+
const handleLastPage = vi.fn()
165+
const props = createMockPaginationProps({ currentPage: 2, handleLastPage })
166+
renderPaginationControl(props)
167+
168+
await userEvent.click(screen.getByRole('button', { name: /paginationLast/i }))
169+
expect(handleLastPage).toHaveBeenCalledTimes(1)
170+
})
171+
})
172+
})

src/components/Common/PaginationControl/PaginationControl.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import PaginationLastIcon from '@/assets/icons/pagination_last.svg?react'
1212
const DefaultPaginationControl = ({
1313
currentPage,
1414
totalPages,
15+
totalItems,
1516
isFetching,
1617
handleFirstPage,
1718
handlePreviousPage,
@@ -23,7 +24,8 @@ const DefaultPaginationControl = ({
2324
const { t } = useTranslation('common')
2425
const Components = useComponentContext()
2526

26-
if (totalPages < 2) {
27+
const shouldHidePagination = totalItems === 0
28+
if (shouldHidePagination) {
2729
return null
2830
}
2931

src/components/Common/PaginationControl/PaginationControlTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type PaginationControlProps = {
88
handleItemsPerPageChange: (n: PaginationItemsPerPage) => void
99
currentPage: number
1010
totalPages: number
11+
totalItems?: number
1112
itemsPerPage?: PaginationItemsPerPage
1213
isFetching?: boolean
1314
}

src/components/Company/Locations/LocationsList/List.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const List = () => {
1414
handleEditLocation,
1515
currentPage,
1616
totalPages,
17+
totalItems,
1718
handleFirstPage,
1819
handleItemsPerPageChange,
1920
handleLastPage,
@@ -89,6 +90,7 @@ export const List = () => {
8990
handleItemsPerPageChange,
9091
currentPage,
9192
totalPages,
93+
totalItems,
9294
itemsPerPage,
9395
},
9496
emptyState: () => (

src/components/Company/Locations/LocationsList/LocationsList.tsx

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useBase } from '@/components/Base/useBase'
1010
import { Flex } from '@/components/Common'
1111
import { companyEvents } from '@/shared/constants'
1212
import type { PaginationItemsPerPage } from '@/components/Common/PaginationControl/PaginationControlTypes'
13+
import { usePagination } from '@/hooks/usePagination'
1314

1415
interface LocationsListProps extends BaseComponentInterface {
1516
companyId: string
@@ -34,23 +35,20 @@ function Root({ companyId, className, children }: LocationsListProps) {
3435
data: { locationList, httpMeta },
3536
} = useLocationsGetSuspense({ companyId, page: currentPage, per: itemsPerPage })
3637

37-
const totalPages = Number(httpMeta.response.headers.get('x-total-pages') ?? 1)
38-
39-
const handleItemsPerPageChange = (newCount: PaginationItemsPerPage) => {
40-
setItemsPerPage(newCount)
41-
}
42-
const handleFirstPage = () => {
43-
setCurrentPage(1)
44-
}
45-
const handlePreviousPage = () => {
46-
setCurrentPage(prevPage => Math.max(prevPage - 1, 1))
47-
}
48-
const handleNextPage = () => {
49-
setCurrentPage(prevPage => Math.min(prevPage + 1, totalPages))
50-
}
51-
const handleLastPage = () => {
52-
setCurrentPage(totalPages)
53-
}
38+
const {
39+
totalPages,
40+
totalItems,
41+
handleFirstPage,
42+
handlePreviousPage,
43+
handleNextPage,
44+
handleLastPage,
45+
handleItemsPerPageChange,
46+
} = usePagination(httpMeta, {
47+
currentPage,
48+
itemsPerPage,
49+
setCurrentPage,
50+
setItemsPerPage,
51+
})
5452

5553
const handleContinue = () => {
5654
onEvent(companyEvents.COMPANY_LOCATION_DONE)
@@ -69,6 +67,7 @@ function Root({ companyId, className, children }: LocationsListProps) {
6967
locationList: locationList ?? [],
7068
currentPage,
7169
totalPages,
70+
totalItems,
7271
handleFirstPage,
7372
handlePreviousPage,
7473
handleNextPage,

src/components/Company/Locations/LocationsList/useLocationsList.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { PaginationItemsPerPage } from '@/components/Common/PaginationContr
55
type LocationsListContextType = {
66
locationList: Location[]
77
totalPages: number
8+
totalItems: number
89
currentPage: number
910
itemsPerPage: PaginationItemsPerPage
1011
handleItemsPerPageChange: (n: PaginationItemsPerPage) => void

0 commit comments

Comments
 (0)