@navette-js/tables 1.0.0-beta.1 → 1.0.0-beta.2
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/README.md +2 -2
- package/lib/index.d.ts +149 -148
- package/lib/index.js +167 -166
- package/lib/query-builder.d.ts +53 -53
- package/lib/query-builder.js +89 -95
- package/package.json +7 -8
- package/views/column-manager.ejs +2 -2
- package/views/index.ejs +14 -15
package/lib/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
views.namespace('filament-tables', fileURLToPath(new URL('../views', import.meta.url)));
|
|
6
6
|
export * from "./query-builder.js";
|
|
7
|
-
/**
|
|
7
|
+
/** Render hook slots of a table. */
|
|
8
8
|
export const TablesRenderHook = {
|
|
9
9
|
CONTENT_AFTER: 'tables::content.after',
|
|
10
10
|
CONTENT_BEFORE: 'tables::content.before',
|
|
@@ -26,17 +26,17 @@ export const TablesRenderHook = {
|
|
|
26
26
|
TOOLBAR_START: 'tables::toolbar.start',
|
|
27
27
|
FILTER_INDICATORS: 'tables::filter.indicators',
|
|
28
28
|
HEADER_CELL: 'tables::header.cell',
|
|
29
|
-
/**
|
|
29
|
+
/** Legacy aliases of the column manager trigger slots. */
|
|
30
30
|
TOOLBAR_TOGGLE_COLUMN_TRIGGER_AFTER: 'tables::toolbar.toggle-column-trigger.after',
|
|
31
31
|
TOOLBAR_TOGGLE_COLUMN_TRIGGER_BEFORE: 'tables::toolbar.toggle-column-trigger.before',
|
|
32
32
|
};
|
|
33
|
-
/**
|
|
33
|
+
/** Methods and properties that trigger the table's loading state (`wire:target`). */
|
|
34
34
|
export const LOADING_TARGETS = 'activeTab,applyTableFilters,gotoPage,nextPage,previousPage,removeTableFilter,removeTableFilters,reorderTable,resetTableColumnSearch,resetTableFiltersForm,resetTableSearch,setPage,sortTable,tableColumnSearches,tableFilters,tableGrouping,tableRecordsPerPage,tableSearch,tableSort,toggleTableReordering';
|
|
35
35
|
const headline = (s) => s.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
|
36
|
-
// ------------------------------------------------------------------
|
|
37
|
-
/** Base
|
|
36
|
+
// ------------------------------------------------------------------ columns
|
|
37
|
+
/** Base class of table columns. */
|
|
38
38
|
export class Column {
|
|
39
|
-
/**
|
|
39
|
+
/** Default setup for every instance created afterwards by `make()`, e.g. `TextColumn.configureUsing((column) => column.sortable())`. */
|
|
40
40
|
static configureUsing(configure) { configureUsing(this, configure); }
|
|
41
41
|
name;
|
|
42
42
|
_label = null;
|
|
@@ -50,20 +50,20 @@ export class Column {
|
|
|
50
50
|
_sortColumn = null;
|
|
51
51
|
_searchColumn = null;
|
|
52
52
|
_url = null;
|
|
53
|
-
/**
|
|
53
|
+
/** Label of the column group this column belongs to (set by ColumnGroup). */
|
|
54
54
|
_group = null;
|
|
55
55
|
constructor(name) { this.name = name; }
|
|
56
56
|
label(label) { this._label = label; return this; }
|
|
57
|
-
/** `column
|
|
57
|
+
/** `column`: column (or query alias) to sort by when it differs from the name, e.g. for a relationship. */
|
|
58
58
|
sortable(condition = true) { this._sortable = condition !== false; this._sortColumn = typeof condition === 'object' ? condition.column : null; this._sortInverted = typeof condition === 'object' && condition.inverted === true; return this; }
|
|
59
59
|
getSortColumn() { return this._sortColumn ?? this.name; }
|
|
60
60
|
_sortInverted = false;
|
|
61
|
-
/**
|
|
61
|
+
/** `inverted`: the sort column runs opposite to the displayed value (e.g. an age sorted by birth date). */
|
|
62
62
|
isSortInverted() { return this._sortInverted; }
|
|
63
|
-
/**
|
|
63
|
+
/** Link on the cell; takes precedence over the row action. */
|
|
64
64
|
url(resolver) { this._url = resolver; return this; }
|
|
65
65
|
getUrl(record) { return this._url ? this._url(record) : null; }
|
|
66
|
-
/** `isGlobal: false`
|
|
66
|
+
/** `isGlobal: false` excludes the column from the global search; `isIndividual` adds a search field for this column. */
|
|
67
67
|
getSearchColumn() { return this._searchColumn ?? this.name; }
|
|
68
68
|
searchable(options = true) {
|
|
69
69
|
this._searchColumn = typeof options === 'object' ? (options.column ?? null) : null;
|
|
@@ -71,7 +71,7 @@ export class Column {
|
|
|
71
71
|
this._individuallySearchable = typeof options === 'object' && options.isIndividual === true;
|
|
72
72
|
return this;
|
|
73
73
|
}
|
|
74
|
-
/**
|
|
74
|
+
/** Lets the user hide the column from the column manager. */
|
|
75
75
|
toggleable(condition = true, options = {}) { this._toggleable = condition; this._toggledHiddenByDefault = options.isToggledHiddenByDefault === true; return this; }
|
|
76
76
|
alignment(alignment) { this._alignment = alignment; return this; }
|
|
77
77
|
alignEnd() { return this.alignment('end'); }
|
|
@@ -85,10 +85,10 @@ export class Column {
|
|
|
85
85
|
isToggleable() { return this._toggleable; }
|
|
86
86
|
isToggledHiddenByDefault() { return this._toggledHiddenByDefault; }
|
|
87
87
|
getAlignment() { return this._alignment; }
|
|
88
|
-
/**
|
|
88
|
+
/** Suffix of the `fi-ta-header-cell-…` / `fi-ta-cell-…` classes: underscores become dashes, dots are kept. */
|
|
89
89
|
getCssName() { return this.name.replace(/_/g, '-'); }
|
|
90
90
|
async getState(record) { return this._state ? this._state({ record }) : record[this.name]; }
|
|
91
|
-
/**
|
|
91
|
+
/** True for editable cells: they are not wrapped in a link or button to the record. */
|
|
92
92
|
isClickDisabled() { return false; }
|
|
93
93
|
}
|
|
94
94
|
export class TextColumn extends Column {
|
|
@@ -106,40 +106,40 @@ export class TextColumn extends Column {
|
|
|
106
106
|
_limit = null;
|
|
107
107
|
_tooltip = null;
|
|
108
108
|
static make(name) { return configured(new TextColumn(name)); }
|
|
109
|
-
/**
|
|
109
|
+
/** Displays the value in a badge. */
|
|
110
110
|
badge(condition = true) { this._badge = condition; return this; }
|
|
111
111
|
color(color) { this._color = color; return this; }
|
|
112
112
|
icon(resolver) { this._icon = resolver; return this; }
|
|
113
|
-
/**
|
|
113
|
+
/** Text displayed instead of the raw value (e.g. an enum label). */
|
|
114
114
|
formatStateUsing(formatter) { this._format = formatter; return this; }
|
|
115
|
-
/**
|
|
115
|
+
/** Short date, e.g. "Nov 9, 2026". */
|
|
116
116
|
date(condition = true) { this._date = condition; return this; }
|
|
117
117
|
_dateTime = false;
|
|
118
|
-
/** Date
|
|
118
|
+
/** Date and time, e.g. "Nov 9, 2026 14:30:00". */
|
|
119
119
|
dateTime(condition = true) { this._dateTime = condition; this._date = condition; return this; }
|
|
120
|
-
/**
|
|
120
|
+
/** Number formatted with thousands separators. */
|
|
121
121
|
_decimals = null;
|
|
122
|
-
/** `numeric(1)
|
|
122
|
+
/** `numeric(1)`: fixed number of decimals. */
|
|
123
123
|
numeric(condition = true) { this._numeric = condition !== false; this._decimals = typeof condition === 'number' ? condition : null; return this; }
|
|
124
|
-
/**
|
|
124
|
+
/** Summary row below the table. */
|
|
125
125
|
summarize(summarizer) { this._summarizers = Array.isArray(summarizer) ? summarizer : [summarizer]; return this; }
|
|
126
126
|
getSummarizers() { return this._summarizers; }
|
|
127
127
|
weight(weight) { this._weight = weight; return this; }
|
|
128
|
-
/**
|
|
128
|
+
/** Truncates the displayed text to `length` characters, followed by "...". */
|
|
129
129
|
limit(length) { this._limit = length; return this; }
|
|
130
|
-
/**
|
|
130
|
+
/** Tooltip on hover; `null` for none. */
|
|
131
131
|
tooltip(tooltip) { this._tooltip = tooltip; return this; }
|
|
132
132
|
copyable(condition = true) { this._copyable = condition; return this; }
|
|
133
|
-
/**
|
|
133
|
+
/** Amount formatted in the given currency, e.g. `money('usd')`. */
|
|
134
134
|
money(currency = 'usd') { this._money = currency; return this; }
|
|
135
|
-
/**
|
|
135
|
+
/** Text displayed when the value is empty. */
|
|
136
136
|
placeholder(text) { this._placeholder = text; return this; }
|
|
137
|
-
/**
|
|
137
|
+
/** Cell content: a short markup for plain text, a full markup as soon as an option (badge, icon, color…) is set. */
|
|
138
138
|
async toHtml(record) {
|
|
139
139
|
const state = await this.getState(record);
|
|
140
140
|
if ((state === null || state === undefined || state === '') && this._placeholder !== null)
|
|
141
141
|
return `<div class="fi-ta-text"><p class="fi-ta-placeholder">${e(this._placeholder)}</p></div>`;
|
|
142
|
-
//
|
|
142
|
+
// Empty value without placeholder: render a bare container.
|
|
143
143
|
if (state === null || state === undefined || state === '')
|
|
144
144
|
return `<div class="${cls(['fi-ta-text', this._money || this._numeric ? 'fi-numeric' : null, (this._money || this._numeric) && this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null])}"></div>`;
|
|
145
145
|
const currency = typeof this._money === 'function' ? this._money(record) : this._money;
|
|
@@ -153,7 +153,7 @@ export class TextColumn extends Column {
|
|
|
153
153
|
const color = typeof this._color === 'function' ? this._color(record) : (this._color ?? 'primary');
|
|
154
154
|
const iconName = this._icon?.(record) ?? null;
|
|
155
155
|
const classes = ['fi-badge', 'fi-size-sm', ...(color !== 'gray' ? (colorManager?.getComponentClasses('badge', color) ?? []) : [])].join(' ');
|
|
156
|
-
//
|
|
156
|
+
// Without an icon the cell uses the short markup, which writes the classes in the other order.
|
|
157
157
|
return `<div class="${iconName ? 'fi-ta-text-item fi-ta-text' : 'fi-ta-text fi-ta-text-item'} fi-ta-text-has-badges"><span class="${classes}">${iconName ? icon(iconName, { size: 'sm', attributes: new Attributes({ 'aria-hidden': 'true' }) }) : ''}${e(shown)}</span></div>`;
|
|
158
158
|
}
|
|
159
159
|
if (!this._weight && !this._copyable)
|
|
@@ -171,7 +171,7 @@ export class TextColumn extends Column {
|
|
|
171
171
|
}">${e(limited)}</span>` : e(limited);
|
|
172
172
|
return `<div class="${cls(['fi-ta-text-item', 'fi-size-sm', this._weight ? `fi-font-${this._weight}` : null, 'fi-ta-text', this._money || this._numeric ? 'fi-numeric' : null, align])}">${content}</div>`;
|
|
173
173
|
}
|
|
174
|
-
//
|
|
174
|
+
// Outside a badge: an icon precedes the text (full markup) and a color tints the text.
|
|
175
175
|
const plainIcon = this._icon?.(record) ?? null;
|
|
176
176
|
const plainColor = typeof this._color === 'function' ? this._color(record) : this._color;
|
|
177
177
|
const colorClasses = plainColor ? (colorManager?.getComponentClasses('text', plainColor) ?? []) : [];
|
|
@@ -193,7 +193,7 @@ export class TextColumn extends Column {
|
|
|
193
193
|
return `<div class="${cls(['fi-ta-text-item', 'fi-size-sm', this._weight ? `fi-font-${this._weight}` : null, 'fi-ta-text', this._money || this._numeric ? 'fi-numeric' : null, align])}">${inner}</div>`;
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
|
-
/**
|
|
196
|
+
/** Column summary below the table: sum, average, count or range. */
|
|
197
197
|
export class Summarizer {
|
|
198
198
|
_label = null;
|
|
199
199
|
_money = null;
|
|
@@ -202,33 +202,33 @@ export class Summarizer {
|
|
|
202
202
|
getLabel() { return this._label; }
|
|
203
203
|
format(value) { return this._money ? new Intl.NumberFormat('en-US', { style: 'currency', currency: this._money }).format(value) : new Intl.NumberFormat('en-US').format(value); }
|
|
204
204
|
}
|
|
205
|
-
/**
|
|
205
|
+
/** Column total. */
|
|
206
206
|
export class Sum extends Summarizer {
|
|
207
207
|
kind = 'sum';
|
|
208
208
|
static make() { return configured(new Sum()); }
|
|
209
209
|
getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.sum.label'); }
|
|
210
210
|
summarize(column, values) { return { type: 'text', label: this.getLabel(), value: this.format(values.sum[column] ?? 0) }; }
|
|
211
211
|
}
|
|
212
|
-
/**
|
|
212
|
+
/** Column average. */
|
|
213
213
|
export class Average extends Summarizer {
|
|
214
214
|
kind = 'avg';
|
|
215
215
|
static make() { return configured(new Average()); }
|
|
216
216
|
getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.average.label'); }
|
|
217
217
|
summarize(column, values) { return { type: 'text', label: this.getLabel(), value: this.format(values.avg[column] ?? 0) }; }
|
|
218
218
|
}
|
|
219
|
-
/**
|
|
219
|
+
/** Record count. */
|
|
220
220
|
export class Count extends Summarizer {
|
|
221
221
|
kind = 'count';
|
|
222
222
|
static make() { return configured(new Count()); }
|
|
223
223
|
getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.count.label'); }
|
|
224
224
|
summarize(_column, values) { return { type: 'text', label: this.getLabel(), value: this.format(values.count) }; }
|
|
225
225
|
}
|
|
226
|
-
/**
|
|
226
|
+
/** Smallest and largest value of a column. */
|
|
227
227
|
export class Range extends Summarizer {
|
|
228
228
|
kind = 'range';
|
|
229
229
|
_minimalDateTime = false;
|
|
230
230
|
static make() { return configured(new Range()); }
|
|
231
|
-
/** Dates
|
|
231
|
+
/** Dates: day only when the bounds fall on different days, day and time otherwise; a single value when both are equal. */
|
|
232
232
|
minimalDateTimeDifference(condition = true) { this._minimalDateTime = condition; return this; }
|
|
233
233
|
summarize(column, values) {
|
|
234
234
|
let bounds = [values.min[column], values.max[column]].filter((v) => v !== null && v !== undefined && v !== '').map(String);
|
|
@@ -248,7 +248,7 @@ export class Range extends Summarizer {
|
|
|
248
248
|
return { type: 'range', label: this._label, value: bounds.join(' - ') };
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
|
-
/**
|
|
251
|
+
/** Row grouping: rows are sorted by the column and each group gets a header row. */
|
|
252
252
|
export class Group {
|
|
253
253
|
id;
|
|
254
254
|
_label = null;
|
|
@@ -261,17 +261,17 @@ export class Group {
|
|
|
261
261
|
constructor(id) { this.id = id; }
|
|
262
262
|
static make(id) { return configured(new Group(id)); }
|
|
263
263
|
label(label) { this._label = label; return this; }
|
|
264
|
-
/**
|
|
264
|
+
/** Query column holding the group value, when it differs from the id (relationship). */
|
|
265
265
|
column(column) { this._column = column; return this; }
|
|
266
|
-
/**
|
|
266
|
+
/** Group title computed from the record (e.g. an enum label). */
|
|
267
267
|
getTitleUsing(resolver) { this._title = resolver; return this; }
|
|
268
268
|
getDescriptionUsing(resolver) { this._description = resolver; return this; }
|
|
269
|
-
/**
|
|
269
|
+
/** Aliases using the `get…FromRecordUsing` naming. */
|
|
270
270
|
getTitleFromRecordUsing(resolver) { return this.getTitleUsing(resolver); }
|
|
271
271
|
getDescriptionFromRecordUsing(resolver) { return this.getDescriptionUsing(resolver); }
|
|
272
|
-
/**
|
|
272
|
+
/** Groups collapse when their header is clicked. */
|
|
273
273
|
collapsible(condition = true) { this._collapsible = condition; return this; }
|
|
274
|
-
/**
|
|
274
|
+
/** The column is a date: rows from the same day form one group. */
|
|
275
275
|
date(condition = true) { this._date = condition; return this; }
|
|
276
276
|
titlePrefixedWithLabel(condition = true) { this._titlePrefixedWithLabel = condition; return this; }
|
|
277
277
|
getId() { return this.id; }
|
|
@@ -285,7 +285,7 @@ export class Group {
|
|
|
285
285
|
isCollapsible() { return this._collapsible; }
|
|
286
286
|
isDate() { return this._date; }
|
|
287
287
|
isTitlePrefixedWithLabel() { return this._titlePrefixedWithLabel; }
|
|
288
|
-
/**
|
|
288
|
+
/** Group value of a record, as text; `null` when empty. */
|
|
289
289
|
getStringKey(record) {
|
|
290
290
|
let key = record?.[this.getColumn()];
|
|
291
291
|
if (key === null || key === undefined || key === '')
|
|
@@ -303,7 +303,7 @@ export class Group {
|
|
|
303
303
|
return key;
|
|
304
304
|
}
|
|
305
305
|
getDescription(record) { return this._description ? this._description(record) : null; }
|
|
306
|
-
/**
|
|
306
|
+
/** Restricts a query to the records of the group with the given key. */
|
|
307
307
|
scope(query, key) {
|
|
308
308
|
if (key === null)
|
|
309
309
|
query.where((sub) => sub.whereNull(this.getColumn()).orWhere(this.getColumn(), ''));
|
|
@@ -314,35 +314,35 @@ export class Group {
|
|
|
314
314
|
}
|
|
315
315
|
}
|
|
316
316
|
/**
|
|
317
|
-
*
|
|
318
|
-
*
|
|
317
|
+
* Column layout: as soon as a table has one, records are rendered as cards (`fi-ta-content`) instead of table rows,
|
|
318
|
+
* and sorting goes through a "Sort by" select.
|
|
319
319
|
*/
|
|
320
320
|
export class ColumnLayout {
|
|
321
321
|
_children;
|
|
322
322
|
constructor(children) { this._children = children; }
|
|
323
323
|
getChildren() { return this._children; }
|
|
324
|
-
/**
|
|
324
|
+
/** Leaf columns, in order: the ones the table sorts, searches and toggles. */
|
|
325
325
|
getColumns() { return this._children.flatMap((child) => (child instanceof ColumnLayout ? child.getColumns() : [child])); }
|
|
326
326
|
}
|
|
327
|
-
/**
|
|
327
|
+
/** Columns side by side, from a given breakpoint. */
|
|
328
328
|
export class Split extends ColumnLayout {
|
|
329
329
|
_from = null;
|
|
330
330
|
static make(children) { return new Split(children); }
|
|
331
331
|
from(breakpoint) { this._from = breakpoint; return this; }
|
|
332
332
|
getClasses() { return ['fi-ta-split', ...(this._from ? [`${this._from}:fi-ta-split`] : [])]; }
|
|
333
333
|
}
|
|
334
|
-
/**
|
|
334
|
+
/** Columns stacked vertically. */
|
|
335
335
|
export class Stack extends ColumnLayout {
|
|
336
336
|
_space = null;
|
|
337
337
|
_alignment = 'start';
|
|
338
338
|
static make(children) { return new Stack(children); }
|
|
339
|
-
/**
|
|
339
|
+
/** Gap between columns: 1 (small), 2 (medium) or 3 (large). */
|
|
340
340
|
space(space = 1) { this._space = space; return this; }
|
|
341
341
|
alignment(alignment) { this._alignment = alignment; return this; }
|
|
342
342
|
getClasses() { return ['fi-ta-stack', `fi-align-${this._alignment}`, ...(this._space ? [`fi-gap-${['sm', 'md', 'lg'][this._space - 1]}`] : [])]; }
|
|
343
343
|
}
|
|
344
344
|
const layoutCell = (classes, html) => `<div class="${['fi-grid-col', 'fi-growable', ...classes].join(' ')}" style="--col-span-default: span 1 / span 1;">${html}</div>`;
|
|
345
|
-
/**
|
|
345
|
+
/** Renders a record through the layout, skipping columns the user has hidden. */
|
|
346
346
|
async function renderColumnLayout(node, record, key, visible) {
|
|
347
347
|
if (node instanceof ColumnLayout) {
|
|
348
348
|
const children = [];
|
|
@@ -353,11 +353,11 @@ async function renderColumnLayout(node, record, key, visible) {
|
|
|
353
353
|
if (!visible.has(node))
|
|
354
354
|
return '';
|
|
355
355
|
const alignment = node.getAlignment();
|
|
356
|
-
//
|
|
356
|
+
// Inside a layout, text is inline and carries its own alignment.
|
|
357
357
|
const html = (await node.toHtml(record, key)).replace(/class="([^"]*\bfi-ta-text\b[^"]*)"/, (_match, classes) => `class="${classes.replace(/ fi-align-\w+/g, '')} fi-inline${alignment ? ` fi-align-${alignment}` : ''}"`);
|
|
358
358
|
return layoutCell(['fi-ta-col', ...(alignment ? [`fi-align-${alignment}`] : [])], html);
|
|
359
359
|
}
|
|
360
|
-
/**
|
|
360
|
+
/** Column group: a shared label above the column headers. */
|
|
361
361
|
export class ColumnGroup {
|
|
362
362
|
label;
|
|
363
363
|
_columns = [];
|
|
@@ -367,24 +367,24 @@ export class ColumnGroup {
|
|
|
367
367
|
c._group = this.label; return this; }
|
|
368
368
|
getColumns() { return this._columns; }
|
|
369
369
|
}
|
|
370
|
-
/**
|
|
370
|
+
/** Color swatch column. */
|
|
371
371
|
export class ColorColumn extends Column {
|
|
372
372
|
static make(name) { return configured(new ColorColumn(name)); }
|
|
373
373
|
async toHtml(record) {
|
|
374
374
|
const state = await this.getState(record);
|
|
375
|
-
//
|
|
375
|
+
// Only hex or simple functional colors are injected into the style attribute (no CSS injection).
|
|
376
376
|
const color = typeof state === 'string' && /^(#[0-9a-fA-F]{3,8}|(rgb|hsl|oklch)a?\([0-9.,%\s/deg-]+\))$/.test(state.trim()) ? state.trim() : null;
|
|
377
377
|
return `<div class="fi-ta-color">${color ? `<div aria-label="${e(color)}" role="img" class="fi-ta-color-item" style="background-color: ${color};"></div>` : ''}</div>`;
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
|
-
/** Image
|
|
380
|
+
/** Image column: the state is the image URL. Only local or http(s) URLs are rendered. */
|
|
381
381
|
export class ImageColumn extends Column {
|
|
382
382
|
_alt = null;
|
|
383
383
|
_height = '2.5rem';
|
|
384
384
|
_circular = false;
|
|
385
385
|
static make(name) { return configured(new ImageColumn(name)); }
|
|
386
386
|
alt(resolver) { this._alt = resolver; return this; }
|
|
387
|
-
/**
|
|
387
|
+
/** CSS height of the image (`2.5rem` by default). */
|
|
388
388
|
imageHeight(height) { if (/^[0-9.]+(rem|px|em)$/.test(height))
|
|
389
389
|
this._height = height; return this; }
|
|
390
390
|
circular(condition = true) { this._circular = condition; return this; }
|
|
@@ -397,7 +397,7 @@ export class ImageColumn extends Column {
|
|
|
397
397
|
return `<div class="fi-ta-image"><img${alt !== null ? ` alt="${e(alt)}"` : ''} src="${e(src)}" style="height: ${this._height};"${this._circular ? ' class="fi-circular"' : ''} /></div>`;
|
|
398
398
|
}
|
|
399
399
|
}
|
|
400
|
-
/**
|
|
400
|
+
/** Icon depending on the value; `boolean()` shows a green check or a red cross. */
|
|
401
401
|
export class IconColumn extends Column {
|
|
402
402
|
_boolean = false;
|
|
403
403
|
static make(name) { return configured(new IconColumn(name)); }
|
|
@@ -413,7 +413,7 @@ export class IconColumn extends Column {
|
|
|
413
413
|
}
|
|
414
414
|
const isBlank = (value) => value === null || value === undefined || (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0);
|
|
415
415
|
const isNumeric = (value) => (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)));
|
|
416
|
-
/**
|
|
416
|
+
/** String rules accepted by `rules([...])`, with their messages. An unknown rule throws at declaration time instead of being ignored. */
|
|
417
417
|
const STRING_RULES = {
|
|
418
418
|
boolean: () => ({ name: 'boolean', passes: (v) => [true, false, 0, 1, '0', '1'].includes(v), message: (a) => `The ${a} field must be true or false.` }),
|
|
419
419
|
string: () => ({ name: 'string', passes: (v) => typeof v === 'string', message: (a) => `The ${a} field must be a string.` }),
|
|
@@ -426,10 +426,10 @@ const STRING_RULES = {
|
|
|
426
426
|
in: (list) => ({ name: 'in', passes: (v) => list.split(',').includes(String(v)), message: (a) => `The selected ${a} is invalid.` }),
|
|
427
427
|
};
|
|
428
428
|
/**
|
|
429
|
-
* Base
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
429
|
+
* Base class of in-cell editable columns. The browser calls `updateTableColumnState(column, key, value)` on the host component:
|
|
430
|
+
* the column must be declared, visible and enabled for this record, and the value must pass the rules; only then do
|
|
431
|
+
* `beforeStateUpdated`, the write and `afterStateUpdated` run. The write does not check model permissions: use `disabled()`
|
|
432
|
+
* to restrict editing.
|
|
433
433
|
*/
|
|
434
434
|
export class EditableColumn extends Column {
|
|
435
435
|
_disabled = false;
|
|
@@ -442,31 +442,31 @@ export class EditableColumn extends Column {
|
|
|
442
442
|
_tooltip = null;
|
|
443
443
|
disabled(condition = true) { this._disabled = condition; return this; }
|
|
444
444
|
async isDisabledFor(record) { return typeof this._disabled === 'function' ? this._disabled(record) : this._disabled; }
|
|
445
|
-
/**
|
|
445
|
+
/** An editable cell is neither a link nor a button to the record. */
|
|
446
446
|
isClickDisabled() { return true; }
|
|
447
447
|
isEditable() { return true; }
|
|
448
|
-
/**
|
|
448
|
+
/** Rules applied to the received value: strings (`required`, `max:255`, `in:a,b`…) or `{ name, passes, message }` objects. */
|
|
449
449
|
rules(rules) {
|
|
450
450
|
for (const rule of rules)
|
|
451
451
|
if (typeof rule === 'string' && rule !== 'required' && rule !== 'nullable' && !STRING_RULES[rule.split(':')[0]])
|
|
452
|
-
throw new Error(`
|
|
452
|
+
throw new Error(`Validation rule not supported by columns: ${rule}`);
|
|
453
453
|
this._rules = [...rules];
|
|
454
454
|
return this;
|
|
455
455
|
}
|
|
456
456
|
getRules() { return this._rules; }
|
|
457
|
-
/**
|
|
457
|
+
/** Same as adding `required` to the rules. */
|
|
458
458
|
required(condition = true) { this._rules = [...this._rules.filter((rule) => rule !== 'required'), ...(condition ? ['required'] : [])]; return this; }
|
|
459
459
|
isRequired() { return this._rules.includes('required'); }
|
|
460
|
-
/**
|
|
460
|
+
/** Column name used in messages (defaults to the label with a lowercase first letter). */
|
|
461
461
|
validationAttribute(label) { this._validationAttribute = label; return this; }
|
|
462
|
-
/**
|
|
462
|
+
/** Custom messages by rule name, e.g. `{ max: 'Too long.' }`. */
|
|
463
463
|
validationMessages(messages) { this._validationMessages = { ...messages }; return this; }
|
|
464
464
|
getValidationAttribute() { return this._validationAttribute ?? this.getLabel().replace(/^./, (c) => c.toLowerCase()); }
|
|
465
|
-
/**
|
|
465
|
+
/** Rules specific to the column type (e.g. select options). */
|
|
466
466
|
getImplicitRules() { return []; }
|
|
467
467
|
/**
|
|
468
|
-
*
|
|
469
|
-
*
|
|
468
|
+
* Error message, or `null` if the value is valid. An empty value is only checked by `required`, and several errors
|
|
469
|
+
* produce the first message followed by "(and N more errors)".
|
|
470
470
|
*/
|
|
471
471
|
validate(input) {
|
|
472
472
|
const attribute = this.getValidationAttribute();
|
|
@@ -479,11 +479,11 @@ export class EditableColumn extends Column {
|
|
|
479
479
|
fail('required', `The ${attribute} field is required.`);
|
|
480
480
|
}
|
|
481
481
|
else {
|
|
482
|
-
//
|
|
482
|
+
// Column-type rules first: a malformed value (list, forged object) stops here.
|
|
483
483
|
const implicit = this.getImplicitRules().find((rule) => !rule.passes(input));
|
|
484
484
|
if (implicit)
|
|
485
485
|
return this._validationMessages[implicit.name] ?? implicit.message(attribute);
|
|
486
|
-
//
|
|
486
|
+
// String rules only apply to scalars: a list or an object fails them.
|
|
487
487
|
const isScalar = ['string', 'number', 'boolean'].includes(typeof input);
|
|
488
488
|
for (const rule of this._rules) {
|
|
489
489
|
if (rule === 'required' || rule === 'nullable')
|
|
@@ -497,20 +497,20 @@ export class EditableColumn extends Column {
|
|
|
497
497
|
return null;
|
|
498
498
|
return errors.length === 1 ? errors[0] : `${errors[0]} (and ${errors.length - 1} more error${errors.length > 2 ? 's' : ''})`;
|
|
499
499
|
}
|
|
500
|
-
/**
|
|
500
|
+
/** Custom write, replacing the update of the record's column. Its return value is sent back to the browser. */
|
|
501
501
|
updateStateUsing(callback) { this._updateStateUsing = callback; return this; }
|
|
502
502
|
beforeStateUpdated(callback) { this._beforeStateUpdated = callback; return this; }
|
|
503
503
|
afterStateUpdated(callback) { this._afterStateUpdated = callback; return this; }
|
|
504
|
-
/**
|
|
504
|
+
/** Tooltip on the control, shown while no error is displayed. */
|
|
505
505
|
tooltip(tooltip) { this._tooltip = tooltip; return this; }
|
|
506
506
|
getUpdateStateMessages() {
|
|
507
507
|
return { saving: t('filament-tables::table.columns.editable.saving'), saved: t('filament-tables::table.columns.editable.saved'), failure: t('filament-tables::table.columns.editable.failure') };
|
|
508
508
|
}
|
|
509
|
-
/**
|
|
509
|
+
/** Value written from a validated value: blank values become `null`. */
|
|
510
510
|
castState(input) { return isBlank(input) ? null : input; }
|
|
511
511
|
/**
|
|
512
|
-
*
|
|
513
|
-
*
|
|
512
|
+
* Saves an already validated value. Only the column's declared name is written: a relationship column (`author.name`)
|
|
513
|
+
* can only be edited with `updateStateUsing()`.
|
|
514
514
|
*/
|
|
515
515
|
async updateState(record, input, adapter) {
|
|
516
516
|
const state = this.castState(input);
|
|
@@ -529,7 +529,7 @@ export class EditableColumn extends Column {
|
|
|
529
529
|
await this._afterStateUpdated?.({ state, record });
|
|
530
530
|
return state;
|
|
531
531
|
}
|
|
532
|
-
/**
|
|
532
|
+
/** `x-tooltip` expression of editable controls: the column tooltip, replaced by the validation error when there is one. */
|
|
533
533
|
tooltipExpression(record) {
|
|
534
534
|
const tooltip = typeof this._tooltip === 'function' ? this._tooltip(record) : this._tooltip;
|
|
535
535
|
return `error === undefined ? ${tooltip !== null && tooltip !== '' ? `{
|
|
@@ -544,7 +544,7 @@ export class EditableColumn extends Column {
|
|
|
544
544
|
aria: { content: null },
|
|
545
545
|
}`;
|
|
546
546
|
}
|
|
547
|
-
/**
|
|
547
|
+
/** Shared opening markup of editable cells: lazily loaded Alpine component, server state and record key. */
|
|
548
548
|
editableAttributes(component, asset, recordKey, state) {
|
|
549
549
|
return new Attributes().merge({
|
|
550
550
|
'x-load': 'x-load',
|
|
@@ -563,8 +563,7 @@ export class EditableColumn extends Column {
|
|
|
563
563
|
static STATUS_HTML = `<p x-cloak x-show="error" x-bind:id="$id('table-column-error')" x-ref="error" x-text="error" class="fi-sr-only"></p><p role="status" aria-live="polite" aria-atomic="true" x-text="status" class="fi-sr-only"></p>`;
|
|
564
564
|
}
|
|
565
565
|
/**
|
|
566
|
-
*
|
|
567
|
-
* options déclarées.
|
|
566
|
+
* In-cell editable select (native select only). The received value must be one of the declared options.
|
|
568
567
|
*/
|
|
569
568
|
export class SelectColumn extends EditableColumn {
|
|
570
569
|
_options = [];
|
|
@@ -634,11 +633,11 @@ export class SelectColumn extends EditableColumn {
|
|
|
634
633
|
}" x-on:click.prevent.stop class="fi-input-wrp"><select x-model="state" ${select.toHtml()}>${options}</select></div><p x-cloak x-show="error" x-bind:id="$id('table-column-error')" x-ref="error" x-text="error" class="fi-sr-only"></p><p role="status" aria-live="polite" aria-atomic="true" x-text="status" class="fi-sr-only"></p></div>`;
|
|
635
634
|
}
|
|
636
635
|
}
|
|
637
|
-
/**
|
|
636
|
+
/** Boolean value received from the browser (already checked by the `boolean` rule): `true`, `false` or `null`. */
|
|
638
637
|
const toBoolean = (value) => (isBlank(value) ? null : value === true || value === 1 || value === '1');
|
|
639
638
|
const ALIGNMENTS = ['start', 'center', 'end', 'left', 'right', 'justify', 'between'];
|
|
640
639
|
const alignmentClass = (alignment) => (alignment && ALIGNMENTS.includes(alignment) ? `fi-align-${alignment}` : null);
|
|
641
|
-
/**
|
|
640
|
+
/** In-cell editable toggle. The received value must be a boolean (`boolean` rule by default). */
|
|
642
641
|
export class ToggleColumn extends EditableColumn {
|
|
643
642
|
_onColor = null;
|
|
644
643
|
_offColor = null;
|
|
@@ -646,17 +645,17 @@ export class ToggleColumn extends EditableColumn {
|
|
|
646
645
|
_offIcon = null;
|
|
647
646
|
constructor(name) { super(name); this._rules = ['boolean']; }
|
|
648
647
|
static make(name) { return configured(new ToggleColumn(name)); }
|
|
649
|
-
/**
|
|
648
|
+
/** Toggle color when on (`primary` by default) and off (`gray`). */
|
|
650
649
|
onColor(color) { this._onColor = color; return this; }
|
|
651
650
|
offColor(color) { this._offColor = color; return this; }
|
|
652
|
-
/**
|
|
651
|
+
/** Icons shown inside the toggle's thumb. */
|
|
653
652
|
onIcon(icon) { this._onIcon = icon; return this; }
|
|
654
653
|
offIcon(icon) { this._offIcon = icon; return this; }
|
|
655
654
|
castState(input) { return toBoolean(input); }
|
|
656
655
|
async toHtml(record, recordKey = '') {
|
|
657
656
|
const state = Boolean(await this.getState(record));
|
|
658
657
|
const isDisabled = await this.isDisabledFor(record);
|
|
659
|
-
//
|
|
658
|
+
// Gray is the toggle's default color and adds no class.
|
|
660
659
|
const colorClasses = (color) => (color === 'gray' ? [] : (colorManager?.getComponentClasses('toggle', color) ?? []));
|
|
661
660
|
const onColorClasses = colorClasses(this._onColor ?? 'primary');
|
|
662
661
|
const onClasses = ['fi-toggle-on', ...onColorClasses].join(' ');
|
|
@@ -668,11 +667,11 @@ export class ToggleColumn extends EditableColumn {
|
|
|
668
667
|
'x-bind:aria-invalid': "error ? 'true' : null", 'x-bind:aria-busy': "isLoading ? 'true' : null", 'x-bind:aria-describedby': "getAccessibilityAttributes()['aria-describedby']",
|
|
669
668
|
'x-tooltip': this.tooltipExpression(record),
|
|
670
669
|
}).class(['fi-toggle']);
|
|
671
|
-
//
|
|
670
|
+
// The live toggle is hidden (x-cloak) until Alpine starts; a static copy is shown meanwhile.
|
|
672
671
|
return `<div wire:ignore.self ${outer.toHtml()}><input type="hidden" value="${state ? 1 : 0}" x-ref="serverState" /><div aria-checked="${state ? 'true' : 'false'}" x-bind:aria-checked="state ? 'true' : 'false'" x-bind:tabindex="$el.hasAttribute('disabled') ? '-1' : '0'" x-bind:aria-disabled="isLoading || $el.hasAttribute('disabled') ? 'true' : null" x-on:click.prevent.stop="if (! $el.hasAttribute('disabled')) { clearError(); state = ! state }" x-on:keydown.enter.prevent.stop="if (! $el.hasAttribute('disabled')) { clearError(); state = ! state }" x-on:keydown.space.prevent.stop="if (! $el.hasAttribute('disabled')) { clearError(); state = ! state }" x-bind:class="state ? ${js(onClasses)} : ${js(offClasses)}"${state ? ' x-cloak' : ''} role="switch" ${button.toHtml()}><div><div aria-hidden="true">${toggleIcon(this._offIcon)}</div><div aria-hidden="true">${toggleIcon(this._onIcon)}</div></div></div>${state ? `<div x-cloak="inline-flex" wire:ignore class="${e(['fi-toggle fi-toggle-on fi-hidden', ...onColorClasses].join(' '))}"><div><div aria-hidden="true"></div><div aria-hidden="true">${toggleIcon(this._onIcon)}</div></div></div>` : ''}${EditableColumn.STATUS_HTML}</div>`;
|
|
673
672
|
}
|
|
674
673
|
}
|
|
675
|
-
/**
|
|
674
|
+
/** In-cell editable checkbox. The received value must be a boolean (`boolean` rule by default). */
|
|
676
675
|
export class CheckboxColumn extends EditableColumn {
|
|
677
676
|
constructor(name) { super(name); this._rules = ['boolean']; }
|
|
678
677
|
static make(name) { return configured(new CheckboxColumn(name)); }
|
|
@@ -692,7 +691,7 @@ export class CheckboxColumn extends EditableColumn {
|
|
|
692
691
|
}" x-model="state" ${input.toHtml()} />${EditableColumn.STATUS_HTML}</div>`;
|
|
693
692
|
}
|
|
694
693
|
}
|
|
695
|
-
/**
|
|
694
|
+
/** In-cell editable text input. Only a string or a number is accepted from the browser. */
|
|
696
695
|
export class TextInputColumn extends EditableColumn {
|
|
697
696
|
_type = 'text';
|
|
698
697
|
_mask = null;
|
|
@@ -708,9 +707,9 @@ export class TextInputColumn extends EditableColumn {
|
|
|
708
707
|
_prefixInline = false;
|
|
709
708
|
_suffixInline = false;
|
|
710
709
|
static make(name) { return configured(new TextInputColumn(name)); }
|
|
711
|
-
/**
|
|
710
|
+
/** HTML input type (`text` by default; `number`, `email`, `color`…). */
|
|
712
711
|
type(type) { this._type = type && /^[a-z-]+$/.test(type) ? type : 'text'; return this; }
|
|
713
|
-
/**
|
|
712
|
+
/** Alpine input mask (`x-mask`); the input then becomes a text input. */
|
|
714
713
|
mask(mask) { this._mask = mask; return this; }
|
|
715
714
|
inputMode(mode) { this._inputMode = mode; return this; }
|
|
716
715
|
step(step) { this._step = step; return this; }
|
|
@@ -731,7 +730,7 @@ export class TextInputColumn extends EditableColumn {
|
|
|
731
730
|
const state = raw === undefined ? null : raw;
|
|
732
731
|
const isDisabled = await this.isDisabledFor(record);
|
|
733
732
|
const type = this._mask ? 'text' : this._type;
|
|
734
|
-
//
|
|
733
|
+
// Affix icon: gray is the default color and adds no class.
|
|
735
734
|
const affixIcon = (name, color) => (name ? icon(name, { attributes: new Attributes().class(color && color !== 'gray' ? ['fi-color', `fi-color-${color.replace(/[^a-zA-Z0-9_-]/g, '')}`] : []) }) : '');
|
|
736
735
|
const outer = this.editableAttributes('textInputTableColumn', 'text-input', recordKey, state).class(['fi-ta-text-input']);
|
|
737
736
|
const input = new Attributes().merge({
|
|
@@ -749,8 +748,8 @@ export class TextInputColumn extends EditableColumn {
|
|
|
749
748
|
}
|
|
750
749
|
}
|
|
751
750
|
/**
|
|
752
|
-
*
|
|
753
|
-
*
|
|
751
|
+
* Custom-rendered column: `view()` takes a function returning the cell HTML, or the name of an EJS view rendered with
|
|
752
|
+
* `{ state, record, column }`. This HTML is the application's own: data inside it must be escaped there (`e()`).
|
|
754
753
|
*/
|
|
755
754
|
export class ViewColumn extends Column {
|
|
756
755
|
_view = null;
|
|
@@ -760,26 +759,26 @@ export class ViewColumn extends Column {
|
|
|
760
759
|
async toHtml(record) {
|
|
761
760
|
const state = await this.getState(record);
|
|
762
761
|
if (this._view === null)
|
|
763
|
-
throw new Error(`ViewColumn ${this.name}
|
|
762
|
+
throw new Error(`ViewColumn ${this.name}: no view declared (view()).`);
|
|
764
763
|
if (typeof this._view === 'function')
|
|
765
764
|
return this._view({ state, record, column: this });
|
|
766
765
|
return views.render(this._view, { ...this._viewData, state, record, column: this, getState: () => state, getRecord: () => record });
|
|
767
766
|
}
|
|
768
767
|
}
|
|
769
|
-
/** @deprecated
|
|
768
|
+
/** @deprecated Use `TextColumn.make(name).badge()`. */
|
|
770
769
|
export class BadgeColumn extends TextColumn {
|
|
771
770
|
constructor(name) { super(name); this.badge(); }
|
|
772
771
|
static make(name) { return configured(new BadgeColumn(name)); }
|
|
773
772
|
}
|
|
774
|
-
/** @deprecated
|
|
773
|
+
/** @deprecated Use `IconColumn.make(name).boolean()`. */
|
|
775
774
|
export class BooleanColumn extends IconColumn {
|
|
776
775
|
constructor(name) { super(name); this.boolean(); }
|
|
777
776
|
static make(name) { return configured(new BooleanColumn(name)); }
|
|
778
777
|
}
|
|
779
778
|
/**
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
779
|
+
* Writes an editable cell value received from the browser. Everything is checked again: the column must be declared, editable
|
|
780
|
+
* and visible, the record within the adapter's scope and not disabled for this column, `authorize` (the host's edit permission)
|
|
781
|
+
* must accept it, and the value must pass the column's rules.
|
|
783
782
|
*/
|
|
784
783
|
export async function updateTableColumnState(table, host, adapter, columnName, recordKey, input, options = {}) {
|
|
785
784
|
const unavailable = { error: t('filament-tables::table.columns.editable.unavailable') };
|
|
@@ -796,7 +795,7 @@ export async function updateTableColumnState(table, host, adapter, columnName, r
|
|
|
796
795
|
return { error };
|
|
797
796
|
return column.updateState(record, input, adapter);
|
|
798
797
|
}
|
|
799
|
-
/**
|
|
798
|
+
/** "Trashed" filter for soft-deletable models. */
|
|
800
799
|
export class TrashedFilter {
|
|
801
800
|
name = 'trashed';
|
|
802
801
|
static make() { return configured(new TrashedFilter()); }
|
|
@@ -811,7 +810,7 @@ export class TrashedFilter {
|
|
|
811
810
|
}
|
|
812
811
|
}
|
|
813
812
|
const filterLabel = (name) => name.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
|
814
|
-
/**
|
|
813
|
+
/** Filters on a column value picked from a list (single select). */
|
|
815
814
|
export class SelectFilter {
|
|
816
815
|
name;
|
|
817
816
|
_label = null;
|
|
@@ -820,9 +819,9 @@ export class SelectFilter {
|
|
|
820
819
|
constructor(name) { this.name = name; }
|
|
821
820
|
static make(name) { return configured(new SelectFilter(name)); }
|
|
822
821
|
label(label) { this._label = label; return this; }
|
|
823
|
-
/**
|
|
822
|
+
/** Filtered column when it differs from the filter name (e.g. a relationship's foreign key). */
|
|
824
823
|
column(column) { this._column = column; return this; }
|
|
825
|
-
/**
|
|
824
|
+
/** Fixed options, or an async function (e.g. records of a relationship). */
|
|
826
825
|
options(options) {
|
|
827
826
|
this._options = typeof options === 'function' ? options : (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
|
|
828
827
|
return this;
|
|
@@ -831,21 +830,21 @@ export class SelectFilter {
|
|
|
831
830
|
getDefaultState() { return { value: null }; }
|
|
832
831
|
getFormDefinition() { return { placeholder: t('filament-tables::table.filters.select.placeholder'), options: Array.isArray(this._options) ? this._options : [] }; }
|
|
833
832
|
getFormOptions() { return typeof this._options === 'function' ? this._options() : Promise.resolve(this._options); }
|
|
834
|
-
/**
|
|
833
|
+
/** Options are evaluated on every render, so the select gets a key derived from them (forces a re-render when they change). */
|
|
835
834
|
hasDynamicOptions = true;
|
|
836
835
|
isActive(state) { return typeof state.value === 'string' && state.value !== ''; }
|
|
837
836
|
apply(query, state) {
|
|
838
837
|
if (!this.isActive(state))
|
|
839
838
|
return;
|
|
840
839
|
const value = String(state.value).slice(0, 255);
|
|
841
|
-
//
|
|
840
|
+
// Fixed options: only an offered value filters. Loaded options: the value is a bound parameter, an unknown value matches nothing.
|
|
842
841
|
if (Array.isArray(this._options) && !this._options.some(([option]) => option === value))
|
|
843
842
|
return;
|
|
844
843
|
const column = this._column ?? this.name;
|
|
845
844
|
(query.modifiers ??= []).push((q) => { q.where(column, value); });
|
|
846
845
|
}
|
|
847
846
|
}
|
|
848
|
-
/**
|
|
847
|
+
/** Three-state filter on a boolean column: all, yes, no. */
|
|
849
848
|
export class TernaryFilter {
|
|
850
849
|
name;
|
|
851
850
|
_label = null;
|
|
@@ -863,7 +862,7 @@ export class TernaryFilter {
|
|
|
863
862
|
(query.modifiers ??= []).push((q) => { q.where(column, on ? 1 : 0); });
|
|
864
863
|
}
|
|
865
864
|
}
|
|
866
|
-
/**
|
|
865
|
+
/** Custom form filter (`schema()`): the query and indicators are written by the application. */
|
|
867
866
|
export class Filter {
|
|
868
867
|
name;
|
|
869
868
|
_label = null;
|
|
@@ -873,11 +872,11 @@ export class Filter {
|
|
|
873
872
|
constructor(name) { this.name = name; }
|
|
874
873
|
static make(name) { return configured(new Filter(name)); }
|
|
875
874
|
label(label) { this._label = label; return this; }
|
|
876
|
-
/**
|
|
875
|
+
/** Filter fields; their names are the only state keys accepted from the browser. */
|
|
877
876
|
schema(components) { this._components = components; return this; }
|
|
878
|
-
/**
|
|
877
|
+
/** Query condition built from the sanitized form state. */
|
|
879
878
|
query(modifier) { this._query = modifier; return this; }
|
|
880
|
-
/**
|
|
879
|
+
/** Indicators of applied values as `{ field: label }`; removing one clears its field. */
|
|
881
880
|
indicateUsing(resolver) { this._indicate = resolver; return this; }
|
|
882
881
|
fields() { return this._components.map((c) => (typeof c?.getName === 'function' ? String(c.getName()) : null)).filter((name) => name !== null); }
|
|
883
882
|
getLabel() { return this._label ?? filterLabel(this.name); }
|
|
@@ -925,13 +924,13 @@ export class Table {
|
|
|
925
924
|
_groupingDirectionSettingHidden = false;
|
|
926
925
|
_groupingSettingsInDropdownOnDesktop = false;
|
|
927
926
|
_collapsedGroupsByDefault = false;
|
|
928
|
-
/**
|
|
927
|
+
/** Groupings offered in the toolbar (a column id is shorthand for `Group.make(id)`). */
|
|
929
928
|
groups(groups) { this._groups = groups.map((g) => (typeof g === 'string' ? Group.make(g) : g)); return this; }
|
|
930
929
|
getGroups() { return this._groups; }
|
|
931
930
|
getGroup(id) { return this._groups.find((g) => g.getId() === id) ?? null; }
|
|
932
931
|
/**
|
|
933
|
-
*
|
|
934
|
-
*
|
|
932
|
+
* Default grouping. If it is one of `groups()`, it is only preselected when the table opens (`initializeTableGrouping`)
|
|
933
|
+
* and the user can remove it; otherwise it always applies, without a selector.
|
|
935
934
|
*/
|
|
936
935
|
defaultGroup(group, direction = 'asc') { this._defaultGroup = group; this._defaultGroupDirection = direction === 'desc' ? 'desc' : 'asc'; return this; }
|
|
937
936
|
getDefaultGroup() {
|
|
@@ -943,21 +942,21 @@ export class Table {
|
|
|
943
942
|
}
|
|
944
943
|
getDefaultGroupDirection() { return this._defaultGroupDirection; }
|
|
945
944
|
isDefaultGroupSelectable() { const group = this.getDefaultGroup(); return group !== null && this.getGroup(group.getId()) !== null; }
|
|
946
|
-
/**
|
|
945
|
+
/** Shows only group headers and summaries, without rows or pagination. */
|
|
947
946
|
groupsOnly(condition = true) { this._groupsOnly = condition; return this; }
|
|
948
947
|
isGroupsOnly() { return this._groupsOnly; }
|
|
949
|
-
/**
|
|
948
|
+
/** Hides the toolbar grouping selector. */
|
|
950
949
|
groupingSettingsHidden(condition = true) { this._groupingSettingsHidden = condition; return this; }
|
|
951
950
|
areGroupingSettingsHidden() { return this._groupingSettingsHidden; }
|
|
952
|
-
/**
|
|
951
|
+
/** Hides the grouping direction (ascending / descending) choice. */
|
|
953
952
|
groupingDirectionSettingHidden(condition = true) { this._groupingDirectionSettingHidden = condition; return this; }
|
|
954
953
|
isGroupingDirectionSettingHidden() { return this._groupingDirectionSettingHidden; }
|
|
955
|
-
/**
|
|
954
|
+
/** Puts the grouping selector in a dropdown on desktop too. */
|
|
956
955
|
groupingSettingsInDropdownOnDesktop(condition = true) { this._groupingSettingsInDropdownOnDesktop = condition; return this; }
|
|
957
|
-
/** @deprecated
|
|
956
|
+
/** @deprecated Use `groupingSettingsInDropdownOnDesktop()`. */
|
|
958
957
|
groupsInDropdownOnDesktop(condition = true) { return this.groupingSettingsInDropdownOnDesktop(condition); }
|
|
959
958
|
areGroupingSettingsInDropdownOnDesktop() { return this._groupingSettingsInDropdownOnDesktop; }
|
|
960
|
-
/**
|
|
959
|
+
/** Collapsible groups start collapsed. */
|
|
961
960
|
collapsedGroupsByDefault(condition = true) { this._collapsedGroupsByDefault = condition; return this; }
|
|
962
961
|
areGroupsCollapsedByDefault() { return this._collapsedGroupsByDefault; }
|
|
963
962
|
_columnsLayout = null;
|
|
@@ -966,45 +965,45 @@ export class Table {
|
|
|
966
965
|
this._columnsLayout = columns.some((c) => c instanceof ColumnLayout) ? columns.filter((c) => !(c instanceof ColumnGroup)) : null;
|
|
967
966
|
return this;
|
|
968
967
|
}
|
|
969
|
-
/**
|
|
968
|
+
/** Column layout, or `null` for a regular table. */
|
|
970
969
|
getColumnsLayout() { return this._columnsLayout; }
|
|
971
|
-
/** Action
|
|
970
|
+
/** Action mounted when a row is clicked (e.g. `'edit'`), instead of a URL. */
|
|
972
971
|
recordAction(name) { this._recordAction = name; return this; }
|
|
973
972
|
getRecordAction() { return this._recordAction; }
|
|
974
|
-
/**
|
|
973
|
+
/** Heading shown in the table header. */
|
|
975
974
|
heading(heading) { this._heading = heading; return this; }
|
|
976
975
|
getHeading() { return this._heading; }
|
|
977
|
-
/** Actions
|
|
976
|
+
/** Actions shown in the table header. */
|
|
978
977
|
headerActions(actions) { this._headerActions = actions; return this; }
|
|
979
978
|
getHeaderActions() { return this._headerActions; }
|
|
980
|
-
/**
|
|
979
|
+
/** Bulk actions ("Bulk actions" menu); if never called, the host provides its default ones. */
|
|
981
980
|
groupedBulkActions(actions) { this._bulkActions = actions; return this; }
|
|
982
981
|
getBulkActions() { return this._bulkActions; }
|
|
983
|
-
/**
|
|
982
|
+
/** Row actions: `Action`s or an `ActionGroup` (dropdown). */
|
|
984
983
|
recordActions(actions) { this._recordActions = actions; return this; }
|
|
985
984
|
getRecordActions() { return this._recordActions; }
|
|
986
|
-
/**
|
|
985
|
+
/** CSS classes added to a row depending on its record. */
|
|
987
986
|
recordClasses(resolver) { this._recordClasses = resolver; return this; }
|
|
988
987
|
getRecordClasses(record) { return this._recordClasses ? this._recordClasses(record) : null; }
|
|
989
988
|
filters(filters, options = {}) { this._filters = filters; if (options.layout)
|
|
990
989
|
this._filtersLayout = options.layout; return this; }
|
|
991
990
|
filtersLayout(layout) { this._filtersLayout = layout; return this; }
|
|
992
991
|
getFiltersLayout() { return this._filtersLayout; }
|
|
993
|
-
/**
|
|
992
|
+
/** No-op kept for API compatibility: filters always wait for the "Apply" button. */
|
|
994
993
|
deferFilters() { return this; }
|
|
995
994
|
defaultSort(column, direction = 'asc') { this._defaultSort = { column, direction }; return this; }
|
|
996
995
|
recordUrl(resolver) { this._recordUrl = resolver; return this; }
|
|
997
996
|
_reorderColumn = null;
|
|
998
|
-
/**
|
|
997
|
+
/** Lets users reorder rows by hand; `column` stores the position. */
|
|
999
998
|
reorderable(column) { this._reorderColumn = column; return this; }
|
|
1000
999
|
getReorderColumn() { return this._reorderColumn; }
|
|
1001
1000
|
paginationPageOptions(options) { this._pageOptions = options; return this; }
|
|
1002
1001
|
_defaultPageOption = null;
|
|
1003
|
-
/**
|
|
1002
|
+
/** Rows per page when the table opens. */
|
|
1004
1003
|
defaultPaginationPageOption(option) { this._defaultPageOption = option; return this; }
|
|
1005
1004
|
getDefaultPageOption() { return this._defaultPageOption !== null && this._pageOptions.includes(this._defaultPageOption) ? this._defaultPageOption : (this._pageOptions.includes(10) ? 10 : this._pageOptions[0]); }
|
|
1006
1005
|
_paginationMode = 'default';
|
|
1007
|
-
/** `'simple'
|
|
1006
|
+
/** `'simple'`: previous / next buttons only, without page numbers or total. */
|
|
1008
1007
|
paginationMode(mode) { this._paginationMode = mode; return this; }
|
|
1009
1008
|
getPaginationMode() { return this._paginationMode; }
|
|
1010
1009
|
stackedOnMobile(condition = true) { this._stackedOnMobile = condition; return this; }
|
|
@@ -1018,24 +1017,26 @@ export class Table {
|
|
|
1018
1017
|
isStackedOnMobile() { return this._stackedOnMobile; }
|
|
1019
1018
|
getPluralModelLabel() { return this._label; }
|
|
1020
1019
|
hasToggleableColumns() { return this._columns.some((c) => c.isToggleable()); }
|
|
1021
|
-
/**
|
|
1020
|
+
/** Default column manager state. */
|
|
1022
1021
|
getDefaultColumnState() {
|
|
1023
1022
|
return this._columns.map((c) => ({ type: 'column', name: c.name, label: c.getLabel(), isHidden: false, isToggled: !c.isToggleable() || !c.isToggledHiddenByDefault(), isToggleable: c.isToggleable(), isToggledHiddenByDefault: c.isToggleable() ? c.isToggledHiddenByDefault() : null }));
|
|
1024
1023
|
}
|
|
1025
1024
|
getDefaultFilterState() { return Object.fromEntries(this._filters.map((f) => [f.name, f.getDefaultState()])); }
|
|
1026
1025
|
}
|
|
1027
|
-
/**
|
|
1026
|
+
/** Visible columns: those left checked in the column manager. Browser state can only hide columns declared toggleable. */
|
|
1028
1027
|
export function getVisibleColumns(table, host) {
|
|
1029
1028
|
const hidden = new Set((Array.isArray(host.tableColumns) ? host.tableColumns : []).filter((c) => c && c.isToggled === false).map((c) => c.name));
|
|
1030
1029
|
return table.getColumns().filter((c) => !(c.isToggleable() && hidden.has(c.name)));
|
|
1031
1030
|
}
|
|
1032
|
-
/**
|
|
1031
|
+
/** Sanitizes column state from the browser: only `isToggled` of toggleable columns is kept. */
|
|
1033
1032
|
export function sanitizeColumnState(table, state) {
|
|
1034
1033
|
const received = new Map((Array.isArray(state) ? state : []).filter((c) => c && typeof c === 'object').map((c) => [String(c.name), c.isToggled]));
|
|
1035
1034
|
return table.getDefaultColumnState().map((c) => (c.isToggleable && typeof received.get(c.name) === 'boolean' ? { ...c, isToggled: received.get(c.name) } : c));
|
|
1036
1035
|
}
|
|
1037
|
-
/**
|
|
1038
|
-
*
|
|
1036
|
+
/**
|
|
1037
|
+
* List query (search, column searches, filters, sort) for the host's state, without pagination.
|
|
1038
|
+
* Only what the table declares is accepted, whatever state the browser sends.
|
|
1039
|
+
*/
|
|
1039
1040
|
export function getTableQuery(table, host) {
|
|
1040
1041
|
const [column, direction] = (host.tableSort ?? '').split(':');
|
|
1041
1042
|
const sortColumn = table.getColumn(column);
|
|
@@ -1048,7 +1049,7 @@ export function getTableQuery(table, host) {
|
|
|
1048
1049
|
const grouping = getTableGrouping(table, host);
|
|
1049
1050
|
if (grouping)
|
|
1050
1051
|
query.groupSort = { column: grouping.group.getColumn(), direction: grouping.direction };
|
|
1051
|
-
//
|
|
1052
|
+
// The host can restrict the query (active list tab).
|
|
1052
1053
|
host.modifyTableQuery?.(query);
|
|
1053
1054
|
for (const filter of table.getFilters()) {
|
|
1054
1055
|
const state = host.tableFilters?.[filter.name];
|
|
@@ -1056,7 +1057,7 @@ export function getTableQuery(table, host) {
|
|
|
1056
1057
|
}
|
|
1057
1058
|
return query;
|
|
1058
1059
|
}
|
|
1059
|
-
/**
|
|
1060
|
+
/** Current grouping: only a group declared by the table is accepted, whatever state the browser sends. */
|
|
1060
1061
|
export function getTableGrouping(table, host) {
|
|
1061
1062
|
if (host.isTableReordering === true)
|
|
1062
1063
|
return null;
|
|
@@ -1064,25 +1065,25 @@ export function getTableGrouping(table, host) {
|
|
|
1064
1065
|
const group = table.getGroup(id);
|
|
1065
1066
|
if (group)
|
|
1066
1067
|
return { group, direction: direction === 'desc' ? 'desc' : 'asc' };
|
|
1067
|
-
//
|
|
1068
|
+
// A default grouping missing from the selector always applies; one listed in the selector only applies when chosen.
|
|
1068
1069
|
const fallback = table.isDefaultGroupSelectable() ? null : table.getDefaultGroup();
|
|
1069
1070
|
return fallback ? { group: fallback, direction: table.getDefaultGroupDirection() } : null;
|
|
1070
1071
|
}
|
|
1071
|
-
/**
|
|
1072
|
+
/** When the table opens (host mount): preselects the default grouping when the user can change it. */
|
|
1072
1073
|
export function initializeTableGrouping(table, host) {
|
|
1073
1074
|
const group = table.getDefaultGroup();
|
|
1074
1075
|
if ((host.tableGrouping === null || host.tableGrouping === undefined || host.tableGrouping === '') && group && table.isDefaultGroupSelectable())
|
|
1075
1076
|
host.tableGrouping = `${group.getId()}:${table.getDefaultGroupDirection()}`;
|
|
1076
1077
|
}
|
|
1077
1078
|
export function countActiveFilters(table, host) {
|
|
1078
|
-
//
|
|
1079
|
+
// The count is the number of indicators; an active filter without indicators counts as one.
|
|
1079
1080
|
return table.getFilters().reduce((total, f) => { const s = host.tableFilters?.[f.name]; return total + (!s || typeof s !== 'object' ? 0 : f.getIndicators ? f.getIndicators(s).length : f.isActive(s) ? 1 : 0); }, 0);
|
|
1080
1081
|
}
|
|
1081
|
-
/**
|
|
1082
|
+
/** Indicators of the applied filters (only filters that declare some have them). */
|
|
1082
1083
|
export function getFilterIndicators(table, host) {
|
|
1083
1084
|
return table.getFilters().flatMap((f) => { const s = host.tableFilters?.[f.name]; return s && typeof s === 'object' && f.getIndicators ? f.getIndicators(s).map((indicator) => ({ filter: f.name, ...indicator })) : []; });
|
|
1084
1085
|
}
|
|
1085
|
-
/**
|
|
1086
|
+
/** Cycles a column's sort: none → asc → desc → none. */
|
|
1086
1087
|
export function sortTable(host, column = null, direction = null) {
|
|
1087
1088
|
const [current, currentDirection] = (host.tableSort ?? '').split(':');
|
|
1088
1089
|
if (column === (host.tableSort ? current : null))
|
|
@@ -1092,13 +1093,13 @@ export function sortTable(host, column = null, direction = null) {
|
|
|
1092
1093
|
host.tableSort = direction ? `${column}:${direction}` : null;
|
|
1093
1094
|
host.paginators.page = 1;
|
|
1094
1095
|
}
|
|
1095
|
-
/**
|
|
1096
|
+
/** Loads the page of records matching the host's state. */
|
|
1096
1097
|
export async function getTableRecords(table, host, adapter) {
|
|
1097
1098
|
const options = table.getPageOptions();
|
|
1098
1099
|
const perPage = options.includes(Number(host.tableRecordsPerPage)) ? Number(host.tableRecordsPerPage) : options[0];
|
|
1099
1100
|
return adapter.paginate({ ...getTableQuery(table, host), page: Number(host.paginators.page) || 1, perPage });
|
|
1100
1101
|
}
|
|
1101
|
-
/**
|
|
1102
|
+
/** Pagination elements: the first and last pages and a window around the current page, with `...` gaps. */
|
|
1102
1103
|
export function paginationElements(current, last, onEachSide = 0) {
|
|
1103
1104
|
const range = (a, b) => Array.from({ length: Math.max(0, b - a + 1) }, (_, i) => a + i);
|
|
1104
1105
|
if (last < onEachSide * 2 + 8)
|
|
@@ -1112,9 +1113,9 @@ export function paginationElements(current, last, onEachSide = 0) {
|
|
|
1112
1113
|
}
|
|
1113
1114
|
let colorManager = null;
|
|
1114
1115
|
export const useColorManager = (manager) => { colorManager = manager; };
|
|
1115
|
-
/**
|
|
1116
|
+
/** Maximum number of rows shown (and reorderable) in reordering mode. */
|
|
1116
1117
|
export const REORDER_LIMIT = 1000;
|
|
1117
|
-
/**
|
|
1118
|
+
/** Summary row; with `groupsOnly`, a leading label cell, then one cell per column. */
|
|
1118
1119
|
function summaryRowHtml(row, span, groupsOnly, placeholders) {
|
|
1119
1120
|
const items = (cell) => cell.items.map((item) => `<div class="fi-ta-${item.type}-summary">${item.label !== null ? `<span class="fi-ta-${item.type}-summary-label">${e(item.label)}</span>` : ''}<span>${e(item.value)}</span></div>`).join('');
|
|
1120
1121
|
const heading = groupsOnly
|
|
@@ -1123,16 +1124,16 @@ function summaryRowHtml(row, span, groupsOnly, placeholders) {
|
|
|
1123
1124
|
return `<tr class="fi-ta-row fi-ta-summary-row${row.striped ? ' fi-striped' : ''}">${placeholders.selection ? '<td></td>' : ''}${heading}${row.cells.map((cell) => `<td class="fi-ta-cell fi-align-${e(cell.alignment)} ">${items(cell)}</td>`).join('')}${placeholders.actions ? '<td></td>' : ''}</tr>`;
|
|
1124
1125
|
}
|
|
1125
1126
|
/**
|
|
1126
|
-
*
|
|
1127
|
-
*
|
|
1127
|
+
* Table summaries: a single "Summary" row without pagination; with several pages, a header then "This page" and "All …" rows.
|
|
1128
|
+
* Also returns what is needed to compute a group's summary row (query restricted to the group).
|
|
1128
1129
|
*/
|
|
1129
1130
|
async function summarize(table, host, adapter, visible, page, options) {
|
|
1130
|
-
//
|
|
1131
|
+
// Groups only: the grouped column has no summary cell (its label already leads the row).
|
|
1131
1132
|
const columns = options.groupsOnly && options.group ? visible.filter((c) => c.name !== options.group.getColumn()) : visible;
|
|
1132
1133
|
const lists = columns.map((c) => (c instanceof TextColumn ? c.getSummarizers() : []));
|
|
1133
1134
|
if (!lists.some((list) => list.length) || !page.records.length)
|
|
1134
1135
|
return null;
|
|
1135
|
-
//
|
|
1136
|
+
// The first cell holds the row label and spans up to the first summarized column after it.
|
|
1136
1137
|
const next = lists.findIndex((list, i) => i > 0 && list.length > 0);
|
|
1137
1138
|
const span = options.groupsOnly ? 0 : next < 0 ? columns.length : next;
|
|
1138
1139
|
const columnOf = (c) => c.getSortColumn?.() ?? c.name;
|
|
@@ -1166,23 +1167,23 @@ async function summarize(table, host, adapter, visible, page, options) {
|
|
|
1166
1167
|
: [{ heading: t('filament-tables::table.summary.heading', { label }), cells: cells(all), striped: true }];
|
|
1167
1168
|
return {
|
|
1168
1169
|
html: header + rows.map(rowHtml).join(''),
|
|
1169
|
-
/**
|
|
1170
|
+
/** Group summary row: aggregates over all the group's records (not only those on the page). */
|
|
1170
1171
|
group: async (group, key, title) => {
|
|
1171
1172
|
const values = await aggregate({ ...query, modifiers: [...(query.modifiers ?? []), (q) => group.scope(q, key)] });
|
|
1172
1173
|
return rowHtml({ heading: options.groupsOnly ? (title ?? '') : t('filament-tables::table.summary.subheadings.group', { group: title ?? '', label }), cells: cells(values), striped: false });
|
|
1173
1174
|
},
|
|
1174
1175
|
};
|
|
1175
1176
|
}
|
|
1176
|
-
/**
|
|
1177
|
+
/** Maximum records read for a `groupsOnly` table (which is not paginated). */
|
|
1177
1178
|
export const GROUPS_ONLY_LIMIT = 1000;
|
|
1178
1179
|
export async function renderTable(table, host, adapter, options = {}) {
|
|
1179
1180
|
const reorderColumn = options.canReorder === false ? null : table.getReorderColumn();
|
|
1180
1181
|
const reordering = reorderColumn !== null && host.isTableReordering === true;
|
|
1181
|
-
//
|
|
1182
|
+
// Reordering: all rows of the query, in position order, without pagination or actions.
|
|
1182
1183
|
if (reordering)
|
|
1183
1184
|
options = { ...options, bulkActions: [], headerActions: options.headerActions, rowActions: undefined, rowActionsHtml: undefined, filtersForm: null };
|
|
1184
1185
|
const grouping = reordering ? null : getTableGrouping(table, host);
|
|
1185
|
-
//
|
|
1186
|
+
// Groups only: no pagination and no record rows, just one summary row per group.
|
|
1186
1187
|
const groupsOnly = grouping !== null && table.isGroupsOnly();
|
|
1187
1188
|
const page = reordering
|
|
1188
1189
|
? await adapter.paginate({ ...getTableQuery(table, host), sort: { column: reorderColumn, direction: 'asc' }, page: 1, perPage: REORDER_LIMIT })
|
|
@@ -1194,7 +1195,7 @@ export async function renderTable(table, host, adapter, options = {}) {
|
|
|
1194
1195
|
const hasBulk = (options.bulkActions ?? []).length > 0;
|
|
1195
1196
|
const selectable = (hasBulk || reordering) && !groupsOnly;
|
|
1196
1197
|
const filled = (value) => value !== null && value.trim() !== '';
|
|
1197
|
-
//
|
|
1198
|
+
// A new group starts when the title changes; the previous group then gets its summary row.
|
|
1198
1199
|
let previousGroup = { key: null, title: null };
|
|
1199
1200
|
const columnsLayout = table.getColumnsLayout();
|
|
1200
1201
|
const visibleSet = new Set(visible);
|
|
@@ -1213,9 +1214,9 @@ export async function renderTable(table, host, adapter, options = {}) {
|
|
|
1213
1214
|
if (!groupsOnly)
|
|
1214
1215
|
groupHeader = {
|
|
1215
1216
|
key: groupKey, title: groupTitle, label, description, collapsible: grouping.group.isCollapsible(),
|
|
1216
|
-
//
|
|
1217
|
+
// Keys of the whole group (not only the page), for its "select all" checkbox.
|
|
1217
1218
|
keys: hasBulk ? await adapter.listKeys({ ...query, modifiers: [...(query.modifiers ?? []), (q) => grouping.group.scope(q, groupKey)] }) : [],
|
|
1218
|
-
//
|
|
1219
|
+
// Header with nothing to announce: hidden from assistive technologies.
|
|
1219
1220
|
ariaHidden: !(filled(groupTitle) || filled(label) || filled(description) || grouping.group.isCollapsible() || selectable),
|
|
1220
1221
|
};
|
|
1221
1222
|
}
|
|
@@ -1241,19 +1242,19 @@ export async function renderTable(table, host, adapter, options = {}) {
|
|
|
1241
1242
|
wasClickable: reordering && (recordAction !== null || table.getRecordUrl(record) !== null),
|
|
1242
1243
|
key, url: reordering ? null : recordAction ? null : table.getRecordUrl(record), cells, actions: groupsOnly ? [] : ((await options.rowActions?.(record)) ?? []),
|
|
1243
1244
|
actionsHtml: options.rowActionsHtml && !groupsOnly ? await options.rowActionsHtml(record) : null, classes: table.getRecordClasses(record),
|
|
1244
|
-
//
|
|
1245
|
+
// Row click: the action is mounted with the record key.
|
|
1245
1246
|
mountExpression: reordering ? null : recordAction ? `mountAction(${js(recordAction)}, {}, ${js({ table: true, recordKey: key })})` : null,
|
|
1246
1247
|
});
|
|
1247
1248
|
}
|
|
1248
1249
|
const hasRowActions = rows.some((row) => row.actionsHtml !== null || row.actions.length > 0);
|
|
1249
|
-
//
|
|
1250
|
+
// Summaries: never while reordering; group summaries go before the next group's header.
|
|
1250
1251
|
const summary = reordering ? null : await summarize(table, host, adapter, visible, page, { group: grouping?.group ?? null, groupsOnly, placeholders: { selection: selectable, actions: hasRowActions } });
|
|
1251
1252
|
let trailingGroupSummary = '';
|
|
1252
1253
|
if (summary && grouping) {
|
|
1253
1254
|
for (const row of rows)
|
|
1254
1255
|
if (row.summaryBefore)
|
|
1255
1256
|
row.groupSummaryHtml = await summary.group(grouping.group, row.summaryBefore.key, row.summaryBefore.title);
|
|
1256
|
-
//
|
|
1257
|
+
// Last group on the page: summarized only if it does not continue on the next page.
|
|
1257
1258
|
let trailing = filled(previousGroup.title);
|
|
1258
1259
|
if (trailing && !groupsOnly && page.page < page.lastPage) {
|
|
1259
1260
|
const nextRecord = (await adapter.paginate({ ...getTableQuery(table, host), page: page.page * page.perPage + 1, perPage: 1 })).records[0];
|
|
@@ -1277,11 +1278,11 @@ export async function renderTable(table, host, adapter, options = {}) {
|
|
|
1277
1278
|
grouping: { directionHidden: table.isGroupingDirectionSettingHidden(), inDropdownOnDesktop: table.areGroupingSettingsInDropdownOnDesktop(), collapsedByDefault: table.areGroupsCollapsedByDefault(), groupsOnly },
|
|
1278
1279
|
id: host.id, targets: LOADING_TARGETS, table, rows, page, number,
|
|
1279
1280
|
reorder: reorderColumn !== null ? { active: reordering } : null,
|
|
1280
|
-
//
|
|
1281
|
+
// No checkboxes without bulk actions (or in groups-only mode), no actions column without row actions.
|
|
1281
1282
|
selectable, hasRowActions,
|
|
1282
|
-
//
|
|
1283
|
+
// The key changes on every render to force the checkbox to be replaced.
|
|
1283
1284
|
randomKey: () => Array.from(randomBytes(16), (b) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[b % 62]).join(''),
|
|
1284
|
-
//
|
|
1285
|
+
// "Select all" covers every record of the current query.
|
|
1285
1286
|
allCount: table.getPaginationMode() === 'simple' ? '' : page.total, resultsCount: table.getPaginationMode() === 'simple' ? rows.length : page.total,
|
|
1286
1287
|
columns: visible.map((c) => ({ name: c.name, grouped: c._group !== null, cssName: c.getCssName(), alignment: c.getAlignment(), label: c.getLabel(), sortable: !reordering && c.isSortable(), individuallySearchable: !reordering && c.isIndividuallySearchable(), sort: sortColumn === c.name ? (sortDirection === 'desc' ? 'descending' : 'ascending') : 'none' })),
|
|
1287
1288
|
linkClasses: classes('link', 'primary'), dangerLinkClasses: classes('link', 'danger'), primaryButtonClasses: classes('button', 'primary'), badgeClasses: classes('badge', 'primary'), indicatorBadgeClasses: classes('badge', 'primary'),
|
|
@@ -1289,7 +1290,7 @@ export async function renderTable(table, host, adapter, options = {}) {
|
|
|
1289
1290
|
filtersForm: options.filtersForm ?? null, activeFiltersCount: countActiveFilters(table, host), filtersLayout: table.getFiltersLayout(), filterIndicators: getFilterIndicators(table, host),
|
|
1290
1291
|
hasColumnManager: !reordering && table.hasToggleableColumns(),
|
|
1291
1292
|
afterContainer: options.after ?? '', heading: table.getHeading(), headerActions: options.headerActions ?? [], summaryHtml: summary?.html ?? '', trailingGroupSummary,
|
|
1292
|
-
//
|
|
1293
|
+
// Column group header row: one cell per ungrouped column, one merged cell per run of columns in the same group.
|
|
1293
1294
|
headerGroups: visible.some((c) => c._group !== null) ? visible.reduce((runs, c) => {
|
|
1294
1295
|
const last = runs[runs.length - 1];
|
|
1295
1296
|
if (c._group !== null && last && last.label === c._group)
|