@beeblock/svelar-datatable 0.1.5 → 0.1.7
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/package.json +5 -1
- package/src/export/ExportManager.ts +30 -0
- package/src/export/clipboard.ts +14 -0
- package/src/export/csv.ts +36 -0
- package/src/export/excel.ts +44 -0
- package/src/export/index.ts +6 -0
- package/src/export/pdf.ts +9 -0
- package/src/export/print.ts +42 -0
- package/src/index.ts +22 -0
- package/src/state/DataTableStore.ts +599 -0
- package/src/state/ServerDataTableStore.ts +168 -0
- package/src/state/index.ts +2 -0
- package/src/types.ts +241 -0
- package/src/ui/DataTable.svelte +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beeblock/svelar-datatable",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Full-featured DataTable plugin for Svelar — sorting, searching, pagination, inline editing, export, and server-side processing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
"files": [
|
|
46
46
|
"dist",
|
|
47
47
|
"src/ui",
|
|
48
|
+
"src/state",
|
|
49
|
+
"src/export",
|
|
50
|
+
"src/types.ts",
|
|
51
|
+
"src/index.ts",
|
|
48
52
|
"src/publishable",
|
|
49
53
|
"LICENSE"
|
|
50
54
|
],
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ColumnDef, ExportFormat } from '../types.js';
|
|
2
|
+
import { generateCSV, downloadCSV } from './csv.js';
|
|
3
|
+
import { copyToClipboard } from './clipboard.js';
|
|
4
|
+
import { printTable } from './print.js';
|
|
5
|
+
import { exportExcel } from './excel.js';
|
|
6
|
+
import { exportPdf } from './pdf.js';
|
|
7
|
+
|
|
8
|
+
export class ExportManager<T = any> {
|
|
9
|
+
async export(format: ExportFormat, data: T[], columns: ColumnDef<T>[], filename?: string): Promise<void> {
|
|
10
|
+
switch (format) {
|
|
11
|
+
case 'csv': {
|
|
12
|
+
const csv = generateCSV(data, columns);
|
|
13
|
+
downloadCSV(csv, filename ?? 'export.csv');
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
case 'excel':
|
|
17
|
+
await exportExcel(data, columns, filename ?? 'export.xlsx');
|
|
18
|
+
break;
|
|
19
|
+
case 'pdf':
|
|
20
|
+
exportPdf(data, columns, filename ?? 'export.pdf');
|
|
21
|
+
break;
|
|
22
|
+
case 'clipboard':
|
|
23
|
+
await copyToClipboard(data, columns);
|
|
24
|
+
break;
|
|
25
|
+
case 'print':
|
|
26
|
+
printTable(data, columns);
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ColumnDef } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export async function copyToClipboard<T>(data: T[], columns: ColumnDef<T>[]): Promise<void> {
|
|
4
|
+
const visibleCols = columns.filter((c) => c.visible !== false);
|
|
5
|
+
const header = visibleCols.map((c) => c.header).join('\t');
|
|
6
|
+
const rows = data.map((row: any) =>
|
|
7
|
+
visibleCols.map((col) => {
|
|
8
|
+
const val = row[col.key];
|
|
9
|
+
return val === null || val === undefined ? '' : String(val);
|
|
10
|
+
}).join('\t')
|
|
11
|
+
);
|
|
12
|
+
const text = [header, ...rows].join('\n');
|
|
13
|
+
await navigator.clipboard.writeText(text);
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ColumnDef } from '../types.js';
|
|
2
|
+
|
|
3
|
+
function escapeCSV(value: any): string {
|
|
4
|
+
if (value === null || value === undefined) return '';
|
|
5
|
+
const str = String(value);
|
|
6
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
7
|
+
return '"' + str.replace(/"/g, '""') + '"';
|
|
8
|
+
}
|
|
9
|
+
return str;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function generateCSV<T>(data: T[], columns: ColumnDef<T>[]): string {
|
|
13
|
+
const visibleCols = columns.filter((c) => c.visible !== false);
|
|
14
|
+
const header = visibleCols.map((c) => escapeCSV(c.header)).join(',');
|
|
15
|
+
const rows = data.map((row: any) =>
|
|
16
|
+
visibleCols.map((col) => escapeCSV(row[col.key])).join(',')
|
|
17
|
+
);
|
|
18
|
+
// BOM for Excel UTF-8 compatibility
|
|
19
|
+
return '\uFEFF' + [header, ...rows].join('\r\n');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function downloadCSV(content: string, filename: string = 'export.csv') {
|
|
23
|
+
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
|
|
24
|
+
downloadBlob(blob, filename);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function downloadBlob(blob: Blob, filename: string) {
|
|
28
|
+
const url = URL.createObjectURL(blob);
|
|
29
|
+
const a = document.createElement('a');
|
|
30
|
+
a.href = url;
|
|
31
|
+
a.download = filename;
|
|
32
|
+
document.body.appendChild(a);
|
|
33
|
+
a.click();
|
|
34
|
+
document.body.removeChild(a);
|
|
35
|
+
URL.revokeObjectURL(url);
|
|
36
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ColumnDef } from '../types.js';
|
|
2
|
+
import { downloadBlob } from './csv.js';
|
|
3
|
+
|
|
4
|
+
export async function exportExcel<T>(data: T[], columns: ColumnDef<T>[], filename: string = 'export.xlsx') {
|
|
5
|
+
let ExcelJS: any;
|
|
6
|
+
try {
|
|
7
|
+
ExcelJS = await import('exceljs');
|
|
8
|
+
} catch {
|
|
9
|
+
throw new Error('exceljs is required for Excel export. Install it: npm install exceljs');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const workbook = new ExcelJS.Workbook();
|
|
13
|
+
const worksheet = workbook.addWorksheet('Data');
|
|
14
|
+
const visibleCols = columns.filter((c) => c.visible !== false);
|
|
15
|
+
|
|
16
|
+
// Header row
|
|
17
|
+
worksheet.columns = visibleCols.map((col) => ({
|
|
18
|
+
header: col.header,
|
|
19
|
+
key: col.key,
|
|
20
|
+
width: 20,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Style header
|
|
24
|
+
const headerRow = worksheet.getRow(1);
|
|
25
|
+
headerRow.font = { bold: true };
|
|
26
|
+
headerRow.fill = {
|
|
27
|
+
type: 'pattern',
|
|
28
|
+
pattern: 'solid',
|
|
29
|
+
fgColor: { argb: 'FFF0F0F0' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Data rows
|
|
33
|
+
for (const row of data) {
|
|
34
|
+
const rowData: Record<string, any> = {};
|
|
35
|
+
for (const col of visibleCols) {
|
|
36
|
+
rowData[col.key] = (row as any)[col.key] ?? '';
|
|
37
|
+
}
|
|
38
|
+
worksheet.addRow(rowData);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const buffer = await workbook.xlsx.writeBuffer();
|
|
42
|
+
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
|
43
|
+
downloadBlob(blob, filename);
|
|
44
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { generateCSV, downloadCSV, downloadBlob } from './csv.js';
|
|
2
|
+
export { copyToClipboard } from './clipboard.js';
|
|
3
|
+
export { printTable } from './print.js';
|
|
4
|
+
export { exportExcel } from './excel.js';
|
|
5
|
+
export { exportPdf } from './pdf.js';
|
|
6
|
+
export { ExportManager } from './ExportManager.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ColumnDef } from '../types.js';
|
|
2
|
+
import { generateCSV } from './csv.js';
|
|
3
|
+
import { printTable } from './print.js';
|
|
4
|
+
|
|
5
|
+
export function exportPdf<T>(data: T[], columns: ColumnDef<T>[], filename: string = 'export.pdf') {
|
|
6
|
+
// Use browser print as fallback (generates PDF via print dialog)
|
|
7
|
+
// For server-side PDF, users should use @beeblock/svelar/pdf directly
|
|
8
|
+
printTable(data, columns, filename.replace('.pdf', ''));
|
|
9
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ColumnDef } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export function printTable<T>(data: T[], columns: ColumnDef<T>[], title?: string) {
|
|
4
|
+
const visibleCols = columns.filter((c) => c.visible !== false);
|
|
5
|
+
|
|
6
|
+
const headerCells = visibleCols.map((c) => `<th style="border:1px solid #ddd;padding:8px 12px;text-align:left;background:#f5f5f5;font-weight:600;">${escapeHtml(c.header)}</th>`).join('');
|
|
7
|
+
const bodyRows = data.map((row: any) => {
|
|
8
|
+
const cells = visibleCols.map((col) => {
|
|
9
|
+
const val = row[col.key];
|
|
10
|
+
return `<td style="border:1px solid #ddd;padding:8px 12px;">${escapeHtml(val === null || val === undefined ? '' : String(val))}</td>`;
|
|
11
|
+
}).join('');
|
|
12
|
+
return `<tr>${cells}</tr>`;
|
|
13
|
+
}).join('');
|
|
14
|
+
|
|
15
|
+
const html = `<!DOCTYPE html>
|
|
16
|
+
<html><head>
|
|
17
|
+
<title>${escapeHtml(title ?? 'Data Export')}</title>
|
|
18
|
+
<style>
|
|
19
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 20px; }
|
|
20
|
+
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
21
|
+
h1 { font-size: 18px; margin-bottom: 16px; }
|
|
22
|
+
@media print { h1 { margin-top: 0; } }
|
|
23
|
+
</style>
|
|
24
|
+
</head><body>
|
|
25
|
+
${title ? `<h1>${escapeHtml(title)}</h1>` : ''}
|
|
26
|
+
<table>
|
|
27
|
+
<thead><tr>${headerCells}</tr></thead>
|
|
28
|
+
<tbody>${bodyRows}</tbody>
|
|
29
|
+
</table>
|
|
30
|
+
</body></html>`;
|
|
31
|
+
|
|
32
|
+
const win = window.open('', '_blank');
|
|
33
|
+
if (!win) return;
|
|
34
|
+
win.document.write(html);
|
|
35
|
+
win.document.close();
|
|
36
|
+
win.focus();
|
|
37
|
+
setTimeout(() => win.print(), 250);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function escapeHtml(str: string): string {
|
|
41
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { DataTableStore } from './state/DataTableStore.js';
|
|
2
|
+
export { ServerDataTableStore } from './state/ServerDataTableStore.js';
|
|
3
|
+
export { ExportManager } from './export/ExportManager.js';
|
|
4
|
+
export type {
|
|
5
|
+
ColumnDef,
|
|
6
|
+
ColumnType,
|
|
7
|
+
FilterOperator,
|
|
8
|
+
SelectionMode,
|
|
9
|
+
EditorMode,
|
|
10
|
+
FieldType,
|
|
11
|
+
ExportFormat,
|
|
12
|
+
SortState,
|
|
13
|
+
FilterState,
|
|
14
|
+
PaginationState,
|
|
15
|
+
DataTableRequest,
|
|
16
|
+
DataTableResponse,
|
|
17
|
+
EditorFieldDef,
|
|
18
|
+
ButtonDef,
|
|
19
|
+
DataTableConfig,
|
|
20
|
+
DataTableClassNames,
|
|
21
|
+
DataTableState,
|
|
22
|
+
} from './types.js';
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import type { ColumnDef, DataTableConfig, DataTableState, FilterState, SortState, PaginationState } from '../types.js';
|
|
2
|
+
|
|
3
|
+
type Listener = () => void;
|
|
4
|
+
|
|
5
|
+
export class DataTableStore<T = any> {
|
|
6
|
+
private _state: DataTableState<T>;
|
|
7
|
+
private _listeners = new Set<Listener>();
|
|
8
|
+
private _columns: ColumnDef<T>[] = [];
|
|
9
|
+
private _rowIdFn: (row: T) => string | number;
|
|
10
|
+
private _stateSaveKey: string | null = null;
|
|
11
|
+
private _lastSelectedId: string | number | null = null;
|
|
12
|
+
private _paginate: boolean = true;
|
|
13
|
+
|
|
14
|
+
constructor(config: DataTableConfig<T>) {
|
|
15
|
+
this._columns = config.columns;
|
|
16
|
+
this._stateSaveKey = config.stateSaveKey ?? null;
|
|
17
|
+
this._paginate = config.paginate !== false;
|
|
18
|
+
this._rowIdFn = typeof config.rowId === 'function'
|
|
19
|
+
? config.rowId
|
|
20
|
+
: (row: any) => row[config.rowId as string ?? 'id'];
|
|
21
|
+
|
|
22
|
+
const savedState = this._loadState();
|
|
23
|
+
|
|
24
|
+
this._state = {
|
|
25
|
+
allRows: config.data ?? [],
|
|
26
|
+
filteredRows: [],
|
|
27
|
+
sortedRows: [],
|
|
28
|
+
paginatedRows: [],
|
|
29
|
+
sort: savedState?.sort ?? [],
|
|
30
|
+
filters: savedState?.filters ?? [],
|
|
31
|
+
globalSearch: savedState?.globalSearch ?? '',
|
|
32
|
+
pagination: {
|
|
33
|
+
page: savedState?.page ?? 1,
|
|
34
|
+
perPage: config.perPage ?? 15,
|
|
35
|
+
total: 0,
|
|
36
|
+
lastPage: 1,
|
|
37
|
+
},
|
|
38
|
+
selectedIds: new Set(),
|
|
39
|
+
columnVisibility: savedState?.columnVisibility ?? Object.fromEntries(
|
|
40
|
+
config.columns.map((c) => [c.key, c.visible !== false])
|
|
41
|
+
),
|
|
42
|
+
columnOrder: savedState?.columnOrder ?? config.columns.map((c) => c.key),
|
|
43
|
+
loading: false,
|
|
44
|
+
error: null,
|
|
45
|
+
editingRowId: null,
|
|
46
|
+
editingColumn: null,
|
|
47
|
+
editorMode: null,
|
|
48
|
+
formData: {},
|
|
49
|
+
validationErrors: {},
|
|
50
|
+
draw: 0,
|
|
51
|
+
excelFocusedCell: null,
|
|
52
|
+
excelEditingCell: null,
|
|
53
|
+
excelEditValue: '',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
this._recompute();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
subscribe(listener: Listener): () => void {
|
|
60
|
+
this._listeners.add(listener);
|
|
61
|
+
return () => this._listeners.delete(listener);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getState(): DataTableState<T> {
|
|
65
|
+
return this._state;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private _notify() {
|
|
69
|
+
for (const listener of this._listeners) listener();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private _saveState() {
|
|
73
|
+
if (!this._stateSaveKey) return;
|
|
74
|
+
try {
|
|
75
|
+
const toSave = {
|
|
76
|
+
sort: this._state.sort,
|
|
77
|
+
filters: this._state.filters,
|
|
78
|
+
globalSearch: this._state.globalSearch,
|
|
79
|
+
page: this._state.pagination.page,
|
|
80
|
+
columnVisibility: this._state.columnVisibility,
|
|
81
|
+
columnOrder: this._state.columnOrder,
|
|
82
|
+
};
|
|
83
|
+
localStorage.setItem(this._stateSaveKey, JSON.stringify(toSave));
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private _loadState(): any {
|
|
88
|
+
if (!this._stateSaveKey) return null;
|
|
89
|
+
try {
|
|
90
|
+
const raw = localStorage.getItem(this._stateSaveKey);
|
|
91
|
+
return raw ? JSON.parse(raw) : null;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Set data (replaces all rows)
|
|
98
|
+
setData(rows: T[]) {
|
|
99
|
+
this._state = { ...this._state, allRows: rows };
|
|
100
|
+
this._recompute();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Sorting
|
|
104
|
+
setSort(sort: SortState[]) {
|
|
105
|
+
this._state = { ...this._state, sort, pagination: { ...this._state.pagination, page: 1 } };
|
|
106
|
+
this._recompute();
|
|
107
|
+
this._saveState();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
toggleSort(column: string, multiSort: boolean = false) {
|
|
111
|
+
const existing = this._state.sort.find((s) => s.column === column);
|
|
112
|
+
let newSort: SortState[];
|
|
113
|
+
|
|
114
|
+
if (existing) {
|
|
115
|
+
if (existing.direction === 'asc') {
|
|
116
|
+
newSort = multiSort
|
|
117
|
+
? this._state.sort.map((s) => s.column === column ? { ...s, direction: 'desc' as const } : s)
|
|
118
|
+
: [{ column, direction: 'desc' }];
|
|
119
|
+
} else {
|
|
120
|
+
// Remove sort
|
|
121
|
+
newSort = multiSort
|
|
122
|
+
? this._state.sort.filter((s) => s.column !== column)
|
|
123
|
+
: [];
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
newSort = multiSort
|
|
127
|
+
? [...this._state.sort, { column, direction: 'asc' }]
|
|
128
|
+
: [{ column, direction: 'asc' }];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.setSort(newSort);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Searching
|
|
135
|
+
setGlobalSearch(search: string) {
|
|
136
|
+
this._state = { ...this._state, globalSearch: search, pagination: { ...this._state.pagination, page: 1 } };
|
|
137
|
+
this._recompute();
|
|
138
|
+
this._saveState();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Filtering
|
|
142
|
+
setFilters(filters: FilterState[]) {
|
|
143
|
+
this._state = { ...this._state, filters, pagination: { ...this._state.pagination, page: 1 } };
|
|
144
|
+
this._recompute();
|
|
145
|
+
this._saveState();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
setColumnFilter(column: string, value: any, operator: FilterState['operator'] = 'like') {
|
|
149
|
+
const filters = this._state.filters.filter((f) => f.column !== column);
|
|
150
|
+
if (value !== '' && value !== null && value !== undefined) {
|
|
151
|
+
filters.push({ column, value, operator });
|
|
152
|
+
}
|
|
153
|
+
this.setFilters(filters);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Pagination
|
|
157
|
+
setPage(page: number) {
|
|
158
|
+
const lastPage = this._state.pagination.lastPage;
|
|
159
|
+
const safePage = Math.max(1, Math.min(page, lastPage));
|
|
160
|
+
this._state = { ...this._state, pagination: { ...this._state.pagination, page: safePage } };
|
|
161
|
+
this._recompute();
|
|
162
|
+
this._saveState();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
setPerPage(perPage: number) {
|
|
166
|
+
this._state = { ...this._state, pagination: { ...this._state.pagination, perPage, page: 1 } };
|
|
167
|
+
this._recompute();
|
|
168
|
+
this._saveState();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Column visibility
|
|
172
|
+
toggleColumnVisibility(column: string) {
|
|
173
|
+
const vis = { ...this._state.columnVisibility };
|
|
174
|
+
vis[column] = !vis[column];
|
|
175
|
+
this._state = { ...this._state, columnVisibility: vis };
|
|
176
|
+
this._notify();
|
|
177
|
+
this._saveState();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
setColumnVisibility(visibility: Record<string, boolean>) {
|
|
181
|
+
this._state = { ...this._state, columnVisibility: visibility };
|
|
182
|
+
this._notify();
|
|
183
|
+
this._saveState();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Column reorder
|
|
187
|
+
reorderColumns(columnOrder: string[]) {
|
|
188
|
+
this._state = { ...this._state, columnOrder };
|
|
189
|
+
this._notify();
|
|
190
|
+
this._saveState();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Selection
|
|
194
|
+
getRowId(row: T): string | number {
|
|
195
|
+
return this._rowIdFn(row);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
toggleSelect(rowId: string | number) {
|
|
199
|
+
const selected = new Set(this._state.selectedIds);
|
|
200
|
+
if (selected.has(rowId)) {
|
|
201
|
+
selected.delete(rowId);
|
|
202
|
+
} else {
|
|
203
|
+
selected.add(rowId);
|
|
204
|
+
}
|
|
205
|
+
this._lastSelectedId = rowId;
|
|
206
|
+
this._state = { ...this._state, selectedIds: selected };
|
|
207
|
+
this._notify();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
getLastSelectedId(): string | number | null {
|
|
211
|
+
return this._lastSelectedId;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
selectSingle(rowId: string | number) {
|
|
215
|
+
this._state = { ...this._state, selectedIds: new Set([rowId]) };
|
|
216
|
+
this._notify();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
selectAll() {
|
|
220
|
+
const ids = this._state.filteredRows.map((row) => this.getRowId(row));
|
|
221
|
+
this._state = { ...this._state, selectedIds: new Set(ids) };
|
|
222
|
+
this._notify();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
deselectAll() {
|
|
226
|
+
this._state = { ...this._state, selectedIds: new Set() };
|
|
227
|
+
this._notify();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
selectRange(fromId: string | number, toId: string | number) {
|
|
231
|
+
const rows = this._state.sortedRows;
|
|
232
|
+
const fromIdx = rows.findIndex((r) => this.getRowId(r) === fromId);
|
|
233
|
+
const toIdx = rows.findIndex((r) => this.getRowId(r) === toId);
|
|
234
|
+
if (fromIdx === -1 || toIdx === -1) return;
|
|
235
|
+
|
|
236
|
+
const start = Math.min(fromIdx, toIdx);
|
|
237
|
+
const end = Math.max(fromIdx, toIdx);
|
|
238
|
+
const selected = new Set(this._state.selectedIds);
|
|
239
|
+
for (let i = start; i <= end; i++) {
|
|
240
|
+
selected.add(this.getRowId(rows[i]));
|
|
241
|
+
}
|
|
242
|
+
this._state = { ...this._state, selectedIds: selected };
|
|
243
|
+
this._notify();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
getSelectedRows(): T[] {
|
|
247
|
+
return this._state.allRows.filter((row) => this._state.selectedIds.has(this.getRowId(row)));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Editor
|
|
251
|
+
openEditor(rowId: string | number | null, column: string | null, mode: 'inline' | 'bubble' | 'modal') {
|
|
252
|
+
const row = rowId !== null
|
|
253
|
+
? this._state.allRows.find((r) => this.getRowId(r) === rowId)
|
|
254
|
+
: null;
|
|
255
|
+
const formData = row ? { ...(row as any) } : {};
|
|
256
|
+
this._state = {
|
|
257
|
+
...this._state,
|
|
258
|
+
editingRowId: rowId,
|
|
259
|
+
editingColumn: column,
|
|
260
|
+
editorMode: mode,
|
|
261
|
+
formData,
|
|
262
|
+
validationErrors: {},
|
|
263
|
+
};
|
|
264
|
+
this._notify();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
closeEditor() {
|
|
268
|
+
this._state = {
|
|
269
|
+
...this._state,
|
|
270
|
+
editingRowId: null,
|
|
271
|
+
editingColumn: null,
|
|
272
|
+
editorMode: null,
|
|
273
|
+
formData: {},
|
|
274
|
+
validationErrors: {},
|
|
275
|
+
};
|
|
276
|
+
this._notify();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
setFormField(name: string, value: any) {
|
|
280
|
+
this._state = {
|
|
281
|
+
...this._state,
|
|
282
|
+
formData: { ...this._state.formData, [name]: value },
|
|
283
|
+
};
|
|
284
|
+
this._notify();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
setValidationErrors(errors: Record<string, string>) {
|
|
288
|
+
this._state = { ...this._state, validationErrors: errors };
|
|
289
|
+
this._notify();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Loading
|
|
293
|
+
setLoading(loading: boolean) {
|
|
294
|
+
this._state = { ...this._state, loading };
|
|
295
|
+
this._notify();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
setError(error: string | null) {
|
|
299
|
+
this._state = { ...this._state, error };
|
|
300
|
+
this._notify();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Server-side response
|
|
304
|
+
setServerResponse(response: { data: T[]; recordsTotal: number; recordsFiltered: number; draw: number }) {
|
|
305
|
+
this._state = {
|
|
306
|
+
...this._state,
|
|
307
|
+
allRows: response.data,
|
|
308
|
+
filteredRows: response.data,
|
|
309
|
+
sortedRows: response.data,
|
|
310
|
+
paginatedRows: response.data,
|
|
311
|
+
loading: false,
|
|
312
|
+
draw: response.draw,
|
|
313
|
+
pagination: {
|
|
314
|
+
...this._state.pagination,
|
|
315
|
+
total: response.recordsFiltered,
|
|
316
|
+
lastPage: Math.ceil(response.recordsFiltered / this._state.pagination.perPage) || 1,
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
this._notify();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Clear state
|
|
323
|
+
resetState() {
|
|
324
|
+
this._state = {
|
|
325
|
+
...this._state,
|
|
326
|
+
sort: [],
|
|
327
|
+
filters: [],
|
|
328
|
+
globalSearch: '',
|
|
329
|
+
pagination: { ...this._state.pagination, page: 1 },
|
|
330
|
+
selectedIds: new Set(),
|
|
331
|
+
};
|
|
332
|
+
this._recompute();
|
|
333
|
+
if (this._stateSaveKey) {
|
|
334
|
+
try { localStorage.removeItem(this._stateSaveKey); } catch {}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Excel mode
|
|
339
|
+
focusCell(rowIndex: number, columnKey: string) {
|
|
340
|
+
this._state = {
|
|
341
|
+
...this._state,
|
|
342
|
+
excelFocusedCell: { rowIndex, columnKey },
|
|
343
|
+
};
|
|
344
|
+
this._notify();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
startCellEdit() {
|
|
348
|
+
const focused = this._state.excelFocusedCell;
|
|
349
|
+
if (!focused) return;
|
|
350
|
+
const row = this._state.paginatedRows[focused.rowIndex] as any;
|
|
351
|
+
if (!row) return;
|
|
352
|
+
const col = this._columns.find((c) => c.key === focused.columnKey);
|
|
353
|
+
if (!col || col.editable === false) return;
|
|
354
|
+
this._state = {
|
|
355
|
+
...this._state,
|
|
356
|
+
excelEditingCell: { ...focused },
|
|
357
|
+
excelEditValue: row[focused.columnKey] != null ? String(row[focused.columnKey]) : '',
|
|
358
|
+
};
|
|
359
|
+
this._notify();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
setExcelEditValue(value: string) {
|
|
363
|
+
this._state = { ...this._state, excelEditValue: value };
|
|
364
|
+
// No notify needed — the input component manages its own value
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Returns { row, columnKey, oldValue, newValue } if there was a change, null otherwise
|
|
368
|
+
commitCellEdit(): { row: any; columnKey: string; oldValue: any; newValue: any } | null {
|
|
369
|
+
const editing = this._state.excelEditingCell;
|
|
370
|
+
if (!editing) return null;
|
|
371
|
+
const row = this._state.paginatedRows[editing.rowIndex] as any;
|
|
372
|
+
if (!row) { this.cancelCellEdit(); return null; }
|
|
373
|
+
|
|
374
|
+
const col = this._columns.find((c) => c.key === editing.columnKey);
|
|
375
|
+
const oldValue = row[editing.columnKey];
|
|
376
|
+
let newValue: any = this._state.excelEditValue;
|
|
377
|
+
|
|
378
|
+
// Cast value based on column type
|
|
379
|
+
if (col?.type === 'number') newValue = newValue === '' ? null : Number(newValue);
|
|
380
|
+
else if (col?.type === 'boolean') newValue = newValue === 'true' || newValue === '1';
|
|
381
|
+
else if (newValue === '') newValue = null;
|
|
382
|
+
|
|
383
|
+
this._state = {
|
|
384
|
+
...this._state,
|
|
385
|
+
excelEditingCell: null,
|
|
386
|
+
excelEditValue: '',
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
if (newValue === oldValue || (newValue === null && (oldValue === null || oldValue === undefined))) {
|
|
390
|
+
this._notify();
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Update local data for client-side
|
|
395
|
+
row[editing.columnKey] = newValue;
|
|
396
|
+
this._notify();
|
|
397
|
+
|
|
398
|
+
return { row, columnKey: editing.columnKey, oldValue, newValue };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
cancelCellEdit() {
|
|
402
|
+
this._state = {
|
|
403
|
+
...this._state,
|
|
404
|
+
excelEditingCell: null,
|
|
405
|
+
excelEditValue: '',
|
|
406
|
+
};
|
|
407
|
+
this._notify();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
clearExcelFocus() {
|
|
411
|
+
this._state = {
|
|
412
|
+
...this._state,
|
|
413
|
+
excelFocusedCell: null,
|
|
414
|
+
excelEditingCell: null,
|
|
415
|
+
excelEditValue: '',
|
|
416
|
+
};
|
|
417
|
+
this._notify();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
getVisibleColumns(): ColumnDef[] {
|
|
421
|
+
return this._state.columnOrder
|
|
422
|
+
.filter((key) => this._state.columnVisibility[key] !== false)
|
|
423
|
+
.map((key) => this._columns.find((c) => c.key === key))
|
|
424
|
+
.filter(Boolean) as ColumnDef[];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Navigate to adjacent cell. Returns 'page-next' | 'page-prev' | null
|
|
428
|
+
navigateCell(direction: 'up' | 'down' | 'left' | 'right'): 'page-next' | 'page-prev' | null {
|
|
429
|
+
const focused = this._state.excelFocusedCell;
|
|
430
|
+
const rows = this._state.paginatedRows;
|
|
431
|
+
const visibleCols = this.getVisibleColumns();
|
|
432
|
+
if (visibleCols.length === 0) return null;
|
|
433
|
+
|
|
434
|
+
if (!focused) {
|
|
435
|
+
// Focus first editable cell
|
|
436
|
+
const firstEditable = visibleCols.find((c) => c.editable !== false);
|
|
437
|
+
if (firstEditable && rows.length > 0) {
|
|
438
|
+
this.focusCell(0, firstEditable.key);
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
let { rowIndex, columnKey } = focused;
|
|
444
|
+
const colIdx = visibleCols.findIndex((c) => c.key === columnKey);
|
|
445
|
+
if (colIdx === -1) return null;
|
|
446
|
+
|
|
447
|
+
switch (direction) {
|
|
448
|
+
case 'left': {
|
|
449
|
+
// Find previous editable column
|
|
450
|
+
for (let i = colIdx - 1; i >= 0; i--) {
|
|
451
|
+
if (visibleCols[i].editable !== false) {
|
|
452
|
+
this.focusCell(rowIndex, visibleCols[i].key);
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// Wrap to last editable col of previous row
|
|
457
|
+
for (let i = visibleCols.length - 1; i >= 0; i--) {
|
|
458
|
+
if (visibleCols[i].editable !== false) {
|
|
459
|
+
if (rowIndex > 0) {
|
|
460
|
+
this.focusCell(rowIndex - 1, visibleCols[i].key);
|
|
461
|
+
return null;
|
|
462
|
+
} else {
|
|
463
|
+
return 'page-prev';
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
case 'right': {
|
|
470
|
+
for (let i = colIdx + 1; i < visibleCols.length; i++) {
|
|
471
|
+
if (visibleCols[i].editable !== false) {
|
|
472
|
+
this.focusCell(rowIndex, visibleCols[i].key);
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// Wrap to first editable col of next row
|
|
477
|
+
for (let i = 0; i < visibleCols.length; i++) {
|
|
478
|
+
if (visibleCols[i].editable !== false) {
|
|
479
|
+
if (rowIndex < rows.length - 1) {
|
|
480
|
+
this.focusCell(rowIndex + 1, visibleCols[i].key);
|
|
481
|
+
return null;
|
|
482
|
+
} else {
|
|
483
|
+
return 'page-next';
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
case 'up': {
|
|
490
|
+
if (rowIndex > 0) {
|
|
491
|
+
this.focusCell(rowIndex - 1, columnKey);
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
return 'page-prev';
|
|
495
|
+
}
|
|
496
|
+
case 'down': {
|
|
497
|
+
if (rowIndex < rows.length - 1) {
|
|
498
|
+
this.focusCell(rowIndex + 1, columnKey);
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
return 'page-next';
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Core recompute pipeline: filter -> sort -> paginate
|
|
507
|
+
private _recompute() {
|
|
508
|
+
let rows = [...this._state.allRows];
|
|
509
|
+
|
|
510
|
+
// 1. Global search
|
|
511
|
+
if (this._state.globalSearch) {
|
|
512
|
+
const searchLower = this._state.globalSearch.toLowerCase();
|
|
513
|
+
const searchableCols = this._columns.filter((c) => c.searchable !== false);
|
|
514
|
+
rows = rows.filter((row: any) =>
|
|
515
|
+
searchableCols.some((col) => {
|
|
516
|
+
const val = row[col.key];
|
|
517
|
+
return val !== null && val !== undefined && String(val).toLowerCase().includes(searchLower);
|
|
518
|
+
})
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// 2. Column filters
|
|
523
|
+
for (const filter of this._state.filters) {
|
|
524
|
+
rows = rows.filter((row: any) => {
|
|
525
|
+
const val = row[filter.column];
|
|
526
|
+
switch (filter.operator) {
|
|
527
|
+
case '=': return val == filter.value;
|
|
528
|
+
case '!=': return val != filter.value;
|
|
529
|
+
case '>': return val > filter.value;
|
|
530
|
+
case '<': return val < filter.value;
|
|
531
|
+
case '>=': return val >= filter.value;
|
|
532
|
+
case '<=': return val <= filter.value;
|
|
533
|
+
case 'like': return val !== null && val !== undefined && String(val).toLowerCase().includes(String(filter.value).toLowerCase());
|
|
534
|
+
case 'not_like': return val === null || val === undefined || !String(val).toLowerCase().includes(String(filter.value).toLowerCase());
|
|
535
|
+
case 'in': return Array.isArray(filter.value) && filter.value.includes(val);
|
|
536
|
+
case 'between': return Array.isArray(filter.value) && val >= filter.value[0] && val <= filter.value[1];
|
|
537
|
+
case 'null': return val === null || val === undefined;
|
|
538
|
+
case 'not_null': return val !== null && val !== undefined;
|
|
539
|
+
default: return true;
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const filteredRows = rows;
|
|
545
|
+
|
|
546
|
+
// 3. Sort
|
|
547
|
+
if (this._state.sort.length > 0) {
|
|
548
|
+
rows = [...rows].sort((a: any, b: any) => {
|
|
549
|
+
for (const s of this._state.sort) {
|
|
550
|
+
const colDef = this._columns.find((c) => c.key === s.column);
|
|
551
|
+
const aVal = a[s.column];
|
|
552
|
+
const bVal = b[s.column];
|
|
553
|
+
|
|
554
|
+
let cmp = 0;
|
|
555
|
+
if (aVal === null || aVal === undefined) cmp = -1;
|
|
556
|
+
else if (bVal === null || bVal === undefined) cmp = 1;
|
|
557
|
+
else if (colDef?.type === 'number') cmp = Number(aVal) - Number(bVal);
|
|
558
|
+
else if (colDef?.type === 'date') cmp = new Date(aVal).getTime() - new Date(bVal).getTime();
|
|
559
|
+
else if (colDef?.type === 'boolean') cmp = (aVal ? 1 : 0) - (bVal ? 1 : 0);
|
|
560
|
+
else cmp = String(aVal).localeCompare(String(bVal));
|
|
561
|
+
|
|
562
|
+
if (cmp !== 0) return s.direction === 'desc' ? -cmp : cmp;
|
|
563
|
+
}
|
|
564
|
+
return 0;
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const sortedRows = rows;
|
|
569
|
+
|
|
570
|
+
// 4. Paginate
|
|
571
|
+
const total = filteredRows.length;
|
|
572
|
+
const perPage = this._state.pagination.perPage;
|
|
573
|
+
let paginatedRows: T[];
|
|
574
|
+
let page: number;
|
|
575
|
+
let lastPage: number;
|
|
576
|
+
|
|
577
|
+
if (this._paginate) {
|
|
578
|
+
lastPage = Math.ceil(total / perPage) || 1;
|
|
579
|
+
page = Math.min(this._state.pagination.page, lastPage);
|
|
580
|
+
const start = (page - 1) * perPage;
|
|
581
|
+
paginatedRows = sortedRows.slice(start, start + perPage);
|
|
582
|
+
} else {
|
|
583
|
+
// No pagination — all rows visible (used with virtual scroll)
|
|
584
|
+
paginatedRows = sortedRows;
|
|
585
|
+
page = 1;
|
|
586
|
+
lastPage = 1;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
this._state = {
|
|
590
|
+
...this._state,
|
|
591
|
+
filteredRows,
|
|
592
|
+
sortedRows,
|
|
593
|
+
paginatedRows,
|
|
594
|
+
pagination: { page, perPage, total, lastPage },
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
this._notify();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { DataTableConfig, DataTableRequest, DataTableResponse, DataTableState, FilterState, SortState } from '../types.js';
|
|
2
|
+
import { DataTableStore } from './DataTableStore.js';
|
|
3
|
+
|
|
4
|
+
function getCsrfToken(cookieName = 'XSRF-TOKEN'): string | null {
|
|
5
|
+
if (typeof document === 'undefined') return null;
|
|
6
|
+
const escaped = cookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
7
|
+
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]*)`));
|
|
8
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ServerDataTableStore<T = any> extends DataTableStore<T> {
|
|
12
|
+
private _serverUrl: string;
|
|
13
|
+
private _serverMethod: 'GET' | 'POST';
|
|
14
|
+
private _drawCounter = 0;
|
|
15
|
+
private _abortController: AbortController | null = null;
|
|
16
|
+
private _fetchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
17
|
+
private _serverColumns: { data: string; name: string; searchable: boolean; orderable: boolean }[];
|
|
18
|
+
private _csrfCookieName: string;
|
|
19
|
+
private _csrfHeaderName: string;
|
|
20
|
+
|
|
21
|
+
constructor(config: DataTableConfig<T>) {
|
|
22
|
+
super(config);
|
|
23
|
+
this._serverUrl = config.serverUrl!;
|
|
24
|
+
this._serverMethod = config.serverMethod ?? 'GET';
|
|
25
|
+
this._csrfCookieName = config.csrfCookieName ?? 'XSRF-TOKEN';
|
|
26
|
+
this._csrfHeaderName = config.csrfHeaderName ?? 'X-CSRF-Token';
|
|
27
|
+
this._serverColumns = config.columns.map((c) => ({
|
|
28
|
+
data: c.key,
|
|
29
|
+
name: c.key,
|
|
30
|
+
searchable: c.searchable !== false,
|
|
31
|
+
orderable: c.sortable !== false,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private _buildHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
|
36
|
+
const headers: Record<string, string> = { ...extra };
|
|
37
|
+
const token = getCsrfToken(this._csrfCookieName);
|
|
38
|
+
if (token) {
|
|
39
|
+
headers[this._csrfHeaderName] = token;
|
|
40
|
+
}
|
|
41
|
+
return headers;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override setSort(sort: SortState[]) {
|
|
45
|
+
const state = this.getState();
|
|
46
|
+
// Update sort in state without recomputing locally
|
|
47
|
+
(this as any)._state = { ...state, sort, pagination: { ...state.pagination, page: 1 } };
|
|
48
|
+
this._debouncedFetch();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override setGlobalSearch(search: string) {
|
|
52
|
+
const state = this.getState();
|
|
53
|
+
(this as any)._state = { ...state, globalSearch: search, pagination: { ...state.pagination, page: 1 } };
|
|
54
|
+
this._debouncedFetch();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
override setFilters(filters: FilterState[]) {
|
|
58
|
+
const state = this.getState();
|
|
59
|
+
(this as any)._state = { ...state, filters, pagination: { ...state.pagination, page: 1 } };
|
|
60
|
+
this._debouncedFetch();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
override setPage(page: number) {
|
|
64
|
+
const state = this.getState();
|
|
65
|
+
(this as any)._state = { ...state, pagination: { ...state.pagination, page } };
|
|
66
|
+
this._fetchFromServer();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override setPerPage(perPage: number) {
|
|
70
|
+
const state = this.getState();
|
|
71
|
+
(this as any)._state = { ...state, pagination: { ...state.pagination, perPage, page: 1 } };
|
|
72
|
+
this._fetchFromServer();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private _debouncedFetch() {
|
|
76
|
+
if (this._fetchDebounceTimer) clearTimeout(this._fetchDebounceTimer);
|
|
77
|
+
this._fetchDebounceTimer = setTimeout(() => this._fetchFromServer(), 300);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async _fetchFromServer() {
|
|
81
|
+
if (this._abortController) this._abortController.abort();
|
|
82
|
+
this._abortController = new AbortController();
|
|
83
|
+
|
|
84
|
+
const state = this.getState();
|
|
85
|
+
this.setLoading(true);
|
|
86
|
+
this.setError(null);
|
|
87
|
+
|
|
88
|
+
const draw = ++this._drawCounter;
|
|
89
|
+
const request: DataTableRequest = {
|
|
90
|
+
draw,
|
|
91
|
+
start: (state.pagination.page - 1) * state.pagination.perPage,
|
|
92
|
+
length: state.pagination.perPage,
|
|
93
|
+
search: { value: state.globalSearch, regex: false },
|
|
94
|
+
order: state.sort.map((s) => ({
|
|
95
|
+
column: this._serverColumns.findIndex((c) => c.data === s.column),
|
|
96
|
+
dir: s.direction,
|
|
97
|
+
})),
|
|
98
|
+
columns: this._serverColumns.map((col) => ({
|
|
99
|
+
...col,
|
|
100
|
+
search: {
|
|
101
|
+
value: state.filters.find((f) => f.column === col.data)?.value ?? '',
|
|
102
|
+
regex: false,
|
|
103
|
+
},
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
let response: Response;
|
|
109
|
+
|
|
110
|
+
if (this._serverMethod === 'POST') {
|
|
111
|
+
response = await fetch(this._serverUrl, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: this._buildHeaders({ 'Content-Type': 'application/json' }),
|
|
114
|
+
body: JSON.stringify(request),
|
|
115
|
+
signal: this._abortController.signal,
|
|
116
|
+
});
|
|
117
|
+
} else {
|
|
118
|
+
const params = new URLSearchParams();
|
|
119
|
+
params.set('draw', String(request.draw));
|
|
120
|
+
params.set('start', String(request.start));
|
|
121
|
+
params.set('length', String(request.length));
|
|
122
|
+
params.set('search[value]', request.search.value);
|
|
123
|
+
params.set('search[regex]', String(request.search.regex));
|
|
124
|
+
|
|
125
|
+
request.order.forEach((o, i) => {
|
|
126
|
+
params.set(`order[${i}][column]`, String(o.column));
|
|
127
|
+
params.set(`order[${i}][dir]`, o.dir);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
request.columns.forEach((c, i) => {
|
|
131
|
+
params.set(`columns[${i}][data]`, c.data);
|
|
132
|
+
params.set(`columns[${i}][name]`, c.name);
|
|
133
|
+
params.set(`columns[${i}][searchable]`, String(c.searchable));
|
|
134
|
+
params.set(`columns[${i}][orderable]`, String(c.orderable));
|
|
135
|
+
params.set(`columns[${i}][search][value]`, c.search.value);
|
|
136
|
+
params.set(`columns[${i}][search][regex]`, String(c.search.regex));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const url = `${this._serverUrl}?${params.toString()}`;
|
|
140
|
+
response = await fetch(url, { signal: this._abortController.signal });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
throw new Error(`Server responded with ${response.status}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const json: DataTableResponse<T> = await response.json();
|
|
148
|
+
|
|
149
|
+
// Only accept if this is the latest draw
|
|
150
|
+
if (json.draw === draw) {
|
|
151
|
+
this.setServerResponse(json);
|
|
152
|
+
}
|
|
153
|
+
} catch (err: any) {
|
|
154
|
+
if (err.name === 'AbortError') return;
|
|
155
|
+
this.setLoading(false);
|
|
156
|
+
this.setError(err.message ?? 'Failed to fetch data');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async initialFetch() {
|
|
161
|
+
await this._fetchFromServer();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
destroy() {
|
|
165
|
+
if (this._abortController) this._abortController.abort();
|
|
166
|
+
if (this._fetchDebounceTimer) clearTimeout(this._fetchDebounceTimer);
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
|
|
3
|
+
// Column definition
|
|
4
|
+
export type ColumnType = 'string' | 'number' | 'date' | 'boolean' | 'html' | 'custom';
|
|
5
|
+
export type FilterOperator = '=' | '!=' | '>' | '<' | '>=' | '<=' | 'like' | 'not_like' | 'in' | 'between' | 'null' | 'not_null';
|
|
6
|
+
export type SelectionMode = 'none' | 'single' | 'multi';
|
|
7
|
+
export type EditorMode = 'inline' | 'bubble' | 'modal' | 'excel';
|
|
8
|
+
export type FieldType = 'text' | 'textarea' | 'number' | 'select' | 'multi-select' | 'checkbox' | 'radio' | 'date' | 'datetime' | 'upload' | 'hidden' | 'readonly';
|
|
9
|
+
export type ExportFormat = 'csv' | 'excel' | 'pdf' | 'clipboard' | 'print';
|
|
10
|
+
|
|
11
|
+
export interface ColumnDef<T = any> {
|
|
12
|
+
key: string;
|
|
13
|
+
header: string;
|
|
14
|
+
type?: ColumnType;
|
|
15
|
+
sortable?: boolean;
|
|
16
|
+
searchable?: boolean;
|
|
17
|
+
filterable?: boolean;
|
|
18
|
+
visible?: boolean;
|
|
19
|
+
width?: string;
|
|
20
|
+
minWidth?: string;
|
|
21
|
+
maxWidth?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
headerClassName?: string;
|
|
24
|
+
orderable?: boolean;
|
|
25
|
+
defaultSort?: 'asc' | 'desc';
|
|
26
|
+
// Footer
|
|
27
|
+
footer?: string | ((rows: T[]) => string | number);
|
|
28
|
+
// Editor
|
|
29
|
+
editable?: boolean;
|
|
30
|
+
editorField?: EditorFieldDef;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SortState {
|
|
34
|
+
column: string;
|
|
35
|
+
direction: 'asc' | 'desc';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FilterState {
|
|
39
|
+
column: string;
|
|
40
|
+
value: any;
|
|
41
|
+
operator: FilterOperator;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PaginationState {
|
|
45
|
+
page: number;
|
|
46
|
+
perPage: number;
|
|
47
|
+
total: number;
|
|
48
|
+
lastPage: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Server-side protocol (compatible with jQuery DataTables wire protocol)
|
|
52
|
+
export interface DataTableRequest {
|
|
53
|
+
draw: number;
|
|
54
|
+
start: number;
|
|
55
|
+
length: number;
|
|
56
|
+
search: { value: string; regex: boolean };
|
|
57
|
+
order: { column: number; dir: 'asc' | 'desc' }[];
|
|
58
|
+
columns: {
|
|
59
|
+
data: string;
|
|
60
|
+
name: string;
|
|
61
|
+
searchable: boolean;
|
|
62
|
+
orderable: boolean;
|
|
63
|
+
search: { value: string; regex: boolean };
|
|
64
|
+
}[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DataTableResponse<T = any> {
|
|
68
|
+
draw: number;
|
|
69
|
+
recordsTotal: number;
|
|
70
|
+
recordsFiltered: number;
|
|
71
|
+
data: T[];
|
|
72
|
+
error?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Editor
|
|
76
|
+
export interface EditorFieldDef {
|
|
77
|
+
name: string;
|
|
78
|
+
type: FieldType;
|
|
79
|
+
label: string;
|
|
80
|
+
placeholder?: string;
|
|
81
|
+
options?: { label: string; value: any }[];
|
|
82
|
+
multiple?: boolean;
|
|
83
|
+
required?: boolean;
|
|
84
|
+
disabled?: boolean;
|
|
85
|
+
className?: string;
|
|
86
|
+
dependsOn?: string;
|
|
87
|
+
dependsOnValue?: any;
|
|
88
|
+
showWhen?: (formData: Record<string, any>) => boolean;
|
|
89
|
+
defaultValue?: any;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Buttons
|
|
93
|
+
export interface ButtonDef {
|
|
94
|
+
key: string;
|
|
95
|
+
label: string;
|
|
96
|
+
icon?: Component<any>;
|
|
97
|
+
action?: string | ((selectedRows: any[], allData: any[]) => void | Promise<void>);
|
|
98
|
+
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost';
|
|
99
|
+
disabled?: boolean | ((selectedRows: any[]) => boolean);
|
|
100
|
+
collection?: ButtonDef[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// CSS class overrides for full Tailwind customization
|
|
104
|
+
export interface DataTableClassNames {
|
|
105
|
+
container?: string;
|
|
106
|
+
toolbar?: string;
|
|
107
|
+
toolbarLeft?: string;
|
|
108
|
+
toolbarRight?: string;
|
|
109
|
+
searchInput?: string;
|
|
110
|
+
table?: string;
|
|
111
|
+
thead?: string;
|
|
112
|
+
th?: string;
|
|
113
|
+
thSortable?: string;
|
|
114
|
+
tbody?: string;
|
|
115
|
+
tr?: string;
|
|
116
|
+
trSelected?: string;
|
|
117
|
+
trEven?: string;
|
|
118
|
+
td?: string;
|
|
119
|
+
tfoot?: string;
|
|
120
|
+
tf?: string;
|
|
121
|
+
pagination?: string;
|
|
122
|
+
paginationInfo?: string;
|
|
123
|
+
paginationControls?: string;
|
|
124
|
+
pageButton?: string;
|
|
125
|
+
pageButtonActive?: string;
|
|
126
|
+
perPageSelect?: string;
|
|
127
|
+
btn?: string;
|
|
128
|
+
btnCreate?: string;
|
|
129
|
+
btnEdit?: string;
|
|
130
|
+
btnDelete?: string;
|
|
131
|
+
editorModal?: string;
|
|
132
|
+
editorBackdrop?: string;
|
|
133
|
+
editorField?: string;
|
|
134
|
+
editorInput?: string;
|
|
135
|
+
editorLabel?: string;
|
|
136
|
+
loading?: string;
|
|
137
|
+
empty?: string;
|
|
138
|
+
error?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Full config
|
|
142
|
+
export interface DataTableConfig<T = any> {
|
|
143
|
+
// Data source
|
|
144
|
+
data?: T[];
|
|
145
|
+
serverUrl?: string;
|
|
146
|
+
serverMethod?: 'GET' | 'POST';
|
|
147
|
+
// CSRF token config (defaults to Svelar conventions)
|
|
148
|
+
csrfCookieName?: string;
|
|
149
|
+
csrfHeaderName?: string;
|
|
150
|
+
// Columns
|
|
151
|
+
columns: ColumnDef<T>[];
|
|
152
|
+
// Features
|
|
153
|
+
sortable?: boolean;
|
|
154
|
+
searchable?: boolean;
|
|
155
|
+
paginate?: boolean;
|
|
156
|
+
selectable?: SelectionMode;
|
|
157
|
+
// Pagination
|
|
158
|
+
perPage?: number;
|
|
159
|
+
perPageOptions?: number[];
|
|
160
|
+
// Search
|
|
161
|
+
searchDebounceMs?: number;
|
|
162
|
+
// State
|
|
163
|
+
stateSaveKey?: string;
|
|
164
|
+
// Row identity
|
|
165
|
+
rowId?: string | ((row: T) => string | number);
|
|
166
|
+
// Row classes
|
|
167
|
+
rowClass?: string | ((row: T, index: number) => string);
|
|
168
|
+
// Buttons
|
|
169
|
+
buttons?: (ButtonDef | ExportFormat)[];
|
|
170
|
+
// Editor
|
|
171
|
+
editorMode?: EditorMode;
|
|
172
|
+
editorFields?: EditorFieldDef[];
|
|
173
|
+
// Callbacks
|
|
174
|
+
onSort?: (sort: SortState[]) => void;
|
|
175
|
+
onFilter?: (filters: FilterState[]) => void;
|
|
176
|
+
onPageChange?: (page: number, perPage: number) => void;
|
|
177
|
+
onSelect?: (selectedRows: T[]) => void;
|
|
178
|
+
onRowClick?: (row: T, event: MouseEvent) => void;
|
|
179
|
+
onEdit?: (row: T, data: Record<string, any>) => void | Promise<void>;
|
|
180
|
+
onCellEdit?: (row: T, columnKey: string, newValue: any, oldValue: any) => void | Promise<void>;
|
|
181
|
+
onCreate?: (data: Record<string, any>) => void | Promise<void>;
|
|
182
|
+
onDelete?: (rows: T[]) => void | Promise<void>;
|
|
183
|
+
// Virtual scroll
|
|
184
|
+
virtualScroll?: boolean;
|
|
185
|
+
virtualRowHeight?: number;
|
|
186
|
+
// Responsive
|
|
187
|
+
responsive?: boolean;
|
|
188
|
+
// Row grouping
|
|
189
|
+
groupBy?: string;
|
|
190
|
+
// Detail/child rows
|
|
191
|
+
expandable?: boolean;
|
|
192
|
+
// Empty state
|
|
193
|
+
emptyText?: string;
|
|
194
|
+
// Loading
|
|
195
|
+
loadingText?: string;
|
|
196
|
+
// CSS
|
|
197
|
+
className?: string;
|
|
198
|
+
compact?: boolean;
|
|
199
|
+
striped?: boolean;
|
|
200
|
+
hover?: boolean;
|
|
201
|
+
bordered?: boolean;
|
|
202
|
+
// Tailwind / custom class overrides
|
|
203
|
+
classNames?: DataTableClassNames;
|
|
204
|
+
// Unstyled mode — disables all built-in CSS
|
|
205
|
+
unstyled?: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Store state shape
|
|
209
|
+
export interface DataTableState<T = any> {
|
|
210
|
+
// Source data
|
|
211
|
+
allRows: T[];
|
|
212
|
+
// Derived
|
|
213
|
+
filteredRows: T[];
|
|
214
|
+
sortedRows: T[];
|
|
215
|
+
paginatedRows: T[];
|
|
216
|
+
// State
|
|
217
|
+
sort: SortState[];
|
|
218
|
+
filters: FilterState[];
|
|
219
|
+
globalSearch: string;
|
|
220
|
+
pagination: PaginationState;
|
|
221
|
+
// Selection
|
|
222
|
+
selectedIds: Set<string | number>;
|
|
223
|
+
// Column state
|
|
224
|
+
columnVisibility: Record<string, boolean>;
|
|
225
|
+
columnOrder: string[];
|
|
226
|
+
// Loading
|
|
227
|
+
loading: boolean;
|
|
228
|
+
error: string | null;
|
|
229
|
+
// Editor
|
|
230
|
+
editingRowId: string | number | null;
|
|
231
|
+
editingColumn: string | null;
|
|
232
|
+
editorMode: EditorMode | null;
|
|
233
|
+
formData: Record<string, any>;
|
|
234
|
+
validationErrors: Record<string, string>;
|
|
235
|
+
// Server draw
|
|
236
|
+
draw: number;
|
|
237
|
+
// Excel mode
|
|
238
|
+
excelFocusedCell: { rowIndex: number; columnKey: string } | null;
|
|
239
|
+
excelEditingCell: { rowIndex: number; columnKey: string } | null;
|
|
240
|
+
excelEditValue: string;
|
|
241
|
+
}
|
package/src/ui/DataTable.svelte
CHANGED
|
@@ -149,6 +149,10 @@
|
|
|
149
149
|
if (row) await onEdit(row, formData);
|
|
150
150
|
}
|
|
151
151
|
store.closeEditor();
|
|
152
|
+
// Auto-refetch for server-side stores after successful edit/create
|
|
153
|
+
if ('initialFetch' in store) {
|
|
154
|
+
await (store as any).initialFetch();
|
|
155
|
+
}
|
|
152
156
|
} catch (err: any) {
|
|
153
157
|
if (err.errors) {
|
|
154
158
|
store.setValidationErrors(err.errors);
|