@pagamio/frontend-commons-lib 0.8.350 → 0.8.352
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/components/ui/PagamioSearchableCombobox.d.ts +21 -1
- package/lib/components/ui/PagamioSearchableCombobox.js +15 -8
- package/lib/form-engine/components/inputs/searchable-combobox/SearchableComboboxInput.js +1 -1
- package/lib/form-engine/types/index.d.ts +11 -0
- package/lib/pagamio-table/data-table/exportUtils.d.ts +3 -4
- package/lib/pagamio-table/data-table/exportUtils.js +68 -8
- package/lib/pagamio-table/data-table/pdfExportUtils.js +17 -5
- package/lib/shared/hooks/index.d.ts +1 -0
- package/lib/shared/hooks/index.js +1 -0
- package/lib/shared/hooks/useInfiniteCursor.d.ts +104 -0
- package/lib/shared/hooks/useInfiniteCursor.js +73 -0
- package/package.json +3 -3
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import type { UsePagamioComboboxReturn } from '../../shared/hooks/usePagamioCombobox';
|
|
3
|
+
/**
|
|
4
|
+
* Optional in-popover action — replaces the old "+ Create New X" sentinel
|
|
5
|
+
* option pattern that some `<Select>` callers used to inline a "create a
|
|
6
|
+
* new item" affordance. Rendered as a button row pinned to the top of the
|
|
7
|
+
* popover, above the options list. Clicking it closes the popover and
|
|
8
|
+
* fires `onClick` (typically opening a quick-create modal). The handler
|
|
9
|
+
* is responsible for the side effect; the combobox does NOT update its
|
|
10
|
+
* `value` automatically.
|
|
11
|
+
*/
|
|
12
|
+
export interface SearchableComboboxActionButton {
|
|
13
|
+
/** Label shown on the action button (e.g. "+ New category"). */
|
|
14
|
+
label: React.ReactNode;
|
|
15
|
+
onClick: () => void;
|
|
16
|
+
}
|
|
3
17
|
export interface PagamioSearchableComboboxProps<T extends {
|
|
4
18
|
id: string;
|
|
5
19
|
}> {
|
|
@@ -8,6 +22,12 @@ export interface PagamioSearchableComboboxProps<T extends {
|
|
|
8
22
|
/** Controlled selected id, or null when nothing is selected. */
|
|
9
23
|
value: string | null;
|
|
10
24
|
onChange: (id: string | null) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Optional action button rendered at the top of the popover. Used to
|
|
27
|
+
* surface a "+ Create new …" affordance inside the picker itself,
|
|
28
|
+
* matching where the old static-select sentinel option lived.
|
|
29
|
+
*/
|
|
30
|
+
actionButton?: SearchableComboboxActionButton;
|
|
11
31
|
/**
|
|
12
32
|
* Optional pre-resolved selected option. When set, the trigger shows its
|
|
13
33
|
* label without needing to fetch via `restoreIds`. If both this and the
|
|
@@ -39,4 +59,4 @@ export interface PagamioSearchableComboboxProps<T extends {
|
|
|
39
59
|
}
|
|
40
60
|
export declare function PagamioSearchableCombobox<T extends {
|
|
41
61
|
id: string;
|
|
42
|
-
}>({ combobox, value, onChange, selectedOption, placeholder, searchPlaceholder, emptyMessage, cappedMessage, loadingMessage, disabled, required, name, getLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }: PagamioSearchableComboboxProps<T>): import("react/jsx-runtime").JSX.Element;
|
|
62
|
+
}>({ combobox, value, onChange, actionButton, selectedOption, placeholder, searchPlaceholder, emptyMessage, cappedMessage, loadingMessage, disabled, required, name, getLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }: PagamioSearchableComboboxProps<T>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -19,7 +19,7 @@ function defaultGetLabel(option) {
|
|
|
19
19
|
const o = option;
|
|
20
20
|
return o.name ?? option.id;
|
|
21
21
|
}
|
|
22
|
-
export function PagamioSearchableCombobox({ combobox, value, onChange, selectedOption, placeholder = 'Select…', searchPlaceholder = 'Search...', emptyMessage = 'No options found', cappedMessage = 'Showing top results — refine your search.', loadingMessage = 'Loading…', disabled, required, name, getLabel = defaultGetLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }) {
|
|
22
|
+
export function PagamioSearchableCombobox({ combobox, value, onChange, actionButton, selectedOption, placeholder = 'Select…', searchPlaceholder = 'Search...', emptyMessage = 'No options found', cappedMessage = 'Showing top results — refine your search.', loadingMessage = 'Loading…', disabled, required, name, getLabel = defaultGetLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }) {
|
|
23
23
|
const [open, setOpen] = React.useState(false);
|
|
24
24
|
const inputRef = React.useRef(null);
|
|
25
25
|
// Focus the search input when the popover opens — matches the Select's
|
|
@@ -52,11 +52,18 @@ export function PagamioSearchableCombobox({ combobox, value, onChange, selectedO
|
|
|
52
52
|
: placeholder }), _jsx(ChevronDownIcon, { className: "ml-2 h-4 w-4 opacity-50 shrink-0" })] }) }), _jsxs(PopoverContent, { align: "start", sideOffset: 4,
|
|
53
53
|
// Mirrors SelectContent: same shadow + animation classes, popover
|
|
54
54
|
// background, popover-foreground text. Width matches trigger.
|
|
55
|
-
className: cn('p-0 w-[var(--radix-popover-trigger-width)] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md', contentClassName), children: [_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { ref: inputRef, type: "text", placeholder: searchPlaceholder, value: combobox.search, onChange: (e) => combobox.setSearch(e.target.value), onKeyDown: (e) => e.stopPropagation(), className: "w-full px-2 py-1 text-sm border border-input rounded bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" }) }),
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
className: cn('p-0 w-[var(--radix-popover-trigger-width)] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md', contentClassName), children: [_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { ref: inputRef, type: "text", placeholder: searchPlaceholder, value: combobox.search, onChange: (e) => combobox.setSearch(e.target.value), onKeyDown: (e) => e.stopPropagation(), className: "w-full px-2 py-1 text-sm border border-input rounded bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" }) }), _jsxs("div", { className: "p-1 max-h-[200px] overflow-y-auto", children: [actionButton ? (_jsx("button", { type: "button", onClick: () => {
|
|
56
|
+
setOpen(false);
|
|
57
|
+
actionButton.onClick();
|
|
58
|
+
}, className: cn(
|
|
59
|
+
// Same row geometry as an option row so the picker reads
|
|
60
|
+
// as one consistent list. Primary-coloured label keeps it
|
|
61
|
+
// visually distinct from real options.
|
|
62
|
+
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none text-left font-medium text-primary', 'hover:bg-muted focus:bg-muted'), children: actionButton.label }, "__action__")) : null, combobox.isLoading && combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: loadingMessage })) : combobox.error ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-destructive", children: combobox.error.message || 'Failed to load options.' })) : combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: emptyMessage })) : (_jsxs(_Fragment, { children: [combobox.options.map((option) => {
|
|
63
|
+
const isSelected = option.id === value;
|
|
64
|
+
return (_jsx("button", { type: "button", role: "option", "aria-selected": isSelected, onClick: () => handleSelect(option.id), className: cn(
|
|
65
|
+
// Mirrors SelectItem styling: padding, hover via
|
|
66
|
+
// focus:bg-muted, selected via bg-accent.
|
|
67
|
+
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none text-left', 'hover:bg-muted focus:bg-muted focus:text-foreground', isSelected && 'bg-accent text-foreground'), children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "line-clamp-1", children: getLabel(option) }), isSelected ? (_jsx("span", { className: "absolute right-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(CheckIcon, { className: "h-4 w-4" }) })) : null] })) }, option.id));
|
|
68
|
+
}), combobox.isCapped ? (_jsx("div", { className: "mt-1 border-t px-2 py-1.5 text-xs text-muted-foreground", children: cappedMessage })) : null] }))] })] })] }), name ? _jsx("input", { type: "hidden", name: name, value: value ?? '' }) : null] }));
|
|
62
69
|
}
|
|
@@ -34,7 +34,7 @@ const SearchableComboboxInput = forwardRef(({ field, value, onChange }, ref) =>
|
|
|
34
34
|
debounceMs: source.debounceMs,
|
|
35
35
|
restoreIds,
|
|
36
36
|
});
|
|
37
|
-
return (_jsxs("div", { ref: ref, children: [field.label && (_jsx("label", { htmlFor: field.name, className: "block text-sm font-medium text-foreground mb-1", children: field.label })), _jsx(PagamioSearchableCombobox, { combobox: combobox, value: value ?? null, onChange: (id) => onChange?.(id), placeholder: field.placeholder, disabled: field.disabled, required: field.validation?.required ? true : undefined, name: field.name, getLabel: source.getLabel })] }));
|
|
37
|
+
return (_jsxs("div", { ref: ref, children: [field.label && (_jsx("label", { htmlFor: field.name, className: "block text-sm font-medium text-foreground mb-1", children: field.label })), _jsx(PagamioSearchableCombobox, { combobox: combobox, value: value ?? null, onChange: (id) => onChange?.(id), actionButton: field.actionButton, placeholder: field.placeholder, disabled: field.disabled, required: field.validation?.required ? true : undefined, name: field.name, getLabel: source.getLabel })] }));
|
|
38
38
|
});
|
|
39
39
|
SearchableComboboxInput.displayName = 'SearchableComboboxInput';
|
|
40
40
|
export default SearchableComboboxInput;
|
|
@@ -215,6 +215,17 @@ export interface Field {
|
|
|
215
215
|
* Tells the form engine how to fetch lookup results.
|
|
216
216
|
*/
|
|
217
217
|
source?: ComboboxSourceSpec;
|
|
218
|
+
/**
|
|
219
|
+
* Optional in-picker action for `searchable-combobox`. Renders as the
|
|
220
|
+
* first row of the popover (above the options) — the same place a
|
|
221
|
+
* "+ Create new …" sentinel option used to live in the old static
|
|
222
|
+
* `<Select>`. Clicking it closes the popover and fires `onClick`; the
|
|
223
|
+
* handler owns the side effect (e.g. opens a quick-create modal).
|
|
224
|
+
*/
|
|
225
|
+
actionButton?: {
|
|
226
|
+
label: string;
|
|
227
|
+
onClick: () => void;
|
|
228
|
+
};
|
|
218
229
|
}
|
|
219
230
|
export interface DependentFieldUpdate {
|
|
220
231
|
field: string;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import 'jspdf-autotable';
|
|
2
1
|
import { type MRT_ColumnDef } from 'mantine-react-table';
|
|
3
2
|
interface ThemeColors {
|
|
4
3
|
primary: {
|
|
@@ -36,9 +35,9 @@ interface CsvExportOptions {
|
|
|
36
35
|
delimiter?: string;
|
|
37
36
|
filename?: string;
|
|
38
37
|
}
|
|
39
|
-
export declare const exportToCsv: <T extends Record<string, any>>(data: T[],
|
|
40
|
-
export declare const exportToXlsx: <T extends Record<string, any>>(data: T[],
|
|
41
|
-
export declare const exportToPdf: <T extends Record<string, any>>(data: T[],
|
|
38
|
+
export declare const exportToCsv: <T extends Record<string, any>>(data: T[], allColumns: MRT_ColumnDef<T>[], options?: CsvExportOptions) => void;
|
|
39
|
+
export declare const exportToXlsx: <T extends Record<string, any>>(data: T[], allColumns: MRT_ColumnDef<T>[], options?: XlsxExportOptions) => void;
|
|
40
|
+
export declare const exportToPdf: <T extends Record<string, any>>(data: T[], allColumns: MRT_ColumnDef<T>[], options?: PdfExportOptions) => Promise<void>;
|
|
42
41
|
/**
|
|
43
42
|
* Sends an export file to the user via email using multipart/form-data
|
|
44
43
|
* Automatically includes Authorization header from cookies if available
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import jsPDF from 'jspdf';
|
|
2
|
-
import 'jspdf-autotable';
|
|
1
|
+
import { jsPDF } from 'jspdf';
|
|
2
|
+
import { applyPlugin } from 'jspdf-autotable';
|
|
3
3
|
import Papa from 'papaparse';
|
|
4
4
|
import * as XLSX from 'xlsx';
|
|
5
|
+
// jspdf-autotable v5 no longer auto-attaches `autoTable` to jsPDF instances.
|
|
6
|
+
// applyPlugin restores the `doc.autoTable(...)` call style used below.
|
|
7
|
+
applyPlugin(jsPDF);
|
|
5
8
|
// Helper function to convert RGB string to RGB values
|
|
6
9
|
const parseRgbString = (rgbString) => {
|
|
7
10
|
// Handle rgb(r, g, b) format
|
|
@@ -42,6 +45,50 @@ const loadImageAsBase64 = async (url) => {
|
|
|
42
45
|
return null;
|
|
43
46
|
}
|
|
44
47
|
};
|
|
48
|
+
/**
|
|
49
|
+
* jsPDF's addImage only embeds raster formats (PNG/JPEG/WEBP), not SVG. Many
|
|
50
|
+
* logos are SVGs, so rasterise any SVG data URL onto a canvas and return a PNG
|
|
51
|
+
* data URL. Raster inputs pass through unchanged.
|
|
52
|
+
*/
|
|
53
|
+
const ensureRasterImage = async (dataUrl,
|
|
54
|
+
// High render width so SVG logos stay crisp once scaled down into the PDF
|
|
55
|
+
// (the logo prints at ~30mm; rendering at 600px gives ample DPI headroom).
|
|
56
|
+
targetWidthPx = 600) => {
|
|
57
|
+
const isSvg = dataUrl.startsWith('data:image/svg');
|
|
58
|
+
if (!isSvg) {
|
|
59
|
+
const match = /^data:image\/(png|jpeg|jpg|webp)/i.exec(dataUrl);
|
|
60
|
+
if (!match)
|
|
61
|
+
return null;
|
|
62
|
+
const fmt = match[1].toUpperCase();
|
|
63
|
+
return {
|
|
64
|
+
dataUrl,
|
|
65
|
+
format: fmt === 'JPG' ? 'JPEG' : fmt,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const img = new Image();
|
|
70
|
+
img.onload = () => {
|
|
71
|
+
// SVGs may report 0 intrinsic size; fall back to a 2:1 logo ratio so the
|
|
72
|
+
// canvas is never degenerate (which would yield a blank/blurry raster).
|
|
73
|
+
const ratio = img.naturalWidth > 0 ? img.naturalHeight / img.naturalWidth : 0.5;
|
|
74
|
+
const canvas = document.createElement('canvas');
|
|
75
|
+
canvas.width = targetWidthPx;
|
|
76
|
+
canvas.height = Math.max(1, Math.round(targetWidthPx * ratio));
|
|
77
|
+
const ctx = canvas.getContext('2d');
|
|
78
|
+
if (!ctx)
|
|
79
|
+
return resolve(null);
|
|
80
|
+
ctx.imageSmoothingEnabled = true;
|
|
81
|
+
ctx.imageSmoothingQuality = 'high';
|
|
82
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
83
|
+
resolve({ dataUrl: canvas.toDataURL('image/png'), format: 'PNG' });
|
|
84
|
+
};
|
|
85
|
+
img.onerror = () => resolve(null);
|
|
86
|
+
// Force the SVG to rasterise at the target size rather than its (often
|
|
87
|
+
// missing) intrinsic size.
|
|
88
|
+
img.width = targetWidthPx;
|
|
89
|
+
img.src = dataUrl;
|
|
90
|
+
});
|
|
91
|
+
};
|
|
45
92
|
// Helper functions for formatting different data types
|
|
46
93
|
const formatStatusObject = (value) => {
|
|
47
94
|
if ('label' in value && typeof value.label === 'string') {
|
|
@@ -224,8 +271,10 @@ const formatCellContentForExcel = (value, columnKey) => {
|
|
|
224
271
|
return String(value);
|
|
225
272
|
};
|
|
226
273
|
// CSV Export
|
|
227
|
-
export const exportToCsv = (data,
|
|
274
|
+
export const exportToCsv = (data, allColumns, options = {}) => {
|
|
228
275
|
const { title = 'Data Export', includeTimestamp = true, includeHeaders = true, delimiter = ',', filename } = options;
|
|
276
|
+
// Drop display-only columns (no accessorKey) so keys/headers stay aligned.
|
|
277
|
+
const columns = allColumns.filter((col) => Boolean(col.accessorKey));
|
|
229
278
|
const columnKeys = columns.map((col) => col.accessorKey);
|
|
230
279
|
const headers = columns.map((col) => formatHeaderText(col.header || ''));
|
|
231
280
|
// Prepare data with proper formatting
|
|
@@ -471,7 +520,9 @@ const addMergedCells = (worksheet, title, hasSubtitle, headers) => {
|
|
|
471
520
|
}
|
|
472
521
|
};
|
|
473
522
|
// XLSX Export
|
|
474
|
-
export const exportToXlsx = (data,
|
|
523
|
+
export const exportToXlsx = (data, allColumns, options = {}) => {
|
|
524
|
+
// Drop display-only columns (no accessorKey) so keys/headers stay aligned.
|
|
525
|
+
const columns = allColumns.filter((col) => Boolean(col.accessorKey));
|
|
475
526
|
const { title = 'Data Export', subtitle, colors, sheetName = 'Data', includeTimestamp = true, autoFitColumns = true, } = options;
|
|
476
527
|
const columnKeys = columns.map((col) => col.accessorKey);
|
|
477
528
|
const headers = columns.map((col) => formatHeaderText(col.header || ''));
|
|
@@ -509,6 +560,11 @@ export const exportToXlsx = (data, columns, options = {}) => {
|
|
|
509
560
|
};
|
|
510
561
|
// Helper function to check if a column likely contains images
|
|
511
562
|
const isImageColumn = (columnKey, data) => {
|
|
563
|
+
// Display-only columns (e.g. an "actions" column) have no accessorKey, so
|
|
564
|
+
// columnKey can be undefined here. They hold no exportable data.
|
|
565
|
+
if (!columnKey) {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
512
568
|
const imageKeywords = ['image', 'photo', 'picture', 'avatar', 'logo'];
|
|
513
569
|
const keyLower = columnKey.toLowerCase();
|
|
514
570
|
// Check if column key suggests images
|
|
@@ -579,7 +635,11 @@ const prepareTableDataForPdf = async (data, columns, doc) => {
|
|
|
579
635
|
return { tableData, imageInfo };
|
|
580
636
|
};
|
|
581
637
|
// PDF Export
|
|
582
|
-
export const exportToPdf = async (data,
|
|
638
|
+
export const exportToPdf = async (data, allColumns, options = {}) => {
|
|
639
|
+
// Only data-bound columns are exportable; display-only columns (e.g. an
|
|
640
|
+
// "actions" column) have no accessorKey and would otherwise produce
|
|
641
|
+
// undefined keys/headers that crash downstream formatting.
|
|
642
|
+
const columns = allColumns.filter((col) => Boolean(col.accessorKey));
|
|
583
643
|
const { title = 'Data Export', subtitle = `Generated on ${new Date().toLocaleDateString('en-US', {
|
|
584
644
|
year: 'numeric',
|
|
585
645
|
month: 'long',
|
|
@@ -606,10 +666,11 @@ export const exportToPdf = async (data, columns, options = {}) => {
|
|
|
606
666
|
if (logo?.url) {
|
|
607
667
|
try {
|
|
608
668
|
const logoBase64 = await loadImageAsBase64(logo.url);
|
|
609
|
-
|
|
669
|
+
const raster = logoBase64 ? await ensureRasterImage(logoBase64) : null;
|
|
670
|
+
if (raster) {
|
|
610
671
|
const logoWidth = logo.width || 30;
|
|
611
672
|
const logoHeight = logo.height || 15;
|
|
612
|
-
doc.addImage(
|
|
673
|
+
doc.addImage(raster.dataUrl, raster.format, margin, currentY, logoWidth, logoHeight);
|
|
613
674
|
currentY += logoHeight + 5;
|
|
614
675
|
}
|
|
615
676
|
}
|
|
@@ -638,7 +699,6 @@ export const exportToPdf = async (data, columns, options = {}) => {
|
|
|
638
699
|
const columnHeaders = columns.map((col) => formatHeaderText(col.header || String(col.accessorKey)));
|
|
639
700
|
// Format table data with enhanced handling for images, status, etc.
|
|
640
701
|
const { tableData, imageInfo } = await prepareTableDataForPdf(data, columns, doc);
|
|
641
|
-
console.log(`PDF export: Processing ${tableData.length} rows with ${imageInfo.size} images`);
|
|
642
702
|
// Calculate optimal column widths
|
|
643
703
|
const availableWidth = pageWidth - 2 * margin;
|
|
644
704
|
const numColumns = columnHeaders.length;
|
|
@@ -20,11 +20,23 @@ export const createPdfExportOptions = (config, title) => {
|
|
|
20
20
|
primary: config.theme.colors.primary,
|
|
21
21
|
core: config.theme.colors.core,
|
|
22
22
|
},
|
|
23
|
-
logo:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
logo: scalePdfLogo(config.branding.navbarLogo?.path || config.branding.logo.path, config.branding.navbarLogo?.width || config.branding.logo.width, config.branding.navbarLogo?.height || config.branding.logo.height),
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Branding logo dimensions in `config` are CSS pixels (sized for the navbar).
|
|
28
|
+
* `exportToPdf` interprets logo width/height as millimetres, so passing the raw
|
|
29
|
+
* pixel values renders a giant logo that fills the page. Scale to a fixed print
|
|
30
|
+
* width (mm) while preserving the source aspect ratio.
|
|
31
|
+
*/
|
|
32
|
+
const PDF_LOGO_WIDTH_MM = 32;
|
|
33
|
+
const PDF_LOGO_FALLBACK_HEIGHT_MM = 12;
|
|
34
|
+
const scalePdfLogo = (url, pxWidth, pxHeight) => {
|
|
35
|
+
const aspect = pxWidth && pxHeight && pxWidth > 0 ? pxHeight / pxWidth : undefined;
|
|
36
|
+
return {
|
|
37
|
+
url,
|
|
38
|
+
width: PDF_LOGO_WIDTH_MM,
|
|
39
|
+
height: aspect ? Math.round(PDF_LOGO_WIDTH_MM * aspect) : PDF_LOGO_FALLBACK_HEIGHT_MM,
|
|
28
40
|
};
|
|
29
41
|
};
|
|
30
42
|
/**
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useInfiniteCursor — TanStack `useInfiniteQuery` wrapper for cursor-paginated
|
|
3
|
+
* Pagamio endpoints.
|
|
4
|
+
*
|
|
5
|
+
* Matches the backend cursor contract from `pagamio-nestjs-api-commons`:
|
|
6
|
+
* `GET /<resource>/feed` → CursorPaginatedResponseDto<T> =
|
|
7
|
+
* { data: T[], meta: { nextCursor, previousCursor, hasNextPage,
|
|
8
|
+
* hasPreviousPage, count, total? } }
|
|
9
|
+
*
|
|
10
|
+
* The hook treats `nextCursor` as an opaque base64 string — never parse or
|
|
11
|
+
* construct cursors on the client. If the backend changes its cursor encoding,
|
|
12
|
+
* the frontend doesn't need to know.
|
|
13
|
+
*
|
|
14
|
+
* For tables and any page-numbered UI, use `usePagamioTable` (offset paging)
|
|
15
|
+
* instead. Cursor pagination is for infinite-scroll surfaces (storefront,
|
|
16
|
+
* mobile feeds) where "Page X of Y" UX isn't shown.
|
|
17
|
+
*
|
|
18
|
+
* SRP: Wires `useInfiniteQuery` to the cursor contract. Callers own:
|
|
19
|
+
* - flattening `data.pages` into a single list
|
|
20
|
+
* - intersection-observer / "load more" wiring
|
|
21
|
+
* - reading `data.pages[0]?.meta.total` for header counts (only present
|
|
22
|
+
* when the caller passed `withTotal=true` on the first page).
|
|
23
|
+
*/
|
|
24
|
+
import { type QueryKey, type UseInfiniteQueryResult } from '@tanstack/react-query';
|
|
25
|
+
/**
|
|
26
|
+
* Wire shape for one page of a cursor-paginated Pagamio response.
|
|
27
|
+
*
|
|
28
|
+
* Mirrors `CursorPaginatedResponseDto<T>` from `@pagamio/nestjs-api-commons`.
|
|
29
|
+
* `total` is optional and only set when the caller passes `withTotal=true`
|
|
30
|
+
* (conventionally first page only — see backend `.agent/conventions/pagination.md`).
|
|
31
|
+
*/
|
|
32
|
+
export type CursorResponse<T> = {
|
|
33
|
+
data: T[];
|
|
34
|
+
meta: {
|
|
35
|
+
nextCursor: string | null;
|
|
36
|
+
previousCursor: string | null;
|
|
37
|
+
hasNextPage: boolean;
|
|
38
|
+
hasPreviousPage: boolean;
|
|
39
|
+
count: number;
|
|
40
|
+
total?: number;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Documentation-as-code marker for the cursor meta shape. Mirrors the role
|
|
45
|
+
* of `pagamioMapping` for offset pagination — gives consumers a single named
|
|
46
|
+
* export to reference. Not consumed by `useInfiniteCursor` itself (TanStack
|
|
47
|
+
* handles the wiring); useful for grep / IDE jump-to-definition.
|
|
48
|
+
*/
|
|
49
|
+
export declare const cursorMapping: {
|
|
50
|
+
responseType: "cursor";
|
|
51
|
+
data: string;
|
|
52
|
+
nextCursor: string;
|
|
53
|
+
hasNextPage: string;
|
|
54
|
+
total: string;
|
|
55
|
+
};
|
|
56
|
+
export interface UseInfiniteCursorConfig<T> {
|
|
57
|
+
/** TanStack query key. Include scope params (orgId, BU id, filters) so
|
|
58
|
+
* changes invalidate correctly. */
|
|
59
|
+
queryKey: QueryKey;
|
|
60
|
+
/** Fetches one page. Receives the cursor for the next page (undefined on
|
|
61
|
+
* first page) and an AbortSignal forwarded from TanStack. */
|
|
62
|
+
queryFn: (args: {
|
|
63
|
+
cursor: string | undefined;
|
|
64
|
+
signal: AbortSignal;
|
|
65
|
+
}) => Promise<CursorResponse<T>>;
|
|
66
|
+
enabled?: boolean;
|
|
67
|
+
staleTime?: number;
|
|
68
|
+
gcTime?: number;
|
|
69
|
+
/**
|
|
70
|
+
* When true, the previous page set stays visible while the query refetches
|
|
71
|
+
* after a key change (filter swap, sort change). Prevents the consumer's
|
|
72
|
+
* grid from flashing to a skeleton between filter changes. Maps to
|
|
73
|
+
* TanStack's `placeholderData: keepPreviousData`.
|
|
74
|
+
*/
|
|
75
|
+
keepPreviousResults?: boolean;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Cursor-paginated infinite query for Pagamio `/feed` endpoints.
|
|
79
|
+
*
|
|
80
|
+
* Returns the raw TanStack `useInfiniteQueryResult` — no custom shape — so
|
|
81
|
+
* callers keep full access to `fetchNextPage`, `hasNextPage`, `isFetchingNextPage`,
|
|
82
|
+
* `data.pages`, etc.
|
|
83
|
+
*
|
|
84
|
+
* Example:
|
|
85
|
+
* ```ts
|
|
86
|
+
* const query = useInfiniteCursor<Product>({
|
|
87
|
+
* queryKey: ['catalog', 'products', { businessUnitId, search }],
|
|
88
|
+
* queryFn: ({ cursor, signal }) =>
|
|
89
|
+
* apiClient
|
|
90
|
+
* .get('/ecommerce/catalog/products/feed', {
|
|
91
|
+
* params: { businessUnitId, cursor, limit: 30, withTotal: cursor === undefined },
|
|
92
|
+
* signal,
|
|
93
|
+
* })
|
|
94
|
+
* .then((r) => r.data),
|
|
95
|
+
* });
|
|
96
|
+
*
|
|
97
|
+
* const items = query.data?.pages.flatMap((p) => p.data) ?? [];
|
|
98
|
+
* const total = query.data?.pages[0]?.meta.total;
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export declare function useInfiniteCursor<T>({ queryKey, queryFn, enabled, staleTime, gcTime, keepPreviousResults, }: UseInfiniteCursorConfig<T>): UseInfiniteQueryResult<{
|
|
102
|
+
pages: Array<CursorResponse<T>>;
|
|
103
|
+
pageParams: Array<string | undefined>;
|
|
104
|
+
}, Error>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useInfiniteCursor — TanStack `useInfiniteQuery` wrapper for cursor-paginated
|
|
3
|
+
* Pagamio endpoints.
|
|
4
|
+
*
|
|
5
|
+
* Matches the backend cursor contract from `pagamio-nestjs-api-commons`:
|
|
6
|
+
* `GET /<resource>/feed` → CursorPaginatedResponseDto<T> =
|
|
7
|
+
* { data: T[], meta: { nextCursor, previousCursor, hasNextPage,
|
|
8
|
+
* hasPreviousPage, count, total? } }
|
|
9
|
+
*
|
|
10
|
+
* The hook treats `nextCursor` as an opaque base64 string — never parse or
|
|
11
|
+
* construct cursors on the client. If the backend changes its cursor encoding,
|
|
12
|
+
* the frontend doesn't need to know.
|
|
13
|
+
*
|
|
14
|
+
* For tables and any page-numbered UI, use `usePagamioTable` (offset paging)
|
|
15
|
+
* instead. Cursor pagination is for infinite-scroll surfaces (storefront,
|
|
16
|
+
* mobile feeds) where "Page X of Y" UX isn't shown.
|
|
17
|
+
*
|
|
18
|
+
* SRP: Wires `useInfiniteQuery` to the cursor contract. Callers own:
|
|
19
|
+
* - flattening `data.pages` into a single list
|
|
20
|
+
* - intersection-observer / "load more" wiring
|
|
21
|
+
* - reading `data.pages[0]?.meta.total` for header counts (only present
|
|
22
|
+
* when the caller passed `withTotal=true` on the first page).
|
|
23
|
+
*/
|
|
24
|
+
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query';
|
|
25
|
+
/**
|
|
26
|
+
* Documentation-as-code marker for the cursor meta shape. Mirrors the role
|
|
27
|
+
* of `pagamioMapping` for offset pagination — gives consumers a single named
|
|
28
|
+
* export to reference. Not consumed by `useInfiniteCursor` itself (TanStack
|
|
29
|
+
* handles the wiring); useful for grep / IDE jump-to-definition.
|
|
30
|
+
*/
|
|
31
|
+
export const cursorMapping = {
|
|
32
|
+
responseType: 'cursor',
|
|
33
|
+
data: 'data',
|
|
34
|
+
nextCursor: 'meta.nextCursor',
|
|
35
|
+
hasNextPage: 'meta.hasNextPage',
|
|
36
|
+
total: 'meta.total',
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Cursor-paginated infinite query for Pagamio `/feed` endpoints.
|
|
40
|
+
*
|
|
41
|
+
* Returns the raw TanStack `useInfiniteQueryResult` — no custom shape — so
|
|
42
|
+
* callers keep full access to `fetchNextPage`, `hasNextPage`, `isFetchingNextPage`,
|
|
43
|
+
* `data.pages`, etc.
|
|
44
|
+
*
|
|
45
|
+
* Example:
|
|
46
|
+
* ```ts
|
|
47
|
+
* const query = useInfiniteCursor<Product>({
|
|
48
|
+
* queryKey: ['catalog', 'products', { businessUnitId, search }],
|
|
49
|
+
* queryFn: ({ cursor, signal }) =>
|
|
50
|
+
* apiClient
|
|
51
|
+
* .get('/ecommerce/catalog/products/feed', {
|
|
52
|
+
* params: { businessUnitId, cursor, limit: 30, withTotal: cursor === undefined },
|
|
53
|
+
* signal,
|
|
54
|
+
* })
|
|
55
|
+
* .then((r) => r.data),
|
|
56
|
+
* });
|
|
57
|
+
*
|
|
58
|
+
* const items = query.data?.pages.flatMap((p) => p.data) ?? [];
|
|
59
|
+
* const total = query.data?.pages[0]?.meta.total;
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function useInfiniteCursor({ queryKey, queryFn, enabled, staleTime, gcTime, keepPreviousResults, }) {
|
|
63
|
+
return useInfiniteQuery({
|
|
64
|
+
queryKey,
|
|
65
|
+
queryFn: ({ pageParam, signal }) => queryFn({ cursor: pageParam, signal }),
|
|
66
|
+
initialPageParam: undefined,
|
|
67
|
+
getNextPageParam: (lastPage) => lastPage.meta.hasNextPage && lastPage.meta.nextCursor ? lastPage.meta.nextCursor : undefined,
|
|
68
|
+
enabled,
|
|
69
|
+
staleTime,
|
|
70
|
+
gcTime,
|
|
71
|
+
...(keepPreviousResults ? { placeholderData: keepPreviousData } : {}),
|
|
72
|
+
});
|
|
73
|
+
}
|
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.352",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
7
7
|
"provenance": false
|
|
@@ -100,8 +100,8 @@
|
|
|
100
100
|
"dayjs": "^1.11.13",
|
|
101
101
|
"file-saver": "^2.0.5",
|
|
102
102
|
"js-cookie": "^3.0.5",
|
|
103
|
-
"jspdf": "^2.
|
|
104
|
-
"jspdf-autotable": "^
|
|
103
|
+
"jspdf": "^4.2.1",
|
|
104
|
+
"jspdf-autotable": "^5.0.8",
|
|
105
105
|
"jwt-decode": "^4.0.0",
|
|
106
106
|
"lodash": "^4.17.21",
|
|
107
107
|
"papaparse": "^5.4.1",
|