@navette-js/tables 1.0.0-beta.3 → 1.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +761 -85
- package/lib/index.js +1377 -236
- package/lib/query-builder.d.ts +92 -2
- package/lib/query-builder.js +164 -23
- package/package.json +6 -6
- package/views/filters.ejs +44 -0
- package/views/index.ejs +129 -106
package/lib/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { Attributes, cls, e, hasRenderHook, icon, js, renderHook, views, configured, configureUsing } from '@navette-js/support';
|
|
2
|
-
import { t } from '@navette-js/support/i18n';
|
|
1
|
+
import { Attributes, cls, e, hasRenderHook, icon, js, markdownToHtml, renderHook, sanitizeHtml, views, configured, configureUsing } from '@navette-js/support';
|
|
2
|
+
import { diffForHumans, t, tc } from '@navette-js/support/i18n';
|
|
3
|
+
import { Checkbox, Select, Toggle } from '@navette-js/forms';
|
|
4
|
+
import { modal } from '@navette-js/support/ui';
|
|
5
|
+
import { applyConditions, loadRelationshipOptions, modelOfHost } from "./query-builder.js";
|
|
3
6
|
import { createHash, randomBytes } from 'node:crypto';
|
|
4
7
|
import { fileURLToPath } from 'node:url';
|
|
5
8
|
views.namespace('filament-tables', fileURLToPath(new URL('../views', import.meta.url)));
|
|
@@ -31,6 +34,8 @@ export const TablesRenderHook = {
|
|
|
31
34
|
TOOLBAR_TOGGLE_COLUMN_TRIGGER_BEFORE: 'tables::toolbar.toggle-column-trigger.before',
|
|
32
35
|
};
|
|
33
36
|
/** Methods and properties that trigger the table's loading state (`wire:target`). */
|
|
37
|
+
/** Screen size names of `visibleFrom()` and `hiddenFrom()`. */
|
|
38
|
+
const BREAKPOINTS = ['sm', 'md', 'lg', 'xl', '2xl'];
|
|
34
39
|
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
40
|
const headline = (s) => s.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
|
36
41
|
// ------------------------------------------------------------------ columns
|
|
@@ -62,6 +67,14 @@ export class Column {
|
|
|
62
67
|
isSortInverted() { return this._sortInverted; }
|
|
63
68
|
/** Link on the cell; takes precedence over the row action. */
|
|
64
69
|
url(resolver) { this._url = resolver; return this; }
|
|
70
|
+
_cellAction = null;
|
|
71
|
+
/**
|
|
72
|
+
* Clicking the cell runs an action on its record: the name of a record action of the table, or an `Action` (checked and
|
|
73
|
+
* authorized like record actions when it is mounted). A column `url()` takes precedence; the action takes precedence over the row's link and action.
|
|
74
|
+
*/
|
|
75
|
+
action(action) { this._cellAction = action; return this; }
|
|
76
|
+
getAction() { return this._cellAction !== null && typeof this._cellAction === 'object' ? this._cellAction : null; }
|
|
77
|
+
getActionName() { return this._cellAction === null ? null : typeof this._cellAction === 'string' ? this._cellAction : this._cellAction.getName(); }
|
|
65
78
|
getUrl(record) { return this._url ? this._url(record) : null; }
|
|
66
79
|
/** `isGlobal: false` excludes the column from the global search; `isIndividual` adds a search field for this column. */
|
|
67
80
|
getSearchColumn() { return this._searchColumn ?? this.name; }
|
|
@@ -74,10 +87,42 @@ export class Column {
|
|
|
74
87
|
/** Lets the user hide the column from the column manager. */
|
|
75
88
|
toggleable(condition = true, options = {}) { this._toggleable = condition; this._toggledHiddenByDefault = options.isToggledHiddenByDefault === true; return this; }
|
|
76
89
|
alignment(alignment) { this._alignment = alignment; return this; }
|
|
90
|
+
_visibleFrom = null;
|
|
91
|
+
_hiddenFrom = null;
|
|
92
|
+
_columnWidth = null;
|
|
93
|
+
_grow = null;
|
|
94
|
+
_wrapHeader = false;
|
|
95
|
+
/** Shows the column only from this screen size up (`sm`, `md`, `lg`, `xl`, `2xl`). */
|
|
96
|
+
visibleFrom(breakpoint) { this._visibleFrom = breakpoint !== null && BREAKPOINTS.includes(breakpoint) ? breakpoint : null; return this; }
|
|
97
|
+
/** Hides the column from this screen size up. */
|
|
98
|
+
hiddenFrom(breakpoint) { this._hiddenFrom = breakpoint !== null && BREAKPOINTS.includes(breakpoint) ? breakpoint : null; return this; }
|
|
99
|
+
/** Classes hiding the header and cells on some screen sizes. */
|
|
100
|
+
getResponsiveClasses() { return [this._hiddenFrom ? `${this._hiddenFrom}:fi-hidden` : null, this._visibleFrom ? `${this._visibleFrom}:fi-visible` : null].filter((c) => c !== null); }
|
|
101
|
+
/** Width of the column, as a CSS length (`200px`, `20%`...). */
|
|
102
|
+
width(width) { const text = typeof width === 'number' ? `${width}px` : width; this._columnWidth = text !== null && /^[0-9.]+(px|rem|em|%|ch|vw)$/.test(text.trim()) ? text.trim() : null; return this; }
|
|
103
|
+
getWidth() { return this._columnWidth; }
|
|
104
|
+
/** The column takes the remaining width of the table (when it has no `width()`). */
|
|
105
|
+
grow(condition = true) { this._grow = condition; return this; }
|
|
106
|
+
/** In a table, columns do not grow unless told to; in a layout (split), they do. */
|
|
107
|
+
canGrow(defaultValue = false) { return this._grow ?? defaultValue; }
|
|
108
|
+
/** Lets a long label wrap in the header. */
|
|
109
|
+
wrapHeader(condition = true) { this._wrapHeader = condition; return this; }
|
|
110
|
+
canHeaderWrap() { return this._wrapHeader; }
|
|
77
111
|
alignEnd() { return this.alignment('end'); }
|
|
78
112
|
alignLeft() { return this.alignment('left'); }
|
|
79
113
|
alignCenter() { return this.alignment('center'); }
|
|
80
114
|
getStateUsing(resolver) { this._state = resolver; return this; }
|
|
115
|
+
/** Fixed state, or a function of the record (alias of `getStateUsing()` for a value). */
|
|
116
|
+
state(state) { this._state = typeof state === 'function' ? ({ record }) => state(record) : () => state; return this; }
|
|
117
|
+
_default = null;
|
|
118
|
+
/** Value used when the state is empty. */
|
|
119
|
+
default(value) { this._default = value; return this; }
|
|
120
|
+
_hidden = false;
|
|
121
|
+
_visible = true;
|
|
122
|
+
/** Removes the column from the table (header, cells, search, sort and column manager). */
|
|
123
|
+
hidden(condition = true) { this._hidden = condition; return this; }
|
|
124
|
+
visible(condition = true) { this._visible = condition; return this; }
|
|
125
|
+
isHidden() { return Boolean(typeof this._hidden === 'function' ? this._hidden() : this._hidden) || !(typeof this._visible === 'function' ? this._visible() : this._visible); }
|
|
81
126
|
getLabel() { return this._label ?? headline(this.name.includes('.') ? this.name.split('.').slice(-2, -1)[0] : this.name); }
|
|
82
127
|
isSortable() { return this._sortable; }
|
|
83
128
|
isGloballySearchable() { return this._searchable; }
|
|
@@ -87,141 +132,408 @@ export class Column {
|
|
|
87
132
|
getAlignment() { return this._alignment; }
|
|
88
133
|
/** Suffix of the `fi-ta-header-cell-…` / `fi-ta-cell-…` classes: underscores become dashes, dots are kept. */
|
|
89
134
|
getCssName() { return this.name.replace(/_/g, '-'); }
|
|
90
|
-
async getState(record) {
|
|
135
|
+
async getState(record) {
|
|
136
|
+
const state = this._state ? await this._state({ record }) : record[this.name];
|
|
137
|
+
return (state === null || state === undefined || state === '') && this._default !== null ? this._default : state;
|
|
138
|
+
}
|
|
91
139
|
/** True for editable cells: they are not wrapped in a link or button to the record. */
|
|
92
140
|
isClickDisabled() { return false; }
|
|
93
141
|
}
|
|
142
|
+
const isNumericValue = (value) => (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)));
|
|
143
|
+
const resolveItem = (option, record, state) => (typeof option === 'function' ? option(record, state) : option);
|
|
144
|
+
const isBlankState = (value) => value === null || value === undefined || (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0);
|
|
145
|
+
/** Date in a PHP-style format (`M j, Y`, `d/m/Y H:i`…), in UTC or in the given IANA time zone. Database values without a zone are read as UTC. */
|
|
146
|
+
export function formatDate(value, format, timeZone = null) {
|
|
147
|
+
const raw = value instanceof Date ? value : new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(String(value)) ? String(value) : `${String(value).replace(' ', 'T').slice(0, 19)}Z`);
|
|
148
|
+
if (Number.isNaN(raw.getTime()))
|
|
149
|
+
return String(value);
|
|
150
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: timeZone ?? 'UTC', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', hourCycle: 'h23', weekday: 'long' }).formatToParts(raw).map((p) => [p.type, p.value]));
|
|
151
|
+
const year = Number(parts.year), month = Number(parts.month), day = Number(parts.day), hour = Number(parts.hour), minute = Number(parts.minute), second = Number(parts.second);
|
|
152
|
+
const monthName = new Intl.DateTimeFormat('en-US', { month: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2000, month - 1, 1)));
|
|
153
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
154
|
+
const zone = timeZone ? (new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'short' }).formatToParts(raw).find((p) => p.type === 'timeZoneName')?.value ?? timeZone) : 'UTC';
|
|
155
|
+
const tokens = {
|
|
156
|
+
d: () => pad(day), j: () => String(day), D: () => parts.weekday.slice(0, 3), l: () => parts.weekday,
|
|
157
|
+
S: () => (day % 10 === 1 && day !== 11 ? 'st' : day % 10 === 2 && day !== 12 ? 'nd' : day % 10 === 3 && day !== 13 ? 'rd' : 'th'),
|
|
158
|
+
m: () => pad(month), n: () => String(month), M: () => monthName.slice(0, 3), F: () => monthName,
|
|
159
|
+
Y: () => String(year), y: () => String(year).slice(-2),
|
|
160
|
+
H: () => pad(hour), G: () => String(hour), h: () => pad(hour % 12 || 12), g: () => String(hour % 12 || 12),
|
|
161
|
+
i: () => pad(minute), s: () => pad(second), a: () => (hour < 12 ? 'am' : 'pm'), A: () => (hour < 12 ? 'AM' : 'PM'), T: () => zone,
|
|
162
|
+
};
|
|
163
|
+
let out = '';
|
|
164
|
+
for (let index = 0; index < format.length; index++) {
|
|
165
|
+
const character = format[index];
|
|
166
|
+
if (character === '\\' && index + 1 < format.length) {
|
|
167
|
+
out += format[++index];
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
out += tokens[character] ? tokens[character]() : character;
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
const TEXT_SIZES = new Set(['xs', 'sm', 'md', 'lg']);
|
|
175
|
+
const tooltipAttribute = (tooltip) => `{
|
|
176
|
+
content: ${js(tooltip)},
|
|
177
|
+
theme: $store.theme,
|
|
178
|
+
allowHTML: false,
|
|
179
|
+
}`;
|
|
94
180
|
export class TextColumn extends Column {
|
|
95
181
|
_weight = null;
|
|
96
182
|
_copyable = false;
|
|
183
|
+
_copyableState = null;
|
|
184
|
+
_copyMessage = null;
|
|
185
|
+
_copyMessageDuration = 2000;
|
|
97
186
|
_money = null;
|
|
98
187
|
_placeholder = null;
|
|
99
188
|
_badge = false;
|
|
100
189
|
_color = null;
|
|
101
190
|
_icon = null;
|
|
191
|
+
_iconColor = null;
|
|
192
|
+
_iconPosition = 'before';
|
|
102
193
|
_format = null;
|
|
103
|
-
_date =
|
|
194
|
+
_date = null;
|
|
195
|
+
_timeZone = null;
|
|
196
|
+
_since = false;
|
|
104
197
|
_numeric = false;
|
|
198
|
+
_decimals = null;
|
|
105
199
|
_summarizers = [];
|
|
106
200
|
_limit = null;
|
|
201
|
+
_words = null;
|
|
202
|
+
_prefix = null;
|
|
203
|
+
_suffix = null;
|
|
107
204
|
_tooltip = null;
|
|
205
|
+
_size = null;
|
|
206
|
+
_fontFamily = null;
|
|
207
|
+
_wrap = false;
|
|
208
|
+
_lineClamp = null;
|
|
209
|
+
_inline = false;
|
|
210
|
+
_descriptionAbove = null;
|
|
211
|
+
_descriptionBelow = null;
|
|
212
|
+
_listWithLineBreaks = false;
|
|
213
|
+
_bulleted = false;
|
|
214
|
+
_listLimit = null;
|
|
215
|
+
_expandableLimitedList = false;
|
|
216
|
+
_separator = null;
|
|
108
217
|
static make(name) { return configured(new TextColumn(name)); }
|
|
109
218
|
/** Displays the value in a badge. */
|
|
110
219
|
badge(condition = true) { this._badge = condition; return this; }
|
|
220
|
+
/** Text color (or badge color): a color name, or a function of the record and the state item. */
|
|
111
221
|
color(color) { this._color = color; return this; }
|
|
112
|
-
|
|
222
|
+
/** Icon next to the text: a name, or a function of the record and the state item returning a name or `null`. */
|
|
223
|
+
icon(icon) { this._icon = icon; return this; }
|
|
224
|
+
iconColor(color) { this._iconColor = color; return this; }
|
|
225
|
+
iconPosition(position) { this._iconPosition = position; return this; }
|
|
113
226
|
/** Text displayed instead of the raw value (e.g. an enum label). */
|
|
114
227
|
formatStateUsing(formatter) { this._format = formatter; return this; }
|
|
115
|
-
/**
|
|
116
|
-
date(
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
228
|
+
/** Date in a PHP-style format, `M j, Y` by default ("Nov 9, 2026"). `timeZone`: IANA zone to display the date in. */
|
|
229
|
+
date(format = 'M j, Y', timeZone = null) { this._date = format === false ? null : format === true ? 'M j, Y' : format; this._timeZone = timeZone; return this; }
|
|
230
|
+
/** Date and time, `M j, Y H:i:s` by default. */
|
|
231
|
+
dateTime(format = 'M j, Y H:i:s', timeZone = null) { return this.date(format === true ? 'M j, Y H:i:s' : format, timeZone); }
|
|
232
|
+
/** Time only, `H:i:s` by default. */
|
|
233
|
+
time(format = 'H:i:s', timeZone = null) { return this.date(format, timeZone); }
|
|
234
|
+
/** Relative date, e.g. "2 days ago". */
|
|
235
|
+
since(condition = true) { this._since = condition; return this; }
|
|
236
|
+
/** Time zone used by `date()`, `dateTime()` and `time()`. */
|
|
237
|
+
timezone(timeZone) { this._timeZone = timeZone; return this; }
|
|
238
|
+
/** Number formatted with thousands separators; `numeric(1)`: fixed number of decimals. */
|
|
123
239
|
numeric(condition = true) { this._numeric = condition !== false; this._decimals = typeof condition === 'number' ? condition : null; return this; }
|
|
124
240
|
/** Summary row below the table. */
|
|
125
241
|
summarize(summarizer) { this._summarizers = Array.isArray(summarizer) ? summarizer : [summarizer]; return this; }
|
|
126
242
|
getSummarizers() { return this._summarizers; }
|
|
243
|
+
/** Font weight: `thin`, `light`, `medium`, `semibold`, `bold`… */
|
|
127
244
|
weight(weight) { this._weight = weight; return this; }
|
|
245
|
+
/** Text size: `xs`, `sm` (default), `md` or `lg`. */
|
|
246
|
+
size(size) { this._size = size; return this; }
|
|
247
|
+
/** Font family: `sans`, `serif` or `mono`. */
|
|
248
|
+
fontFamily(family) { this._fontFamily = family; return this; }
|
|
249
|
+
/** Lets long text wrap onto several lines instead of overflowing. */
|
|
250
|
+
wrap(condition = true) { this._wrap = condition; return this; }
|
|
251
|
+
/** Clamps the text to this number of lines. */
|
|
252
|
+
lineClamp(lines) { this._lineClamp = lines; return this; }
|
|
253
|
+
/** Renders inline (without a block layout), e.g. inside a stacked layout. */
|
|
254
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
128
255
|
/** Truncates the displayed text to `length` characters, followed by "...". */
|
|
129
256
|
limit(length) { this._limit = length; return this; }
|
|
257
|
+
/** Truncates the displayed text to this number of words, followed by "...". */
|
|
258
|
+
words(count) { this._words = count; return this; }
|
|
259
|
+
prefix(prefix) { this._prefix = prefix; return this; }
|
|
260
|
+
suffix(suffix) { this._suffix = suffix; return this; }
|
|
130
261
|
/** Tooltip on hover; `null` for none. */
|
|
131
262
|
tooltip(tooltip) { this._tooltip = tooltip; return this; }
|
|
132
263
|
copyable(condition = true) { this._copyable = condition; return this; }
|
|
264
|
+
/** Value copied to the clipboard, when it differs from the displayed text. */
|
|
265
|
+
copyableState(state) { this._copyableState = state; return this; }
|
|
266
|
+
copyMessage(message) { this._copyMessage = message; return this; }
|
|
267
|
+
copyMessageDuration(milliseconds) { this._copyMessageDuration = milliseconds; return this; }
|
|
133
268
|
/** Amount formatted in the given currency, e.g. `money('usd')`. */
|
|
134
269
|
money(currency = 'usd') { this._money = currency; return this; }
|
|
135
270
|
/** Text displayed when the value is empty. */
|
|
136
271
|
placeholder(text) { this._placeholder = text; return this; }
|
|
137
|
-
/**
|
|
272
|
+
/** Secondary text below the value (`'below'`, the default) or above it. */
|
|
273
|
+
description(text, position = 'below') {
|
|
274
|
+
if (position === 'above')
|
|
275
|
+
this._descriptionAbove = text;
|
|
276
|
+
else
|
|
277
|
+
this._descriptionBelow = text;
|
|
278
|
+
return this;
|
|
279
|
+
}
|
|
280
|
+
/** One value per line, for a state that is a list (or a string split by `separator()`). */
|
|
281
|
+
listWithLineBreaks(condition = true) { this._listWithLineBreaks = condition; return this; }
|
|
282
|
+
/** Bulleted list. */
|
|
283
|
+
bulleted(condition = true) { this._bulleted = condition; return this; }
|
|
284
|
+
/** Shows only the first values of a list; `expandable`: a link reveals the others. */
|
|
285
|
+
limitList(count, expandable = false) { this._listLimit = count; this._expandableLimitedList = expandable; return this; }
|
|
286
|
+
expandableLimitedList(condition = true) { this._expandableLimitedList = condition; return this; }
|
|
287
|
+
/** Splits a string state into a list on this separator. */
|
|
288
|
+
separator(separator = ',') { this._separator = separator; return this; }
|
|
289
|
+
_html = false;
|
|
290
|
+
_markdown = false;
|
|
291
|
+
/**
|
|
292
|
+
* Renders the state as HTML. It is sanitized first: only formatting elements (paragraphs, emphasis, lists, links with safe
|
|
293
|
+
* URLs...) are kept, without any attribute that could run scripts or change the page's styles.
|
|
294
|
+
*/
|
|
295
|
+
html(condition = true) { this._html = condition; return this; }
|
|
296
|
+
/** Renders the state as Markdown. Raw HTML in it is shown as text, and links only keep safe URLs. */
|
|
297
|
+
markdown(condition = true) { this._markdown = condition; return this; }
|
|
298
|
+
/** Item text as HTML: Markdown, sanitized HTML, or escaped text. */
|
|
299
|
+
renderItemText(text, record, item) {
|
|
300
|
+
if (resolveItem(this._markdown, record, item))
|
|
301
|
+
return markdownToHtml(text);
|
|
302
|
+
if (resolveItem(this._html, record, item))
|
|
303
|
+
return sanitizeHtml(text);
|
|
304
|
+
return e(text);
|
|
305
|
+
}
|
|
306
|
+
/** Displayed text of one state item, escaped by the caller. */
|
|
307
|
+
formatItem(item, record) {
|
|
308
|
+
if (this._format)
|
|
309
|
+
return String(this._format({ state: item, record }) ?? '');
|
|
310
|
+
let text;
|
|
311
|
+
const currency = typeof this._money === 'function' ? this._money(record) : this._money;
|
|
312
|
+
if (this._since)
|
|
313
|
+
text = diffForHumans(item) ?? String(item);
|
|
314
|
+
else if (this._date !== null)
|
|
315
|
+
text = formatDate(item, this._date, this._timeZone);
|
|
316
|
+
else if (currency && isNumericValue(item))
|
|
317
|
+
text = new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.toUpperCase() }).format(Number(item));
|
|
318
|
+
else if (this._numeric && isNumericValue(item))
|
|
319
|
+
text = new Intl.NumberFormat('en-US', this._decimals !== null ? { minimumFractionDigits: this._decimals, maximumFractionDigits: this._decimals } : {}).format(Number(item));
|
|
320
|
+
else
|
|
321
|
+
text = String(item);
|
|
322
|
+
if (this._limit !== null && text.length > this._limit)
|
|
323
|
+
text = `${text.slice(0, this._limit).trimEnd()}...`;
|
|
324
|
+
if (this._words !== null) {
|
|
325
|
+
const words = text.split(/\s+/);
|
|
326
|
+
if (words.length > this._words)
|
|
327
|
+
text = `${words.slice(0, this._words).join(' ')}...`;
|
|
328
|
+
}
|
|
329
|
+
return `${this._prefix ?? ''}${text}${this._suffix ?? ''}`;
|
|
330
|
+
}
|
|
331
|
+
stateItems(state) {
|
|
332
|
+
if (Array.isArray(state))
|
|
333
|
+
return state.filter((item) => !isBlankState(item));
|
|
334
|
+
if (this._separator !== null && typeof state === 'string')
|
|
335
|
+
return state.split(this._separator).map((item) => item.trim()).filter(Boolean);
|
|
336
|
+
return [state];
|
|
337
|
+
}
|
|
338
|
+
hasDescriptions() { return this._descriptionAbove !== null || this._descriptionBelow !== null; }
|
|
339
|
+
/** Plain value without per-item options: Filament's short markup. */
|
|
340
|
+
canRenderOptimized(state) {
|
|
341
|
+
return !Array.isArray(state) && this._separator === null && !isBlankState(state) && !this._bulleted && !this._listWithLineBreaks && this._icon === null && this._tooltip === null
|
|
342
|
+
&& this._copyable === false && this._weight === null && this._fontFamily === null && this._lineClamp === null && this._size === null && !this.hasDescriptions() && !this._wrap && this._html === false && this._markdown === false;
|
|
343
|
+
}
|
|
344
|
+
alignClass() { return this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null; }
|
|
345
|
+
containerClasses() { return ['fi-ta-text', this._inline ? 'fi-inline' : null, this._numeric || this._money ? 'fi-numeric' : null, this.alignClass()]; }
|
|
346
|
+
/** Cell content, as Filament renders it: a short markup for a plain value, the full markup as soon as an item option is set. */
|
|
138
347
|
async toHtml(record) {
|
|
139
348
|
const state = await this.getState(record);
|
|
140
|
-
if ((state
|
|
141
|
-
return `<div class="
|
|
142
|
-
|
|
143
|
-
if (state
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (this._badge || this._format || this._date) {
|
|
147
|
-
let shown = state === null || state === undefined ? '' : String(state);
|
|
148
|
-
if (this._date && shown)
|
|
149
|
-
shown = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${shown.replace(' ', 'T').slice(0, 19)}Z`)) + (this._dateTime ? ` ${shown.slice(11, 19)}` : '');
|
|
150
|
-
if (this._format)
|
|
151
|
-
shown = this._format({ state, record });
|
|
349
|
+
if (isBlankState(state)) {
|
|
350
|
+
return `<div class="${cls(this.containerClasses())}">${this._placeholder !== null ? `<p class="fi-ta-placeholder">${e(this._placeholder)}</p>` : ''}</div>`;
|
|
351
|
+
}
|
|
352
|
+
if (this.canRenderOptimized(state)) {
|
|
353
|
+
let html = e(this.formatItem(state, record));
|
|
354
|
+
const color = resolveItem(this._color, record, state);
|
|
152
355
|
if (this._badge) {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
const classes = ['fi-badge', 'fi-size-sm', ...(color !== 'gray' ? (colorManager?.getComponentClasses('badge', color) ?? []) : [])].join(' ');
|
|
156
|
-
// Without an icon the cell uses the short markup, which writes the classes in the other order.
|
|
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>`;
|
|
356
|
+
const badgeColor = color ?? 'primary';
|
|
357
|
+
html = `<span class="${cls(['fi-badge', 'fi-size-sm', ...(badgeColor !== 'gray' ? (colorManager?.getComponentClasses('badge', badgeColor) ?? []) : [])])}">${html}</span>`;
|
|
158
358
|
}
|
|
159
|
-
|
|
160
|
-
|
|
359
|
+
const classes = this._badge ? ['fi-ta-text', 'fi-ta-text-item', 'fi-ta-text-has-badges'] : ['fi-ta-text', 'fi-ta-text-item', 'fi-size-sm', ...(color ? (colorManager?.getComponentClasses('text', color) ?? []) : [])];
|
|
360
|
+
return `<div class="${cls([...classes, this._inline ? 'fi-inline' : null, this._numeric || this._money ? 'fi-numeric' : null, this.alignClass()])}">${html}</div>`;
|
|
161
361
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
362
|
+
let items = this.stateItems(state);
|
|
363
|
+
let count = items.length;
|
|
364
|
+
const limit = this._listLimit ?? count;
|
|
365
|
+
const overLimit = limit && count > limit ? count - limit : 0;
|
|
366
|
+
if (overLimit && (!this._listWithLineBreaks || !this._expandableLimitedList))
|
|
367
|
+
items = items.slice(0, limit);
|
|
368
|
+
// Several values without line breaks: one comma-separated item.
|
|
369
|
+
let collapsedHtml = null;
|
|
370
|
+
if (count > 1 && !this._listWithLineBreaks && !this._badge) {
|
|
371
|
+
collapsedHtml = items.map((item) => e(this.formatItem(item, record))).join(', ');
|
|
372
|
+
count = 1;
|
|
373
|
+
items = [items[0]];
|
|
374
|
+
}
|
|
375
|
+
const itemData = (item) => {
|
|
376
|
+
const formatted = collapsedHtml !== null ? collapsedHtml.replace(/<[^>]+>/g, '') : this.formatItem(item, record);
|
|
377
|
+
const color = resolveItem(this._color, record, item) ?? (this._badge ? 'primary' : null);
|
|
378
|
+
const sizeOption = resolveItem(this._size, record, item);
|
|
379
|
+
const size = sizeOption === 'base' ? 'md' : sizeOption ?? 'sm';
|
|
380
|
+
const iconName = this._icon === null ? null : resolveItem(this._icon, record, item);
|
|
381
|
+
const iconColor = resolveItem(this._iconColor, record, item);
|
|
382
|
+
const iconHtml = iconName ? icon(iconName, { size: size === 'md' ? 'md' : size === 'lg' ? 'lg' : 'sm', attributes: new Attributes({ 'aria-hidden': 'true' }).class(iconColor ? (colorManager?.getComponentClasses('icon-column', iconColor) ?? []) : []) }) : '';
|
|
383
|
+
const copyable = resolveItem(this._copyable, record, item);
|
|
384
|
+
const tooltip = resolveItem(this._tooltip, record, item);
|
|
385
|
+
const fontFamily = resolveItem(this._fontFamily, record, item);
|
|
386
|
+
const weight = resolveItem(this._weight, record, item);
|
|
387
|
+
const itemClasses = ['fi-ta-text-item', fontFamily ? `fi-font-${fontFamily}` : null];
|
|
388
|
+
if (!this._badge)
|
|
389
|
+
itemClasses.push(TEXT_SIZES.has(size) ? `fi-size-${size}` : size, weight ? `fi-font-${weight}` : null, ...(color ? (colorManager?.getComponentClasses('text', color) ?? []) : []));
|
|
390
|
+
const itemStyle = !this._badge && this._lineClamp ? ` style="--line-clamp: ${Math.floor(this._lineClamp)}"` : '';
|
|
391
|
+
let content = null;
|
|
392
|
+
if (this._badge || copyable || (tooltip !== null && tooltip !== '')) {
|
|
393
|
+
const attributes = [];
|
|
394
|
+
if (copyable) {
|
|
395
|
+
const copied = resolveItem(this._copyableState, record, item) ?? formatted;
|
|
396
|
+
const message = this._copyMessage ?? t('filament::components/copyable.messages.copied');
|
|
397
|
+
attributes.push(...copyableAttributes(copied, formatted, message, this._copyMessageDuration));
|
|
398
|
+
}
|
|
399
|
+
if (tooltip !== null && tooltip !== '')
|
|
400
|
+
attributes.push(`x-tooltip="${e(tooltipAttribute(tooltip))}"`);
|
|
401
|
+
const contentClasses = [copyable ? 'fi-copyable' : null];
|
|
402
|
+
if (this._badge)
|
|
403
|
+
contentClasses.push('fi-badge', TEXT_SIZES.has(size) ? `fi-size-${size}` : size, ...(color && color !== 'gray' ? (colorManager?.getComponentClasses('badge', color) ?? []) : []));
|
|
404
|
+
const classString = cls(contentClasses);
|
|
405
|
+
content = `<span ${[...attributes, classString ? `class="${classString}"` : ''].filter(Boolean).join(' ')}>`;
|
|
406
|
+
}
|
|
407
|
+
const before = this._iconPosition === 'before' ? iconHtml : '', after = this._iconPosition === 'after' ? iconHtml : '';
|
|
408
|
+
const inner = `${content ?? ''}${before}${collapsedHtml ?? this.renderItemText(formatted, record, item)}${after}${content ? '</span>' : ''}`;
|
|
409
|
+
return { itemClasses, itemStyle, inner };
|
|
410
|
+
};
|
|
411
|
+
const containerExtra = [this._badge ? 'fi-ta-text-has-badges' : null, this._lineClamp && !this._badge ? 'fi-ta-text-has-line-clamp' : null, this._wrap ? 'fi-wrapped' : null];
|
|
412
|
+
const descriptionAbove = typeof this._descriptionAbove === 'function' ? this._descriptionAbove(record) : this._descriptionAbove;
|
|
413
|
+
const descriptionBelow = typeof this._descriptionBelow === 'function' ? this._descriptionBelow(record) : this._descriptionBelow;
|
|
414
|
+
const hasDescriptions = (descriptionAbove ?? '') !== '' || (descriptionBelow ?? '') !== '';
|
|
415
|
+
if (count === 1 && !this._bulleted && !hasDescriptions && !this._lineClamp) {
|
|
416
|
+
const { itemClasses, itemStyle, inner } = itemData(items[0]);
|
|
417
|
+
return `<div class="${cls([...itemClasses, ...this.containerClasses(), ...containerExtra])}"${itemStyle}>${inner}</div>`;
|
|
173
418
|
}
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
419
|
+
const container = [...this.containerClasses(), ...containerExtra, this._bulleted ? 'fi-bulleted' : null, this._listWithLineBreaks ? 'fi-ta-text-has-line-breaks' : null];
|
|
420
|
+
const listItems = (forList) => forList.map((item, index) => {
|
|
421
|
+
const { itemClasses, itemStyle, inner } = itemData(item);
|
|
422
|
+
const hidden = index + 1 > limit ? ' x-show="! isLimited" x-cloak x-transition' : '';
|
|
423
|
+
return `<li${hidden} class="${cls(itemClasses)}"${itemStyle}>${inner}</li>`;
|
|
424
|
+
}).join('');
|
|
425
|
+
if (hasDescriptions || overLimit) {
|
|
426
|
+
const expandable = overLimit && this._expandableLimitedList;
|
|
427
|
+
let body;
|
|
428
|
+
if (count === 1 && !this._bulleted) {
|
|
429
|
+
const { itemClasses, itemStyle, inner } = itemData(items[0]);
|
|
430
|
+
body = `<p class="${cls(itemClasses)}"${itemStyle}>${inner}</p>`;
|
|
431
|
+
}
|
|
432
|
+
else
|
|
433
|
+
body = `<ul>${listItems(items)}</ul>`;
|
|
434
|
+
let more = '';
|
|
435
|
+
if (overLimit) {
|
|
436
|
+
more = expandable
|
|
437
|
+
? `<div class="fi-ta-text-list-limited-message"><div role="button" x-bind:aria-expanded="(! isLimited).toString()" x-on:click.prevent.stop="isLimited = false" x-show="isLimited" class="fi-link fi-size-xs">${e(tc('filament-tables::table.columns.text.actions.expand_list', overLimit, { count: overLimit }))}</div><div role="button" x-bind:aria-expanded="(! isLimited).toString()" x-on:click.prevent.stop="isLimited = true" x-cloak x-show="! isLimited" class="fi-link fi-size-xs">${e(tc('filament-tables::table.columns.text.actions.collapse_list', overLimit, { count: overLimit }))}</div></div>`
|
|
438
|
+
: `<div class="fi-ta-text-list-limited-message">${e(tc('filament-tables::table.columns.text.more_list_items', overLimit, { count: overLimit }))}</div>`;
|
|
439
|
+
}
|
|
440
|
+
return `<div${expandable ? ' x-data="{ isLimited: true }"' : ''} class="${cls([...container, hasDescriptions ? 'fi-ta-text-has-descriptions' : null, overLimit ? 'fi-ta-text-list-limited' : null])}">`
|
|
441
|
+
+ `${(descriptionAbove ?? '') !== '' ? `<p class="fi-ta-text-description">${e(descriptionAbove)}</p>` : ''}${body}${more}`
|
|
442
|
+
+ `${(descriptionBelow ?? '') !== '' ? `<p class="fi-ta-text-description">${e(descriptionBelow)}</p>` : ''}</div>`;
|
|
443
|
+
}
|
|
444
|
+
return `<ul class="${cls(container)}">${listItems(items)}</ul>`;
|
|
194
445
|
}
|
|
195
446
|
}
|
|
196
|
-
/**
|
|
447
|
+
/**
|
|
448
|
+
* Column summary below the table: sum, average, count, range, distinct values, or your own with `Summarizer.make().using()`.
|
|
449
|
+
*/
|
|
197
450
|
export class Summarizer {
|
|
451
|
+
kind = 'custom';
|
|
198
452
|
_label = null;
|
|
199
453
|
_money = null;
|
|
454
|
+
_decimals = null;
|
|
455
|
+
_prefix = null;
|
|
456
|
+
_suffix = null;
|
|
457
|
+
_formatStateUsing = null;
|
|
458
|
+
_labelHidden = false;
|
|
459
|
+
_hidden = false;
|
|
460
|
+
_using = null;
|
|
461
|
+
_scope = null;
|
|
462
|
+
/**
|
|
463
|
+
* Restricts the records summarized by this summarizer (all records, the page, or a group), e.g.
|
|
464
|
+
* `Count.make().query((query) => query.where('is_published', 1))`. The function receives the data adapter's query builder.
|
|
465
|
+
*/
|
|
466
|
+
query(scope) { this._scope = scope; return this; }
|
|
467
|
+
getQueryScope() { return this._scope; }
|
|
468
|
+
static make() { return configured(new Summarizer()); }
|
|
200
469
|
label(label) { this._label = label; return this; }
|
|
201
470
|
money(currency = 'usd') { this._money = currency.toUpperCase(); return this; }
|
|
471
|
+
/** Number formatting, with a fixed number of decimals. */
|
|
472
|
+
numeric(decimals = null) { this._decimals = decimals; return this; }
|
|
473
|
+
prefix(prefix) { this._prefix = prefix; return this; }
|
|
474
|
+
suffix(suffix) { this._suffix = suffix; return this; }
|
|
475
|
+
/** Formats the value yourself (the result is escaped). */
|
|
476
|
+
formatStateUsing(callback) { this._formatStateUsing = callback; return this; }
|
|
477
|
+
/** Keeps the label for screen readers only. */
|
|
478
|
+
hiddenLabel(condition = true) { this._labelHidden = condition; return this; }
|
|
479
|
+
isLabelHidden() { return this._labelHidden; }
|
|
480
|
+
hidden(condition = true) { this._hidden = condition; return this; }
|
|
481
|
+
visible(condition = true) { return this.hidden(typeof condition === 'function' ? () => !condition() : !condition); }
|
|
482
|
+
isHidden() { return typeof this._hidden === 'function' ? this._hidden() : this._hidden; }
|
|
483
|
+
/** Computes the value from the summarized records (`records()` loads them: keep the dataset small). */
|
|
484
|
+
using(callback) { this._using = callback; return this; }
|
|
485
|
+
hasCustomCalculation() { return this._using !== null; }
|
|
486
|
+
async compute(dataset) { return this._using ? this._using(dataset) : null; }
|
|
202
487
|
getLabel() { return this._label; }
|
|
203
|
-
|
|
488
|
+
/** Formatted value: `formatStateUsing()`, money or number, then the prefix and suffix. */
|
|
489
|
+
format(value) {
|
|
490
|
+
let text;
|
|
491
|
+
if (this._formatStateUsing)
|
|
492
|
+
text = this._formatStateUsing(value) ?? '';
|
|
493
|
+
else if (typeof value === 'number' && Number.isFinite(value)) {
|
|
494
|
+
const digits = this._decimals !== null ? { minimumFractionDigits: this._decimals, maximumFractionDigits: this._decimals } : {};
|
|
495
|
+
text = this._money ? new Intl.NumberFormat('en-US', { style: 'currency', currency: this._money, ...digits }).format(value) : new Intl.NumberFormat('en-US', digits).format(value);
|
|
496
|
+
}
|
|
497
|
+
else
|
|
498
|
+
text = value === null || value === undefined ? '' : String(value);
|
|
499
|
+
return `${this._prefix ?? ''}${text}${this._suffix ?? ''}`;
|
|
500
|
+
}
|
|
501
|
+
item(value, type = 'text') { return { type, label: this.getLabel(), value: this.format(value), labelHidden: this._labelHidden }; }
|
|
502
|
+
/** Summary item from the aggregates (or from `customValue`, computed by `using()`). */
|
|
503
|
+
summarize(_column, _values, customValue) { return this.item(customValue ?? null); }
|
|
204
504
|
}
|
|
205
505
|
/** Column total. */
|
|
206
506
|
export class Sum extends Summarizer {
|
|
207
507
|
kind = 'sum';
|
|
208
508
|
static make() { return configured(new Sum()); }
|
|
209
509
|
getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.sum.label'); }
|
|
210
|
-
summarize(column, values) { return
|
|
510
|
+
summarize(column, values, customValue) { return this.item(customValue ?? values.sum[column] ?? 0); }
|
|
211
511
|
}
|
|
212
512
|
/** Column average. */
|
|
213
513
|
export class Average extends Summarizer {
|
|
214
514
|
kind = 'avg';
|
|
215
515
|
static make() { return configured(new Average()); }
|
|
216
516
|
getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.average.label'); }
|
|
217
|
-
summarize(column, values) { return
|
|
517
|
+
summarize(column, values, customValue) { return this.item(customValue ?? values.avg[column] ?? 0); }
|
|
218
518
|
}
|
|
219
519
|
/** Record count. */
|
|
220
520
|
export class Count extends Summarizer {
|
|
221
521
|
kind = 'count';
|
|
222
522
|
static make() { return configured(new Count()); }
|
|
223
523
|
getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.count.label'); }
|
|
224
|
-
summarize(_column, values) { return
|
|
524
|
+
summarize(_column, values, customValue) { return this.item(customValue ?? values.count); }
|
|
525
|
+
}
|
|
526
|
+
/** Distinct values of the column, as a (bulleted) list. */
|
|
527
|
+
export class Values extends Summarizer {
|
|
528
|
+
kind = 'values';
|
|
529
|
+
_bulleted = true;
|
|
530
|
+
static make() { return configured(new Values()); }
|
|
531
|
+
bulleted(condition = true) { this._bulleted = condition; return this; }
|
|
532
|
+
constructor() { super(); this._using = async ({ column, records }) => [...new Set((await records()).map((record) => record[column]).filter((value) => value !== null && value !== undefined && value !== ''))]; }
|
|
533
|
+
summarize(_column, _values, customValue) {
|
|
534
|
+
const list = Array.isArray(customValue) ? customValue : [];
|
|
535
|
+
return { type: 'values', label: this.getLabel(), value: '', labelHidden: this._labelHidden, values: list.map((value) => this.format(value)), bulleted: this._bulleted };
|
|
536
|
+
}
|
|
225
537
|
}
|
|
226
538
|
/** Smallest and largest value of a column. */
|
|
227
539
|
export class Range extends Summarizer {
|
|
@@ -230,7 +542,7 @@ export class Range extends Summarizer {
|
|
|
230
542
|
static make() { return configured(new Range()); }
|
|
231
543
|
/** Dates: day only when the bounds fall on different days, day and time otherwise; a single value when both are equal. */
|
|
232
544
|
minimalDateTimeDifference(condition = true) { this._minimalDateTime = condition; return this; }
|
|
233
|
-
summarize(column, values) {
|
|
545
|
+
summarize(column, values, _customValue) {
|
|
234
546
|
let bounds = [values.min[column], values.max[column]].filter((v) => v !== null && v !== undefined && v !== '').map(String);
|
|
235
547
|
if (!bounds.length)
|
|
236
548
|
return null;
|
|
@@ -245,7 +557,7 @@ export class Range extends Summarizer {
|
|
|
245
557
|
bounds = times[0] === times[1] ? [times[0]] : times;
|
|
246
558
|
}
|
|
247
559
|
}
|
|
248
|
-
return { type: 'range', label: this._label, value: bounds.join(' - ') };
|
|
560
|
+
return { type: 'range', label: this._label, value: `${this._prefix ?? ''}${bounds.join(' - ')}${this._suffix ?? ''}`, labelHidden: this._labelHidden };
|
|
249
561
|
}
|
|
250
562
|
}
|
|
251
563
|
/** Row grouping: rows are sorted by the column and each group gets a header row. */
|
|
@@ -258,7 +570,17 @@ export class Group {
|
|
|
258
570
|
_collapsible = false;
|
|
259
571
|
_date = false;
|
|
260
572
|
_titlePrefixedWithLabel = true;
|
|
573
|
+
_keyUsing = null;
|
|
574
|
+
_scopeUsing = null;
|
|
575
|
+
_orderUsing = null;
|
|
261
576
|
constructor(id) { this.id = id; }
|
|
577
|
+
/** Group key of a record, when it is not the column value (records with the same key form a group). Use with `scopeQueryByKeyUsing()`. */
|
|
578
|
+
getKeyFromRecordUsing(resolver) { this._keyUsing = resolver; return this; }
|
|
579
|
+
/** Restricts the query to one group (summaries, "select the group"): needed when `getKeyFromRecordUsing()` is not the column value. */
|
|
580
|
+
scopeQueryByKeyUsing(scope) { this._scopeUsing = scope; return this; }
|
|
581
|
+
/** Orders the query by group, when ordering by the column is not right (e.g. a status with a custom order). */
|
|
582
|
+
orderQueryUsing(order) { this._orderUsing = order; return this; }
|
|
583
|
+
getOrderQueryUsing() { return this._orderUsing; }
|
|
262
584
|
static make(id) { return configured(new Group(id)); }
|
|
263
585
|
label(label) { this._label = label; return this; }
|
|
264
586
|
/** Query column holding the group value, when it differs from the id (relationship). */
|
|
@@ -287,6 +609,10 @@ export class Group {
|
|
|
287
609
|
isTitlePrefixedWithLabel() { return this._titlePrefixedWithLabel; }
|
|
288
610
|
/** Group value of a record, as text; `null` when empty. */
|
|
289
611
|
getStringKey(record) {
|
|
612
|
+
if (this._keyUsing) {
|
|
613
|
+
const custom = this._keyUsing(record);
|
|
614
|
+
return custom === null || custom === undefined || custom === '' ? null : String(custom);
|
|
615
|
+
}
|
|
290
616
|
let key = record?.[this.getColumn()];
|
|
291
617
|
if (key === null || key === undefined || key === '')
|
|
292
618
|
return null;
|
|
@@ -305,6 +631,10 @@ export class Group {
|
|
|
305
631
|
getDescription(record) { return this._description ? this._description(record) : null; }
|
|
306
632
|
/** Restricts a query to the records of the group with the given key. */
|
|
307
633
|
scope(query, key) {
|
|
634
|
+
if (this._scopeUsing) {
|
|
635
|
+
this._scopeUsing(query, key);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
308
638
|
if (key === null)
|
|
309
639
|
query.where((sub) => sub.whereNull(this.getColumn()).orWhere(this.getColumn(), ''));
|
|
310
640
|
else if (this._date)
|
|
@@ -319,15 +649,46 @@ export class Group {
|
|
|
319
649
|
*/
|
|
320
650
|
export class ColumnLayout {
|
|
321
651
|
_children;
|
|
652
|
+
_grow = null;
|
|
653
|
+
_visibleFrom = null;
|
|
654
|
+
_hiddenFrom = null;
|
|
655
|
+
_columnSpan = { default: 1 };
|
|
656
|
+
_collapsible = false;
|
|
657
|
+
// Collapsed until the user opens it, as in Filament.
|
|
658
|
+
_collapsed = true;
|
|
659
|
+
_hidden = false;
|
|
322
660
|
constructor(children) { this._children = children; }
|
|
323
661
|
getChildren() { return this._children; }
|
|
324
662
|
/** Leaf columns, in order: the ones the table sorts, searches and toggles. */
|
|
325
663
|
getColumns() { return this._children.flatMap((child) => (child instanceof ColumnLayout ? child.getColumns() : [child])); }
|
|
664
|
+
/** `false`: the component takes only the width it needs in a split, instead of sharing the remaining space. */
|
|
665
|
+
grow(condition = true) { this._grow = condition; return this; }
|
|
666
|
+
canGrow() { return this._grow ?? true; }
|
|
667
|
+
visibleFrom(breakpoint) { this._visibleFrom = breakpoint !== null && BREAKPOINTS.includes(breakpoint) ? breakpoint : null; return this; }
|
|
668
|
+
hiddenFrom(breakpoint) { this._hiddenFrom = breakpoint !== null && BREAKPOINTS.includes(breakpoint) ? breakpoint : null; return this; }
|
|
669
|
+
getResponsiveClasses() { return [this._hiddenFrom ? `${this._hiddenFrom}:fi-hidden` : null, this._visibleFrom ? `${this._visibleFrom}:fi-visible` : null].filter((c) => c !== null); }
|
|
670
|
+
/** Columns of a parent `Grid` taken by the component: a number, `'full'`, or one per breakpoint. */
|
|
671
|
+
columnSpan(span) { this._columnSpan = typeof span === 'object' ? { default: 1, ...span } : { default: span }; return this; }
|
|
672
|
+
columnSpanFull() { return this.columnSpan('full'); }
|
|
673
|
+
getColumnSpan() { return this._columnSpan; }
|
|
674
|
+
/**
|
|
675
|
+
* Content hidden behind a toggle button at the bottom of the record. Only one top-level component of the layout can
|
|
676
|
+
* be collapsible. It starts collapsed; `collapsed(false)` shows it open.
|
|
677
|
+
*/
|
|
678
|
+
collapsible(condition = true) { this._collapsible = condition; return this; }
|
|
679
|
+
collapsed(condition = true) { this._collapsible = true; this._collapsed = condition; return this; }
|
|
680
|
+
isCollapsible() { return this._collapsible; }
|
|
681
|
+
isCollapsed() { return this._collapsed; }
|
|
682
|
+
hidden(condition = true) { this._hidden = condition; return this; }
|
|
683
|
+
visible(condition = true) { return this.hidden(typeof condition === 'function' ? () => !condition() : !condition); }
|
|
684
|
+
isHidden() { return typeof this._hidden === 'function' ? this._hidden() : this._hidden; }
|
|
685
|
+
/** Inner markup of the component, around the already rendered children. */
|
|
686
|
+
async renderContent(children, _record, _key) { return `<div class="${this.getClasses().join(' ')}">${children.join('')}</div>`; }
|
|
326
687
|
}
|
|
327
688
|
/** Columns side by side, from a given breakpoint. */
|
|
328
689
|
export class Split extends ColumnLayout {
|
|
329
690
|
_from = null;
|
|
330
|
-
static make(children) { return new Split(children); }
|
|
691
|
+
static make(children) { return configured(new Split(children)); }
|
|
331
692
|
from(breakpoint) { this._from = breakpoint; return this; }
|
|
332
693
|
getClasses() { return ['fi-ta-split', ...(this._from ? [`${this._from}:fi-ta-split`] : [])]; }
|
|
333
694
|
}
|
|
@@ -335,27 +696,76 @@ export class Split extends ColumnLayout {
|
|
|
335
696
|
export class Stack extends ColumnLayout {
|
|
336
697
|
_space = null;
|
|
337
698
|
_alignment = 'start';
|
|
338
|
-
static make(children) { return new Stack(children); }
|
|
699
|
+
static make(children) { return configured(new Stack(children)); }
|
|
339
700
|
/** Gap between columns: 1 (small), 2 (medium) or 3 (large). */
|
|
340
701
|
space(space = 1) { this._space = space; return this; }
|
|
341
702
|
alignment(alignment) { this._alignment = alignment; return this; }
|
|
342
703
|
getClasses() { return ['fi-ta-stack', `fi-align-${this._alignment}`, ...(this._space ? [`fi-gap-${['sm', 'md', 'lg'][this._space - 1]}`] : [])]; }
|
|
343
704
|
}
|
|
344
|
-
|
|
705
|
+
/** Responsive grid of columns: `Grid.make(2)` (2 columns from `lg`) or `Grid.make({ default: 1, md: 2, xl: 3 })`. */
|
|
706
|
+
export class Grid extends ColumnLayout {
|
|
707
|
+
_gridColumns = {};
|
|
708
|
+
constructor(columns = 2) { super([]); this.columns(columns); }
|
|
709
|
+
static make(columns = 2) { return configured(new Grid(columns)); }
|
|
710
|
+
/** Number of columns, or one per breakpoint (\`default\`, \`sm\`, \`md\`, \`lg\`, \`xl\`, \`2xl\`). */
|
|
711
|
+
columns(columns = 2) {
|
|
712
|
+
const added = columns === null ? {} : typeof columns === 'number' ? { lg: columns } : columns;
|
|
713
|
+
this._gridColumns = { ...this._gridColumns, ...Object.fromEntries(Object.entries(added).filter(([, count]) => Number.isInteger(count) && count > 0 && count <= 12)) };
|
|
714
|
+
return this;
|
|
715
|
+
}
|
|
716
|
+
/** Components of the grid. */
|
|
717
|
+
schema(children) { this._children = children; return this; }
|
|
718
|
+
getGridColumns() { return this._gridColumns; }
|
|
719
|
+
getClasses() {
|
|
720
|
+
const columns = this._gridColumns;
|
|
721
|
+
const gap = (count) => (count === 1 ? 'fi-gap-sm' : 'fi-gap-lg');
|
|
722
|
+
return ['fi-ta-grid', gap(columns.default ?? 1), ...['sm', 'md', 'lg', 'xl', '2xl'].filter((breakpoint) => columns[breakpoint]).map((breakpoint) => `${breakpoint}:${gap(columns[breakpoint])}`)];
|
|
723
|
+
}
|
|
724
|
+
async renderContent(children) { return `<div ${new Attributes().grid(this._gridColumns).class(this.getClasses()).toHtml()}>${children.join('')}</div>`; }
|
|
725
|
+
}
|
|
726
|
+
/** Plain container, styled as a panel: often made collapsible. */
|
|
727
|
+
export class Panel extends ColumnLayout {
|
|
728
|
+
static make(children) { return configured(new Panel(children)); }
|
|
729
|
+
getClasses() { return ['fi-ta-panel']; }
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Your own markup inside the record: a function returning HTML, or the name of an EJS view rendered with `record` and
|
|
733
|
+
* `recordKey`. The HTML is inserted as it is: escape record data with `e()` (EJS's `<%= %>` does it).
|
|
734
|
+
*/
|
|
735
|
+
export class View extends ColumnLayout {
|
|
736
|
+
_view;
|
|
737
|
+
_viewData = {};
|
|
738
|
+
constructor(view) { super([]); this._view = view; }
|
|
739
|
+
static make(view) { return configured(new View(view)); }
|
|
740
|
+
/** Extra data passed to the EJS view. */
|
|
741
|
+
viewData(data) { this._viewData = data; return this; }
|
|
742
|
+
/** Columns rendered inside the view are not part of the table: the view has no children. */
|
|
743
|
+
components(children) { this._children = children; return this; }
|
|
744
|
+
getClasses() { return []; }
|
|
745
|
+
async renderContent(children, record, key) {
|
|
746
|
+
if (typeof this._view === 'function')
|
|
747
|
+
return this._view({ record, recordKey: key });
|
|
748
|
+
return views.render(this._view, { ...this._viewData, record, recordKey: key, getRecord: () => record, children: children.join('') });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
345
751
|
/** Renders a record through the layout, skipping columns the user has hidden. */
|
|
346
752
|
async function renderColumnLayout(node, record, key, visible) {
|
|
347
753
|
if (node instanceof ColumnLayout) {
|
|
754
|
+
if (node.isHidden())
|
|
755
|
+
return '';
|
|
348
756
|
const children = [];
|
|
349
757
|
for (const child of node.getChildren())
|
|
350
758
|
children.push(await renderColumnLayout(child, record, key, visible));
|
|
351
|
-
|
|
759
|
+
const cell = new Attributes().gridColumn(node.getColumnSpan()).class([{ 'fi-growable': node.canGrow() }, ...node.getResponsiveClasses()]);
|
|
760
|
+
return `<div ${cell.toHtml()}>${await node.renderContent(children, record, key)}</div>`;
|
|
352
761
|
}
|
|
353
762
|
if (!visible.has(node))
|
|
354
763
|
return '';
|
|
355
764
|
const alignment = node.getAlignment();
|
|
356
765
|
// Inside a layout, text is inline and carries its own alignment.
|
|
357
766
|
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
|
-
|
|
767
|
+
const cell = new Attributes().gridColumn({ default: 1 }).class([{ 'fi-growable': node.canGrow(true) }, 'fi-ta-col', ...(alignment ? [`fi-align-${alignment}`] : []), ...node.getResponsiveClasses()]);
|
|
768
|
+
return `<div ${cell.toHtml()}>${html}</div>`;
|
|
359
769
|
}
|
|
360
770
|
/** Column group: a shared label above the column headers. */
|
|
361
771
|
export class ColumnGroup {
|
|
@@ -367,48 +777,212 @@ export class ColumnGroup {
|
|
|
367
777
|
c._group = this.label; return this; }
|
|
368
778
|
getColumns() { return this._columns; }
|
|
369
779
|
}
|
|
370
|
-
/**
|
|
780
|
+
/** Only plain CSS lengths are written into a style attribute. */
|
|
781
|
+
const cssLength = (value) => {
|
|
782
|
+
if (value === null)
|
|
783
|
+
return null;
|
|
784
|
+
const text = typeof value === 'number' ? `${value}px` : value.trim();
|
|
785
|
+
return /^[0-9.]+(rem|px|em|%|vh|vw)$/.test(text) ? text : null;
|
|
786
|
+
};
|
|
787
|
+
/** Only hex or simple functional colors are written into a style attribute (no CSS injection). */
|
|
788
|
+
const sanitizeCssColor = (value) => (typeof value === 'string' && /^(#[0-9a-fA-F]{3,8}|(rgb|hsl|oklch)a?\([0-9.,%\s/deg-]+\))$/.test(value.trim()) ? value.trim() : null);
|
|
789
|
+
/** Only local or http(s) URLs become image sources. */
|
|
790
|
+
const safeImageUrl = (value) => (typeof value === 'string' && /^(\/(?!\/)|https?:\/\/)/.test(value) ? value : null);
|
|
791
|
+
/** Attributes of a copyable element (Filament's `filamentCopyable` Alpine component). */
|
|
792
|
+
const copyableAttributes = (copied, label, message, duration) => [
|
|
793
|
+
'x-data="filamentCopyable"', 'x-bind="bindings"',
|
|
794
|
+
`x-bind:aria-label="isStandalone ? ${e(js(t('filament::components/copyable.label', { label })))} : null"`,
|
|
795
|
+
`x-on:click.prevent.stop="${e(`if (isStandalone) {\n copy(${js(copied)}, ${js(message)}, ${duration}, ${js(t('filament::components/copyable.messages.failed'))})\n} else {\n window.navigator.clipboard.writeText(${js(copied)})\n $tooltip(${js(message)}, {\n theme: $store.theme,\n timeout: ${duration},\n })\n}`)}"`,
|
|
796
|
+
];
|
|
797
|
+
/** Color swatch per state (a color, or a list of colors). */
|
|
371
798
|
export class ColorColumn extends Column {
|
|
799
|
+
_copyable = false;
|
|
800
|
+
_copyMessage = null;
|
|
801
|
+
_copyMessageDuration = 2000;
|
|
802
|
+
_tooltip = null;
|
|
803
|
+
_placeholder = null;
|
|
804
|
+
_wrap = false;
|
|
805
|
+
_inline = false;
|
|
372
806
|
static make(name) { return configured(new ColorColumn(name)); }
|
|
807
|
+
/** Clicking the swatch copies the color. Browsers only expose the clipboard on HTTPS or `localhost`. */
|
|
808
|
+
copyable(condition = true) { this._copyable = condition; return this; }
|
|
809
|
+
copyMessage(message) { this._copyMessage = message; return this; }
|
|
810
|
+
copyMessageDuration(milliseconds) { this._copyMessageDuration = milliseconds; return this; }
|
|
811
|
+
tooltip(tooltip) { this._tooltip = tooltip; return this; }
|
|
812
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
813
|
+
/** Lets several swatches wrap onto more lines. */
|
|
814
|
+
wrap(condition = true) { this._wrap = condition; return this; }
|
|
815
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
373
816
|
async toHtml(record) {
|
|
374
817
|
const state = await this.getState(record);
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
818
|
+
const align = this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null;
|
|
819
|
+
const base = ['fi-ta-color', this._inline ? 'fi-inline' : null, align];
|
|
820
|
+
if (isBlankState(state))
|
|
821
|
+
return `<div class="${cls(base)}">${this._placeholder !== null ? `<p class="fi-ta-placeholder">${e(this._placeholder)}</p>` : ''}</div>`;
|
|
822
|
+
const items = (Array.isArray(state) ? state : [state]).map((item) => {
|
|
823
|
+
const color = sanitizeCssColor(item);
|
|
824
|
+
const copyable = resolveItem(this._copyable, record, item);
|
|
825
|
+
const tooltip = resolveItem(this._tooltip, record, item);
|
|
826
|
+
const attributes = [];
|
|
827
|
+
// The swatch shows its value only through its color: it is a named image for screen readers.
|
|
828
|
+
if (!copyable && color)
|
|
829
|
+
attributes.push(`aria-label="${e(color)}"`, 'role="img"');
|
|
830
|
+
if (copyable)
|
|
831
|
+
attributes.push(...copyableAttributes(color ?? '', color ?? this.name, this._copyMessage ?? t('filament::components/copyable.messages.copied'), this._copyMessageDuration));
|
|
832
|
+
if (tooltip)
|
|
833
|
+
attributes.push(`x-tooltip="${e(tooltipAttribute(tooltip))}"`);
|
|
834
|
+
attributes.push(`class="${cls(['fi-ta-color-item', copyable ? 'fi-copyable' : null])}"`);
|
|
835
|
+
if (color)
|
|
836
|
+
attributes.push(`style="background-color: ${color};"`);
|
|
837
|
+
return `<div ${attributes.join(' ')}></div>`;
|
|
838
|
+
});
|
|
839
|
+
return `<div class="${cls([...base, this._wrap ? 'fi-wrapped' : null])}">${items.join('')}</div>`;
|
|
378
840
|
}
|
|
379
841
|
}
|
|
380
|
-
/** Image
|
|
842
|
+
/** Image per state (an image URL, or a list of URLs). Only local or http(s) URLs are rendered. */
|
|
381
843
|
export class ImageColumn extends Column {
|
|
382
844
|
_alt = null;
|
|
383
|
-
_height =
|
|
845
|
+
_height = null;
|
|
846
|
+
_width = null;
|
|
384
847
|
_circular = false;
|
|
848
|
+
_square = false;
|
|
849
|
+
_stacked = false;
|
|
850
|
+
_ring = null;
|
|
851
|
+
_overlap = null;
|
|
852
|
+
_limit = null;
|
|
853
|
+
_limitedRemainingText = false;
|
|
854
|
+
_limitedRemainingTextSize = null;
|
|
855
|
+
_defaultImageUrl = null;
|
|
856
|
+
_tooltip = null;
|
|
857
|
+
_placeholder = null;
|
|
858
|
+
_wrap = false;
|
|
859
|
+
_inline = false;
|
|
385
860
|
static make(name) { return configured(new ImageColumn(name)); }
|
|
386
|
-
alt(
|
|
387
|
-
/** CSS height of the
|
|
388
|
-
imageHeight(height) {
|
|
389
|
-
|
|
861
|
+
alt(alt) { this._alt = alt; return this; }
|
|
862
|
+
/** CSS height of the images (`2.5rem` by default, `2rem` when stacked). */
|
|
863
|
+
imageHeight(height) { this._height = cssLength(height); return this; }
|
|
864
|
+
imageWidth(width) { this._width = cssLength(width); return this; }
|
|
865
|
+
/** Same width and height. */
|
|
866
|
+
imageSize(size) { return this.imageHeight(size).imageWidth(size); }
|
|
390
867
|
circular(condition = true) { this._circular = condition; return this; }
|
|
868
|
+
square(condition = true) { this._square = condition; return this; }
|
|
869
|
+
/** Overlapping images, with a `ring` (border width, 0 to 8) and an `overlap` (0 to 8). */
|
|
870
|
+
stacked(condition = true) { this._stacked = condition; return this; }
|
|
871
|
+
ring(width) { this._ring = width; return this; }
|
|
872
|
+
overlap(overlap) { this._overlap = overlap; return this; }
|
|
873
|
+
/** Shows only the first images; `limitedRemainingText()` adds "+3" for the others. */
|
|
874
|
+
limit(count) { this._limit = count; return this; }
|
|
875
|
+
limitedRemainingText(condition = true, size = null) { this._limitedRemainingText = condition; this._limitedRemainingTextSize = size; return this; }
|
|
876
|
+
/** Image shown when the state is empty. */
|
|
877
|
+
defaultImageUrl(url) { this._defaultImageUrl = url; return this; }
|
|
878
|
+
tooltip(tooltip) { this._tooltip = tooltip; return this; }
|
|
879
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
880
|
+
wrap(condition = true) { this._wrap = condition; return this; }
|
|
881
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
391
882
|
async toHtml(record) {
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
883
|
+
let state = await this.getState(record);
|
|
884
|
+
const align = this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null;
|
|
885
|
+
const base = ['fi-ta-image', this._inline ? 'fi-inline' : null, align];
|
|
886
|
+
const fallback = safeImageUrl(typeof this._defaultImageUrl === 'function' ? this._defaultImageUrl(record) : this._defaultImageUrl);
|
|
887
|
+
let items = (Array.isArray(state) ? state : isBlankState(state) ? [] : [state]).filter((item) => safeImageUrl(item) !== null);
|
|
888
|
+
if (!items.length && fallback)
|
|
889
|
+
items = [fallback];
|
|
890
|
+
if (!items.length)
|
|
891
|
+
return `<div class="${cls(base)}">${this._placeholder !== null ? `<p class="fi-ta-placeholder">${e(this._placeholder)}</p>` : ''}</div>`;
|
|
892
|
+
const overLimit = this._limit !== null && items.length > this._limit ? items.length - this._limit : 0;
|
|
893
|
+
if (overLimit)
|
|
894
|
+
items = items.slice(0, this._limit);
|
|
895
|
+
const height = this._height ?? (this._stacked ? '2rem' : '2.5rem');
|
|
896
|
+
const width = this._width ?? (this._circular || this._square ? height : null);
|
|
897
|
+
const style = `height: ${height};${width ? ` width: ${width};` : ''}`;
|
|
898
|
+
const ring = this._stacked && this._ring !== null && Number.isInteger(this._ring) ? `fi-ta-image-ring fi-ta-image-ring-${this._ring}` : null;
|
|
899
|
+
const overlap = this._stacked ? (this._overlap ?? 2) : null;
|
|
900
|
+
const classes = [...base, this._circular ? 'fi-circular' : null, this._wrap ? 'fi-wrapped' : null, this._stacked ? 'fi-stacked' : null, ring, overlap ? `fi-ta-image-overlap-${overlap}` : null];
|
|
901
|
+
const images = items.map((item) => {
|
|
902
|
+
const alt = resolveItem(this._alt, record, item) ?? '';
|
|
903
|
+
const tooltip = resolveItem(this._tooltip, record, item);
|
|
904
|
+
return `<img alt="${e(alt)}" src="${e(String(item))}"${tooltip ? ` x-tooltip="${e(tooltipAttribute(tooltip))}"` : ''} style="${style}" />`;
|
|
905
|
+
}).join('');
|
|
906
|
+
const remaining = overLimit && this._limitedRemainingText ? `<div class="${cls(['fi-ta-image-limited-remaining-text', this._limitedRemainingTextSize ? `fi-size-${this._limitedRemainingTextSize}` : null])}" style="${style}">+${overLimit}</div>` : '';
|
|
907
|
+
return `<div class="${cls(classes)}">${images}${remaining}</div>`;
|
|
398
908
|
}
|
|
399
909
|
}
|
|
400
910
|
/** Icon depending on the value; `boolean()` shows a green check or a red cross. */
|
|
911
|
+
/**
|
|
912
|
+
* Icon per state. `boolean()` shows a green check or a red cross; `icon()` and `color()` choose the icon and color from the
|
|
913
|
+
* record and the state, e.g. `IconColumn.make('status').icon((record) => STATUSES[record.status].icon)`.
|
|
914
|
+
*/
|
|
401
915
|
export class IconColumn extends Column {
|
|
402
916
|
_boolean = false;
|
|
917
|
+
_icon = null;
|
|
918
|
+
_color = null;
|
|
919
|
+
_size = null;
|
|
920
|
+
_trueIcon = null;
|
|
921
|
+
_falseIcon = null;
|
|
922
|
+
_trueColor = null;
|
|
923
|
+
_falseColor = null;
|
|
924
|
+
_tooltip = null;
|
|
925
|
+
_placeholder = null;
|
|
926
|
+
_listWithLineBreaks = false;
|
|
927
|
+
_wrap = false;
|
|
928
|
+
_inline = false;
|
|
403
929
|
static make(name) { return configured(new IconColumn(name)); }
|
|
404
930
|
boolean(condition = true) { this._boolean = condition; return this; }
|
|
931
|
+
icon(icon) { this._icon = icon; return this; }
|
|
932
|
+
color(color) { this._color = color; return this; }
|
|
933
|
+
/** Icon size: `xs`, `sm`, `md`, `lg` (default), `xl` or `2xl`. */
|
|
934
|
+
size(size) { this._size = size; return this; }
|
|
935
|
+
/** Icons and colors of a boolean column; `false` shows no icon for that value. */
|
|
936
|
+
trueIcon(icon) { this._trueIcon = icon; return this; }
|
|
937
|
+
falseIcon(icon) { this._falseIcon = icon; return this; }
|
|
938
|
+
trueColor(color) { this._trueColor = color; return this; }
|
|
939
|
+
falseColor(color) { this._falseColor = color; return this; }
|
|
940
|
+
tooltip(tooltip) { this._tooltip = tooltip; return this; }
|
|
941
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
942
|
+
/** One icon per line, for a state that is a list. */
|
|
943
|
+
listWithLineBreaks(condition = true) { this._listWithLineBreaks = condition; return this; }
|
|
944
|
+
wrap(condition = true) { this._wrap = condition; return this; }
|
|
945
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
946
|
+
isBooleanItem(item) { return this._boolean || typeof item === 'boolean'; }
|
|
947
|
+
iconOf(record, item) {
|
|
948
|
+
const own = this._icon === null ? null : resolveItem(this._icon, record, item);
|
|
949
|
+
if (own)
|
|
950
|
+
return own;
|
|
951
|
+
if (!this.isBooleanItem(item) || item === null || item === undefined)
|
|
952
|
+
return null;
|
|
953
|
+
const on = item === true || item === 1 || item === '1';
|
|
954
|
+
const chosen = on ? this._trueIcon : this._falseIcon;
|
|
955
|
+
return chosen === false ? null : chosen ?? (on ? 'o-check-circle' : 'o-x-circle');
|
|
956
|
+
}
|
|
957
|
+
colorOf(record, item) {
|
|
958
|
+
const own = this._color === null ? null : resolveItem(this._color, record, item);
|
|
959
|
+
if (own)
|
|
960
|
+
return own;
|
|
961
|
+
if (!this.isBooleanItem(item) || item === null || item === undefined)
|
|
962
|
+
return null;
|
|
963
|
+
const on = item === true || item === 1 || item === '1';
|
|
964
|
+
return on ? (this._trueColor ?? 'success') : (this._falseColor ?? 'danger');
|
|
965
|
+
}
|
|
405
966
|
async toHtml(record) {
|
|
406
967
|
const state = await this.getState(record);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
968
|
+
const align = this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null;
|
|
969
|
+
const base = ['fi-ta-icon', this._inline ? 'fi-inline' : null, align];
|
|
970
|
+
if (isBlankState(state))
|
|
971
|
+
return `<div class="${cls(base)}">${this._placeholder !== null ? `<p class="fi-ta-placeholder">${e(this._placeholder)}</p>` : ''}</div>`;
|
|
972
|
+
const items = Array.isArray(state) ? state : [state];
|
|
973
|
+
const html = items.map((item) => {
|
|
974
|
+
const name = this.iconOf(record, item);
|
|
975
|
+
if (!name)
|
|
976
|
+
return '';
|
|
977
|
+
const color = this.colorOf(record, item);
|
|
978
|
+
const size = resolveItem(this._size, record, item);
|
|
979
|
+
const tooltip = resolveItem(this._tooltip, record, item);
|
|
980
|
+
const attributes = new Attributes(tooltip ? { 'x-tooltip': tooltipAttribute(tooltip) } : {}).class(color ? (colorManager?.getComponentClasses('icon-column', color) ?? []) : []);
|
|
981
|
+
// The icon is hidden from assistive technology: a text alternative goes with it.
|
|
982
|
+
const alternative = tooltip ? tooltip : this.isBooleanItem(item) ? t(`filament-tables::table.columns.icon.boolean.${item === true || item === 1 || item === '1' ? 'true' : 'false'}`) : (typeof item === 'string' || typeof item === 'number') ? String(item) : null;
|
|
983
|
+
return `${icon(name, { size: (size && size !== 'base' ? size : 'lg'), attributes })}${alternative ? `<span class="fi-sr-only">${e(alternative)}</span>` : ''}`;
|
|
984
|
+
}).join('');
|
|
985
|
+
return `<div class="${cls([...base, this._listWithLineBreaks ? 'fi-ta-icon-has-line-breaks' : null, this._wrap ? 'fi-wrapped' : null])}">${html}</div>`;
|
|
412
986
|
}
|
|
413
987
|
}
|
|
414
988
|
const isBlank = (value) => value === null || value === undefined || (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0);
|
|
@@ -468,6 +1042,8 @@ export class EditableColumn extends Column {
|
|
|
468
1042
|
* Error message, or `null` if the value is valid. An empty value is only checked by `required`, and several errors
|
|
469
1043
|
* produce the first message followed by "(and N more errors)".
|
|
470
1044
|
*/
|
|
1045
|
+
/** Validation of a value for a given record (column types whose rules depend on the record override it). */
|
|
1046
|
+
async validateFor(_record, input) { return this.validate(input); }
|
|
471
1047
|
validate(input) {
|
|
472
1048
|
const attribute = this.getValidationAttribute();
|
|
473
1049
|
const blank = isBlank(input);
|
|
@@ -530,18 +1106,20 @@ export class EditableColumn extends Column {
|
|
|
530
1106
|
return state;
|
|
531
1107
|
}
|
|
532
1108
|
/** `x-tooltip` expression of editable controls: the column tooltip, replaced by the validation error when there is one. */
|
|
533
|
-
tooltipExpression(record) {
|
|
1109
|
+
tooltipExpression(record, extra = null) {
|
|
534
1110
|
const tooltip = typeof this._tooltip === 'function' ? this._tooltip(record) : this._tooltip;
|
|
1111
|
+
const more = extra ? `
|
|
1112
|
+
${extra}` : '';
|
|
535
1113
|
return `error === undefined ? ${tooltip !== null && tooltip !== '' ? `{
|
|
536
1114
|
content: ${js(tooltip)},
|
|
537
1115
|
theme: $store.theme,
|
|
538
1116
|
allowHTML: false,
|
|
539
|
-
aria: { content: 'auto' }
|
|
1117
|
+
aria: { content: 'auto' },${more}
|
|
540
1118
|
}` : 'false'} : {
|
|
541
1119
|
content: error,
|
|
542
1120
|
theme: $store.theme,
|
|
543
1121
|
allowHTML: false,
|
|
544
|
-
aria: { content: null }
|
|
1122
|
+
aria: { content: null },${more}
|
|
545
1123
|
}`;
|
|
546
1124
|
}
|
|
547
1125
|
/** Shared opening markup of editable cells: lazily loaded Alpine component, server state and record key. */
|
|
@@ -562,30 +1140,95 @@ export class EditableColumn extends Column {
|
|
|
562
1140
|
}
|
|
563
1141
|
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
1142
|
}
|
|
565
|
-
|
|
566
|
-
* In-cell editable select (native select only). The received value must be one of the declared options.
|
|
567
|
-
*/
|
|
1143
|
+
const normalizeSelectOptions = (options) => (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
|
|
568
1144
|
export class SelectColumn extends EditableColumn {
|
|
569
1145
|
_options = [];
|
|
570
1146
|
_selectablePlaceholder = true;
|
|
1147
|
+
_native = true;
|
|
1148
|
+
_searchable = false;
|
|
1149
|
+
_disabledOptions = [];
|
|
1150
|
+
_placeholder = null;
|
|
1151
|
+
_optionsLimit = 50;
|
|
1152
|
+
_canOptionLabelsWrap = true;
|
|
1153
|
+
_position = null;
|
|
1154
|
+
_inline = false;
|
|
571
1155
|
static make(name) { return configured(new SelectColumn(name)); }
|
|
572
|
-
|
|
573
|
-
|
|
1156
|
+
/** `{ value: label }` pairs, `[value, label]` pairs, or a function of the record returning either. */
|
|
1157
|
+
options(options) { this._options = typeof options === 'function' ? options : normalizeSelectOptions(options); return this; }
|
|
1158
|
+
/** Static options (empty when they depend on the record: use `getOptionsFor()`). */
|
|
1159
|
+
getOptions() { return typeof this._options === 'function' ? [] : this._options; }
|
|
1160
|
+
async getOptionsFor(record) {
|
|
1161
|
+
if (this._optionsRelationship)
|
|
1162
|
+
return (this._relationshipOptions ??= loadRelationshipOptions(record?.constructor, this._optionsRelationship.name, this._optionsRelationship.title, this._optionsRelationship.modify));
|
|
1163
|
+
return typeof this._options === 'function' ? normalizeSelectOptions(await this._options(record)) : this._options;
|
|
1164
|
+
}
|
|
1165
|
+
_optionsRelationship = null;
|
|
1166
|
+
_relationshipOptions = null;
|
|
1167
|
+
/**
|
|
1168
|
+
* Options from the records of a relationship of the record's model (an Objection model with `relationMappings`): the
|
|
1169
|
+
* column stores the picked record's key, e.g. `SelectColumn.make('author_id').optionsRelationship('author', 'name')`.
|
|
1170
|
+
* Up to 1,000 options are listed; any related record's key is accepted when saving.
|
|
1171
|
+
*/
|
|
1172
|
+
optionsRelationship(name, titleAttribute = null, modifyQueryUsing = null) {
|
|
1173
|
+
this._optionsRelationship = { name, title: titleAttribute, modify: modifyQueryUsing };
|
|
1174
|
+
this._relationshipOptions = null;
|
|
1175
|
+
return this;
|
|
1176
|
+
}
|
|
574
1177
|
selectablePlaceholder(condition = true) { this._selectablePlaceholder = condition; return this; }
|
|
1178
|
+
/** `false`: a JavaScript select instead of the browser's native one. */
|
|
1179
|
+
native(condition = true) { this._native = condition; return this; }
|
|
1180
|
+
_allowHtml = false;
|
|
1181
|
+
/** Option labels are HTML, sanitized on the server (JavaScript select only; the native select shows their text). */
|
|
1182
|
+
allowHtml(condition = true) { this._allowHtml = condition; return this; }
|
|
1183
|
+
labelForDisplay(label) { return !this._allowHtml ? label : this.isNative() ? label.replace(/<[^>]*>/g, '') : sanitizeHtml(label); }
|
|
1184
|
+
/** A search field in the select (implies a JavaScript select). */
|
|
1185
|
+
searchableOptions(condition = true) { this._searchable = condition; return this; }
|
|
1186
|
+
isNative() { return this._native && !this._searchable; }
|
|
1187
|
+
/** Options that cannot be chosen. A disabled option is also refused on the server. */
|
|
1188
|
+
disableOptionWhen(callback) { this._disabledOptions.push(callback); return this; }
|
|
1189
|
+
isOptionDisabled(value, label, record) { return this._disabledOptions.some((callback) => callback(value, label, record)); }
|
|
1190
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
1191
|
+
/** Maximum number of options listed by the JavaScript select (50 by default). */
|
|
1192
|
+
optionsLimit(limit) { this._optionsLimit = limit; return this; }
|
|
1193
|
+
/** `false`: long option labels are truncated in the JavaScript select instead of wrapping. */
|
|
1194
|
+
allowOptionsLabelsWrap(condition = true) { this._canOptionLabelsWrap = condition; return this; }
|
|
1195
|
+
/** Side where the JavaScript select's dropdown opens (automatic by default). */
|
|
1196
|
+
position(position) { this._position = position; return this; }
|
|
1197
|
+
inline(condition = true) { this._inline = condition; return this; }
|
|
575
1198
|
getImplicitRules() {
|
|
576
|
-
|
|
1199
|
+
// Options that depend on the record are checked by `validateFor()`.
|
|
1200
|
+
if (typeof this._options === 'function' || this._optionsRelationship)
|
|
1201
|
+
return [];
|
|
1202
|
+
const options = this._options;
|
|
1203
|
+
return [{ name: 'in', passes: (v) => (typeof v === 'string' || typeof v === 'number') && options.some(([value]) => value === String(v)), message: (a) => `The selected ${a} is invalid.` }];
|
|
1204
|
+
}
|
|
1205
|
+
/** The value must be one of the record's options, and not a disabled one. */
|
|
1206
|
+
async validateFor(record, input) {
|
|
1207
|
+
if (!isBlank(input)) {
|
|
1208
|
+
// Relationship options: the received key must be one of the related model's records (in the modified query).
|
|
1209
|
+
const options = this._optionsRelationship && (typeof input === 'string' || typeof input === 'number')
|
|
1210
|
+
? await loadRelationshipOptions(record?.constructor, this._optionsRelationship.name, this._optionsRelationship.title, this._optionsRelationship.modify, [String(input)])
|
|
1211
|
+
: await this.getOptionsFor(record);
|
|
1212
|
+
const option = (typeof input === 'string' || typeof input === 'number') ? options.find(([value]) => value === String(input)) : undefined;
|
|
1213
|
+
if (!option || this.isOptionDisabled(option[0], option[1], record))
|
|
1214
|
+
return this._validationMessages.in ?? `The selected ${this.getValidationAttribute()} is invalid.`;
|
|
1215
|
+
}
|
|
1216
|
+
return this.validate(input);
|
|
577
1217
|
}
|
|
578
1218
|
async toHtml(record, recordKey = '') {
|
|
579
1219
|
const state = await this.getState(record);
|
|
580
1220
|
const value = state === null || state === undefined ? '' : String(state);
|
|
581
|
-
const
|
|
1221
|
+
const options = await this.getOptionsFor(record);
|
|
1222
|
+
const found = options.find(([option]) => option === value)?.[1] ?? null;
|
|
1223
|
+
const optionLabel = found === null ? null : this.labelForDisplay(found);
|
|
582
1224
|
const isDisabled = await this.isDisabledFor(record);
|
|
583
|
-
const
|
|
584
|
-
const
|
|
1225
|
+
const isNative = this.isNative();
|
|
1226
|
+
const placeholder = this._placeholder ?? t('filament-forms::components.select.placeholder');
|
|
1227
|
+
const optionsForJs = isNative ? [] : options.map(([option, label]) => ({ label: this.labelForDisplay(label), value: option, isDisabled: this.isOptionDisabled(option, label, record) }));
|
|
585
1228
|
const config = `selectTableColumn({
|
|
586
1229
|
accessibilityAttributes: [],
|
|
587
1230
|
ariaLabel: ${js(this.getLabel())},
|
|
588
|
-
canOptionLabelsWrap:
|
|
1231
|
+
canOptionLabelsWrap: ${js(this._canOptionLabelsWrap)},
|
|
589
1232
|
canSelectPlaceholder: ${js(this._selectablePlaceholder)},
|
|
590
1233
|
clearButtonLabel: ${js(t('filament-forms::components.select.actions.clear.label'))},
|
|
591
1234
|
errorMessage: ${js(t('filament-forms::components.select.error_message'))},
|
|
@@ -594,18 +1237,18 @@ export class SelectColumn extends EditableColumn {
|
|
|
594
1237
|
hasInitialNoOptionsMessage: true,
|
|
595
1238
|
initialOptionLabel: ${js(optionLabel)},
|
|
596
1239
|
isDisabled: ${js(isDisabled)},
|
|
597
|
-
isHtmlAllowed:
|
|
598
|
-
isNative:
|
|
599
|
-
isSearchable:
|
|
1240
|
+
isHtmlAllowed: ${js(this._allowHtml && !isNative)},
|
|
1241
|
+
isNative: ${js(isNative)},
|
|
1242
|
+
isSearchable: ${js(this._searchable)},
|
|
600
1243
|
loadingMessage: ${js(t('filament-forms::components.select.loading_message'))},
|
|
601
|
-
messages: ${js(
|
|
1244
|
+
messages: ${js(this.getUpdateStateMessages())},
|
|
602
1245
|
name: ${js(this.name)},
|
|
603
1246
|
noOptionsMessage: ${js(t('filament-forms::components.select.no_options_message'))},
|
|
604
1247
|
noSearchResultsMessage: ${js(t('filament-forms::components.select.no_search_results_message'))},
|
|
605
|
-
options:
|
|
606
|
-
optionsLimit:
|
|
1248
|
+
options: ${js(optionsForJs)},
|
|
1249
|
+
optionsLimit: ${js(this._optionsLimit)},
|
|
607
1250
|
placeholder: ${js(placeholder)},
|
|
608
|
-
position:
|
|
1251
|
+
position: ${js(this._position)},
|
|
609
1252
|
recordKey: ${js(String(recordKey))},
|
|
610
1253
|
searchableOptionFields: ${js(['label'])},
|
|
611
1254
|
searchDebounce: 1000,
|
|
@@ -614,23 +1257,20 @@ export class SelectColumn extends EditableColumn {
|
|
|
614
1257
|
searchPrompt: ${js(t('filament-forms::components.select.search_prompt'))},
|
|
615
1258
|
state: ${js(state === null || state === undefined ? null : value)},
|
|
616
1259
|
})`;
|
|
1260
|
+
const tooltip = this.tooltipExpression(record, isNative ? null : 'triggerTarget: $el.isConnected && select ? select.selectButton : null,');
|
|
617
1261
|
const select = new Attributes().merge({
|
|
618
1262
|
'aria-label': e(this.getLabel()), disabled: true, 'wire:loading.attr': 'disabled', 'wire:target': LOADING_TARGETS,
|
|
619
1263
|
'x-bind:aria-invalid': "error ? 'true' : null", 'x-bind:aria-busy': "isLoading ? 'true' : null",
|
|
620
1264
|
'x-bind:aria-describedby': "getAccessibilityAttributes([])[ 'aria-describedby' ]", 'x-bind:disabled': isDisabled ? null : 'isLoading', 'x-on:change': 'clearError()',
|
|
621
|
-
'x-tooltip':
|
|
622
|
-
content: error,
|
|
623
|
-
theme: $store.theme,
|
|
624
|
-
allowHTML: false,
|
|
625
|
-
aria: { content: null },
|
|
626
|
-
|
|
627
|
-
}`,
|
|
1265
|
+
'x-tooltip': tooltip,
|
|
628
1266
|
}).class(['fi-select-input']);
|
|
629
|
-
const
|
|
630
|
-
|
|
1267
|
+
const control = isNative
|
|
1268
|
+
? `<select x-model="state" ${select.toHtml()}>${this._selectablePlaceholder ? `<option value="">${e(placeholder)}</option>` : ''}${options.map(([option, label]) => `<option${this.isOptionDisabled(option, label, record) ? ' disabled' : ''} value="${e(option)}">${e(this.labelForDisplay(label))}</option>`).join('')}</select>`
|
|
1269
|
+
: '<div wire:ignore class="fi-select-input"><div x-ref="select"></div></div>';
|
|
1270
|
+
return `<div wire:ignore.self x-load="x-load" x-load-src="${e(views.render('filament-tables::asset', { path: 'columns/select' }).trim())}" x-data="${e(config)}" x-id="['table-column-error']" x-on:click.capture="preventEdit($event)" x-on:keydown.capture="preventEdit($event)" class="${cls(['fi-ta-select', this._inline ? 'fi-inline' : null])}"><input type="hidden" value="${e(value)}" data-option-label="${e(optionLabel ?? '')}" data-disabled="${isDisabled ? 'true' : 'false'}" x-ref="serverState" /><div x-bind:class="{
|
|
631
1271
|
'fi-disabled': isLoading || ${js(isDisabled)},
|
|
632
1272
|
'fi-invalid': error !== undefined,
|
|
633
|
-
}" x-on:click.prevent.stop
|
|
1273
|
+
}" x-on:click.prevent.stop${isNative ? '' : ` x-tooltip="${e(tooltip)}"`} class="fi-input-wrp">${control}</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>`;
|
|
634
1274
|
}
|
|
635
1275
|
}
|
|
636
1276
|
/** Boolean value received from the browser (already checked by the `boolean` rule): `true`, `false` or `null`. */
|
|
@@ -790,120 +1430,402 @@ export async function updateTableColumnState(table, host, adapter, columnName, r
|
|
|
790
1430
|
const record = (await adapter.keysInScope([String(recordKey)])).length ? await adapter.find(String(recordKey)) : null;
|
|
791
1431
|
if (!record || (options.authorize && !(await options.authorize(record))) || (await column.isDisabledFor(record)))
|
|
792
1432
|
return unavailable;
|
|
793
|
-
const error = column.
|
|
1433
|
+
const error = await column.validateFor(record, input);
|
|
794
1434
|
if (error !== null)
|
|
795
1435
|
return { error };
|
|
796
1436
|
return column.updateState(record, input, adapter);
|
|
797
1437
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
static make() { return configured(new TrashedFilter()); }
|
|
802
|
-
getLabel() { return t('filament-tables::table.filters.trashed.label'); }
|
|
803
|
-
getDefaultState() { return { value: null }; }
|
|
804
|
-
getFormDefinition() {
|
|
805
|
-
return { placeholder: t('filament-tables::table.filters.trashed.without_trashed'), options: [['1', t('filament-tables::table.filters.trashed.with_trashed')], ['0', t('filament-tables::table.filters.trashed.only_trashed')]] };
|
|
806
|
-
}
|
|
807
|
-
isActive(state) { return ['1', '0'].includes(String(state.value)); }
|
|
808
|
-
apply(query, state) {
|
|
809
|
-
query.trashed = String(state.value) === '1' ? 'with' : String(state.value) === '0' ? 'only' : 'without';
|
|
810
|
-
}
|
|
811
|
-
}
|
|
1438
|
+
const FILTERS_LAYOUTS = ['dropdown', 'modal', 'above-content', 'above-content-collapsible', 'below-content', 'before-content', 'before-content-collapsible', 'after-content', 'after-content-collapsible'];
|
|
1439
|
+
/** Widths accepted for the filters dropdown, modal or side panel. */
|
|
1440
|
+
const FILTERS_WIDTHS = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', 'full', 'screen-sm', 'screen-md', 'screen-lg', 'screen-xl', 'screen-2xl', 'screen'];
|
|
812
1441
|
const filterLabel = (name) => name.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
|
813
|
-
/**
|
|
814
|
-
export class
|
|
1442
|
+
/** Badge shown above the table for an applied filter. */
|
|
1443
|
+
export class Indicator {
|
|
1444
|
+
_label;
|
|
1445
|
+
_removeField = null;
|
|
1446
|
+
_removable = true;
|
|
1447
|
+
_color = null;
|
|
1448
|
+
constructor(label) { this._label = label; }
|
|
1449
|
+
static make(label) { return new Indicator(label); }
|
|
1450
|
+
label(label) { this._label = label; return this; }
|
|
1451
|
+
getLabel() { return this._label; }
|
|
1452
|
+
/** Field of the filter form cleared when the indicator is removed (by default, the whole filter is reset). */
|
|
1453
|
+
removeField(field) { this._removeField = field; return this; }
|
|
1454
|
+
getRemoveField() { return this._removeField; }
|
|
1455
|
+
/** `false`: no remove button. A forged removal is refused on the server too. */
|
|
1456
|
+
removable(condition = true) { this._removable = condition; return this; }
|
|
1457
|
+
isRemovable() { return this._removable; }
|
|
1458
|
+
color(color) { this._color = color; return this; }
|
|
1459
|
+
getColor() { return this._color; }
|
|
1460
|
+
}
|
|
1461
|
+
const toIndicators = (result) => {
|
|
1462
|
+
if (result === null || result === undefined || result === '')
|
|
1463
|
+
return [];
|
|
1464
|
+
const one = (item, field) => {
|
|
1465
|
+
const indicator = item instanceof Indicator ? item : typeof item === 'string' && item !== '' ? Indicator.make(item) : null;
|
|
1466
|
+
if (!indicator)
|
|
1467
|
+
return null;
|
|
1468
|
+
return { label: indicator.getLabel(), field: indicator.getRemoveField() ?? field ?? '', removable: indicator.isRemovable(), color: indicator.getColor() };
|
|
1469
|
+
};
|
|
1470
|
+
if (typeof result === 'string' || result instanceof Indicator)
|
|
1471
|
+
return [one(result, null)].filter((i) => i !== null);
|
|
1472
|
+
if (Array.isArray(result))
|
|
1473
|
+
return result.map((item) => one(item, null)).filter((i) => i !== null);
|
|
1474
|
+
return Object.entries(result).map(([field, item]) => one(item, field)).filter((i) => i !== null);
|
|
1475
|
+
};
|
|
1476
|
+
/** A received select value: a string of at most 255 characters. */
|
|
1477
|
+
const filterValue = (value) => ((typeof value === 'string' || typeof value === 'number') && String(value) !== '' ? String(value).slice(0, 255) : null);
|
|
1478
|
+
/** Shared options of the built-in filters: label, default state, indicators and custom query. */
|
|
1479
|
+
class BaseFilter {
|
|
815
1480
|
name;
|
|
816
1481
|
_label = null;
|
|
1482
|
+
_default = undefined;
|
|
1483
|
+
_indicator = null;
|
|
1484
|
+
_indicateUsing = null;
|
|
1485
|
+
_query = null;
|
|
1486
|
+
_columnSpan = { default: 1, lg: 1 };
|
|
1487
|
+
constructor(name) { this.name = name; }
|
|
1488
|
+
label(label) { this._label = label; return this; }
|
|
1489
|
+
getLabel() { return this._label ?? filterLabel(this.name); }
|
|
1490
|
+
/** State applied when the table opens and when the user clicks "Reset". */
|
|
1491
|
+
default(value = true) { this._default = value; return this; }
|
|
1492
|
+
/** Label of the indicator (the filter label by default). */
|
|
1493
|
+
indicator(indicator) { this._indicator = indicator; return this; }
|
|
1494
|
+
getIndicator() { return this._indicator ?? this.getLabel(); }
|
|
1495
|
+
/** Indicators of the applied state, replacing the default one. */
|
|
1496
|
+
indicateUsing(callback) { this._indicateUsing = callback; return this; }
|
|
1497
|
+
/** Query condition built from the sanitized state, replacing the default one. */
|
|
1498
|
+
query(modifier) { this._query = modifier; return this; }
|
|
1499
|
+
/** Width of the filter in the filters form grid. */
|
|
1500
|
+
columnSpan(span) { this._columnSpan = typeof span === 'object' ? span : { default: span }; return this; }
|
|
1501
|
+
columnSpanFull() { return this.columnSpan('full'); }
|
|
1502
|
+
getColumnSpan() { return this._columnSpan; }
|
|
1503
|
+
getFormDefinition() { return { placeholder: '', options: [] }; }
|
|
1504
|
+
defaultIndicators(_data) { return null; }
|
|
1505
|
+
getIndicators(state) {
|
|
1506
|
+
const data = this.sanitizeState(state);
|
|
1507
|
+
return toIndicators(this._indicateUsing ? this._indicateUsing(data) : this.defaultIndicators(data));
|
|
1508
|
+
}
|
|
1509
|
+
/** Prefixes a value label with the indicator, unless the indicator is a ready-made `Indicator`. */
|
|
1510
|
+
labelled(valueLabel) {
|
|
1511
|
+
const indicator = this.getIndicator();
|
|
1512
|
+
return indicator instanceof Indicator ? indicator : `${indicator}: ${valueLabel}`;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
/** Value of the "None" option of a relationship select filter. */
|
|
1516
|
+
const EMPTY_RELATIONSHIP_OPTION = '__empty';
|
|
1517
|
+
/** Filters on a column value picked from a list. */
|
|
1518
|
+
export class SelectFilter extends BaseFilter {
|
|
817
1519
|
_column = null;
|
|
818
1520
|
_options = [];
|
|
819
|
-
|
|
1521
|
+
_multiple = false;
|
|
1522
|
+
_searchable = false;
|
|
1523
|
+
_selectablePlaceholder = true;
|
|
1524
|
+
_placeholder = null;
|
|
1525
|
+
_optionLabels = new Map();
|
|
820
1526
|
static make(name) { return configured(new SelectFilter(name)); }
|
|
821
|
-
label(label) { this._label = label; return this; }
|
|
822
1527
|
/** Filtered column when it differs from the filter name (e.g. a relationship's foreign key). */
|
|
823
1528
|
column(column) { this._column = column; return this; }
|
|
1529
|
+
attribute(column) { return this.column(column); }
|
|
1530
|
+
getColumn() { return this._column ?? this.name; }
|
|
824
1531
|
/** Fixed options, or an async function (e.g. records of a relationship). */
|
|
825
1532
|
options(options) {
|
|
826
1533
|
this._options = typeof options === 'function' ? options : (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
|
|
827
1534
|
return this;
|
|
828
1535
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1536
|
+
/** Several values can be picked: records matching any of them are shown. */
|
|
1537
|
+
multiple(condition = true) { this._multiple = condition; return this; }
|
|
1538
|
+
isMultiple() { return this._multiple; }
|
|
1539
|
+
searchable(condition = true) { this._searchable = condition; return this; }
|
|
1540
|
+
/** `false`: the empty option cannot be picked again once a value is chosen. */
|
|
1541
|
+
selectablePlaceholder(condition = true) { this._selectablePlaceholder = condition; return this; }
|
|
1542
|
+
placeholder(placeholder) { this._placeholder = placeholder; return this; }
|
|
1543
|
+
/** No-op: options are always loaded with the form. */
|
|
1544
|
+
preload() { return this; }
|
|
1545
|
+
_relationship = null;
|
|
1546
|
+
_emptyOptionLabel = null;
|
|
1547
|
+
_host = null;
|
|
1548
|
+
/**
|
|
1549
|
+
* Options from the records of a relationship of the table's model (an Objection model with `relationMappings`); records
|
|
1550
|
+
* related to the picked ones are shown. `hasEmptyOption` adds a "None" option for records without related records.
|
|
1551
|
+
*/
|
|
1552
|
+
relationship(name, titleAttribute = null, modifyQueryUsing = null, hasEmptyOption = false) {
|
|
1553
|
+
this._relationship = { name, title: titleAttribute, modify: modifyQueryUsing, empty: hasEmptyOption };
|
|
1554
|
+
return this;
|
|
1555
|
+
}
|
|
1556
|
+
hasRelationship() { return this._relationship !== null; }
|
|
1557
|
+
/** Label of the "None" option of `relationship(..., hasEmptyOption: true)`. */
|
|
1558
|
+
emptyRelationshipOptionLabel(label) { this._emptyOptionLabel = label; return this; }
|
|
1559
|
+
getEmptyRelationshipOptionLabel() { return this._emptyOptionLabel ?? t('filament-tables::table.filters.select.relationship.empty_option_label'); }
|
|
1560
|
+
getFormDefinition() { return { placeholder: this._placeholder ?? t('filament-tables::table.filters.select.placeholder'), options: Array.isArray(this._options) && !this._relationship ? this._options : [] }; }
|
|
1561
|
+
getFormOptions(host = this._host) {
|
|
1562
|
+
if (this._relationship)
|
|
1563
|
+
return this.loadRelationshipOptions(host, null);
|
|
1564
|
+
return typeof this._options === 'function' ? this._options() : Promise.resolve(this._options);
|
|
1565
|
+
}
|
|
1566
|
+
async loadRelationshipOptions(host, keys) {
|
|
1567
|
+
const relation = this._relationship;
|
|
1568
|
+
const options = await loadRelationshipOptions(modelOfHost(host), relation.name, relation.title, relation.modify, keys === null ? null : keys.filter((key) => key !== EMPTY_RELATIONSHIP_OPTION));
|
|
1569
|
+
return relation.empty && (keys === null || keys.includes(EMPTY_RELATIONSHIP_OPTION)) ? [[EMPTY_RELATIONSHIP_OPTION, this.getEmptyRelationshipOptionLabel()], ...options] : options;
|
|
1570
|
+
}
|
|
833
1571
|
/** Options are evaluated on every render, so the select gets a key derived from them (forces a re-render when they change). */
|
|
834
1572
|
hasDynamicOptions = true;
|
|
835
|
-
|
|
1573
|
+
getFormComponents() {
|
|
1574
|
+
const select = Select.make(this._multiple ? 'values' : 'value').label(this.getLabel()).placeholder(this.getFormDefinition().placeholder).options((ctx) => this.getFormOptions(ctx?.livewire));
|
|
1575
|
+
if (this._multiple)
|
|
1576
|
+
select.multiple();
|
|
1577
|
+
if (this._searchable)
|
|
1578
|
+
select.searchable();
|
|
1579
|
+
if (!this._selectablePlaceholder)
|
|
1580
|
+
select.selectablePlaceholder(false);
|
|
1581
|
+
return [select];
|
|
1582
|
+
}
|
|
1583
|
+
defaultValues() { return (Array.isArray(this._default) ? this._default : this._default === undefined || this._default === null ? [] : [this._default]).map(filterValue).filter((v) => v !== null); }
|
|
1584
|
+
getResetState() { return this._multiple ? { values: [] } : { value: null }; }
|
|
1585
|
+
getDefaultState() { const values = this.defaultValues(); return this._multiple ? { values } : { value: values[0] ?? null }; }
|
|
1586
|
+
sanitizeState(state) {
|
|
1587
|
+
const received = state && typeof state === 'object' ? state : {};
|
|
1588
|
+
if (!this._multiple)
|
|
1589
|
+
return { value: filterValue(received.value) };
|
|
1590
|
+
const values = Array.isArray(received.values) ? received.values : [];
|
|
1591
|
+
return { values: [...new Set(values.map(filterValue).filter((v) => v !== null))].slice(0, 100) };
|
|
1592
|
+
}
|
|
1593
|
+
/** Values that can filter: with fixed options, only offered values; loaded options are bound parameters (an unknown value matches nothing). */
|
|
1594
|
+
values(state) {
|
|
1595
|
+
const data = this.sanitizeState(state);
|
|
1596
|
+
const values = this._multiple ? data.values : data.value === null ? [] : [data.value];
|
|
1597
|
+
// Relationship or loaded options: the keys are bound parameters, an unknown key matches nothing.
|
|
1598
|
+
return Array.isArray(this._options) && !this._relationship ? values.filter((value) => this._options.some(([option]) => option === value)) : values;
|
|
1599
|
+
}
|
|
1600
|
+
isActive(state) { return this.values(state).length > 0; }
|
|
836
1601
|
apply(query, state) {
|
|
837
|
-
|
|
1602
|
+
const values = this.values(state);
|
|
1603
|
+
if (!values.length)
|
|
838
1604
|
return;
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
1605
|
+
const data = this.sanitizeState(state);
|
|
1606
|
+
if (this._query) {
|
|
1607
|
+
(query.modifiers ??= []).push((q) => { this._query(q, data); });
|
|
842
1608
|
return;
|
|
843
|
-
|
|
844
|
-
(
|
|
1609
|
+
}
|
|
1610
|
+
if (this._relationship) {
|
|
1611
|
+
const { name, modify } = this._relationship;
|
|
1612
|
+
const keys = values.filter((value) => value !== EMPTY_RELATIONSHIP_OPTION);
|
|
1613
|
+
const empty = this._relationship.empty && values.includes(EMPTY_RELATIONSHIP_OPTION);
|
|
1614
|
+
// Records related to one of the picked records, or (the "None" option) without related records.
|
|
1615
|
+
(query.modifiers ??= []).push((q) => {
|
|
1616
|
+
q.where((inner) => {
|
|
1617
|
+
if (keys.length)
|
|
1618
|
+
inner.orWhere((related) => applyConditions(related, [{ kind: 'related', relationship: name, keys, negate: false, ...(modify ? { modify } : {}) }]));
|
|
1619
|
+
if (empty)
|
|
1620
|
+
inner.orWhere((none) => applyConditions(none, [{ kind: 'related', relationship: name, keys: null, negate: true, ...(modify ? { modify } : {}) }]));
|
|
1621
|
+
});
|
|
1622
|
+
});
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
const column = this.getColumn();
|
|
1626
|
+
(query.modifiers ??= []).push((q) => { if (this._multiple)
|
|
1627
|
+
q.whereIn(column, values);
|
|
1628
|
+
else
|
|
1629
|
+
q.where(column, values[0]); });
|
|
1630
|
+
}
|
|
1631
|
+
/** Labels of the applied values, loaded before the indicators are computed. */
|
|
1632
|
+
async loadIndicatorData(state, host) {
|
|
1633
|
+
const values = this.values(state);
|
|
1634
|
+
if (!values.length)
|
|
1635
|
+
return;
|
|
1636
|
+
this._optionLabels = new Map(this._relationship ? await this.loadRelationshipOptions(host, values) : await this.getFormOptions());
|
|
1637
|
+
}
|
|
1638
|
+
defaultIndicators(data) {
|
|
1639
|
+
const labels = this.values(data).map((value) => this._optionLabels.get(value) ?? (Array.isArray(this._options) ? this._options.find(([option]) => option === value)?.[1] : undefined)).filter((label) => label !== undefined);
|
|
1640
|
+
if (!labels.length)
|
|
1641
|
+
return null;
|
|
1642
|
+
const joined = labels.length > 1 ? `${labels.slice(0, -1).join(', ')} & ${labels[labels.length - 1]}` : labels[0];
|
|
1643
|
+
return [this.labelled(joined)];
|
|
845
1644
|
}
|
|
846
1645
|
}
|
|
847
|
-
/** Three-state filter on a boolean column
|
|
848
|
-
export class TernaryFilter {
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1646
|
+
/** Three-state filter: all, yes, no. By default on a boolean column; `nullable()` checks whether the column is filled. */
|
|
1647
|
+
export class TernaryFilter extends BaseFilter {
|
|
1648
|
+
_column = null;
|
|
1649
|
+
_trueLabel = null;
|
|
1650
|
+
_falseLabel = null;
|
|
1651
|
+
_placeholder = null;
|
|
1652
|
+
_nullable = false;
|
|
1653
|
+
_queries = null;
|
|
852
1654
|
static make(name) { return configured(new TernaryFilter(name)); }
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1655
|
+
attribute(column) { this._column = column; return this; }
|
|
1656
|
+
column(column) { return this.attribute(column); }
|
|
1657
|
+
getColumn() { return this._column ?? this.name; }
|
|
1658
|
+
trueLabel(label) { this._trueLabel = label; return this; }
|
|
1659
|
+
falseLabel(label) { this._falseLabel = label; return this; }
|
|
1660
|
+
placeholder(label) { this._placeholder = label; return this; }
|
|
1661
|
+
getTrueLabel() { return this._trueLabel ?? t('filament-forms::components.select.boolean.true'); }
|
|
1662
|
+
getFalseLabel() { return this._falseLabel ?? t('filament-forms::components.select.boolean.false'); }
|
|
1663
|
+
/** "Yes" shows records where the column is filled, "No" those where it is empty. */
|
|
1664
|
+
nullable(condition = true) { this._nullable = condition; return this; }
|
|
1665
|
+
/** Custom conditions for "yes", "no" and, optionally, the empty choice. */
|
|
1666
|
+
queries(queries) { this._queries = queries; return this; }
|
|
1667
|
+
getFormDefinition() { return { placeholder: this._placeholder ?? '-', options: [['1', this.getTrueLabel()], ['0', this.getFalseLabel()]] }; }
|
|
1668
|
+
getFormComponents() {
|
|
1669
|
+
const definition = this.getFormDefinition();
|
|
1670
|
+
return [Select.make('value').label(this.getLabel()).placeholder(definition.placeholder).options(definition.options)];
|
|
1671
|
+
}
|
|
1672
|
+
getResetState() { return { value: null }; }
|
|
1673
|
+
getDefaultState() { return { value: this._default === true || this._default === 1 || this._default === '1' ? '1' : this._default === false || this._default === 0 || this._default === '0' ? '0' : null }; }
|
|
1674
|
+
sanitizeState(state) {
|
|
1675
|
+
const value = state && typeof state === 'object' ? String(state.value ?? '') : '';
|
|
1676
|
+
return { value: value === '1' || value === '0' ? value : null };
|
|
1677
|
+
}
|
|
1678
|
+
isActive(state) { return this.sanitizeState(state).value !== null; }
|
|
858
1679
|
apply(query, state) {
|
|
859
|
-
|
|
1680
|
+
const data = this.sanitizeState(state);
|
|
1681
|
+
const value = data.value;
|
|
1682
|
+
if (this._query) {
|
|
1683
|
+
(query.modifiers ??= []).push((q) => { this._query(q, data); });
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
if (this._queries) {
|
|
1687
|
+
const modifier = value === '1' ? this._queries.true : value === '0' ? this._queries.false : this._queries.blank;
|
|
1688
|
+
if (modifier)
|
|
1689
|
+
(query.modifiers ??= []).push((q) => { modifier(q); });
|
|
860
1690
|
return;
|
|
861
|
-
|
|
862
|
-
|
|
1691
|
+
}
|
|
1692
|
+
if (value === null)
|
|
1693
|
+
return;
|
|
1694
|
+
const column = this.getColumn(), on = value === '1';
|
|
1695
|
+
(query.modifiers ??= []).push((q) => { if (this._nullable) {
|
|
1696
|
+
if (on)
|
|
1697
|
+
q.whereNotNull(column);
|
|
1698
|
+
else
|
|
1699
|
+
q.whereNull(column);
|
|
1700
|
+
}
|
|
1701
|
+
else
|
|
1702
|
+
q.where(column, on ? 1 : 0); });
|
|
1703
|
+
}
|
|
1704
|
+
defaultIndicators(data) {
|
|
1705
|
+
if (data.value === null)
|
|
1706
|
+
return null;
|
|
1707
|
+
return [this.labelled(data.value === '1' ? this.getTrueLabel() : this.getFalseLabel())];
|
|
863
1708
|
}
|
|
864
1709
|
}
|
|
865
|
-
/**
|
|
866
|
-
export class
|
|
867
|
-
name;
|
|
868
|
-
|
|
1710
|
+
/** "Trashed" filter for soft-deletable models: without (default), with, or only deleted records. */
|
|
1711
|
+
export class TrashedFilter extends TernaryFilter {
|
|
1712
|
+
constructor(name = 'trashed') { super(name); }
|
|
1713
|
+
static make(name = 'trashed') { return configured(new TrashedFilter(name)); }
|
|
1714
|
+
getLabel() { return this._label ?? t('filament-tables::table.filters.trashed.label'); }
|
|
1715
|
+
getTrueLabel() { return this._trueLabel ?? t('filament-tables::table.filters.trashed.with_trashed'); }
|
|
1716
|
+
getFalseLabel() { return this._falseLabel ?? t('filament-tables::table.filters.trashed.only_trashed'); }
|
|
1717
|
+
getFormDefinition() { return { placeholder: this._placeholder ?? t('filament-tables::table.filters.trashed.without_trashed'), options: [['1', this.getTrueLabel()], ['0', this.getFalseLabel()]] }; }
|
|
1718
|
+
apply(query, state) {
|
|
1719
|
+
const value = this.sanitizeState(state).value;
|
|
1720
|
+
query.trashed = value === '1' ? 'with' : value === '0' ? 'only' : 'without';
|
|
1721
|
+
}
|
|
1722
|
+
defaultIndicators(data) {
|
|
1723
|
+
if (data.value === null)
|
|
1724
|
+
return null;
|
|
1725
|
+
return [data.value === '1' ? this.getTrueLabel() : this.getFalseLabel()];
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* Custom filter. Without `schema()`, it is a checkbox (or a toggle with `toggle()`) and `query()` runs when it is checked.
|
|
1730
|
+
* With `schema()`, the query and indicators are written by the application.
|
|
1731
|
+
*/
|
|
1732
|
+
export class Filter extends BaseFilter {
|
|
869
1733
|
_components = [];
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
constructor(name) { this.name = name; }
|
|
1734
|
+
_toggle = false;
|
|
1735
|
+
_formComponents = null;
|
|
873
1736
|
static make(name) { return configured(new Filter(name)); }
|
|
874
|
-
label(label) { this._label = label; return this; }
|
|
875
1737
|
/** Filter fields; their names are the only state keys accepted from the browser. */
|
|
876
|
-
schema(components) { this._components = components; return this; }
|
|
877
|
-
/**
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1738
|
+
schema(components) { this._components = components; this._formComponents = null; return this; }
|
|
1739
|
+
/** Form fields of a filter without `schema()`: a toggle instead of a checkbox. */
|
|
1740
|
+
toggle(condition = true) { this._toggle = condition; this._formComponents = null; return this; }
|
|
1741
|
+
checkbox() { return this.toggle(false); }
|
|
1742
|
+
hasSchema() { return this._components.length > 0; }
|
|
1743
|
+
getFormComponents() {
|
|
1744
|
+
if (this.hasSchema())
|
|
1745
|
+
return this._components;
|
|
1746
|
+
return (this._formComponents ??= [(this._toggle ? Toggle : Checkbox).make('isActive').label(this.getLabel())]);
|
|
1747
|
+
}
|
|
1748
|
+
fields() { return this.getFormComponents().filter((c) => typeof c?.getName === 'function'); }
|
|
1749
|
+
getResetState() { return this.hasSchema() ? Object.fromEntries(this.fields().map((c) => [String(c.getName()), null])) : { isActive: false }; }
|
|
1750
|
+
getDefaultState() {
|
|
1751
|
+
if (!this.hasSchema())
|
|
1752
|
+
return { isActive: this._default === true };
|
|
1753
|
+
return Object.fromEntries(this.fields().map((c) => {
|
|
1754
|
+
const value = typeof c.getDefaultState === 'function' ? c.getDefaultState() : null;
|
|
1755
|
+
return [String(c.getName()), filterFieldValue(value)];
|
|
1756
|
+
}));
|
|
1757
|
+
}
|
|
886
1758
|
sanitizeState(state) {
|
|
887
1759
|
const received = state && typeof state === 'object' ? state : {};
|
|
888
|
-
|
|
1760
|
+
if (!this.hasSchema())
|
|
1761
|
+
return { isActive: received.isActive === true || received.isActive === 1 || received.isActive === '1' };
|
|
1762
|
+
return Object.fromEntries(this.fields().map((c) => { const name = String(c.getName()); const value = received[name]; return [name, filterFieldValue(value)]; }));
|
|
889
1763
|
}
|
|
890
|
-
|
|
1764
|
+
/** A field left empty, or an unchecked checkbox, does not filter. */
|
|
1765
|
+
isActive(state) { return Object.values(this.sanitizeState(state)).some((value) => value !== null && value !== false); }
|
|
891
1766
|
apply(query, state) {
|
|
892
1767
|
const data = this.sanitizeState(state);
|
|
893
1768
|
if (this._query && this.isActive(data))
|
|
894
1769
|
(query.modifiers ??= []).push((q) => { this._query(q, data); });
|
|
895
1770
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
return
|
|
1771
|
+
defaultIndicators(data) {
|
|
1772
|
+
if (!this.isActive(data))
|
|
1773
|
+
return null;
|
|
1774
|
+
const indicator = this.getIndicator();
|
|
1775
|
+
return [indicator];
|
|
1776
|
+
}
|
|
1777
|
+
removeIndicator(state, field) { return field === '' ? this.getResetState() : { ...this.sanitizeState(state), [field]: null }; }
|
|
1778
|
+
}
|
|
1779
|
+
/** Value of a custom filter field: a scalar, or a list of scalars (multiple select, checkbox list, 100 at most); anything else, empty strings and empty lists become `null`. */
|
|
1780
|
+
function filterFieldValue(value) {
|
|
1781
|
+
if (Array.isArray(value)) {
|
|
1782
|
+
const items = [...new Set(value.filter((item) => (typeof item === 'string' && item !== '') || typeof item === 'number').map(String))].slice(0, 100);
|
|
1783
|
+
return items.length ? items : null;
|
|
901
1784
|
}
|
|
902
|
-
|
|
1785
|
+
return ['string', 'number', 'boolean'].includes(typeof value) && value !== '' ? value : null;
|
|
903
1786
|
}
|
|
1787
|
+
/** Look of a filters action (trigger, apply, reset), changed with `filtersTriggerAction()`, `filtersApplyAction()` and `filtersResetAction()`. */
|
|
1788
|
+
export class FiltersAction {
|
|
1789
|
+
_label = null;
|
|
1790
|
+
_icon = undefined;
|
|
1791
|
+
_color = null;
|
|
1792
|
+
_view = null;
|
|
1793
|
+
_hidden = false;
|
|
1794
|
+
label(label) { this._label = label; return this; }
|
|
1795
|
+
icon(icon) { this._icon = icon; return this; }
|
|
1796
|
+
color(color) { this._color = color; return this; }
|
|
1797
|
+
/** Labeled button. */
|
|
1798
|
+
button() { this._view = 'button'; return this; }
|
|
1799
|
+
link() { this._view = 'link'; return this; }
|
|
1800
|
+
/** Icon only, the label becomes the tooltip and accessible name. */
|
|
1801
|
+
iconButton() { this._view = 'icon-button'; return this; }
|
|
1802
|
+
hidden(condition = true) { this._hidden = condition; return this; }
|
|
1803
|
+
resolve(defaults) {
|
|
1804
|
+
return { label: this._label ?? defaults.label, icon: this._icon === undefined ? defaults.icon : this._icon, color: this._color ?? defaults.color, view: this._view ?? defaults.view, hidden: this._hidden };
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
/** Bulk actions grouped in the "Bulk actions" dropdown of the toolbar (`toolbarActions([BulkActionGroup.make([...])])`). */
|
|
1808
|
+
export class BulkActionGroup {
|
|
1809
|
+
_actions;
|
|
1810
|
+
constructor(actions) { this._actions = actions; }
|
|
1811
|
+
static make(actions) { return configured(new BulkActionGroup(actions)); }
|
|
1812
|
+
getActions() { return this._actions; }
|
|
1813
|
+
}
|
|
1814
|
+
/** Upper bound on the rows loaded by an unpaginated table or the `'all'` page option. */
|
|
1815
|
+
export const MAX_UNPAGINATED_RECORDS = 10000;
|
|
904
1816
|
export class Table {
|
|
1817
|
+
/** Default setup for every table, e.g. `Table.configureUsing((table) => table.paginationPageOptions([10, 25]))`. */
|
|
1818
|
+
static configureUsing(configure) { configureUsing(Table, configure); }
|
|
1819
|
+
// Global settings apply first: options set in `table()` override them.
|
|
1820
|
+
constructor() { configured(this); }
|
|
905
1821
|
_columns = [];
|
|
906
1822
|
_recordAction = null;
|
|
1823
|
+
_pageSummary = true;
|
|
1824
|
+
_allTableSummary = true;
|
|
1825
|
+
/** Which summary rows to show: "this page" (`pageCondition`) and the whole table (`allTableCondition`); group summaries are not affected. */
|
|
1826
|
+
summaries(pageCondition = true, allTableCondition = true) { this._pageSummary = pageCondition; this._allTableSummary = allTableCondition; return this; }
|
|
1827
|
+
hasPageSummary() { return this._pageSummary; }
|
|
1828
|
+
hasAllTableSummary() { return this._allTableSummary; }
|
|
907
1829
|
_recordActions = [];
|
|
908
1830
|
_heading = null;
|
|
909
1831
|
_headerActions = [];
|
|
@@ -914,6 +1836,12 @@ export class Table {
|
|
|
914
1836
|
_defaultSort = null;
|
|
915
1837
|
_recordUrl = null;
|
|
916
1838
|
_pageOptions = [5, 10, 25, 50];
|
|
1839
|
+
_paginated = true;
|
|
1840
|
+
_emptyStateHeading = null;
|
|
1841
|
+
_emptyStateDescription = null;
|
|
1842
|
+
_emptyStateIcon = null;
|
|
1843
|
+
_emptyStateActions = [];
|
|
1844
|
+
_emptyState = null;
|
|
917
1845
|
_stackedOnMobile = false;
|
|
918
1846
|
_label = 'records';
|
|
919
1847
|
_groups = [];
|
|
@@ -965,8 +1893,17 @@ export class Table {
|
|
|
965
1893
|
this._columnsLayout = columns.some((c) => c instanceof ColumnLayout) ? columns.filter((c) => !(c instanceof ColumnGroup)) : null;
|
|
966
1894
|
return this;
|
|
967
1895
|
}
|
|
968
|
-
/** Column layout, or `null` for a regular table. */
|
|
969
|
-
getColumnsLayout() {
|
|
1896
|
+
/** Column layout, or `null` for a regular table. The collapsible component is left out (see `getCollapsibleColumnsLayout()`). */
|
|
1897
|
+
getColumnsLayout() {
|
|
1898
|
+
const collapsible = this.getCollapsibleColumnsLayout();
|
|
1899
|
+
return this._columnsLayout && collapsible ? this._columnsLayout.filter((node) => node !== collapsible) : this._columnsLayout;
|
|
1900
|
+
}
|
|
1901
|
+
/** The first top-level layout component marked `collapsible()`, shown behind a toggle button. */
|
|
1902
|
+
getCollapsibleColumnsLayout() { return this._columnsLayout?.find((node) => node instanceof ColumnLayout && node.isCollapsible()) ?? null; }
|
|
1903
|
+
_contentGrid = null;
|
|
1904
|
+
/** Arranges the record cards of a column layout in a grid: `contentGrid({ md: 2, xl: 3 })`. */
|
|
1905
|
+
contentGrid(columns) { this._contentGrid = columns === null ? null : typeof columns === 'number' ? { lg: columns } : columns; return this; }
|
|
1906
|
+
getContentGrid() { return this._contentGrid; }
|
|
970
1907
|
/** Action mounted when a row is clicked (e.g. `'edit'`), instead of a URL. */
|
|
971
1908
|
recordAction(name) { this._recordAction = name; return this; }
|
|
972
1909
|
getRecordAction() { return this._recordAction; }
|
|
@@ -978,26 +1915,154 @@ export class Table {
|
|
|
978
1915
|
getHeaderActions() { return this._headerActions; }
|
|
979
1916
|
/** Bulk actions ("Bulk actions" menu); if never called, the host provides its default ones. */
|
|
980
1917
|
groupedBulkActions(actions) { this._bulkActions = actions; return this; }
|
|
981
|
-
|
|
1918
|
+
/** Bulk actions: those of `groupedBulkActions()`, then those of the `BulkActionGroup`s of `toolbarActions()`; `null` when none is declared. */
|
|
1919
|
+
getBulkActions() {
|
|
1920
|
+
const groups = this._toolbarActions.filter((action) => action instanceof BulkActionGroup);
|
|
1921
|
+
if (this._bulkActions === null && !groups.length)
|
|
1922
|
+
return null;
|
|
1923
|
+
return [...(this._bulkActions ?? []), ...groups.flatMap((group) => group.getActions())];
|
|
1924
|
+
}
|
|
1925
|
+
_toolbarActions = [];
|
|
1926
|
+
/** Actions in the toolbar, left of the search field: buttons, and a `BulkActionGroup` for the bulk actions dropdown. */
|
|
1927
|
+
toolbarActions(actions) { this._toolbarActions = actions; return this; }
|
|
1928
|
+
/** Toolbar actions other than bulk action groups. */
|
|
1929
|
+
getToolbarActions() { return this._toolbarActions.filter((action) => !(action instanceof BulkActionGroup)); }
|
|
982
1930
|
/** Row actions: `Action`s or an `ActionGroup` (dropdown). */
|
|
983
1931
|
recordActions(actions) { this._recordActions = actions; return this; }
|
|
984
1932
|
getRecordActions() { return this._recordActions; }
|
|
1933
|
+
/** Actions given to columns with `action()` (not those named after a record action). */
|
|
1934
|
+
getColumnActions() { return this.getColumns().map((column) => column.getAction()).filter((action) => action !== null); }
|
|
985
1935
|
/** CSS classes added to a row depending on its record. */
|
|
986
1936
|
recordClasses(resolver) { this._recordClasses = resolver; return this; }
|
|
987
1937
|
getRecordClasses(record) { return this._recordClasses ? this._recordClasses(record) : null; }
|
|
988
1938
|
filters(filters, options = {}) { this._filters = filters; if (options.layout)
|
|
989
|
-
this.
|
|
990
|
-
filtersLayout(layout) {
|
|
1939
|
+
this.filtersLayout(options.layout); return this; }
|
|
1940
|
+
filtersLayout(layout) { if (!FILTERS_LAYOUTS.includes(layout))
|
|
1941
|
+
throw new Error(`Unknown filters layout: ${layout}`); this._filtersLayout = layout; return this; }
|
|
991
1942
|
getFiltersLayout() { return this._filtersLayout; }
|
|
992
|
-
|
|
993
|
-
|
|
1943
|
+
_deferFilters = true;
|
|
1944
|
+
_filtersFormColumns = null;
|
|
1945
|
+
_filtersFormWidth = null;
|
|
1946
|
+
_filtersFormMaxHeight = null;
|
|
1947
|
+
_filtersResetPosition = 'header';
|
|
1948
|
+
_filterIndicatorsHidden = false;
|
|
1949
|
+
_persist = { filters: false, search: false, columnSearches: false, sort: false };
|
|
1950
|
+
_recordActionsPosition = 'after-columns';
|
|
1951
|
+
_recordActionsAlignment = null;
|
|
1952
|
+
/**
|
|
1953
|
+
* Where row actions go: after the columns (default), before the columns (after the checkbox), or before the checkbox
|
|
1954
|
+
* (`before-cells`). `after-cells` is the same as `after-columns`.
|
|
1955
|
+
*/
|
|
1956
|
+
recordActionsPosition(position) { if (['before-columns', 'before-cells', 'after-columns', 'after-cells'].includes(position))
|
|
1957
|
+
this._recordActionsPosition = position; return this; }
|
|
1958
|
+
getRecordActionsPosition() { return this._recordActionsPosition; }
|
|
1959
|
+
/** Alignment of row actions in their cell. */
|
|
1960
|
+
recordActionsAlignment(alignment) { this._recordActionsAlignment = alignment; return this; }
|
|
1961
|
+
getRecordActionsAlignment() { return this._recordActionsAlignment; }
|
|
1962
|
+
_isRecordSelectable = null;
|
|
1963
|
+
_maxSelectableRecords = null;
|
|
1964
|
+
_selectsCurrentPageOnly = false;
|
|
1965
|
+
/** Records for which this returns `false` have no checkbox, and are ignored by bulk actions even if the browser sends them. */
|
|
1966
|
+
checkIfRecordIsSelectableUsing(callback) { this._isRecordSelectable = callback; return this; }
|
|
1967
|
+
hasRecordSelectabilityCheck() { return this._isRecordSelectable !== null; }
|
|
1968
|
+
async isRecordSelectable(record) { return this._isRecordSelectable ? Boolean(await this._isRecordSelectable(record)) : true; }
|
|
1969
|
+
/** Maximum number of records the user may select at once (also enforced on the server). */
|
|
1970
|
+
maxSelectableRecords(count) { this._maxSelectableRecords = count !== null && count > 0 ? Math.floor(count) : null; return this; }
|
|
1971
|
+
getMaxSelectableRecords() { return this._maxSelectableRecords; }
|
|
1972
|
+
/** "Select all" only selects the records of the current page. */
|
|
1973
|
+
selectCurrentPageOnly(condition = true) { this._selectsCurrentPageOnly = condition; return this; }
|
|
1974
|
+
selectsCurrentPageOnly() { return this._selectsCurrentPageOnly; }
|
|
1975
|
+
/** `false`: filters apply as soon as a field changes, without the "Apply" button. */
|
|
1976
|
+
deferFilters(condition = true) { this._deferFilters = condition; return this; }
|
|
1977
|
+
_filterActions = {};
|
|
1978
|
+
/** Customizes the button that opens the filters: `filtersTriggerAction((action) => action.button().label('Filter'))`. */
|
|
1979
|
+
filtersTriggerAction(configure) { this._filterActions.trigger = configure; return this; }
|
|
1980
|
+
/** Customizes the "Apply" button of deferred filters. */
|
|
1981
|
+
filtersApplyAction(configure) { this._filterActions.apply = configure; return this; }
|
|
1982
|
+
/** Customizes the "Reset" button (`hidden()` removes it). */
|
|
1983
|
+
filtersResetAction(configure) { this._filterActions.reset = configure; return this; }
|
|
1984
|
+
getFiltersActionView(kind) {
|
|
1985
|
+
const defaults = {
|
|
1986
|
+
trigger: { label: t('filament-tables::table.actions.filter.label'), icon: 'm-funnel', color: 'gray', view: 'icon-button' },
|
|
1987
|
+
apply: { label: t('filament-tables::table.filters.actions.apply.label'), icon: null, color: 'primary', view: 'button' },
|
|
1988
|
+
reset: { label: t('filament-tables::table.filters.actions.reset.label'), icon: null, color: this._filtersResetPosition === 'footer' ? 'gray' : 'danger', view: this._filtersResetPosition === 'footer' ? 'button' : 'link' },
|
|
1989
|
+
}[kind];
|
|
1990
|
+
const configure = this._filterActions[kind];
|
|
1991
|
+
if (!configure)
|
|
1992
|
+
return { ...defaults, hidden: false, customized: false };
|
|
1993
|
+
const action = new FiltersAction();
|
|
1994
|
+
return { ...(configure(action) ?? action).resolve(defaults), customized: true };
|
|
1995
|
+
}
|
|
1996
|
+
_filtersFormSchema = null;
|
|
1997
|
+
/**
|
|
1998
|
+
* Arranges the filters form yourself: the function receives each filter's form group, keyed by filter name, and returns
|
|
1999
|
+
* the form components (e.g. sections). Filters left out are not shown.
|
|
2000
|
+
*/
|
|
2001
|
+
filtersFormSchema(schema) { this._filtersFormSchema = schema; return this; }
|
|
2002
|
+
getFiltersFormSchema() { return this._filtersFormSchema; }
|
|
2003
|
+
hasDeferredFilters() { return this._deferFilters; }
|
|
2004
|
+
/** Columns of the filters form grid (1 in a dropdown or modal, up to 5 above or below the content by default). */
|
|
2005
|
+
filtersFormColumns(columns) { this._filtersFormColumns = columns; return this; }
|
|
2006
|
+
getFiltersFormColumns() {
|
|
2007
|
+
if (this._filtersFormColumns !== null)
|
|
2008
|
+
return this._filtersFormColumns;
|
|
2009
|
+
return ['above-content', 'above-content-collapsible', 'below-content'].includes(this._filtersLayout) ? { sm: 2, lg: 3, xl: 4, '2xl': 5 } : 1;
|
|
2010
|
+
}
|
|
2011
|
+
/** Width of the filters dropdown, modal or side panel: `xs` to `7xl`, `full`, `screen`... */
|
|
2012
|
+
filtersFormWidth(width) { this._filtersFormWidth = width === null || FILTERS_WIDTHS.includes(width) ? width : null; return this; }
|
|
2013
|
+
getFiltersFormWidth() { return this._filtersFormWidth; }
|
|
2014
|
+
/** Maximum height of the filters dropdown, as a CSS length; it scrolls beyond. */
|
|
2015
|
+
filtersFormMaxHeight(height) { this._filtersFormMaxHeight = height !== null && /^[0-9.]+(rem|px|em|vh|%)$/.test(height) ? height : null; return this; }
|
|
2016
|
+
getFiltersFormMaxHeight() { return this._filtersFormMaxHeight; }
|
|
2017
|
+
/** `footer`: the "Reset" button goes next to "Apply" instead of the filters heading. */
|
|
2018
|
+
filtersResetActionPosition(position) { this._filtersResetPosition = position === 'footer' ? 'footer' : 'header'; return this; }
|
|
2019
|
+
getFiltersResetActionPosition() { return this._filtersResetPosition; }
|
|
2020
|
+
/** Hides the active filter badges above the table. */
|
|
2021
|
+
hiddenFilterIndicators(condition = true) { this._filterIndicatorsHidden = condition; return this; }
|
|
2022
|
+
areFilterIndicatorsHidden() { return this._filterIndicatorsHidden; }
|
|
2023
|
+
/** Keeps the applied filters for the user's browser session (restored when the page is opened again). */
|
|
2024
|
+
persistFiltersInSession(condition = true) { this._persist.filters = condition; return this; }
|
|
2025
|
+
persistSearchInSession(condition = true) { this._persist.search = condition; return this; }
|
|
2026
|
+
persistColumnSearchesInSession(condition = true) { this._persist.columnSearches = condition; return this; }
|
|
2027
|
+
persistSortInSession(condition = true) { this._persist.sort = condition; return this; }
|
|
2028
|
+
_persistGrouping = false;
|
|
2029
|
+
persistGroupingInSession(condition = true) { this._persistGrouping = condition; return this; }
|
|
2030
|
+
persistsGroupingInSession() { return this._persistGrouping; }
|
|
2031
|
+
persistsFiltersInSession() { return this._persist.filters; }
|
|
2032
|
+
persistsSearchInSession() { return this._persist.search; }
|
|
2033
|
+
persistsColumnSearchesInSession() { return this._persist.columnSearches; }
|
|
2034
|
+
persistsSortInSession() { return this._persist.sort; }
|
|
994
2035
|
defaultSort(column, direction = 'asc') { this._defaultSort = { column, direction }; return this; }
|
|
995
2036
|
recordUrl(resolver) { this._recordUrl = resolver; return this; }
|
|
996
2037
|
_reorderColumn = null;
|
|
997
2038
|
/** Lets users reorder rows by hand; `column` stores the position. */
|
|
998
2039
|
reorderable(column) { this._reorderColumn = column; return this; }
|
|
999
2040
|
getReorderColumn() { return this._reorderColumn; }
|
|
1000
|
-
|
|
2041
|
+
/** Page sizes offered; `'all'` shows every record (up to `MAX_UNPAGINATED_RECORDS`). */
|
|
2042
|
+
paginationPageOptions(options) { this._pageOptions = options.filter((option) => option === 'all' || (Number.isInteger(option) && option > 0)); return this; }
|
|
2043
|
+
/** `false`: no pagination, every record is shown (up to `MAX_UNPAGINATED_RECORDS`). An array sets the page options. */
|
|
2044
|
+
paginated(condition = true) {
|
|
2045
|
+
if (Array.isArray(condition)) {
|
|
2046
|
+
this.paginationPageOptions(condition);
|
|
2047
|
+
condition = true;
|
|
2048
|
+
}
|
|
2049
|
+
this._paginated = condition;
|
|
2050
|
+
return this;
|
|
2051
|
+
}
|
|
2052
|
+
isPaginated() { return this._paginated && !this._groupsOnly; }
|
|
2053
|
+
/** Heading of the empty state, instead of "No <records>". */
|
|
2054
|
+
emptyStateHeading(heading) { this._emptyStateHeading = heading; return this; }
|
|
2055
|
+
emptyStateDescription(description) { this._emptyStateDescription = description; return this; }
|
|
2056
|
+
emptyStateIcon(icon) { this._emptyStateIcon = icon; return this; }
|
|
2057
|
+
/** Actions shown in the empty state, e.g. a create action. They are mounted like table header actions. */
|
|
2058
|
+
emptyStateActions(actions) { this._emptyStateActions = actions; return this; }
|
|
2059
|
+
getEmptyStateActions() { return this._emptyStateActions; }
|
|
2060
|
+
/** Replaces the whole empty state with your HTML (inserted as it is: escape your data with `e()`). */
|
|
2061
|
+
emptyState(html) { this._emptyState = html; return this; }
|
|
2062
|
+
getEmptyStateHeading() { const heading = typeof this._emptyStateHeading === 'function' ? this._emptyStateHeading() : this._emptyStateHeading; return heading ?? t('filament-tables::table.empty.heading', { model: this.getPluralModelLabel() }); }
|
|
2063
|
+
getEmptyStateDescription() { return typeof this._emptyStateDescription === 'function' ? this._emptyStateDescription() : this._emptyStateDescription; }
|
|
2064
|
+
getEmptyStateIcon() { return this._emptyStateIcon ?? 'o-x-mark'; }
|
|
2065
|
+
async getCustomEmptyState() { return typeof this._emptyState === 'function' ? this._emptyState() : this._emptyState; }
|
|
1001
2066
|
_defaultPageOption = null;
|
|
1002
2067
|
/** Rows per page when the table opens. */
|
|
1003
2068
|
defaultPaginationPageOption(option) { this._defaultPageOption = option; return this; }
|
|
@@ -1008,18 +2073,18 @@ export class Table {
|
|
|
1008
2073
|
getPaginationMode() { return this._paginationMode; }
|
|
1009
2074
|
stackedOnMobile(condition = true) { this._stackedOnMobile = condition; return this; }
|
|
1010
2075
|
pluralModelLabel(label) { this._label = label; return this; }
|
|
1011
|
-
getColumns() { return this._columns; }
|
|
1012
|
-
getColumn(name) { return this.
|
|
2076
|
+
getColumns() { return this._columns.filter((column) => !column.isHidden()); }
|
|
2077
|
+
getColumn(name) { return this.getColumns().find((c) => c.name === name); }
|
|
1013
2078
|
getFilters() { return this._filters; }
|
|
1014
2079
|
getDefaultSort() { return this._defaultSort; }
|
|
1015
2080
|
getRecordUrl(record) { return this._recordUrl ? this._recordUrl(record) : null; }
|
|
1016
2081
|
getPageOptions() { return this._pageOptions; }
|
|
1017
2082
|
isStackedOnMobile() { return this._stackedOnMobile; }
|
|
1018
2083
|
getPluralModelLabel() { return this._label; }
|
|
1019
|
-
hasToggleableColumns() { return this.
|
|
2084
|
+
hasToggleableColumns() { return this.getColumns().some((c) => c.isToggleable()); }
|
|
1020
2085
|
/** Default column manager state. */
|
|
1021
2086
|
getDefaultColumnState() {
|
|
1022
|
-
return this.
|
|
2087
|
+
return this.getColumns().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 }));
|
|
1023
2088
|
}
|
|
1024
2089
|
getDefaultFilterState() { return Object.fromEntries(this._filters.map((f) => [f.name, f.getDefaultState()])); }
|
|
1025
2090
|
}
|
|
@@ -1048,7 +2113,7 @@ export function getTableQuery(table, host) {
|
|
|
1048
2113
|
};
|
|
1049
2114
|
const grouping = getTableGrouping(table, host);
|
|
1050
2115
|
if (grouping)
|
|
1051
|
-
query.groupSort = { column: grouping.group.getColumn(), direction: grouping.direction };
|
|
2116
|
+
query.groupSort = { column: grouping.group.getColumn(), direction: grouping.direction, ...(grouping.group.getOrderQueryUsing() ? { modify: grouping.group.getOrderQueryUsing() } : {}) };
|
|
1052
2117
|
// The host can restrict the query (active list tab).
|
|
1053
2118
|
host.modifyTableQuery?.(query);
|
|
1054
2119
|
for (const filter of table.getFilters()) {
|
|
@@ -1081,7 +2146,42 @@ export function countActiveFilters(table, host) {
|
|
|
1081
2146
|
}
|
|
1082
2147
|
/** Indicators of the applied filters (only filters that declare some have them). */
|
|
1083
2148
|
export function getFilterIndicators(table, host) {
|
|
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 })) : []; });
|
|
2149
|
+
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, removable: indicator.removable !== false, color: indicator.color ?? null })) : []; });
|
|
2150
|
+
}
|
|
2151
|
+
/** Whether an applied filter can be removed: none of its indicators is marked `removable(false)`. */
|
|
2152
|
+
export async function isTableFilterRemovable(table, host, filterName) {
|
|
2153
|
+
const filter = table.getFilters().find((candidate) => candidate.name === filterName);
|
|
2154
|
+
if (!filter)
|
|
2155
|
+
return false;
|
|
2156
|
+
const state = host.tableFilters?.[filter.name];
|
|
2157
|
+
if (!state || typeof state !== 'object' || !filter.getIndicators)
|
|
2158
|
+
return true;
|
|
2159
|
+
await filter.loadIndicatorData?.(state, host);
|
|
2160
|
+
return filter.getIndicators(state).every((indicator) => indicator.removable !== false);
|
|
2161
|
+
}
|
|
2162
|
+
/** Markup of a customized filters action: an icon button, a button or a link, with an optional badge. */
|
|
2163
|
+
function filterActionHtml(view, attributes, badge) {
|
|
2164
|
+
const colors = (component) => (view.color && view.color !== 'gray' ? (colorManager?.getComponentClasses(component, view.color) ?? []) : []);
|
|
2165
|
+
const iconHtml = view.icon ? icon(view.icon) : '';
|
|
2166
|
+
if (view.view === 'icon-button')
|
|
2167
|
+
return `<button title="${e(view.label)}" aria-label="${e(view.label)}" ${attributes} class="${cls(['fi-force-enabled', 'fi-ac-icon-btn-action', 'fi-icon-btn', 'fi-size-md', ...colors('icon-button')])}">${iconHtml}${badge !== null ? `<div class="fi-icon-btn-badge-ctn">${badge}</div>` : ''}</button>`;
|
|
2168
|
+
if (view.view === 'link')
|
|
2169
|
+
return `<button ${attributes} class="${cls(['fi-ac-link-action', 'fi-link', 'fi-size-md', ...colors('link')])}">${iconHtml}<span class="fi-link-label">${e(view.label)}</span>${badge !== null ? `<div class="fi-link-badge-ctn">${badge}</div>` : ''}</button>`;
|
|
2170
|
+
return `<button ${attributes} class="${cls(['fi-ac-btn-action', 'fi-btn', 'fi-size-md', ...colors('button')])}">${iconHtml}${e(view.label)}${badge !== null ? `<div class="fi-btn-badge-ctn">${badge}</div>` : ''}</button>`;
|
|
2171
|
+
}
|
|
2172
|
+
/** Keys whose record passes `checkIfRecordIsSelectableUsing()`, loaded in chunks. Without a check, the keys are returned as they are. */
|
|
2173
|
+
export async function filterSelectableKeys(table, adapter, keys, chunkSize = 100) {
|
|
2174
|
+
if (!table.hasRecordSelectabilityCheck())
|
|
2175
|
+
return keys;
|
|
2176
|
+
const kept = [];
|
|
2177
|
+
for (let index = 0; index < keys.length; index += chunkSize) {
|
|
2178
|
+
const chunk = keys.slice(index, index + chunkSize);
|
|
2179
|
+
const records = await Promise.all(chunk.map((key) => adapter.find(key)));
|
|
2180
|
+
for (let position = 0; position < chunk.length; position++)
|
|
2181
|
+
if (records[position] && (await table.isRecordSelectable(records[position])))
|
|
2182
|
+
kept.push(chunk[position]);
|
|
2183
|
+
}
|
|
2184
|
+
return kept;
|
|
1085
2185
|
}
|
|
1086
2186
|
/** Cycles a column's sort: none → asc → desc → none. */
|
|
1087
2187
|
export function sortTable(host, column = null, direction = null) {
|
|
@@ -1096,8 +2196,12 @@ export function sortTable(host, column = null, direction = null) {
|
|
|
1096
2196
|
/** Loads the page of records matching the host's state. */
|
|
1097
2197
|
export async function getTableRecords(table, host, adapter) {
|
|
1098
2198
|
const options = table.getPageOptions();
|
|
1099
|
-
|
|
1100
|
-
|
|
2199
|
+
// The page size comes from the browser: only a declared option is accepted.
|
|
2200
|
+
const requested = host.tableRecordsPerPage === 'all' || host.tableRecordsPerPage === 'all' ? 'all' : Number(host.tableRecordsPerPage);
|
|
2201
|
+
const option = options.includes(requested) ? requested : options[0];
|
|
2202
|
+
if (!table.isPaginated() || option === 'all')
|
|
2203
|
+
return adapter.paginate({ ...getTableQuery(table, host), page: 1, perPage: MAX_UNPAGINATED_RECORDS });
|
|
2204
|
+
return adapter.paginate({ ...getTableQuery(table, host), page: Number(host.paginators.page) || 1, perPage: option });
|
|
1101
2205
|
}
|
|
1102
2206
|
/** Pagination elements: the first and last pages and a window around the current page, with `...` gaps. */
|
|
1103
2207
|
export function paginationElements(current, last, onEachSide = 0) {
|
|
@@ -1117,11 +2221,16 @@ export const useColorManager = (manager) => { colorManager = manager; };
|
|
|
1117
2221
|
export const REORDER_LIMIT = 1000;
|
|
1118
2222
|
/** Summary row; with `groupsOnly`, a leading label cell, then one cell per column. */
|
|
1119
2223
|
function summaryRowHtml(row, span, groupsOnly, placeholders) {
|
|
1120
|
-
const items = (cell) => cell.items.map((item) =>
|
|
2224
|
+
const items = (cell) => cell.items.map((item) => {
|
|
2225
|
+
const label = item.label !== null && item.label !== '' ? `<span class="fi-ta-${item.type}-summary-label${item.labelHidden ? ' fi-sr-only' : ''}">${e(item.label)}</span>` : '';
|
|
2226
|
+
if (item.type === 'values')
|
|
2227
|
+
return `<div class="fi-ta-values-summary">${label}${item.values?.length ? `<ul${item.bulleted ? ' class="fi-bulleted"' : ''}>${item.values.map((value) => `<li>${e(value)}</li>`).join('')}</ul>` : ''}</div>`;
|
|
2228
|
+
return `<div class="fi-ta-${item.type}-summary">${label}<span>${e(item.value)}</span></div>`;
|
|
2229
|
+
}).join('');
|
|
1121
2230
|
const heading = groupsOnly
|
|
1122
2231
|
? `<th scope="row" class="fi-ta-cell fi-ta-summary-row-heading-cell fi-align-start">${e(row.heading)}</th>`
|
|
1123
2232
|
: `<th scope="row"${span > 1 ? ` colspan="${span}"` : ''} class="fi-ta-cell fi-align-start fi-ta-summary-row-heading-cell ">${e(row.heading)}</th>`;
|
|
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>`;
|
|
2233
|
+
return `<tr class="fi-ta-row fi-ta-summary-row${row.striped ? ' fi-striped' : ''}">${placeholders.selection ? '<td></td>' : ''}${placeholders.actionsBefore ? '<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>`;
|
|
1125
2234
|
}
|
|
1126
2235
|
/**
|
|
1127
2236
|
* Table summaries: a single "Summary" row without pagination; with several pages, a header then "This page" and "All …" rows.
|
|
@@ -1130,7 +2239,7 @@ function summaryRowHtml(row, span, groupsOnly, placeholders) {
|
|
|
1130
2239
|
async function summarize(table, host, adapter, visible, page, options) {
|
|
1131
2240
|
// Groups only: the grouped column has no summary cell (its label already leads the row).
|
|
1132
2241
|
const columns = options.groupsOnly && options.group ? visible.filter((c) => c.name !== options.group.getColumn()) : visible;
|
|
1133
|
-
const lists = columns.map((c) => (c instanceof TextColumn ? c.getSummarizers() : []));
|
|
2242
|
+
const lists = columns.map((c) => (c instanceof TextColumn ? c.getSummarizers().filter((summarizer) => !summarizer.isHidden()) : []));
|
|
1134
2243
|
if (!lists.some((list) => list.length) || !page.records.length)
|
|
1135
2244
|
return null;
|
|
1136
2245
|
// The first cell holds the row label and spans up to the first summarized column after it.
|
|
@@ -1149,28 +2258,50 @@ async function summarize(table, host, adapter, visible, page, options) {
|
|
|
1149
2258
|
min: Object.fromEntries(names('range').map((name) => [name, present(name).map(String).sort()[0] ?? null])),
|
|
1150
2259
|
max: Object.fromEntries(names('range').map((name) => [name, present(name).map(String).sort().pop() ?? null])),
|
|
1151
2260
|
};
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
2261
|
+
// `using()` summarizers get the records of the summarized dataset, loaded once per row.
|
|
2262
|
+
const loader = (dataset) => { let cache = null; return () => (cache ??= Array.isArray(dataset) ? Promise.resolve(dataset) : adapter.paginate({ ...dataset, page: 1, perPage: MAX_UNPAGINATED_RECORDS }).then((result) => result.records)); };
|
|
2263
|
+
// The displayed page as a query (its record keys), for summarizers scoped with `query()`.
|
|
2264
|
+
const pageQuery = { ...query, keys: page.records.map((record) => adapter.getKey(record)) };
|
|
2265
|
+
const cells = async (values, dataset) => {
|
|
2266
|
+
const records = loader(dataset);
|
|
2267
|
+
const datasetQuery = Array.isArray(dataset) ? pageQuery : dataset;
|
|
2268
|
+
return Promise.all(columns.slice(span).map(async (c, i) => ({
|
|
2269
|
+
alignment: c.getAlignment() ?? 'start',
|
|
2270
|
+
items: (await Promise.all(lists[i + span].map(async (s) => {
|
|
2271
|
+
const scope = s.getQueryScope();
|
|
2272
|
+
if (!scope)
|
|
2273
|
+
return s.summarize(columnOf(c), values, s.hasCustomCalculation() ? await s.compute({ column: c.name, records }) : undefined);
|
|
2274
|
+
// Scoped summarizer: its own aggregates and records, on the dataset restricted by its query.
|
|
2275
|
+
const scoped = { ...datasetQuery, modifiers: [...(datasetQuery.modifiers ?? []), (q) => { scope(q); }] };
|
|
2276
|
+
return s.summarize(columnOf(c), await aggregate(scoped), s.hasCustomCalculation() ? await s.compute({ column: c.name, records: loader(scoped) }) : undefined);
|
|
2277
|
+
}))).filter((item) => item !== null),
|
|
2278
|
+
})));
|
|
2279
|
+
};
|
|
1156
2280
|
const label = table.getPluralModelLabel();
|
|
1157
|
-
const
|
|
2281
|
+
const showPage = table.hasPageSummary(), showAll = table.hasAllTableSummary();
|
|
2282
|
+
const isMultiPage = !options.groupsOnly && page.lastPage > 1;
|
|
2283
|
+
// Several pages: "this page" and "all" rows under a heading row; a single page: one "Summary" row.
|
|
2284
|
+
const hasPageSummary = isMultiPage && (showPage || showAll);
|
|
1158
2285
|
const rowHtml = (row) => summaryRowHtml(row, span, options.groupsOnly, options.placeholders);
|
|
1159
2286
|
const header = hasPageSummary
|
|
1160
|
-
? `<tr class="fi-ta-row fi-ta-summary-header-row fi-striped">${options.placeholders.selection ? '<td></td>' : ''}${columns.map((c, i) => {
|
|
2287
|
+
? `<tr class="fi-ta-row fi-ta-summary-header-row fi-striped">${options.placeholders.selection ? '<td></td>' : ''}${options.placeholders.actionsBefore ? '<td></td>' : ''}${columns.map((c, i) => {
|
|
1161
2288
|
const heading = i === 0 ? t('filament-tables::table.summary.heading', { label }) : lists[i].length ? c.getLabel() : null;
|
|
1162
2289
|
return heading === null ? '<td class="fi-ta-cell fi-ta-summary-header-cell"></td>' : `<th scope="col" class="fi-ta-cell fi-ta-summary-header-cell fi-align-${e(i === 0 ? 'start' : (c.getAlignment() ?? 'start'))}">${e(heading)}</th>`;
|
|
1163
2290
|
}).join('')}${options.placeholders.actions ? '<td></td>' : ''}</tr>`
|
|
1164
2291
|
: '';
|
|
1165
2292
|
const rows = hasPageSummary
|
|
1166
|
-
? [
|
|
1167
|
-
|
|
2293
|
+
? [
|
|
2294
|
+
...(showPage ? [{ heading: t('filament-tables::table.summary.subheadings.page', { label }), cells: await cells(onPage, page.records), striped: false }] : []),
|
|
2295
|
+
...(showAll ? [{ heading: t('filament-tables::table.summary.subheadings.all', { label }), cells: await cells(all, query), striped: false }] : []),
|
|
2296
|
+
]
|
|
2297
|
+
: isMultiPage || !(showPage || showAll) ? [] : [{ heading: t('filament-tables::table.summary.heading', { label }), cells: await cells(all, query), striped: true }];
|
|
1168
2298
|
return {
|
|
1169
2299
|
html: header + rows.map(rowHtml).join(''),
|
|
1170
2300
|
/** Group summary row: aggregates over all the group's records (not only those on the page). */
|
|
1171
2301
|
group: async (group, key, title) => {
|
|
1172
|
-
const
|
|
1173
|
-
|
|
2302
|
+
const groupQuery = { ...query, modifiers: [...(query.modifiers ?? []), (q) => group.scope(q, key)] };
|
|
2303
|
+
const values = await aggregate(groupQuery);
|
|
2304
|
+
return rowHtml({ heading: options.groupsOnly ? (title ?? '') : t('filament-tables::table.summary.subheadings.group', { group: title ?? '', label }), cells: await cells(values, groupQuery), striped: false });
|
|
1174
2305
|
},
|
|
1175
2306
|
};
|
|
1176
2307
|
}
|
|
@@ -1224,7 +2355,7 @@ export async function renderTable(table, host, adapter, options = {}) {
|
|
|
1224
2355
|
const cells = [];
|
|
1225
2356
|
if (!groupsOnly)
|
|
1226
2357
|
for (const column of visible)
|
|
1227
|
-
cells.push({ name: column.name, cssName: column.getCssName(), alignment: column.getAlignment(), label: column.getLabel(), url: column.getUrl(record), html: await column.toHtml(record, key), plain: column.isClickDisabled() });
|
|
2358
|
+
cells.push({ name: column.name, cssName: column.getCssName(), alignment: column.getAlignment(), responsive: column.getResponsiveClasses(), label: column.getLabel(), url: column.getUrl(record), html: await column.toHtml(record, key), plain: column.isClickDisabled(), mountExpression: !reordering && column.getActionName() !== null && !column.isClickDisabled() ? `mountAction(${js(column.getActionName())}, {}, ${js({ table: true, recordKey: key })})` : null });
|
|
1228
2359
|
const recordAction = table.getRecordAction();
|
|
1229
2360
|
if (reordering)
|
|
1230
2361
|
for (const cell of cells)
|
|
@@ -1235,8 +2366,11 @@ export async function renderTable(table, host, adapter, options = {}) {
|
|
|
1235
2366
|
for (const node of columnsLayout)
|
|
1236
2367
|
contentHtml += await renderColumnLayout(node, record, key, visibleSet);
|
|
1237
2368
|
}
|
|
2369
|
+
const collapsibleLayout = columnsLayout && !groupsOnly && !reordering ? table.getCollapsibleColumnsLayout() : null;
|
|
2370
|
+
const collapsibleHtml = collapsibleLayout && !collapsibleLayout.isHidden() ? await renderColumnLayout(collapsibleLayout, record, key, visibleSet) : null;
|
|
1238
2371
|
rows.push({
|
|
1239
|
-
|
|
2372
|
+
selectable: selectable && !reordering ? await table.isRecordSelectable(record) : true,
|
|
2373
|
+
contentHtml, collapsibleHtml, collapsed: collapsibleLayout?.isCollapsed() ?? false, summaryBefore, groupSummaryHtml: '',
|
|
1240
2374
|
groupHeader, groupKey, collapsedExpression: grouping?.group.isCollapsible() ? `isGroupCollapsed(${js(groupTitle)})` : null,
|
|
1241
2375
|
reorder: reordering ? { id: `${host.id}-table-reorder-${createHash('md5').update(key).digest('hex')}`, label: String(record[table.getColumns()[0]?.name ?? ''] ?? key), position: rows.length + 1 } : null,
|
|
1242
2376
|
wasClickable: reordering && (recordAction !== null || table.getRecordUrl(record) !== null),
|
|
@@ -1247,8 +2381,9 @@ export async function renderTable(table, host, adapter, options = {}) {
|
|
|
1247
2381
|
});
|
|
1248
2382
|
}
|
|
1249
2383
|
const hasRowActions = rows.some((row) => row.actionsHtml !== null || row.actions.length > 0);
|
|
2384
|
+
const actionsBefore = table.getRecordActionsPosition().startsWith('before');
|
|
1250
2385
|
// Summaries: never while reordering; group summaries go before the next group's header.
|
|
1251
|
-
const summary = reordering ? null : await summarize(table, host, adapter, visible, page, { group: grouping?.group ?? null, groupsOnly, placeholders: { selection: selectable, actions: hasRowActions } });
|
|
2386
|
+
const summary = reordering ? null : await summarize(table, host, adapter, visible, page, { group: grouping?.group ?? null, groupsOnly, placeholders: { selection: selectable, actions: hasRowActions && !actionsBefore, actionsBefore: hasRowActions && actionsBefore } });
|
|
1252
2387
|
let trailingGroupSummary = '';
|
|
1253
2388
|
if (summary && grouping) {
|
|
1254
2389
|
for (const row of rows)
|
|
@@ -1271,23 +2406,28 @@ export async function renderTable(table, host, adapter, options = {}) {
|
|
|
1271
2406
|
}
|
|
1272
2407
|
const scopes = host.getRenderHookScopes?.() ?? [];
|
|
1273
2408
|
return views.render('filament-tables::index', {
|
|
1274
|
-
contentLayout: columnsLayout !== null,
|
|
2409
|
+
contentLayout: columnsLayout !== null, contentGrid: table.getContentGrid(),
|
|
1275
2410
|
hook: (name, data = {}) => renderHook(name, scopes, data), hasHook: (name) => hasRenderHook(name, scopes),
|
|
1276
2411
|
visibleColumns: visible, isReordering: reordering,
|
|
1277
2412
|
groups: reordering || table.areGroupingSettingsHidden() ? [] : table.getGroups().map((g) => ({ id: g.getId(), label: g.getLabel() })),
|
|
1278
|
-
grouping: { directionHidden: table.isGroupingDirectionSettingHidden(), inDropdownOnDesktop: table.areGroupingSettingsInDropdownOnDesktop(), collapsedByDefault: table.areGroupsCollapsedByDefault(), groupsOnly },
|
|
2413
|
+
grouping: { active: grouping !== null, directionHidden: table.isGroupingDirectionSettingHidden(), inDropdownOnDesktop: table.areGroupingSettingsInDropdownOnDesktop(), collapsedByDefault: table.areGroupsCollapsedByDefault(), groupsOnly },
|
|
1279
2414
|
id: host.id, targets: LOADING_TARGETS, table, rows, page, number,
|
|
1280
2415
|
reorder: reorderColumn !== null ? { active: reordering } : null,
|
|
1281
2416
|
// No checkboxes without bulk actions (or in groups-only mode), no actions column without row actions.
|
|
1282
|
-
|
|
2417
|
+
toolbarActions: reordering ? [] : (options.toolbarActions ?? []),
|
|
2418
|
+
selectable, hasRowActions, actionsPosition: table.getRecordActionsPosition(), actionsAlignment: table.getRecordActionsAlignment() ?? (actionsBefore ? 'start' : null),
|
|
1283
2419
|
// The key changes on every render to force the checkbox to be replaced.
|
|
1284
2420
|
randomKey: () => Array.from(randomBytes(16), (b) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[b % 62]).join(''),
|
|
1285
2421
|
// "Select all" covers every record of the current query.
|
|
1286
|
-
allCount: table.getPaginationMode() === 'simple' ? '' : page.
|
|
1287
|
-
|
|
1288
|
-
|
|
2422
|
+
allCount: table.getPaginationMode() === 'simple' ? '' : table.selectsCurrentPageOnly() ? page.records.length : table.hasRecordSelectabilityCheck() && selectable ? (await filterSelectableKeys(table, adapter, await adapter.listKeys(getTableQuery(table, host)))).length : page.total,
|
|
2423
|
+
maxSelectableRecords: table.getMaxSelectableRecords(), selectsCurrentPageOnly: table.selectsCurrentPageOnly(), resultsCount: table.getPaginationMode() === 'simple' ? rows.length : page.total,
|
|
2424
|
+
columns: visible.map((c) => ({ name: c.name, grouped: c._group !== null, cssName: c.getCssName(), alignment: c.getAlignment(), responsive: c.getResponsiveClasses(), width: c.getWidth(), growable: c.getWidth() === null && c.canGrow(), wrapHeader: c.canHeaderWrap(), label: c.getLabel(), sortable: !reordering && c.isSortable(), individuallySearchable: !reordering && c.isIndividuallySearchable(), sort: sortColumn === c.name ? (sortDirection === 'desc' ? 'descending' : 'ascending') : 'none' })),
|
|
2425
|
+
linkClasses: classes('link', 'primary'), dangerLinkClasses: classes('link', 'danger'), primaryButtonClasses: classes('button', 'primary'), badgeClasses: classes('badge', 'primary'), indicatorBadgeClasses: classes('badge', 'primary'), grayButtonClasses: classes('button', 'gray'), indicatorColorClasses: (color) => classes('badge', color),
|
|
1289
2426
|
bulkActions: options.bulkActions ?? [],
|
|
1290
|
-
filtersForm: options.filtersForm ?? null, activeFiltersCount: countActiveFilters(table, host), filtersLayout: table.getFiltersLayout(), filterIndicators: getFilterIndicators(table, host),
|
|
2427
|
+
filtersForm: options.filtersForm ?? null, activeFiltersCount: countActiveFilters(table, host), filtersLayout: table.getFiltersLayout(), filterIndicators: table.areFilterIndicatorsHidden() ? [] : getFilterIndicators(table, host),
|
|
2428
|
+
filterActions: { trigger: table.getFiltersActionView('trigger'), apply: table.getFiltersActionView('apply'), reset: table.getFiltersActionView('reset') },
|
|
2429
|
+
filterActionHtml: (view, attributes, badge = null) => filterActionHtml(view, attributes, badge),
|
|
2430
|
+
filtersDeferred: table.hasDeferredFilters(), filtersResetPosition: table.getFiltersResetActionPosition(), filtersFormWidth: table.getFiltersFormWidth(), filtersFormMaxHeight: table.getFiltersFormMaxHeight(), modal,
|
|
1291
2431
|
hasColumnManager: !reordering && table.hasToggleableColumns(),
|
|
1292
2432
|
afterContainer: options.after ?? '', heading: table.getHeading(), headerActions: options.headerActions ?? [], summaryHtml: summary?.html ?? '', trailingGroupSummary,
|
|
1293
2433
|
// Column group header row: one cell per ungrouped column, one merged cell per run of columns in the same group.
|
|
@@ -1300,7 +2440,8 @@ export async function renderTable(table, host, adapter, options = {}) {
|
|
|
1300
2440
|
return runs;
|
|
1301
2441
|
}, []) : null,
|
|
1302
2442
|
isSearchable: !reordering && table.getColumns().some((c) => c.isGloballySearchable()),
|
|
1303
|
-
|
|
2443
|
+
emptyState: { custom: await table.getCustomEmptyState(), heading: table.getEmptyStateHeading(), description: table.getEmptyStateDescription(), icon: table.getEmptyStateIcon(), actions: options.emptyStateActions ?? [] },
|
|
2444
|
+
pagination: reordering || groupsOnly || !table.isPaginated() ? '' : views.render('filament::components.pagination', {
|
|
1304
2445
|
id: host.id, targets: LOADING_TARGETS + ',paginators', page, number, pageOptions: table.getPageOptions(), elements: paginationElements(page.page, page.lastPage), simple: table.getPaginationMode() === 'simple',
|
|
1305
2446
|
attrs: (o) => new Attributes(o),
|
|
1306
2447
|
}),
|