@pagamio/frontend-commons-lib 0.8.356 → 0.8.358
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/pagamio-table/data-table/ExportButton.d.ts +9 -2
- package/lib/pagamio-table/data-table/ExportButton.js +32 -4
- package/lib/pagamio-table/data-table/index.js +1 -1
- package/lib/pagamio-table/data-table/types.d.ts +31 -1
- package/lib/shared/hooks/usePagamioTable.js +12 -1
- package/package.json +1 -1
|
@@ -51,11 +51,18 @@ type ExportDropdownProps<T extends Record<string, any>> = {
|
|
|
51
51
|
xlsxOptions?: XlsxExportOptions;
|
|
52
52
|
csvOptions?: CsvExportOptions;
|
|
53
53
|
exportAll?: boolean;
|
|
54
|
-
fetchData?: (params?: Record<string, string
|
|
54
|
+
fetchData?: (params?: Record<string, string>, onProgress?: (fetched: number, total: number) => void) => Promise<T[]>;
|
|
55
|
+
asyncExport?: {
|
|
56
|
+
threshold: number;
|
|
57
|
+
totalRows: number;
|
|
58
|
+
requestExport: (format: 'csv' | 'xlsx' | 'pdf') => Promise<void>;
|
|
59
|
+
onQueued?: (message: string) => void;
|
|
60
|
+
onError?: (error: Error) => void;
|
|
61
|
+
};
|
|
55
62
|
enableEmailExport?: boolean;
|
|
56
63
|
emailExportApiUrl?: string;
|
|
57
64
|
onEmailExportSuccess?: (message: string) => void;
|
|
58
65
|
onEmailExportError?: (error: Error) => void;
|
|
59
66
|
};
|
|
60
|
-
declare const ExportDropdown: <T extends Record<string, any>>({ data, columns, containerClassName, buttonClassName, extraOptions, pdfOptions, xlsxOptions, csvOptions, exportAll, fetchData, enableEmailExport, emailExportApiUrl, onEmailExportSuccess, onEmailExportError, }: ExportDropdownProps<T>) => import("react/jsx-runtime").JSX.Element;
|
|
67
|
+
declare const ExportDropdown: <T extends Record<string, any>>({ data, columns, containerClassName, buttonClassName, extraOptions, pdfOptions, xlsxOptions, csvOptions, exportAll, fetchData, asyncExport, enableEmailExport, emailExportApiUrl, onEmailExportSuccess, onEmailExportError, }: ExportDropdownProps<T>) => import("react/jsx-runtime").JSX.Element;
|
|
61
68
|
export default ExportDropdown;
|
|
@@ -5,10 +5,11 @@ import { HiMail } from 'react-icons/hi';
|
|
|
5
5
|
import { useEffect, useRef, useState } from 'react';
|
|
6
6
|
import { Button, Modal, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components';
|
|
7
7
|
import { exportToCsv, exportToPdf, exportToXlsx, generateExportAsFile, sendExportEmail } from './exportUtils';
|
|
8
|
-
const ExportDropdown = ({ data, columns, containerClassName, buttonClassName, extraOptions = [], pdfOptions, xlsxOptions, csvOptions, exportAll = false, fetchData, enableEmailExport = false, emailExportApiUrl, onEmailExportSuccess, onEmailExportError, }) => {
|
|
8
|
+
const ExportDropdown = ({ data, columns, containerClassName, buttonClassName, extraOptions = [], pdfOptions, xlsxOptions, csvOptions, exportAll = false, fetchData, asyncExport, enableEmailExport = false, emailExportApiUrl, onEmailExportSuccess, onEmailExportError, }) => {
|
|
9
9
|
const [exportType, setExportType] = useState(null);
|
|
10
10
|
const [excludedColumns, setExcludedColumns] = useState(['action']);
|
|
11
11
|
const [isExporting, setIsExporting] = useState(false);
|
|
12
|
+
const [exportProgress, setExportProgress] = useState(null);
|
|
12
13
|
const [exportAllData, setExportAllData] = useState(false);
|
|
13
14
|
const [selectOpen, setSelectOpen] = useState(false);
|
|
14
15
|
const [emailAddress, setEmailAddress] = useState('');
|
|
@@ -113,11 +114,31 @@ const ExportDropdown = ({ data, columns, containerClassName, buttonClassName, ex
|
|
|
113
114
|
setPendingEmailFormat(null);
|
|
114
115
|
setIsExporting(false);
|
|
115
116
|
};
|
|
117
|
+
// When exporting all and the dataset is too large to pull into the browser,
|
|
118
|
+
// hand off to the async backend job instead of paging through it client-side.
|
|
119
|
+
const shouldQueueAsync = exportAllData && !!asyncExport && asyncExport.totalRows > asyncExport.threshold;
|
|
116
120
|
const handleContinue = async () => {
|
|
117
121
|
const includedColumns = getIncludedColumns();
|
|
118
122
|
setIsExporting(true);
|
|
123
|
+
setExportProgress(null);
|
|
119
124
|
try {
|
|
120
|
-
|
|
125
|
+
if (shouldQueueAsync && asyncExport) {
|
|
126
|
+
// Async path: server generates the file off the request path and
|
|
127
|
+
// delivers it via export history / email. Nothing to render here.
|
|
128
|
+
const format = (isEmailExport ? emailFormat : exportType);
|
|
129
|
+
try {
|
|
130
|
+
await asyncExport.requestExport(format);
|
|
131
|
+
asyncExport.onQueued?.('Your export is being prepared. It will appear in export history when ready.');
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
asyncExport.onError?.(err instanceof Error ? err : new Error('Export request failed'));
|
|
135
|
+
}
|
|
136
|
+
setExportType(null);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const exportData = exportAllData && fetchData
|
|
140
|
+
? await fetchData({ exportAll: 'true' }, (fetched, total) => setExportProgress({ fetched, total }))
|
|
141
|
+
: data;
|
|
121
142
|
if (isEmailExport) {
|
|
122
143
|
// Store data, format and show email modal
|
|
123
144
|
setPendingExportData({ data: exportData, columns: includedColumns });
|
|
@@ -138,6 +159,7 @@ const ExportDropdown = ({ data, columns, containerClassName, buttonClassName, ex
|
|
|
138
159
|
}
|
|
139
160
|
finally {
|
|
140
161
|
setIsExporting(false);
|
|
162
|
+
setExportProgress(null);
|
|
141
163
|
}
|
|
142
164
|
};
|
|
143
165
|
const handleExportChange = (type) => {
|
|
@@ -157,14 +179,20 @@ const ExportDropdown = ({ data, columns, containerClassName, buttonClassName, ex
|
|
|
157
179
|
};
|
|
158
180
|
// Show popover when exportType is set and Select is closed
|
|
159
181
|
const popoverOpened = exportType !== null && !selectOpen;
|
|
160
|
-
return (_jsxs("div", { className: `w-40 ${containerClassName ?? ''}`, children: [_jsxs(Select, { value: exportType ?? 'none', open: selectOpen, onOpenChange: setSelectOpen, onValueChange: (value) => handleExportChange(value === 'none' ? null : value), children: [_jsx(SelectTrigger, { className: `w-full ${buttonClassName ?? ''}`, children: _jsx(SelectValue, { placeholder: "Export Data" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "none", children: _jsx("span", { className: "text-muted-foreground", children: "Export Data As" }) }, "reset"), exportOptions.map((option) => (_jsx(SelectItem, { value: option.value, children: option.label }, option.value))), extraOptions.map((option) => (_jsx(SelectItem, { value: option.value, children: option.label }, option.value)))] })] }, "export-dropdown"), popoverOpened && (_jsxs("div", { ref: popoverRef, className: "mt-2 bg-background rounded-md shadow-lg p-4 z-50 absolute w-45", children: [_jsx("div", { className: "text-sm text-muted-foreground pb-3", children: "Uncheck to exclude column from export" }), _jsxs("div", { className: "mb-4 p-3 bg-muted rounded-md", children: [_jsxs("div", { className: "flex items-center gap-x-2", children: [_jsx(Checkbox, { id: "export-all-data", checked: exportAllData, onChange: (event) => setExportAllData(event.currentTarget.checked), className: "checked:!bg-primary checked:!border-primary" }), _jsx(Label, { htmlFor: "export-all-data", className: "text-sm font-medium", children: "Export all data" })] }), _jsx("p", { className: "text-xs text-muted-foreground mt-1 ml-6", children:
|
|
182
|
+
return (_jsxs("div", { className: `w-40 ${containerClassName ?? ''}`, children: [_jsxs(Select, { value: exportType ?? 'none', open: selectOpen, onOpenChange: setSelectOpen, onValueChange: (value) => handleExportChange(value === 'none' ? null : value), children: [_jsx(SelectTrigger, { className: `w-full ${buttonClassName ?? ''}`, children: _jsx(SelectValue, { placeholder: "Export Data" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "none", children: _jsx("span", { className: "text-muted-foreground", children: "Export Data As" }) }, "reset"), exportOptions.map((option) => (_jsx(SelectItem, { value: option.value, children: option.label }, option.value))), extraOptions.map((option) => (_jsx(SelectItem, { value: option.value, children: option.label }, option.value)))] })] }, "export-dropdown"), popoverOpened && (_jsxs("div", { ref: popoverRef, className: "mt-2 bg-background rounded-md shadow-lg p-4 z-50 absolute w-45", children: [_jsx("div", { className: "text-sm text-muted-foreground pb-3", children: "Uncheck to exclude column from export" }), _jsxs("div", { className: "mb-4 p-3 bg-muted rounded-md", children: [_jsxs("div", { className: "flex items-center gap-x-2", children: [_jsx(Checkbox, { id: "export-all-data", checked: exportAllData, onChange: (event) => setExportAllData(event.currentTarget.checked), className: "checked:!bg-primary checked:!border-primary" }), _jsx(Label, { htmlFor: "export-all-data", className: "text-sm font-medium", children: "Export all data" })] }), _jsx("p", { className: "text-xs text-muted-foreground mt-1 ml-6", children: shouldQueueAsync
|
|
183
|
+
? 'This dataset is large — it will be prepared in the background and appear in export history.'
|
|
184
|
+
: 'This will fetch all records for the table' })] }), _jsx(Stack, { children: columns.map((col) => {
|
|
161
185
|
if (col.accessorKey !== 'action') {
|
|
162
186
|
return (_jsxs("div", { className: "flex items-center gap-x-2", children: [_jsx(Checkbox, { id: col.header, checked: !excludedColumns.includes(col.accessorKey), onChange: (event) => handleCheckboxChange(col.accessorKey, !event.currentTarget.checked), className: "checked:!bg-primary checked:!border-primary" }), _jsx(Label, { htmlFor: "rememberMe", children: col.header })] }, col.accessorKey));
|
|
163
187
|
}
|
|
164
188
|
}) }), _jsx(Button, { variant: "primary", className: `w-full mt-4 px-4 py-2 rounded-md text-white flex items-center justify-center
|
|
165
189
|
${getIncludedColumns().length === 0 || isExporting
|
|
166
190
|
? 'bg-gray-300 cursor-not-allowed'
|
|
167
|
-
: 'bg-primary hover:bg-primary/90'}`, onClick: handleContinue, disabled: getIncludedColumns().length === 0 || isExporting, children: isExporting ? (_jsxs(_Fragment, { children: [_jsxs("svg", { className: "animate-spin -ml-1 mr-2 h-4 w-4 text-white", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }),
|
|
191
|
+
: 'bg-primary hover:bg-primary/90'}`, onClick: handleContinue, disabled: getIncludedColumns().length === 0 || isExporting, children: isExporting ? (_jsxs(_Fragment, { children: [_jsxs("svg", { className: "animate-spin -ml-1 mr-2 h-4 w-4 text-white", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }), exportProgress && exportProgress.total > exportProgress.fetched
|
|
192
|
+
? `Fetching ${exportProgress.fetched}/${exportProgress.total}...`
|
|
193
|
+
: isEmailExport
|
|
194
|
+
? 'Preparing...'
|
|
195
|
+
: 'Exporting...'] })) : ('Continue') })] })), _jsx(Modal, { isOpen: showEmailModal, onClose: handleEmailModalClose, title: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(HiMail, { className: "w-5 h-5 text-primary" }), _jsx("span", { children: "Send Export via Email" })] }), size: "md", primaryButton: {
|
|
168
196
|
label: 'Send Email',
|
|
169
197
|
onClick: () => {
|
|
170
198
|
handleEmailModalSubmit().catch((error) => {
|
|
@@ -97,7 +97,7 @@ function CustomToolbar({ filters, appliedFilters, onFilterChange, onApply, onCle
|
|
|
97
97
|
else {
|
|
98
98
|
handleFilter(name, value);
|
|
99
99
|
}
|
|
100
|
-
}, handleApplyFilters: onApply, resetFilters: onClearFilters ?? (() => { }), showSearch: searchEnabled, searchQuery: searchQuery, onSearch: onSearch, isNarrow: isNarrow, children: _jsxs("div", { className: "flex flex-wrap items-center justify-end gap-2", children: [exportConfig?.enabled && (_jsx(ExportDropdown, { containerClassName: isNarrow ? 'w-full' : undefined, columns: columns, data: data, buttonClassName: "h-10", extraOptions: exportConfig.extraOptions, pdfOptions: exportConfig.pdf, xlsxOptions: exportConfig.xlsx, csvOptions: exportConfig.csv, exportAll: !!exportConfig.fetchAllData, fetchData: exportConfig.fetchAllData, enableEmailExport: !!exportConfig.email, emailExportApiUrl: exportConfig.email?.apiUrl, onEmailExportSuccess: exportConfig.email?.onSuccess, onEmailExportError: exportConfig.email?.onError })), addButton &&
|
|
100
|
+
}, handleApplyFilters: onApply, resetFilters: onClearFilters ?? (() => { }), showSearch: searchEnabled, searchQuery: searchQuery, onSearch: onSearch, isNarrow: isNarrow, children: _jsxs("div", { className: "flex flex-wrap items-center justify-end gap-2", children: [exportConfig?.enabled && (_jsx(ExportDropdown, { containerClassName: isNarrow ? 'w-full' : undefined, columns: columns, data: data, buttonClassName: "h-10", extraOptions: exportConfig.extraOptions, pdfOptions: exportConfig.pdf, xlsxOptions: exportConfig.xlsx, csvOptions: exportConfig.csv, exportAll: !!exportConfig.fetchAllData, fetchData: exportConfig.fetchAllData, asyncExport: exportConfig.asyncExport, enableEmailExport: !!exportConfig.email, emailExportApiUrl: exportConfig.email?.apiUrl, onEmailExportSuccess: exportConfig.email?.onSuccess, onEmailExportError: exportConfig.email?.onError })), addButton &&
|
|
101
101
|
(typeof addButton === 'boolean' ? (_jsx(IconButton, { onClick: onAdd ?? (() => { }), icon: HiPlusSm, label: "add-new-entry", className: cn('items-center text-nowrap', isNarrow ? 'w-full' : undefined), children: addText ?? 'Add New Entry' })) : (addButton))] }) }));
|
|
102
102
|
}
|
|
103
103
|
// ─── PagamioTable Component ──────────────────────────────────────────
|
|
@@ -82,12 +82,42 @@ export interface ExportConfig<T extends Record<string, any>> {
|
|
|
82
82
|
xlsx?: XlsxExportOptions;
|
|
83
83
|
csv?: CsvExportOptions;
|
|
84
84
|
extraOptions?: ExtraOption[];
|
|
85
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Fetch the full dataset for "Export all data" (not just the current page).
|
|
87
|
+
* `onProgress(fetched, total)` is invoked after each page so the UI can show
|
|
88
|
+
* progress on long, multi-request exports. `usePagamioTable` auto-wires this
|
|
89
|
+
* for server-paginated tables; a consumer may override (e.g. a dedicated
|
|
90
|
+
* export endpoint).
|
|
91
|
+
*/
|
|
92
|
+
fetchAllData?: (params?: Record<string, string>, onProgress?: (fetched: number, total: number) => void) => Promise<T[]>;
|
|
86
93
|
email?: {
|
|
87
94
|
apiUrl: string;
|
|
88
95
|
onSuccess?: (message: string) => void;
|
|
89
96
|
onError?: (error: Error) => void;
|
|
90
97
|
};
|
|
98
|
+
/**
|
|
99
|
+
* Async (queued) export for datasets too large to page through and render in
|
|
100
|
+
* the browser. When "Export all data" is checked and `totalRows > threshold`,
|
|
101
|
+
* the export button calls `requestExport(format)` instead of fetching the
|
|
102
|
+
* whole set client-side — the backend generates the file off the request path
|
|
103
|
+
* and delivers it via export history / email.
|
|
104
|
+
*
|
|
105
|
+
* `usePagamioTable` auto-wires `totalRows` from the table's row count; the
|
|
106
|
+
* consumer supplies `threshold` and `requestExport` (which POSTs to
|
|
107
|
+
* `/exports` with its `resourceKey` + current filters).
|
|
108
|
+
*/
|
|
109
|
+
asyncExport?: {
|
|
110
|
+
/** Row count above which export is queued instead of run client-side. */
|
|
111
|
+
threshold: number;
|
|
112
|
+
/** Current total server rows (auto-wired by the hook). */
|
|
113
|
+
totalRows: number;
|
|
114
|
+
/** Queue a server-side export of the full dataset in the chosen format. */
|
|
115
|
+
requestExport: (format: 'csv' | 'xlsx' | 'pdf') => Promise<void>;
|
|
116
|
+
/** Called after a successful queue request (e.g. to toast the user). */
|
|
117
|
+
onQueued?: (message: string) => void;
|
|
118
|
+
/** Called if the queue request fails. */
|
|
119
|
+
onError?: (error: Error) => void;
|
|
120
|
+
};
|
|
91
121
|
}
|
|
92
122
|
export interface ToolbarConfig<T extends Record<string, any>> {
|
|
93
123
|
filters?: BaseFilter[];
|
|
@@ -274,9 +274,10 @@ export const usePagamioTable = ({ queryKey, queryFn, enabled: queryEnabled = tru
|
|
|
274
274
|
// Pages through the same `queryFn` at the backend's max `limit`, preserving
|
|
275
275
|
// the user's current sort/filters/search so the export matches what they see.
|
|
276
276
|
// Returns the full flattened dataset for the client-side export utils.
|
|
277
|
-
const fetchAllPages = useCallback(async () => {
|
|
277
|
+
const fetchAllPages = useCallback(async (_params, onProgress) => {
|
|
278
278
|
if (!isServerMode || !queryFn) {
|
|
279
279
|
// Client-mode tables already hold the whole filtered/sorted set.
|
|
280
|
+
onProgress?.(processedData.length, processedData.length);
|
|
280
281
|
return processedData;
|
|
281
282
|
}
|
|
282
283
|
const buildPageParams = (page) => buildQueryParams(queryParamConfig, { mode: paginationConfig.mode, page, size: MAX_EXPORT_PAGE_SIZE }, { mode: sortingConfig.mode, sortConfig }, { mode: filteringConfig.mode, searchMode, filters: appliedFilters });
|
|
@@ -288,6 +289,7 @@ export const usePagamioTable = ({ queryKey, queryFn, enabled: queryEnabled = tru
|
|
|
288
289
|
const all = [...first.data];
|
|
289
290
|
const total = first.total || serverTotal;
|
|
290
291
|
const totalPages = Math.max(1, Math.ceil(total / MAX_EXPORT_PAGE_SIZE));
|
|
292
|
+
onProgress?.(all.length, total);
|
|
291
293
|
// Pages are zero/one-based per the consumer's `queryParamConfig.transform`;
|
|
292
294
|
// `buildQueryParams` already applies that transform, so we just feed it the
|
|
293
295
|
// hook's internal zero-based index and let the config bump it.
|
|
@@ -297,6 +299,7 @@ export const usePagamioTable = ({ queryKey, queryFn, enabled: queryEnabled = tru
|
|
|
297
299
|
if (pageData.length === 0)
|
|
298
300
|
break; // defensive: stop if a page comes back empty
|
|
299
301
|
all.push(...pageData);
|
|
302
|
+
onProgress?.(all.length, total);
|
|
300
303
|
}
|
|
301
304
|
return all;
|
|
302
305
|
}, [
|
|
@@ -366,6 +369,14 @@ export const usePagamioTable = ({ queryKey, queryFn, enabled: queryEnabled = tru
|
|
|
366
369
|
export: {
|
|
367
370
|
...toolbar.export,
|
|
368
371
|
fetchAllData: toolbar.export.fetchAllData ?? fetchAllPages,
|
|
372
|
+
// Keep the async-export row count in sync with the live total so
|
|
373
|
+
// the consumer only has to supply `threshold` + `requestExport`.
|
|
374
|
+
...(toolbar.export.asyncExport && {
|
|
375
|
+
asyncExport: {
|
|
376
|
+
...toolbar.export.asyncExport,
|
|
377
|
+
totalRows: totalItems,
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
369
380
|
},
|
|
370
381
|
}),
|
|
371
382
|
},
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pagamio/frontend-commons-lib",
|
|
3
3
|
"description": "Pagamio library for Frontend reusable components like the form engine and table container",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.358",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
7
7
|
"provenance": false
|