Skip to content

Commit 02597a8

Browse files
authored
Merge pull request #158 from auth0/feat/mm-invitations-ui-primitives
feat(react): add UI primitives and data table updates
2 parents 459ab5d + 84ef293 commit 02597a8

6 files changed

Lines changed: 1069 additions & 96 deletions

File tree

packages/react/src/components/auth0/shared/data-pagination.tsx

Lines changed: 67 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@ import {
1010
Pagination,
1111
PaginationContent,
1212
PaginationItem,
13-
PaginationLink,
1413
PaginationPrevious,
1514
PaginationNext,
16-
PaginationEllipsis,
1715
} from '@/components/auth0/shared/pagination';
1816
import {
1917
Select,
@@ -44,12 +42,12 @@ export const defaultLabels: DataPaginationLabels = {
4442
showing: 'Showing',
4543
to: 'to',
4644
of: 'of',
47-
results: 'results',
45+
results: '',
4846
totalResults: 'total results',
49-
show: 'Show',
47+
show: '',
5048
perPage: 'per page',
51-
previous: 'Previous',
52-
next: 'Next',
49+
previous: '',
50+
next: '',
5351
goToPage: 'Go to page {page}',
5452
goToPrevious: 'Go to previous page',
5553
goToNext: 'Go to next page',
@@ -66,6 +64,7 @@ export interface RegularPaginationState {
6664

6765
export interface CheckpointPaginationState {
6866
pageSize: number;
67+
currentPage?: number;
6968
totalItems?: number;
7069
hasNextPage: boolean;
7170
hasPreviousPage: boolean;
@@ -86,6 +85,14 @@ export interface DataPaginationProps {
8685
onPreviousPage?: () => void;
8786
}
8887

88+
interface PageRangeInfoProps {
89+
totalItems: number;
90+
currentPage: number;
91+
pageSize: number;
92+
locale?: string;
93+
labels: DataPaginationLabels;
94+
}
95+
8996
const formatNumber = (num: number | undefined | null, locale?: string): string => {
9097
if (num === null || num === undefined || isNaN(num)) {
9198
return '0';
@@ -96,32 +103,46 @@ const formatNumber = (num: number | undefined | null, locale?: string): string =
96103
return num.toLocaleString(resolvedLocale);
97104
};
98105

99-
const interpolate = (str: string, values: Record<string, string | number>): string =>
100-
str.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ''));
101-
102-
const generatePageNumbers = (
106+
const getPageRange = (
107+
totalItems: number,
103108
currentPage: number,
104-
totalPages: number,
105-
maxVisible: number = 5,
106-
): (number | 'ellipsis')[] => {
107-
if (totalPages <= maxVisible) return Array.from({ length: totalPages }, (_, i) => i + 1);
108-
109-
const pages: (number | 'ellipsis')[] = [1];
110-
const range = Math.floor((maxVisible - 3) / 2);
111-
let start = Math.max(2, currentPage - range);
112-
let end = Math.min(totalPages - 1, currentPage + range);
113-
114-
if (currentPage <= range + 2) end = maxVisible - 2;
115-
if (currentPage >= totalPages - range - 1) start = totalPages - (maxVisible - 2);
116-
117-
if (start > 2) pages.push('ellipsis');
118-
for (let i = start; i <= end; i++) pages.push(i);
119-
if (end < totalPages - 1) pages.push('ellipsis');
120-
pages.push(totalPages);
121-
122-
return pages;
109+
pageSize: number,
110+
locale?: string,
111+
): { start: string; end: string } => {
112+
const start = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1;
113+
const end = Math.min(currentPage * pageSize, totalItems);
114+
return {
115+
start: formatNumber(start, locale),
116+
end: formatNumber(end, locale),
117+
};
123118
};
124119

120+
/**
121+
* Renders the page range info.
122+
*
123+
* @param props - Component props.
124+
* @param props.totalItems - Total number of items.
125+
* @param props.currentPage - Current page number.
126+
* @param props.pageSize - Number of items per page.
127+
* @param props.locale - Locale identifier for number formatting.
128+
* @param props.labels - Label text configuration.
129+
* @returns JSX element displaying the page range info.
130+
*/
131+
function PageRangeInfo({ totalItems, currentPage, pageSize, locale, labels }: PageRangeInfoProps) {
132+
const range = getPageRange(totalItems, currentPage, pageSize, locale);
133+
return (
134+
<>
135+
{labels.showing}{' '}
136+
<span className="font-medium text-foreground">
137+
{range.start}-{range.end}
138+
</span>{' '}
139+
{labels.of}{' '}
140+
<span className="font-medium text-foreground">{formatNumber(totalItems, locale)}</span>
141+
{labels.results && <> {labels.results}</>}
142+
</>
143+
);
144+
}
145+
125146
/**
126147
*
127148
* @param props - Component props.
@@ -170,18 +191,6 @@ export function DataPagination({
170191
return Array.from(uniqueOptions).sort((a, b) => a - b);
171192
}, [shouldShowPageSizeSelector, pageSizeOptions, currentPageSize]);
172193

173-
const pageNumbers = useMemo(
174-
() =>
175-
isRegular && regularState
176-
? generatePageNumbers(
177-
regularState.currentPage,
178-
regularState.totalPages,
179-
regularState.maxVisiblePages,
180-
)
181-
: [],
182-
[isRegular, regularState],
183-
);
184-
185194
useEffect(() => {
186195
if (ariaLiveRegionRef.current) {
187196
if (isRegular && regularState) {
@@ -196,7 +205,6 @@ export function DataPagination({
196205
return null;
197206
}
198207

199-
const shouldShowPageNumbers = isRegular;
200208
const safeCurrentPageSize = currentPageSize as number;
201209

202210
return (
@@ -214,32 +222,22 @@ export function DataPagination({
214222
{showPageInfo && (
215223
<div className="text-sm text-foreground whitespace-nowrap sm:mr-auto">
216224
{isRegular && regularState ? (
217-
<>
218-
{labels.showing}{' '}
219-
<span className="font-medium text-foreground">
220-
{formatNumber(
221-
regularState.totalItems === 0
222-
? 0
223-
: (regularState.currentPage - 1) * regularState.pageSize + 1,
224-
locale,
225-
)}
226-
</span>{' '}
227-
{labels.to}{' '}
228-
<span className="font-medium text-foreground">
229-
{formatNumber(
230-
Math.min(
231-
regularState.currentPage * regularState.pageSize,
232-
regularState.totalItems,
233-
),
234-
locale,
235-
)}
236-
</span>{' '}
237-
{labels.of}{' '}
238-
<span className="font-medium text-foreground">
239-
{formatNumber(regularState.totalItems, locale)}
240-
</span>{' '}
241-
{labels.results}
242-
</>
225+
<PageRangeInfo
226+
totalItems={regularState.totalItems}
227+
currentPage={regularState.currentPage}
228+
pageSize={regularState.pageSize}
229+
locale={locale}
230+
labels={labels}
231+
/>
232+
) : checkpointState?.totalItems !== undefined &&
233+
checkpointState?.currentPage !== undefined ? (
234+
<PageRangeInfo
235+
totalItems={checkpointState.totalItems}
236+
currentPage={checkpointState.currentPage}
237+
pageSize={checkpointState.pageSize}
238+
locale={locale}
239+
labels={labels}
240+
/>
243241
) : checkpointState?.totalItems !== undefined ? (
244242
<>
245243
<span className="font-medium text-foreground">
@@ -254,7 +252,7 @@ export function DataPagination({
254252
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
255253
{shouldShowPageSizeSelector && allPageSizeOptions.length > 0 && (
256254
<div className="flex items-center justify-center gap-2 whitespace-nowrap sm:justify-start">
257-
<span className="text-sm text-foreground">{labels.show}</span>
255+
{labels.show && <span className="text-sm text-foreground">{labels.show}</span>}
258256
<Select
259257
value={safeCurrentPageSize.toString()}
260258
onValueChange={(value) => onPageSizeChange?.(Number(value))}
@@ -291,28 +289,6 @@ export function DataPagination({
291289
/>
292290
</PaginationItem>
293291

294-
{shouldShowPageNumbers &&
295-
pageNumbers.map((page, idx) => (
296-
<PaginationItem key={`page-${idx}`}>
297-
{page === 'ellipsis' ? (
298-
<PaginationEllipsis>
299-
<span className="sr-only">{labels.morePages}</span>
300-
</PaginationEllipsis>
301-
) : (
302-
<PaginationLink
303-
onClick={() => onPageChange?.(page)}
304-
isActive={regularState.currentPage === page}
305-
aria-label={interpolate(labels.goToPage, {
306-
page: formatNumber(page, locale),
307-
})}
308-
aria-current={regularState.currentPage === page ? 'page' : undefined}
309-
>
310-
{formatNumber(page, locale)}
311-
</PaginationLink>
312-
)}
313-
</PaginationItem>
314-
))}
315-
316292
<PaginationItem>
317293
<PaginationNext
318294
label={labels.next}

packages/react/src/components/auth0/shared/data-table.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ export interface EmptyStateProps {
110110
action?: ActionButton;
111111
}
112112

113+
export interface DataTableSortConfig {
114+
key: string | null;
115+
direction: 'asc' | 'desc';
116+
}
117+
113118
export interface DataTableProps<Item> {
114119
data: Item[];
115120
columns: Column<Item>[];
@@ -119,6 +124,10 @@ export interface DataTableProps<Item> {
119124
onRowClick?: (rowData: Item) => void;
120125
className?: string;
121126
headerAlign?: AlignmentType;
127+
/** When provided, sorting is delegated to the parent (server-side). */
128+
onSortChange?: (sortConfig: DataTableSortConfig) => void;
129+
/** Controlled sort state. Used with onSortChange for server-side sorting. */
130+
sortConfig?: DataTableSortConfig;
122131
}
123132

124133
const ALIGNMENT_CLASSES = {
@@ -387,13 +396,43 @@ export function DataTable<Item>({
387396
onRowClick,
388397
className,
389398
headerAlign = 'left',
399+
onSortChange,
400+
sortConfig,
390401
}: DataTableProps<Item>) {
391-
const [sorting, setSorting] = useState<SortingState>([]);
402+
const isServerSideSort = !!onSortChange;
403+
404+
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
405+
406+
// Convert controlled sortConfig to TanStack SortingState for header display
407+
const sorting: SortingState = useMemo(() => {
408+
if (isServerSideSort && sortConfig?.key) {
409+
return [{ id: sortConfig.key, desc: sortConfig.direction === 'desc' }];
410+
}
411+
return internalSorting;
412+
}, [isServerSideSort, sortConfig, internalSorting]);
413+
414+
const handleSortingChange = React.useCallback(
415+
(updater: SortingState | ((old: SortingState) => SortingState)) => {
416+
const newSorting = typeof updater === 'function' ? updater(sorting) : updater;
417+
418+
if (isServerSideSort) {
419+
const sort = newSorting[0];
420+
if (sort) {
421+
onSortChange({ key: sort.id, direction: sort.desc ? 'desc' : 'asc' });
422+
} else {
423+
onSortChange({ key: null, direction: 'asc' });
424+
}
425+
} else {
426+
setInternalSorting(newSorting);
427+
}
428+
},
429+
[isServerSideSort, onSortChange, sorting],
430+
);
392431

393432
const tableColumns = useMemo<ColumnDef<Item>[]>(() => {
394433
return columns.map((column, index) => {
395434
return {
396-
id: `column-${index}`,
435+
id: column.accessorKey ? String(column.accessorKey) : `column-${index}`,
397436
accessorKey: column.accessorKey as string,
398437
header: column.title,
399438
size: column.width
@@ -454,9 +493,10 @@ export function DataTable<Item>({
454493
state: {
455494
sorting,
456495
},
457-
onSortingChange: setSorting,
496+
onSortingChange: handleSortingChange,
458497
getCoreRowModel: getCoreRowModel(),
459-
getSortedRowModel: getSortedRowModel(),
498+
getSortedRowModel: isServerSideSort ? undefined : getSortedRowModel(),
499+
manualSorting: isServerSideSort,
460500
manualPagination: true,
461501
});
462502

0 commit comments

Comments
 (0)