@navette-js/tables 0.0.0-stage → 1.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +8 -2
- package/lib/index.d.ts +786 -0
- package/lib/index.js +1307 -0
- package/lib/query-builder.d.ts +302 -0
- package/lib/query-builder.js +875 -0
- package/package.json +50 -4
- package/views/asset.ejs +1 -0
- package/views/column-manager.ejs +146 -0
- package/views/index.ejs +791 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1307 @@
|
|
|
1
|
+
import { Attributes, cls, e, hasRenderHook, icon, js, renderHook, views, configured, configureUsing } from '@navette-js/support';
|
|
2
|
+
import { t } from '@navette-js/support/i18n';
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
views.namespace('filament-tables', fileURLToPath(new URL('../views', import.meta.url)));
|
|
6
|
+
export * from "./query-builder.js";
|
|
7
|
+
/** Emplacements de render hook d'une table — mêmes noms que `Filament\Tables\View\TablesRenderHook`. */
|
|
8
|
+
export const TablesRenderHook = {
|
|
9
|
+
CONTENT_AFTER: 'tables::content.after',
|
|
10
|
+
CONTENT_BEFORE: 'tables::content.before',
|
|
11
|
+
SELECTION_INDICATOR_ACTIONS_AFTER: 'tables::selection.indicator.actions.after',
|
|
12
|
+
SELECTION_INDICATOR_ACTIONS_BEFORE: 'tables::selection.indicator.actions.before',
|
|
13
|
+
HEADER_AFTER: 'tables::header.after',
|
|
14
|
+
HEADER_BEFORE: 'tables::header.before',
|
|
15
|
+
TOOLBAR_AFTER: 'tables::toolbar.after',
|
|
16
|
+
TOOLBAR_BEFORE: 'tables::toolbar.before',
|
|
17
|
+
TOOLBAR_COLUMN_MANAGER_TRIGGER_AFTER: 'tables::toolbar.toggle-column-trigger.after',
|
|
18
|
+
TOOLBAR_COLUMN_MANAGER_TRIGGER_BEFORE: 'tables::toolbar.toggle-column-trigger.before',
|
|
19
|
+
TOOLBAR_END: 'tables::toolbar.end',
|
|
20
|
+
TOOLBAR_GROUPING_SELECTOR_AFTER: 'tables::toolbar.grouping-selector.after',
|
|
21
|
+
TOOLBAR_GROUPING_SELECTOR_BEFORE: 'tables::toolbar.grouping-selector.before',
|
|
22
|
+
TOOLBAR_REORDER_TRIGGER_AFTER: 'tables::toolbar.reorder-trigger.after',
|
|
23
|
+
TOOLBAR_REORDER_TRIGGER_BEFORE: 'tables::toolbar.reorder-trigger.before',
|
|
24
|
+
TOOLBAR_SEARCH_AFTER: 'tables::toolbar.search.after',
|
|
25
|
+
TOOLBAR_SEARCH_BEFORE: 'tables::toolbar.search.before',
|
|
26
|
+
TOOLBAR_START: 'tables::toolbar.start',
|
|
27
|
+
FILTER_INDICATORS: 'tables::filter.indicators',
|
|
28
|
+
HEADER_CELL: 'tables::header.cell',
|
|
29
|
+
/** Anciens noms, conservés par Filament. */
|
|
30
|
+
TOOLBAR_TOGGLE_COLUMN_TRIGGER_AFTER: 'tables::toolbar.toggle-column-trigger.after',
|
|
31
|
+
TOOLBAR_TOGGLE_COLUMN_TRIGGER_BEFORE: 'tables::toolbar.toggle-column-trigger.before',
|
|
32
|
+
};
|
|
33
|
+
/** Cibles de chargement de la table (`wire:target`), dans l'ordre émis par Filament. */
|
|
34
|
+
export const LOADING_TARGETS = 'activeTab,applyTableFilters,gotoPage,nextPage,previousPage,removeTableFilter,removeTableFilters,reorderTable,resetTableColumnSearch,resetTableFiltersForm,resetTableSearch,setPage,sortTable,tableColumnSearches,tableFilters,tableGrouping,tableRecordsPerPage,tableSearch,tableSort,toggleTableReordering';
|
|
35
|
+
const headline = (s) => s.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
|
36
|
+
// ------------------------------------------------------------------ colonnes
|
|
37
|
+
/** Base commune des colonnes (port partiel de Filament\Tables\Columns\Column). */
|
|
38
|
+
export class Column {
|
|
39
|
+
/** Réglage par défaut de toutes les instances créées ensuite par `make()` : `TextInput.configureUsing((input) => input.maxLength(255))`. */
|
|
40
|
+
static configureUsing(configure) { configureUsing(this, configure); }
|
|
41
|
+
name;
|
|
42
|
+
_label = null;
|
|
43
|
+
_sortable = false;
|
|
44
|
+
_searchable = false;
|
|
45
|
+
_individuallySearchable = false;
|
|
46
|
+
_toggleable = false;
|
|
47
|
+
_toggledHiddenByDefault = false;
|
|
48
|
+
_alignment = null;
|
|
49
|
+
_state = null;
|
|
50
|
+
_sortColumn = null;
|
|
51
|
+
_searchColumn = null;
|
|
52
|
+
_url = null;
|
|
53
|
+
/** Libellé du groupe de colonnes auquel la colonne appartient (posé par ColumnGroup). */
|
|
54
|
+
_group = null;
|
|
55
|
+
constructor(name) { this.name = name; }
|
|
56
|
+
label(label) { this._label = label; return this; }
|
|
57
|
+
/** `column` : colonne (ou alias de la requête) utilisée pour trier quand elle diffère du nom, par exemple pour une relation. */
|
|
58
|
+
sortable(condition = true) { this._sortable = condition !== false; this._sortColumn = typeof condition === 'object' ? condition.column : null; this._sortInverted = typeof condition === 'object' && condition.inverted === true; return this; }
|
|
59
|
+
getSortColumn() { return this._sortColumn ?? this.name; }
|
|
60
|
+
_sortInverted = false;
|
|
61
|
+
/** Vrai quand la colonne de tri évolue en sens inverse de la valeur affichée (`inverted` : un âge trié par sa date). */
|
|
62
|
+
isSortInverted() { return this._sortInverted; }
|
|
63
|
+
/** Lien posé sur la cellule ; il prend le pas sur l'action de ligne. */
|
|
64
|
+
url(resolver) { this._url = resolver; return this; }
|
|
65
|
+
getUrl(record) { return this._url ? this._url(record) : null; }
|
|
66
|
+
/** `isGlobal: false` exclut la colonne de la recherche globale ; `isIndividual` ajoute un champ de recherche propre à la colonne. */
|
|
67
|
+
getSearchColumn() { return this._searchColumn ?? this.name; }
|
|
68
|
+
searchable(options = true) {
|
|
69
|
+
this._searchColumn = typeof options === 'object' ? (options.column ?? null) : null;
|
|
70
|
+
this._searchable = typeof options === 'boolean' ? options : options.isGlobal !== false;
|
|
71
|
+
this._individuallySearchable = typeof options === 'object' && options.isIndividual === true;
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
/** Colonne que l'utilisateur peut masquer depuis le gestionnaire de colonnes. */
|
|
75
|
+
toggleable(condition = true, options = {}) { this._toggleable = condition; this._toggledHiddenByDefault = options.isToggledHiddenByDefault === true; return this; }
|
|
76
|
+
alignment(alignment) { this._alignment = alignment; return this; }
|
|
77
|
+
alignEnd() { return this.alignment('end'); }
|
|
78
|
+
alignLeft() { return this.alignment('left'); }
|
|
79
|
+
alignCenter() { return this.alignment('center'); }
|
|
80
|
+
getStateUsing(resolver) { this._state = resolver; return this; }
|
|
81
|
+
getLabel() { return this._label ?? headline(this.name.includes('.') ? this.name.split('.').slice(-2, -1)[0] : this.name); }
|
|
82
|
+
isSortable() { return this._sortable; }
|
|
83
|
+
isGloballySearchable() { return this._searchable; }
|
|
84
|
+
isIndividuallySearchable() { return this._individuallySearchable; }
|
|
85
|
+
isToggleable() { return this._toggleable; }
|
|
86
|
+
isToggledHiddenByDefault() { return this._toggledHiddenByDefault; }
|
|
87
|
+
getAlignment() { return this._alignment; }
|
|
88
|
+
/** Suffixe des classes `fi-ta-header-cell-…` / `fi-ta-cell-…` : les tirets bas deviennent des tirets, les points sont conservés (comme Filament). */
|
|
89
|
+
getCssName() { return this.name.replace(/_/g, '-'); }
|
|
90
|
+
async getState(record) { return this._state ? this._state({ record }) : record[this.name]; }
|
|
91
|
+
/** Vrai pour une cellule modifiable : elle n'est enveloppée ni d'un lien ni d'un bouton vers l'enregistrement. */
|
|
92
|
+
isClickDisabled() { return false; }
|
|
93
|
+
}
|
|
94
|
+
export class TextColumn extends Column {
|
|
95
|
+
_weight = null;
|
|
96
|
+
_copyable = false;
|
|
97
|
+
_money = null;
|
|
98
|
+
_placeholder = null;
|
|
99
|
+
_badge = false;
|
|
100
|
+
_color = null;
|
|
101
|
+
_icon = null;
|
|
102
|
+
_format = null;
|
|
103
|
+
_date = false;
|
|
104
|
+
_numeric = false;
|
|
105
|
+
_summarizers = [];
|
|
106
|
+
_limit = null;
|
|
107
|
+
_tooltip = null;
|
|
108
|
+
static make(name) { return configured(new TextColumn(name)); }
|
|
109
|
+
/** Affiche la valeur dans une pastille. */
|
|
110
|
+
badge(condition = true) { this._badge = condition; return this; }
|
|
111
|
+
color(color) { this._color = color; return this; }
|
|
112
|
+
icon(resolver) { this._icon = resolver; return this; }
|
|
113
|
+
/** Texte affiché à la place de la valeur brute (libellé d'une énumération, par exemple). */
|
|
114
|
+
formatStateUsing(formatter) { this._format = formatter; return this; }
|
|
115
|
+
/** Date courte, comme `->date()` de Filament (« Nov 9, 2026 »). */
|
|
116
|
+
date(condition = true) { this._date = condition; return this; }
|
|
117
|
+
_dateTime = false;
|
|
118
|
+
/** Date et heure, « M j, Y H:i:s » (format par défaut de Filament). */
|
|
119
|
+
dateTime(condition = true) { this._dateTime = condition; this._date = condition; return this; }
|
|
120
|
+
/** Nombre formaté avec séparateurs de milliers (`->numeric()`). */
|
|
121
|
+
_decimals = null;
|
|
122
|
+
/** `numeric(1)` : nombre de décimales fixe. */
|
|
123
|
+
numeric(condition = true) { this._numeric = condition !== false; this._decimals = typeof condition === 'number' ? condition : null; return this; }
|
|
124
|
+
/** Ligne de totaux sous la table. */
|
|
125
|
+
summarize(summarizer) { this._summarizers = Array.isArray(summarizer) ? summarizer : [summarizer]; return this; }
|
|
126
|
+
getSummarizers() { return this._summarizers; }
|
|
127
|
+
weight(weight) { this._weight = weight; return this; }
|
|
128
|
+
/** Tronque le texte affiché à `length` caractères, suivi de « ... » (port de `limit()`, Str::limit). */
|
|
129
|
+
limit(length) { this._limit = length; return this; }
|
|
130
|
+
/** Infobulle au survol du texte ; `null` pour ne pas en afficher. */
|
|
131
|
+
tooltip(tooltip) { this._tooltip = tooltip; return this; }
|
|
132
|
+
copyable(condition = true) { this._copyable = condition; return this; }
|
|
133
|
+
/** Montant formaté dans la devise donnée (`money('usd')`). */
|
|
134
|
+
money(currency = 'usd') { this._money = currency; return this; }
|
|
135
|
+
/** Texte affiché quand la valeur est vide. */
|
|
136
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
137
|
+
/** Contenu de cellule. Filament a deux balisages : un chemin court pour le texte simple, un chemin complet dès qu'une option est posée. */
|
|
138
|
+
async toHtml(record) {
|
|
139
|
+
const state = await this.getState(record);
|
|
140
|
+
if ((state === null || state === undefined || state === '') && this._placeholder !== null)
|
|
141
|
+
return `<div class="fi-ta-text"><p class="fi-ta-placeholder">${e(this._placeholder)}</p></div>`;
|
|
142
|
+
// Valeur vide sans texte de remplacement : Filament rend un conteneur nu.
|
|
143
|
+
if (state === null || state === undefined || state === '')
|
|
144
|
+
return `<div class="${cls(['fi-ta-text', this._money || this._numeric ? 'fi-numeric' : null, (this._money || this._numeric) && this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null])}"></div>`;
|
|
145
|
+
const currency = typeof this._money === 'function' ? this._money(record) : this._money;
|
|
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 });
|
|
152
|
+
if (this._badge) {
|
|
153
|
+
const color = typeof this._color === 'function' ? this._color(record) : (this._color ?? 'primary');
|
|
154
|
+
const iconName = this._icon?.(record) ?? null;
|
|
155
|
+
const classes = ['fi-badge', 'fi-size-sm', ...(color !== 'gray' ? (colorManager?.getComponentClasses('badge', color) ?? []) : [])].join(' ');
|
|
156
|
+
// Comme Filament : sans icône, la cellule passe par le chemin court, qui écrit les classes dans l'autre ordre.
|
|
157
|
+
return `<div class="${iconName ? 'fi-ta-text-item fi-ta-text' : 'fi-ta-text fi-ta-text-item'} fi-ta-text-has-badges"><span class="${classes}">${iconName ? icon(iconName, { size: 'sm', attributes: new Attributes({ 'aria-hidden': 'true' }) }) : ''}${e(shown)}</span></div>`;
|
|
158
|
+
}
|
|
159
|
+
if (!this._weight && !this._copyable)
|
|
160
|
+
return `<div class="${cls(['fi-ta-text', 'fi-ta-text-item', 'fi-size-sm', this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null])}">${e(shown)}</div>`;
|
|
161
|
+
}
|
|
162
|
+
const text = state === null || state === undefined ? '' : currency ? new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.toUpperCase() }).format(Number(state)) : this._numeric && Number.isFinite(Number(state)) ? new Intl.NumberFormat('en-US', this._decimals !== null ? { minimumFractionDigits: this._decimals, maximumFractionDigits: this._decimals } : {}).format(Number(state)) : String(state);
|
|
163
|
+
const align = this._alignment && this._alignment !== 'start' ? `fi-align-${this._alignment}` : null;
|
|
164
|
+
const tooltip = typeof this._tooltip === 'function' ? this._tooltip(record) : this._tooltip;
|
|
165
|
+
if (this._limit !== null || (tooltip !== null && tooltip !== '')) {
|
|
166
|
+
const limited = this._limit !== null && text.length > this._limit ? `${text.slice(0, this._limit).trimEnd()}...` : text;
|
|
167
|
+
const content = tooltip !== null && tooltip !== '' ? `<span x-tooltip="{
|
|
168
|
+
content: ${e(js(tooltip))},
|
|
169
|
+
theme: $store.theme,
|
|
170
|
+
allowHTML: false,
|
|
171
|
+
}">${e(limited)}</span>` : e(limited);
|
|
172
|
+
return `<div class="${cls(['fi-ta-text-item', 'fi-size-sm', this._weight ? `fi-font-${this._weight}` : null, 'fi-ta-text', this._money || this._numeric ? 'fi-numeric' : null, align])}">${content}</div>`;
|
|
173
|
+
}
|
|
174
|
+
// Hors pastille : une icône précède le texte (chemin complet), une couleur teinte le texte.
|
|
175
|
+
const plainIcon = this._icon?.(record) ?? null;
|
|
176
|
+
const plainColor = typeof this._color === 'function' ? this._color(record) : this._color;
|
|
177
|
+
const colorClasses = plainColor ? (colorManager?.getComponentClasses('text', plainColor) ?? []) : [];
|
|
178
|
+
if (plainIcon)
|
|
179
|
+
return `<div class="${cls(['fi-ta-text-item', 'fi-size-sm', this._weight ? `fi-font-${this._weight}` : null, 'fi-ta-text', ...colorClasses, align])}">${icon(plainIcon, { size: 'sm', attributes: new Attributes({ 'aria-hidden': 'true' }) })}${e(text)}</div>`;
|
|
180
|
+
if (!this._weight && !this._copyable)
|
|
181
|
+
return `<div class="${cls(['fi-ta-text', 'fi-ta-text-item', 'fi-size-sm', this._money || this._numeric ? 'fi-numeric' : null, ...colorClasses, align])}">${e(text)}</div>`;
|
|
182
|
+
const inner = this._copyable
|
|
183
|
+
? `<span x-data="filamentCopyable" x-bind="bindings" x-bind:aria-label="isStandalone ? ${e(js(t('filament::components/copyable.label', { label: text })))} : null" x-on:click.prevent.stop="if (isStandalone) {
|
|
184
|
+
copy(${e(js(text))}, ${e(js(t('filament::components/copyable.messages.copied')))}, 2000, ${e(js(t('filament::components/copyable.messages.failed')))})
|
|
185
|
+
} else {
|
|
186
|
+
window.navigator.clipboard.writeText(${e(js(text))})
|
|
187
|
+
$tooltip(${e(js(t('filament::components/copyable.messages.copied')))}, {
|
|
188
|
+
theme: $store.theme,
|
|
189
|
+
timeout: 2000,
|
|
190
|
+
})
|
|
191
|
+
}" class="fi-copyable">${e(text)}</span>`
|
|
192
|
+
: e(text);
|
|
193
|
+
return `<div class="${cls(['fi-ta-text-item', 'fi-size-sm', this._weight ? `fi-font-${this._weight}` : null, 'fi-ta-text', this._money || this._numeric ? 'fi-numeric' : null, align])}">${inner}</div>`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Résumé d'une colonne sous la table (port de Summarizers\Summarizer) : somme, moyenne, nombre ou étendue. */
|
|
197
|
+
export class Summarizer {
|
|
198
|
+
_label = null;
|
|
199
|
+
_money = null;
|
|
200
|
+
label(label) { this._label = label; return this; }
|
|
201
|
+
money(currency = 'usd') { this._money = currency.toUpperCase(); return this; }
|
|
202
|
+
getLabel() { return this._label; }
|
|
203
|
+
format(value) { return this._money ? new Intl.NumberFormat('en-US', { style: 'currency', currency: this._money }).format(value) : new Intl.NumberFormat('en-US').format(value); }
|
|
204
|
+
}
|
|
205
|
+
/** Total d'une colonne (port du summarizer Sum). */
|
|
206
|
+
export class Sum extends Summarizer {
|
|
207
|
+
kind = 'sum';
|
|
208
|
+
static make() { return configured(new Sum()); }
|
|
209
|
+
getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.sum.label'); }
|
|
210
|
+
summarize(column, values) { return { type: 'text', label: this.getLabel(), value: this.format(values.sum[column] ?? 0) }; }
|
|
211
|
+
}
|
|
212
|
+
/** Moyenne d'une colonne (port du summarizer Average). */
|
|
213
|
+
export class Average extends Summarizer {
|
|
214
|
+
kind = 'avg';
|
|
215
|
+
static make() { return configured(new Average()); }
|
|
216
|
+
getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.average.label'); }
|
|
217
|
+
summarize(column, values) { return { type: 'text', label: this.getLabel(), value: this.format(values.avg[column] ?? 0) }; }
|
|
218
|
+
}
|
|
219
|
+
/** Nombre d'enregistrements (port du summarizer Count). */
|
|
220
|
+
export class Count extends Summarizer {
|
|
221
|
+
kind = 'count';
|
|
222
|
+
static make() { return configured(new Count()); }
|
|
223
|
+
getLabel() { return this._label ?? t('filament-tables::table.summary.summarizers.count.label'); }
|
|
224
|
+
summarize(_column, values) { return { type: 'text', label: this.getLabel(), value: this.format(values.count) }; }
|
|
225
|
+
}
|
|
226
|
+
/** Plus petite et plus grande valeur d'une colonne (port du summarizer Range). */
|
|
227
|
+
export class Range extends Summarizer {
|
|
228
|
+
kind = 'range';
|
|
229
|
+
_minimalDateTime = false;
|
|
230
|
+
static make() { return configured(new Range()); }
|
|
231
|
+
/** Dates : le jour seul quand les deux bornes diffèrent de jour, le jour et l'heure sinon ; une seule valeur si elles sont identiques. */
|
|
232
|
+
minimalDateTimeDifference(condition = true) { this._minimalDateTime = condition; return this; }
|
|
233
|
+
summarize(column, values) {
|
|
234
|
+
let bounds = [values.min[column], values.max[column]].filter((v) => v !== null && v !== undefined && v !== '').map(String);
|
|
235
|
+
if (!bounds.length)
|
|
236
|
+
return null;
|
|
237
|
+
if (this._minimalDateTime && bounds.length === 2) {
|
|
238
|
+
const date = (value) => new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${value.slice(0, 10)}T00:00:00Z`));
|
|
239
|
+
const dateTime = (value) => `${date(value)} ${value.length > 10 ? value.slice(11, 19) : '00:00:00'}`;
|
|
240
|
+
const days = bounds.map(date);
|
|
241
|
+
if (days[0] !== days[1])
|
|
242
|
+
bounds = days;
|
|
243
|
+
else {
|
|
244
|
+
const times = bounds.map(dateTime);
|
|
245
|
+
bounds = times[0] === times[1] ? [times[0]] : times;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { type: 'range', label: this._label, value: bounds.join(' - ') };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Regroupement des lignes d'une table (port de Grouping\Group) : les lignes sont triées par la colonne, un en-tête précède chaque groupe. */
|
|
252
|
+
export class Group {
|
|
253
|
+
id;
|
|
254
|
+
_label = null;
|
|
255
|
+
_column = null;
|
|
256
|
+
_title = null;
|
|
257
|
+
_description = null;
|
|
258
|
+
_collapsible = false;
|
|
259
|
+
_date = false;
|
|
260
|
+
_titlePrefixedWithLabel = true;
|
|
261
|
+
constructor(id) { this.id = id; }
|
|
262
|
+
static make(id) { return configured(new Group(id)); }
|
|
263
|
+
label(label) { this._label = label; return this; }
|
|
264
|
+
/** Colonne de la requête qui porte la valeur du groupe, quand elle diffère de l'identifiant (relation). */
|
|
265
|
+
column(column) { this._column = column; return this; }
|
|
266
|
+
/** Titre du groupe tiré de l'enregistrement (libellé d'une énumération, par exemple). */
|
|
267
|
+
getTitleUsing(resolver) { this._title = resolver; return this; }
|
|
268
|
+
getDescriptionUsing(resolver) { this._description = resolver; return this; }
|
|
269
|
+
/** Noms de Filament (`getTitleFromRecordUsing`, `getDescriptionFromRecordUsing`). */
|
|
270
|
+
getTitleFromRecordUsing(resolver) { return this.getTitleUsing(resolver); }
|
|
271
|
+
getDescriptionFromRecordUsing(resolver) { return this.getDescriptionUsing(resolver); }
|
|
272
|
+
/** Les groupes se replient d'un clic sur leur en-tête. */
|
|
273
|
+
collapsible(condition = true) { this._collapsible = condition; return this; }
|
|
274
|
+
/** La colonne est une date : les lignes d'un même jour forment un groupe. */
|
|
275
|
+
date(condition = true) { this._date = condition; return this; }
|
|
276
|
+
titlePrefixedWithLabel(condition = true) { this._titlePrefixedWithLabel = condition; return this; }
|
|
277
|
+
getId() { return this.id; }
|
|
278
|
+
getLabel() {
|
|
279
|
+
if (this._label !== null)
|
|
280
|
+
return this._label;
|
|
281
|
+
const base = (this.id.includes('.') ? this.id.slice(0, this.id.lastIndexOf('.')) : this.id).split('.').pop() ?? '';
|
|
282
|
+
return base.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[-_]+/g, ' ').toLowerCase().replace(/^./, (c) => c.toUpperCase());
|
|
283
|
+
}
|
|
284
|
+
getColumn() { return this._column ?? this.id; }
|
|
285
|
+
isCollapsible() { return this._collapsible; }
|
|
286
|
+
isDate() { return this._date; }
|
|
287
|
+
isTitlePrefixedWithLabel() { return this._titlePrefixedWithLabel; }
|
|
288
|
+
/** Valeur du groupe pour un enregistrement, en texte ; `null` quand elle est vide. */
|
|
289
|
+
getStringKey(record) {
|
|
290
|
+
let key = record?.[this.getColumn()];
|
|
291
|
+
if (key === null || key === undefined || key === '')
|
|
292
|
+
return null;
|
|
293
|
+
if (typeof key === 'boolean')
|
|
294
|
+
key = key ? 1 : 0;
|
|
295
|
+
return this._date ? String(key).slice(0, 10) : String(key);
|
|
296
|
+
}
|
|
297
|
+
getTitle(record) {
|
|
298
|
+
if (this._title)
|
|
299
|
+
return this._title(record);
|
|
300
|
+
const key = this.getStringKey(record);
|
|
301
|
+
if (key !== null && this._date)
|
|
302
|
+
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${key}T00:00:00Z`));
|
|
303
|
+
return key;
|
|
304
|
+
}
|
|
305
|
+
getDescription(record) { return this._description ? this._description(record) : null; }
|
|
306
|
+
/** Restreint une requête aux enregistrements du groupe de clé donnée. */
|
|
307
|
+
scope(query, key) {
|
|
308
|
+
if (key === null)
|
|
309
|
+
query.where((sub) => sub.whereNull(this.getColumn()).orWhere(this.getColumn(), ''));
|
|
310
|
+
else if (this._date)
|
|
311
|
+
query.whereRaw('date(??) = ?', [this.getColumn(), key]);
|
|
312
|
+
else
|
|
313
|
+
query.where(this.getColumn(), key);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Mise en page des colonnes (port de Tables\Columns\Layout) : dès qu'une table en contient une, ses enregistrements sont
|
|
318
|
+
* rendus en cartes (`fi-ta-content`) et non plus en lignes de tableau ; le tri passe par un sélecteur « Sort by ».
|
|
319
|
+
*/
|
|
320
|
+
export class ColumnLayout {
|
|
321
|
+
_children;
|
|
322
|
+
constructor(children) { this._children = children; }
|
|
323
|
+
getChildren() { return this._children; }
|
|
324
|
+
/** Colonnes feuilles, dans l'ordre : ce sont elles que la table trie, cherche et masque. */
|
|
325
|
+
getColumns() { return this._children.flatMap((child) => (child instanceof ColumnLayout ? child.getColumns() : [child])); }
|
|
326
|
+
}
|
|
327
|
+
/** Colonnes côte à côte, à partir d'un point de rupture (port de Layout\Split). */
|
|
328
|
+
export class Split extends ColumnLayout {
|
|
329
|
+
_from = null;
|
|
330
|
+
static make(children) { return new Split(children); }
|
|
331
|
+
from(breakpoint) { this._from = breakpoint; return this; }
|
|
332
|
+
getClasses() { return ['fi-ta-split', ...(this._from ? [`${this._from}:fi-ta-split`] : [])]; }
|
|
333
|
+
}
|
|
334
|
+
/** Colonnes l'une sous l'autre (port de Layout\Stack). */
|
|
335
|
+
export class Stack extends ColumnLayout {
|
|
336
|
+
_space = null;
|
|
337
|
+
_alignment = 'start';
|
|
338
|
+
static make(children) { return new Stack(children); }
|
|
339
|
+
/** Espace entre les colonnes : 1 (petit), 2 (moyen) ou 3 (grand). */
|
|
340
|
+
space(space = 1) { this._space = space; return this; }
|
|
341
|
+
alignment(alignment) { this._alignment = alignment; return this; }
|
|
342
|
+
getClasses() { return ['fi-ta-stack', `fi-align-${this._alignment}`, ...(this._space ? [`fi-gap-${['sm', 'md', 'lg'][this._space - 1]}`] : [])]; }
|
|
343
|
+
}
|
|
344
|
+
const layoutCell = (classes, html) => `<div class="${['fi-grid-col', 'fi-growable', ...classes].join(' ')}" style="--col-span-default: span 1 / span 1;">${html}</div>`;
|
|
345
|
+
/** Rendu d'un enregistrement selon la mise en page : les colonnes masquées par l'utilisateur sont sautées. */
|
|
346
|
+
async function renderColumnLayout(node, record, key, visible) {
|
|
347
|
+
if (node instanceof ColumnLayout) {
|
|
348
|
+
const children = [];
|
|
349
|
+
for (const child of node.getChildren())
|
|
350
|
+
children.push(await renderColumnLayout(child, record, key, visible));
|
|
351
|
+
return layoutCell([], `<div class="${node.getClasses().join(' ')}">${children.join('')}</div>`);
|
|
352
|
+
}
|
|
353
|
+
if (!visible.has(node))
|
|
354
|
+
return '';
|
|
355
|
+
const alignment = node.getAlignment();
|
|
356
|
+
// Dans une mise en page, le texte est « en ligne » et porte lui-même l'alignement (comme Filament).
|
|
357
|
+
const html = (await node.toHtml(record, key)).replace(/class="([^"]*\bfi-ta-text\b[^"]*)"/, (_match, classes) => `class="${classes.replace(/ fi-align-\w+/g, '')} fi-inline${alignment ? ` fi-align-${alignment}` : ''}"`);
|
|
358
|
+
return layoutCell(['fi-ta-col', ...(alignment ? [`fi-align-${alignment}`] : [])], html);
|
|
359
|
+
}
|
|
360
|
+
/** Groupe de colonnes : un intitulé commun au-dessus des en-têtes (port de ColumnGroup). */
|
|
361
|
+
export class ColumnGroup {
|
|
362
|
+
label;
|
|
363
|
+
_columns = [];
|
|
364
|
+
constructor(label) { this.label = label; }
|
|
365
|
+
static make(label) { return configured(new ColumnGroup(label)); }
|
|
366
|
+
columns(columns) { this._columns = columns; for (const c of columns)
|
|
367
|
+
c._group = this.label; return this; }
|
|
368
|
+
getColumns() { return this._columns; }
|
|
369
|
+
}
|
|
370
|
+
/** Pastille de couleur (port de ColorColumn). */
|
|
371
|
+
export class ColorColumn extends Column {
|
|
372
|
+
static make(name) { return configured(new ColorColumn(name)); }
|
|
373
|
+
async toHtml(record) {
|
|
374
|
+
const state = await this.getState(record);
|
|
375
|
+
// Seules les couleurs hexadécimales ou fonctionnelles simples sont injectées dans le style.
|
|
376
|
+
const color = typeof state === 'string' && /^(#[0-9a-fA-F]{3,8}|(rgb|hsl|oklch)a?\([0-9.,%\s/deg-]+\))$/.test(state.trim()) ? state.trim() : null;
|
|
377
|
+
return `<div class="fi-ta-color">${color ? `<div aria-label="${e(color)}" role="img" class="fi-ta-color-item" style="background-color: ${color};"></div>` : ''}</div>`;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** Image (port partiel de ImageColumn) : l'état est l'adresse de l'image. Seules les adresses locales ou http(s) sont rendues. */
|
|
381
|
+
export class ImageColumn extends Column {
|
|
382
|
+
_alt = null;
|
|
383
|
+
_height = '2.5rem';
|
|
384
|
+
_circular = false;
|
|
385
|
+
static make(name) { return configured(new ImageColumn(name)); }
|
|
386
|
+
alt(resolver) { this._alt = resolver; return this; }
|
|
387
|
+
/** Hauteur CSS de l'image (`2.5rem` par défaut, comme Filament). */
|
|
388
|
+
imageHeight(height) { if (/^[0-9.]+(rem|px|em)$/.test(height))
|
|
389
|
+
this._height = height; return this; }
|
|
390
|
+
circular(condition = true) { this._circular = condition; return this; }
|
|
391
|
+
async toHtml(record) {
|
|
392
|
+
const state = await this.getState(record);
|
|
393
|
+
const src = typeof state === 'string' && /^(\/(?!\/)|https?:\/\/)/.test(state) ? state : null;
|
|
394
|
+
if (!src)
|
|
395
|
+
return '<div class="fi-ta-image"></div>';
|
|
396
|
+
const alt = this._alt?.(record) ?? null;
|
|
397
|
+
return `<div class="fi-ta-image"><img${alt !== null ? ` alt="${e(alt)}"` : ''} src="${e(src)}" style="height: ${this._height};"${this._circular ? ' class="fi-circular"' : ''} /></div>`;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/** Icône selon la valeur ; `boolean()` affiche une coche verte ou une croix rouge (port partiel de IconColumn). */
|
|
401
|
+
export class IconColumn extends Column {
|
|
402
|
+
_boolean = false;
|
|
403
|
+
static make(name) { return configured(new IconColumn(name)); }
|
|
404
|
+
boolean(condition = true) { this._boolean = condition; return this; }
|
|
405
|
+
async toHtml(record) {
|
|
406
|
+
const state = await this.getState(record);
|
|
407
|
+
if (!this._boolean || state === null || state === undefined)
|
|
408
|
+
return '<div class="fi-ta-icon"></div>';
|
|
409
|
+
const on = state === true || state === 1 || state === '1';
|
|
410
|
+
const classes = colorManager?.getComponentClasses('icon-column', on ? 'success' : 'danger') ?? [];
|
|
411
|
+
return `<div class="fi-ta-icon">${icon(on ? 'o-check-circle' : 'o-x-circle', { size: 'lg', attributes: new Attributes().class(classes) })}<span class="fi-sr-only">${e(t(on ? 'filament-tables::table.columns.icon.boolean.true' : 'filament-tables::table.columns.icon.boolean.false'))}</span></div>`;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const isBlank = (value) => value === null || value === undefined || (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0);
|
|
415
|
+
const isNumeric = (value) => (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)));
|
|
416
|
+
/** Règles Laravel reconnues par `rules([...])`, avec leurs messages anglais. Une règle inconnue est refusée à la déclaration plutôt qu'ignorée. */
|
|
417
|
+
const STRING_RULES = {
|
|
418
|
+
boolean: () => ({ name: 'boolean', passes: (v) => [true, false, 0, 1, '0', '1'].includes(v), message: (a) => `The ${a} field must be true or false.` }),
|
|
419
|
+
string: () => ({ name: 'string', passes: (v) => typeof v === 'string', message: (a) => `The ${a} field must be a string.` }),
|
|
420
|
+
numeric: () => ({ name: 'numeric', passes: isNumeric, message: (a) => `The ${a} field must be a number.` }),
|
|
421
|
+
integer: () => ({ name: 'integer', passes: (v) => isNumeric(v) && Number.isInteger(Number(v)) && !/[.eE]/.test(String(v)), message: (a) => `The ${a} field must be an integer.` }),
|
|
422
|
+
email: () => ({ name: 'email', passes: (v) => typeof v === 'string' && /^[^\s@]+@[^\s@]+$/.test(v), message: (a) => `The ${a} field must be a valid email address.` }),
|
|
423
|
+
url: () => ({ name: 'url', passes: (v) => typeof v === 'string' && /^https?:\/\/[^\s/$.?#][^\s]*$/i.test(v), message: (a) => `The ${a} field must be a valid URL.` }),
|
|
424
|
+
min: (n, numeric) => ({ name: 'min', passes: (v) => (numeric && isNumeric(v) ? Number(v) : [...String(v)].length) >= Number(n), message: (a) => numeric ? `The ${a} field must be at least ${n}.` : `The ${a} field must be at least ${n} characters.` }),
|
|
425
|
+
max: (n, numeric) => ({ name: 'max', passes: (v) => (numeric && isNumeric(v) ? Number(v) : [...String(v)].length) <= Number(n), message: (a) => numeric ? `The ${a} field must not be greater than ${n}.` : `The ${a} field must not be greater than ${n} characters.` }),
|
|
426
|
+
in: (list) => ({ name: 'in', passes: (v) => list.split(',').includes(String(v)), message: (a) => `The selected ${a} is invalid.` }),
|
|
427
|
+
};
|
|
428
|
+
/**
|
|
429
|
+
* Base des colonnes modifiables dans la cellule (port des traits CanBeValidated et CanUpdateState de Filament). Le navigateur appelle
|
|
430
|
+
* `updateTableColumnState(colonne, clé, valeur)` sur le composant hôte : la colonne doit être déclarée, visible et active pour cet
|
|
431
|
+
* enregistrement, la valeur passer les règles ; ensuite seulement viennent `beforeStateUpdated`, l'écriture et `afterStateUpdated`.
|
|
432
|
+
* Comme dans Filament, l'écriture ne consulte pas les droits du modèle : `disabled()` reste le moyen de restreindre la modification.
|
|
433
|
+
*/
|
|
434
|
+
export class EditableColumn extends Column {
|
|
435
|
+
_disabled = false;
|
|
436
|
+
_rules = [];
|
|
437
|
+
_validationAttribute = null;
|
|
438
|
+
_validationMessages = {};
|
|
439
|
+
_updateStateUsing = null;
|
|
440
|
+
_beforeStateUpdated = null;
|
|
441
|
+
_afterStateUpdated = null;
|
|
442
|
+
_tooltip = null;
|
|
443
|
+
disabled(condition = true) { this._disabled = condition; return this; }
|
|
444
|
+
async isDisabledFor(record) { return typeof this._disabled === 'function' ? this._disabled(record) : this._disabled; }
|
|
445
|
+
/** Une cellule modifiable n'est ni un lien ni un bouton vers l'enregistrement. */
|
|
446
|
+
isClickDisabled() { return true; }
|
|
447
|
+
isEditable() { return true; }
|
|
448
|
+
/** Règles appliquées à la valeur reçue : chaînes Laravel (`required`, `max:255`, `in:a,b`…) ou règles `{ name, passes, message }`. */
|
|
449
|
+
rules(rules) {
|
|
450
|
+
for (const rule of rules)
|
|
451
|
+
if (typeof rule === 'string' && rule !== 'required' && rule !== 'nullable' && !STRING_RULES[rule.split(':')[0]])
|
|
452
|
+
throw new Error(`Règle de validation non prise en charge par les colonnes : ${rule}`);
|
|
453
|
+
this._rules = [...rules];
|
|
454
|
+
return this;
|
|
455
|
+
}
|
|
456
|
+
getRules() { return this._rules; }
|
|
457
|
+
/** Équivalent de l'ajout de `required` aux règles. */
|
|
458
|
+
required(condition = true) { this._rules = [...this._rules.filter((rule) => rule !== 'required'), ...(condition ? ['required'] : [])]; return this; }
|
|
459
|
+
isRequired() { return this._rules.includes('required'); }
|
|
460
|
+
/** Nom de la colonne dans les messages (par défaut, le libellé avec une minuscule initiale). */
|
|
461
|
+
validationAttribute(label) { this._validationAttribute = label; return this; }
|
|
462
|
+
/** Messages remplaçant ceux de Laravel, par nom de règle : `{ max: 'Trop long.' }`. */
|
|
463
|
+
validationMessages(messages) { this._validationMessages = { ...messages }; return this; }
|
|
464
|
+
getValidationAttribute() { return this._validationAttribute ?? this.getLabel().replace(/^./, (c) => c.toLowerCase()); }
|
|
465
|
+
/** Règles propres au type de colonne (options d'une liste, par exemple), vérifiées après celles de l'application. */
|
|
466
|
+
getImplicitRules() { return []; }
|
|
467
|
+
/**
|
|
468
|
+
* Message d'erreur, ou `null` si la valeur est acceptable. Comme le validateur de Laravel : une valeur vide ne passe que `required`,
|
|
469
|
+
* et plusieurs erreurs donnent le premier message suivi de « (and N more errors) ».
|
|
470
|
+
*/
|
|
471
|
+
validate(input) {
|
|
472
|
+
const attribute = this.getValidationAttribute();
|
|
473
|
+
const blank = isBlank(input);
|
|
474
|
+
const numeric = this._rules.some((rule) => rule === 'numeric' || rule === 'integer');
|
|
475
|
+
const errors = [];
|
|
476
|
+
const fail = (name, message) => errors.push(this._validationMessages[name] ?? message);
|
|
477
|
+
if (blank) {
|
|
478
|
+
if (this.isRequired())
|
|
479
|
+
fail('required', `The ${attribute} field is required.`);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
// Règles du type de colonne d'abord : une valeur de mauvaise forme (liste, objet forgé) s'arrête là.
|
|
483
|
+
const implicit = this.getImplicitRules().find((rule) => !rule.passes(input));
|
|
484
|
+
if (implicit)
|
|
485
|
+
return this._validationMessages[implicit.name] ?? implicit.message(attribute);
|
|
486
|
+
// Les règles Laravel portées ne valent que pour un scalaire : une liste ou un objet les fait échouer.
|
|
487
|
+
const isScalar = ['string', 'number', 'boolean'].includes(typeof input);
|
|
488
|
+
for (const rule of this._rules) {
|
|
489
|
+
if (rule === 'required' || rule === 'nullable')
|
|
490
|
+
continue;
|
|
491
|
+
const resolved = typeof rule === 'string' ? STRING_RULES[rule.split(':')[0]](rule.slice(rule.indexOf(':') + 1), numeric) : rule;
|
|
492
|
+
if (!((typeof rule !== 'string' || isScalar) && resolved.passes(input)))
|
|
493
|
+
fail(resolved.name, resolved.message(attribute));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (!errors.length)
|
|
497
|
+
return null;
|
|
498
|
+
return errors.length === 1 ? errors[0] : `${errors[0]} (and ${errors.length - 1} more error${errors.length > 2 ? 's' : ''})`;
|
|
499
|
+
}
|
|
500
|
+
/** Écriture personnalisée, à la place de la mise à jour de la colonne de l'enregistrement. Sa valeur de retour est renvoyée au navigateur. */
|
|
501
|
+
updateStateUsing(callback) { this._updateStateUsing = callback; return this; }
|
|
502
|
+
beforeStateUpdated(callback) { this._beforeStateUpdated = callback; return this; }
|
|
503
|
+
afterStateUpdated(callback) { this._afterStateUpdated = callback; return this; }
|
|
504
|
+
/** Infobulle au survol du contrôle, tant qu'aucune erreur n'est affichée. */
|
|
505
|
+
tooltip(tooltip) { this._tooltip = tooltip; return this; }
|
|
506
|
+
getUpdateStateMessages() {
|
|
507
|
+
return { saving: t('filament-tables::table.columns.editable.saving'), saved: t('filament-tables::table.columns.editable.saved'), failure: t('filament-tables::table.columns.editable.failure') };
|
|
508
|
+
}
|
|
509
|
+
/** Valeur écrite à partir d'une valeur validée : une valeur vide devient `null` (comme `blank()` dans Filament). */
|
|
510
|
+
castState(input) { return isBlank(input) ? null : input; }
|
|
511
|
+
/**
|
|
512
|
+
* Enregistre une valeur déjà validée (port de `updateState`). Seul le nom déclaré de la colonne est écrit : une colonne de relation
|
|
513
|
+
* (`auteur.nom`) n'est modifiable qu'avec `updateStateUsing()`.
|
|
514
|
+
*/
|
|
515
|
+
async updateState(record, input, adapter) {
|
|
516
|
+
const state = this.castState(input);
|
|
517
|
+
await this._beforeStateUpdated?.({ state, record });
|
|
518
|
+
if (this._updateStateUsing) {
|
|
519
|
+
try {
|
|
520
|
+
return await this._updateStateUsing({ state, record });
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
await this._afterStateUpdated?.({ state, record });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (this.name.includes('.'))
|
|
527
|
+
return { error: t('filament-tables::table.columns.editable.unavailable') };
|
|
528
|
+
await adapter.update(adapter.getKey(record), { [this.name]: state });
|
|
529
|
+
await this._afterStateUpdated?.({ state, record });
|
|
530
|
+
return state;
|
|
531
|
+
}
|
|
532
|
+
/** Expression `x-tooltip` des contrôles modifiables : l'infobulle de la colonne, remplacée par l'erreur de validation quand il y en a une. */
|
|
533
|
+
tooltipExpression(record) {
|
|
534
|
+
const tooltip = typeof this._tooltip === 'function' ? this._tooltip(record) : this._tooltip;
|
|
535
|
+
return `error === undefined ? ${tooltip !== null && tooltip !== '' ? `{
|
|
536
|
+
content: ${js(tooltip)},
|
|
537
|
+
theme: $store.theme,
|
|
538
|
+
allowHTML: false,
|
|
539
|
+
aria: { content: 'auto' },
|
|
540
|
+
}` : 'false'} : {
|
|
541
|
+
content: error,
|
|
542
|
+
theme: $store.theme,
|
|
543
|
+
allowHTML: false,
|
|
544
|
+
aria: { content: null },
|
|
545
|
+
}`;
|
|
546
|
+
}
|
|
547
|
+
/** Ouverture commune des cellules modifiables : composant Alpine chargé à la demande, état serveur et clé de l'enregistrement. */
|
|
548
|
+
editableAttributes(component, asset, recordKey, state) {
|
|
549
|
+
return new Attributes().merge({
|
|
550
|
+
'x-load': 'x-load',
|
|
551
|
+
'x-load-src': views.render('filament-tables::asset', { path: `columns/${asset}` }).trim(),
|
|
552
|
+
'x-data': `${component}({
|
|
553
|
+
messages: ${js(this.getUpdateStateMessages())},
|
|
554
|
+
name: ${js(this.name)},
|
|
555
|
+
recordKey: ${js(String(recordKey))},
|
|
556
|
+
state: ${js(state)},
|
|
557
|
+
})`,
|
|
558
|
+
'x-id': "['table-column-error']",
|
|
559
|
+
'x-on:click.capture': 'preventEdit($event)',
|
|
560
|
+
'x-on:keydown.capture': 'preventEdit($event)',
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
static STATUS_HTML = `<p x-cloak x-show="error" x-bind:id="$id('table-column-error')" x-ref="error" x-text="error" class="fi-sr-only"></p><p role="status" aria-live="polite" aria-atomic="true" x-text="status" class="fi-sr-only"></p>`;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Liste déroulante modifiable dans la cellule (port de SelectColumn, liste native seulement). La valeur reçue doit être l'une des
|
|
567
|
+
* options déclarées.
|
|
568
|
+
*/
|
|
569
|
+
export class SelectColumn extends EditableColumn {
|
|
570
|
+
_options = [];
|
|
571
|
+
_selectablePlaceholder = true;
|
|
572
|
+
static make(name) { return configured(new SelectColumn(name)); }
|
|
573
|
+
options(options) { this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]); return this; }
|
|
574
|
+
getOptions() { return this._options; }
|
|
575
|
+
selectablePlaceholder(condition = true) { this._selectablePlaceholder = condition; return this; }
|
|
576
|
+
getImplicitRules() {
|
|
577
|
+
return [{ name: 'in', passes: (v) => (typeof v === 'string' || typeof v === 'number') && this._options.some(([value]) => value === String(v)), message: (a) => `The selected ${a} is invalid.` }];
|
|
578
|
+
}
|
|
579
|
+
async toHtml(record, recordKey = '') {
|
|
580
|
+
const state = await this.getState(record);
|
|
581
|
+
const value = state === null || state === undefined ? '' : String(state);
|
|
582
|
+
const optionLabel = this._options.find(([option]) => option === value)?.[1] ?? null;
|
|
583
|
+
const isDisabled = await this.isDisabledFor(record);
|
|
584
|
+
const placeholder = t('filament-forms::components.select.placeholder');
|
|
585
|
+
const messages = this.getUpdateStateMessages();
|
|
586
|
+
const config = `selectTableColumn({
|
|
587
|
+
accessibilityAttributes: [],
|
|
588
|
+
ariaLabel: ${js(this.getLabel())},
|
|
589
|
+
canOptionLabelsWrap: true,
|
|
590
|
+
canSelectPlaceholder: ${js(this._selectablePlaceholder)},
|
|
591
|
+
clearButtonLabel: ${js(t('filament-forms::components.select.actions.clear.label'))},
|
|
592
|
+
errorMessage: ${js(t('filament-forms::components.select.error_message'))},
|
|
593
|
+
hasDynamicOptions: false,
|
|
594
|
+
hasDynamicSearchResults: false,
|
|
595
|
+
hasInitialNoOptionsMessage: true,
|
|
596
|
+
initialOptionLabel: ${js(optionLabel)},
|
|
597
|
+
isDisabled: ${js(isDisabled)},
|
|
598
|
+
isHtmlAllowed: false,
|
|
599
|
+
isNative: true,
|
|
600
|
+
isSearchable: false,
|
|
601
|
+
loadingMessage: ${js(t('filament-forms::components.select.loading_message'))},
|
|
602
|
+
messages: ${js(messages)},
|
|
603
|
+
name: ${js(this.name)},
|
|
604
|
+
noOptionsMessage: ${js(t('filament-forms::components.select.no_options_message'))},
|
|
605
|
+
noSearchResultsMessage: ${js(t('filament-forms::components.select.no_search_results_message'))},
|
|
606
|
+
options: [],
|
|
607
|
+
optionsLimit: 50,
|
|
608
|
+
placeholder: ${js(placeholder)},
|
|
609
|
+
position: null,
|
|
610
|
+
recordKey: ${js(String(recordKey))},
|
|
611
|
+
searchableOptionFields: ${js(['label'])},
|
|
612
|
+
searchDebounce: 1000,
|
|
613
|
+
searchingMessage: ${js(t('filament-forms::components.select.searching_message'))},
|
|
614
|
+
searchLabel: ${js(t('filament-forms::components.select.search_label'))},
|
|
615
|
+
searchPrompt: ${js(t('filament-forms::components.select.search_prompt'))},
|
|
616
|
+
state: ${js(state === null || state === undefined ? null : value)},
|
|
617
|
+
})`;
|
|
618
|
+
const select = new Attributes().merge({
|
|
619
|
+
'aria-label': e(this.getLabel()), disabled: true, 'wire:loading.attr': 'disabled', 'wire:target': LOADING_TARGETS,
|
|
620
|
+
'x-bind:aria-invalid': "error ? 'true' : null", 'x-bind:aria-busy': "isLoading ? 'true' : null",
|
|
621
|
+
'x-bind:aria-describedby': "getAccessibilityAttributes([])[ 'aria-describedby' ]", 'x-bind:disabled': isDisabled ? null : 'isLoading', 'x-on:change': 'clearError()',
|
|
622
|
+
'x-tooltip': `error === undefined ? false : {
|
|
623
|
+
content: error,
|
|
624
|
+
theme: $store.theme,
|
|
625
|
+
allowHTML: false,
|
|
626
|
+
aria: { content: null },
|
|
627
|
+
|
|
628
|
+
}`,
|
|
629
|
+
}).class(['fi-select-input']);
|
|
630
|
+
const options = `${this._selectablePlaceholder ? `<option value="">${e(placeholder)}</option>` : ''}${this._options.map(([option, label]) => `<option value="${e(option)}">${e(label)}</option>`).join('')}`;
|
|
631
|
+
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="fi-ta-select"><input type="hidden" value="${e(value)}" data-option-label="${e(optionLabel ?? '')}" data-disabled="${isDisabled ? 'true' : 'false'}" x-ref="serverState" /><div x-bind:class="{
|
|
632
|
+
'fi-disabled': isLoading || ${js(isDisabled)},
|
|
633
|
+
'fi-invalid': error !== undefined,
|
|
634
|
+
}" x-on:click.prevent.stop class="fi-input-wrp"><select x-model="state" ${select.toHtml()}>${options}</select></div><p x-cloak x-show="error" x-bind:id="$id('table-column-error')" x-ref="error" x-text="error" class="fi-sr-only"></p><p role="status" aria-live="polite" aria-atomic="true" x-text="status" class="fi-sr-only"></p></div>`;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/** Valeur booléenne reçue du navigateur (déjà passée par la règle `boolean`) : `true`, `false` ou `null`. */
|
|
638
|
+
const toBoolean = (value) => (isBlank(value) ? null : value === true || value === 1 || value === '1');
|
|
639
|
+
const ALIGNMENTS = ['start', 'center', 'end', 'left', 'right', 'justify', 'between'];
|
|
640
|
+
const alignmentClass = (alignment) => (alignment && ALIGNMENTS.includes(alignment) ? `fi-align-${alignment}` : null);
|
|
641
|
+
/** Interrupteur modifiable dans la cellule (port de ToggleColumn). La valeur reçue doit être un booléen (règle `boolean` par défaut). */
|
|
642
|
+
export class ToggleColumn extends EditableColumn {
|
|
643
|
+
_onColor = null;
|
|
644
|
+
_offColor = null;
|
|
645
|
+
_onIcon = null;
|
|
646
|
+
_offIcon = null;
|
|
647
|
+
constructor(name) { super(name); this._rules = ['boolean']; }
|
|
648
|
+
static make(name) { return configured(new ToggleColumn(name)); }
|
|
649
|
+
/** Couleur de l'interrupteur allumé (`primary` par défaut) et éteint (`gray`). */
|
|
650
|
+
onColor(color) { this._onColor = color; return this; }
|
|
651
|
+
offColor(color) { this._offColor = color; return this; }
|
|
652
|
+
/** Icônes affichées dans le bouton de l'interrupteur. */
|
|
653
|
+
onIcon(icon) { this._onIcon = icon; return this; }
|
|
654
|
+
offIcon(icon) { this._offIcon = icon; return this; }
|
|
655
|
+
castState(input) { return toBoolean(input); }
|
|
656
|
+
async toHtml(record, recordKey = '') {
|
|
657
|
+
const state = Boolean(await this.getState(record));
|
|
658
|
+
const isDisabled = await this.isDisabledFor(record);
|
|
659
|
+
// Le gris est la couleur par défaut de l'interrupteur : il ne pose aucune classe (HasDefaultGrayColor).
|
|
660
|
+
const colorClasses = (color) => (color === 'gray' ? [] : (colorManager?.getComponentClasses('toggle', color) ?? []));
|
|
661
|
+
const onColorClasses = colorClasses(this._onColor ?? 'primary');
|
|
662
|
+
const onClasses = ['fi-toggle-on', ...onColorClasses].join(' ');
|
|
663
|
+
const offClasses = ['fi-toggle-off', ...colorClasses(this._offColor ?? 'gray')].join(' ');
|
|
664
|
+
const toggleIcon = (name) => (name ? icon(name, { size: 'xs' }) : '');
|
|
665
|
+
const outer = this.editableAttributes('toggleTableColumn', 'toggle', recordKey, state).class(['fi-ta-toggle', alignmentClass(this._alignment)]);
|
|
666
|
+
const button = new Attributes().merge({
|
|
667
|
+
'aria-disabled': 'true', 'aria-label': e(this.getLabel()), disabled: isDisabled, 'wire:loading.attr': 'disabled', 'wire:target': LOADING_TARGETS,
|
|
668
|
+
'x-bind:aria-invalid': "error ? 'true' : null", 'x-bind:aria-busy': "isLoading ? 'true' : null", 'x-bind:aria-describedby': "getAccessibilityAttributes()['aria-describedby']",
|
|
669
|
+
'x-tooltip': this.tooltipExpression(record),
|
|
670
|
+
}).class(['fi-toggle']);
|
|
671
|
+
// Interrupteur allumé : masqué (x-cloak) jusqu'au démarrage d'Alpine, remplacé en attendant par une copie statique, comme dans Filament.
|
|
672
|
+
return `<div wire:ignore.self ${outer.toHtml()}><input type="hidden" value="${state ? 1 : 0}" x-ref="serverState" /><div aria-checked="${state ? 'true' : 'false'}" x-bind:aria-checked="state ? 'true' : 'false'" x-bind:tabindex="$el.hasAttribute('disabled') ? '-1' : '0'" x-bind:aria-disabled="isLoading || $el.hasAttribute('disabled') ? 'true' : null" x-on:click.prevent.stop="if (! $el.hasAttribute('disabled')) { clearError(); state = ! state }" x-on:keydown.enter.prevent.stop="if (! $el.hasAttribute('disabled')) { clearError(); state = ! state }" x-on:keydown.space.prevent.stop="if (! $el.hasAttribute('disabled')) { clearError(); state = ! state }" x-bind:class="state ? ${js(onClasses)} : ${js(offClasses)}"${state ? ' x-cloak' : ''} role="switch" ${button.toHtml()}><div><div aria-hidden="true">${toggleIcon(this._offIcon)}</div><div aria-hidden="true">${toggleIcon(this._onIcon)}</div></div></div>${state ? `<div x-cloak="inline-flex" wire:ignore class="${e(['fi-toggle fi-toggle-on fi-hidden', ...onColorClasses].join(' '))}"><div><div aria-hidden="true"></div><div aria-hidden="true">${toggleIcon(this._onIcon)}</div></div></div>` : ''}${EditableColumn.STATUS_HTML}</div>`;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/** Case à cocher modifiable dans la cellule (port de CheckboxColumn). La valeur reçue doit être un booléen (règle `boolean` par défaut). */
|
|
676
|
+
export class CheckboxColumn extends EditableColumn {
|
|
677
|
+
constructor(name) { super(name); this._rules = ['boolean']; }
|
|
678
|
+
static make(name) { return configured(new CheckboxColumn(name)); }
|
|
679
|
+
castState(input) { return toBoolean(input); }
|
|
680
|
+
async toHtml(record, recordKey = '') {
|
|
681
|
+
const state = Boolean(await this.getState(record));
|
|
682
|
+
const isDisabled = await this.isDisabledFor(record);
|
|
683
|
+
const outer = this.editableAttributes('checkboxTableColumn', 'checkbox', recordKey, state).class(['fi-ta-checkbox', alignmentClass(this._alignment)]);
|
|
684
|
+
const input = new Attributes().merge({
|
|
685
|
+
'aria-label': e(this.getLabel()), disabled: true, 'wire:loading.attr': 'disabled', 'wire:target': LOADING_TARGETS,
|
|
686
|
+
'x-bind:aria-invalid': "error ? 'true' : null", 'x-bind:aria-busy': "isLoading ? 'true' : null", 'x-bind:aria-describedby': "getAccessibilityAttributes([])[ 'aria-describedby' ]",
|
|
687
|
+
'x-bind:disabled': isDisabled ? null : 'isLoading', 'x-on:change': 'clearError()', 'x-tooltip': this.tooltipExpression(record),
|
|
688
|
+
}).class(['fi-checkbox-input']);
|
|
689
|
+
return `<div x-on:click.stop wire:ignore.self ${outer.toHtml()}><input type="hidden" value="${state ? 1 : 0}" x-ref="serverState" /><input type="checkbox" x-bind:class="{
|
|
690
|
+
'fi-valid': ! error,
|
|
691
|
+
'fi-invalid': error,
|
|
692
|
+
}" x-model="state" ${input.toHtml()} />${EditableColumn.STATUS_HTML}</div>`;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/** Champ de saisie modifiable dans la cellule (port de TextInputColumn). Seuls un texte ou un nombre sont acceptés du navigateur. */
|
|
696
|
+
export class TextInputColumn extends EditableColumn {
|
|
697
|
+
_type = 'text';
|
|
698
|
+
_mask = null;
|
|
699
|
+
_inputMode = null;
|
|
700
|
+
_step = null;
|
|
701
|
+
_placeholder = null;
|
|
702
|
+
_prefix = null;
|
|
703
|
+
_suffix = null;
|
|
704
|
+
_prefixIcon = null;
|
|
705
|
+
_suffixIcon = null;
|
|
706
|
+
_prefixIconColor = null;
|
|
707
|
+
_suffixIconColor = null;
|
|
708
|
+
_prefixInline = false;
|
|
709
|
+
_suffixInline = false;
|
|
710
|
+
static make(name) { return configured(new TextInputColumn(name)); }
|
|
711
|
+
/** Type HTML du champ (`text` par défaut ; `number`, `email`, `color`…). */
|
|
712
|
+
type(type) { this._type = type && /^[a-z-]+$/.test(type) ? type : 'text'; return this; }
|
|
713
|
+
/** Masque de saisie Alpine (`x-mask`) ; le champ devient alors un champ texte. */
|
|
714
|
+
mask(mask) { this._mask = mask; return this; }
|
|
715
|
+
inputMode(mode) { this._inputMode = mode; return this; }
|
|
716
|
+
step(step) { this._step = step; return this; }
|
|
717
|
+
placeholder(text) { this._placeholder = text; return this; }
|
|
718
|
+
prefix(label, isInline = false) { this._prefix = label; this._prefixInline = isInline; return this; }
|
|
719
|
+
suffix(label, isInline = false) { this._suffix = label; this._suffixInline = isInline; return this; }
|
|
720
|
+
inlinePrefix(condition = true) { this._prefixInline = condition; return this; }
|
|
721
|
+
inlineSuffix(condition = true) { this._suffixInline = condition; return this; }
|
|
722
|
+
prefixIcon(name, isInline = false) { this._prefixIcon = name; this._prefixInline = isInline; return this; }
|
|
723
|
+
suffixIcon(name, isInline = false) { this._suffixIcon = name; this._suffixInline = isInline; return this; }
|
|
724
|
+
prefixIconColor(color) { this._prefixIconColor = color; return this; }
|
|
725
|
+
suffixIconColor(color) { this._suffixIconColor = color; return this; }
|
|
726
|
+
getImplicitRules() {
|
|
727
|
+
return [{ name: 'string', passes: (v) => typeof v === 'string' || (typeof v === 'number' && Number.isFinite(v)), message: (a) => `The ${a} field must be a string.` }];
|
|
728
|
+
}
|
|
729
|
+
async toHtml(record, recordKey = '') {
|
|
730
|
+
const raw = await this.getState(record);
|
|
731
|
+
const state = raw === undefined ? null : raw;
|
|
732
|
+
const isDisabled = await this.isDisabledFor(record);
|
|
733
|
+
const type = this._mask ? 'text' : this._type;
|
|
734
|
+
// Icône d'affixe : la couleur grise est celle par défaut, sans classe (comme InputComponent\WrapperComponent\IconComponent).
|
|
735
|
+
const affixIcon = (name, color) => (name ? icon(name, { attributes: new Attributes().class(color && color !== 'gray' ? ['fi-color', `fi-color-${color.replace(/[^a-zA-Z0-9_-]/g, '')}`] : []) }) : '');
|
|
736
|
+
const outer = this.editableAttributes('textInputTableColumn', 'text-input', recordKey, state).class(['fi-ta-text-input']);
|
|
737
|
+
const input = new Attributes().merge({
|
|
738
|
+
'aria-label': e(this.getLabel()), disabled: true, 'wire:loading.attr': 'disabled', 'wire:target': LOADING_TARGETS,
|
|
739
|
+
'x-bind:aria-invalid': "error ? 'true' : null", 'x-bind:aria-busy': "isLoading ? 'true' : null", 'x-bind:aria-describedby': "getAccessibilityAttributes([])[ 'aria-describedby' ]",
|
|
740
|
+
'x-bind:disabled': isDisabled ? null : 'isLoading', 'x-on:input': 'clearError()', inputmode: this._inputMode, placeholder: this._placeholder ? e(this._placeholder) : null, step: this._step,
|
|
741
|
+
type, 'x-mask': this._mask, 'x-tooltip': this.tooltipExpression(record),
|
|
742
|
+
}).class(['fi-input', alignmentClass(this._alignment ?? 'start')]);
|
|
743
|
+
const prefix = this._prefixIcon || this._prefix ? `<div class="fi-input-wrp-prefix fi-input-wrp-prefix-has-content ${this._prefixInline ? 'fi-inline' : ''} ${this._prefix ? 'fi-input-wrp-prefix-has-label' : ''}">${affixIcon(this._prefixIcon, this._prefixIconColor)}${this._prefix ? `<span class="fi-input-wrp-label">${e(this._prefix)}</span>` : ''}</div>` : '';
|
|
744
|
+
const suffix = this._suffixIcon || this._suffix ? `<div class="fi-input-wrp-suffix ${this._suffixInline ? 'fi-inline' : ''} ${this._suffix ? 'fi-input-wrp-suffix-has-label' : ''}">${this._suffix ? `<span class="fi-input-wrp-label">${e(this._suffix)}</span>` : ''}${affixIcon(this._suffixIcon, this._suffixIconColor)}</div>` : '';
|
|
745
|
+
return `<div wire:ignore.self ${outer.toHtml()}><input type="hidden" value="${e(state)}" x-ref="serverState" /><div x-bind:class="{
|
|
746
|
+
'fi-disabled': isLoading || ${js(isDisabled)},
|
|
747
|
+
'fi-invalid': error !== undefined,
|
|
748
|
+
}" x-on:click.prevent.stop class="fi-input-wrp">${prefix}<div class="fi-input-wrp-content-ctn"><input${type === 'color' ? ` onclick="if (typeof this.showPicker === 'function') { this.showPicker() }"` : ''} x-model.lazy="state" ${input.toHtml()} /></div>${suffix}</div>${EditableColumn.STATUS_HTML}</div>`;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Colonne au rendu libre (port de ViewColumn) : `view()` reçoit une fonction qui renvoie le HTML de la cellule, ou le nom d'une vue EJS
|
|
753
|
+
* rendue avec `{ state, record, column }`. Ce HTML est celui de l'application : les données qu'il contient doivent y être échappées (`e()`).
|
|
754
|
+
*/
|
|
755
|
+
export class ViewColumn extends Column {
|
|
756
|
+
_view = null;
|
|
757
|
+
_viewData = {};
|
|
758
|
+
static make(name) { return configured(new ViewColumn(name)); }
|
|
759
|
+
view(view, data = {}) { this._view = view; this._viewData = data; return this; }
|
|
760
|
+
async toHtml(record) {
|
|
761
|
+
const state = await this.getState(record);
|
|
762
|
+
if (this._view === null)
|
|
763
|
+
throw new Error(`ViewColumn ${this.name} : aucune vue déclarée (view()).`);
|
|
764
|
+
if (typeof this._view === 'function')
|
|
765
|
+
return this._view({ state, record, column: this });
|
|
766
|
+
return views.render(this._view, { ...this._viewData, state, record, column: this, getState: () => state, getRecord: () => record });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/** @deprecated Port de BadgeColumn : utiliser `TextColumn.make(nom).badge()`. */
|
|
770
|
+
export class BadgeColumn extends TextColumn {
|
|
771
|
+
constructor(name) { super(name); this.badge(); }
|
|
772
|
+
static make(name) { return configured(new BadgeColumn(name)); }
|
|
773
|
+
}
|
|
774
|
+
/** @deprecated Port de BooleanColumn : utiliser `IconColumn.make(nom).boolean()`. */
|
|
775
|
+
export class BooleanColumn extends IconColumn {
|
|
776
|
+
constructor(name) { super(name); this.boolean(); }
|
|
777
|
+
static make(name) { return configured(new BooleanColumn(name)); }
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Écriture d'une cellule modifiable reçue du navigateur (port de `updateTableColumnState`). Tout est revérifié : la colonne doit
|
|
781
|
+
* être déclarée, modifiable et visible, l'enregistrement dans la portée de l'adaptateur et non désactivé pour cette colonne,
|
|
782
|
+
* `authorize` (droit de modification de l'hôte) l'accepter, et la valeur passer les règles de la colonne.
|
|
783
|
+
*/
|
|
784
|
+
export async function updateTableColumnState(table, host, adapter, columnName, recordKey, input, options = {}) {
|
|
785
|
+
const unavailable = { error: t('filament-tables::table.columns.editable.unavailable') };
|
|
786
|
+
const column = getVisibleColumns(table, host).find((c) => c.name === columnName);
|
|
787
|
+
if (!(column instanceof EditableColumn))
|
|
788
|
+
return unavailable;
|
|
789
|
+
if (typeof recordKey !== 'string' && typeof recordKey !== 'number')
|
|
790
|
+
return unavailable;
|
|
791
|
+
const record = (await adapter.keysInScope([String(recordKey)])).length ? await adapter.find(String(recordKey)) : null;
|
|
792
|
+
if (!record || (options.authorize && !(await options.authorize(record))) || (await column.isDisabledFor(record)))
|
|
793
|
+
return unavailable;
|
|
794
|
+
const error = column.validate(input);
|
|
795
|
+
if (error !== null)
|
|
796
|
+
return { error };
|
|
797
|
+
return column.updateState(record, input, adapter);
|
|
798
|
+
}
|
|
799
|
+
/** Filtre « corbeille » des modèles à suppression douce (port de TrashedFilter). */
|
|
800
|
+
export class TrashedFilter {
|
|
801
|
+
name = 'trashed';
|
|
802
|
+
static make() { return configured(new TrashedFilter()); }
|
|
803
|
+
getLabel() { return t('filament-tables::table.filters.trashed.label'); }
|
|
804
|
+
getDefaultState() { return { value: null }; }
|
|
805
|
+
getFormDefinition() {
|
|
806
|
+
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')]] };
|
|
807
|
+
}
|
|
808
|
+
isActive(state) { return ['1', '0'].includes(String(state.value)); }
|
|
809
|
+
apply(query, state) {
|
|
810
|
+
query.trashed = String(state.value) === '1' ? 'with' : String(state.value) === '0' ? 'only' : 'without';
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
const filterLabel = (name) => name.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
|
814
|
+
/** Filtre par valeur d'une colonne, choisie dans une liste (port de SelectFilter, sélection simple). */
|
|
815
|
+
export class SelectFilter {
|
|
816
|
+
name;
|
|
817
|
+
_label = null;
|
|
818
|
+
_column = null;
|
|
819
|
+
_options = [];
|
|
820
|
+
constructor(name) { this.name = name; }
|
|
821
|
+
static make(name) { return configured(new SelectFilter(name)); }
|
|
822
|
+
label(label) { this._label = label; return this; }
|
|
823
|
+
/** Colonne filtrée quand elle diffère du nom du filtre (clé étrangère d'une relation, par exemple). */
|
|
824
|
+
column(column) { this._column = column; return this; }
|
|
825
|
+
/** Options fixes, ou fonction asynchrone (enregistrements d'une relation). */
|
|
826
|
+
options(options) {
|
|
827
|
+
this._options = typeof options === 'function' ? options : (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
|
|
828
|
+
return this;
|
|
829
|
+
}
|
|
830
|
+
getLabel() { return this._label ?? filterLabel(this.name); }
|
|
831
|
+
getDefaultState() { return { value: null }; }
|
|
832
|
+
getFormDefinition() { return { placeholder: t('filament-tables::table.filters.select.placeholder'), options: Array.isArray(this._options) ? this._options : [] }; }
|
|
833
|
+
getFormOptions() { return typeof this._options === 'function' ? this._options() : Promise.resolve(this._options); }
|
|
834
|
+
/** Comme Filament : les options d'un SelectFilter sont évaluées à chaque rendu, le sélecteur porte donc une clé dérivée d'elles. */
|
|
835
|
+
hasDynamicOptions = true;
|
|
836
|
+
isActive(state) { return typeof state.value === 'string' && state.value !== ''; }
|
|
837
|
+
apply(query, state) {
|
|
838
|
+
if (!this.isActive(state))
|
|
839
|
+
return;
|
|
840
|
+
const value = String(state.value).slice(0, 255);
|
|
841
|
+
// Options fixes : seule une valeur proposée filtre. Options chargées : la valeur est un paramètre lié, une valeur inconnue ne trouve rien.
|
|
842
|
+
if (Array.isArray(this._options) && !this._options.some(([option]) => option === value))
|
|
843
|
+
return;
|
|
844
|
+
const column = this._column ?? this.name;
|
|
845
|
+
(query.modifiers ??= []).push((q) => { q.where(column, value); });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/** Filtre à trois états sur une colonne booléenne : tous, oui, non (port de TernaryFilter). */
|
|
849
|
+
export class TernaryFilter {
|
|
850
|
+
name;
|
|
851
|
+
_label = null;
|
|
852
|
+
constructor(name) { this.name = name; }
|
|
853
|
+
static make(name) { return configured(new TernaryFilter(name)); }
|
|
854
|
+
label(label) { this._label = label; return this; }
|
|
855
|
+
getLabel() { return this._label ?? filterLabel(this.name); }
|
|
856
|
+
getDefaultState() { return { value: null }; }
|
|
857
|
+
getFormDefinition() { return { placeholder: '-', options: [['1', t('filament-forms::components.select.boolean.true')], ['0', t('filament-forms::components.select.boolean.false')]] }; }
|
|
858
|
+
isActive(state) { return ['1', '0'].includes(String(state.value)); }
|
|
859
|
+
apply(query, state) {
|
|
860
|
+
if (!this.isActive(state))
|
|
861
|
+
return;
|
|
862
|
+
const column = this.name, on = String(state.value) === '1';
|
|
863
|
+
(query.modifiers ??= []).push((q) => { q.where(column, on ? 1 : 0); });
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
/** Filtre à formulaire libre (port de Filter avec `schema()`) : la requête et les indicateurs sont écrits par l'application. */
|
|
867
|
+
export class Filter {
|
|
868
|
+
name;
|
|
869
|
+
_label = null;
|
|
870
|
+
_components = [];
|
|
871
|
+
_query = null;
|
|
872
|
+
_indicate = null;
|
|
873
|
+
constructor(name) { this.name = name; }
|
|
874
|
+
static make(name) { return configured(new Filter(name)); }
|
|
875
|
+
label(label) { this._label = label; return this; }
|
|
876
|
+
/** Champs du filtre ; leurs noms sont les seules clés d'état acceptées du navigateur. */
|
|
877
|
+
schema(components) { this._components = components; return this; }
|
|
878
|
+
/** Condition posée sur la requête à partir de l'état nettoyé du formulaire. */
|
|
879
|
+
query(modifier) { this._query = modifier; return this; }
|
|
880
|
+
/** Pastilles des valeurs appliquées : `{ champ: libellé }` ; retirer une pastille efface son champ. */
|
|
881
|
+
indicateUsing(resolver) { this._indicate = resolver; return this; }
|
|
882
|
+
fields() { return this._components.map((c) => (typeof c?.getName === 'function' ? String(c.getName()) : null)).filter((name) => name !== null); }
|
|
883
|
+
getLabel() { return this._label ?? filterLabel(this.name); }
|
|
884
|
+
getDefaultState() { return Object.fromEntries(this.fields().map((name) => [name, null])); }
|
|
885
|
+
getFormDefinition() { return { placeholder: '', options: [] }; }
|
|
886
|
+
getFormComponents() { return this._components; }
|
|
887
|
+
sanitizeState(state) {
|
|
888
|
+
const received = state && typeof state === 'object' ? state : {};
|
|
889
|
+
return Object.fromEntries(this.fields().map((name) => { const value = received[name]; return [name, ['string', 'number', 'boolean'].includes(typeof value) && value !== '' ? value : null]; }));
|
|
890
|
+
}
|
|
891
|
+
isActive(state) { return Object.values(this.sanitizeState(state)).some((value) => value !== null); }
|
|
892
|
+
apply(query, state) {
|
|
893
|
+
const data = this.sanitizeState(state);
|
|
894
|
+
if (this._query && this.isActive(data))
|
|
895
|
+
(query.modifiers ??= []).push((q) => { this._query(q, data); });
|
|
896
|
+
}
|
|
897
|
+
getIndicators(state) {
|
|
898
|
+
const data = this.sanitizeState(state);
|
|
899
|
+
if (!this._indicate)
|
|
900
|
+
return this.isActive(data) ? [{ label: this.getLabel(), field: '' }] : [];
|
|
901
|
+
return Object.entries(this._indicate(data)).map(([field, label]) => ({ field, label }));
|
|
902
|
+
}
|
|
903
|
+
removeIndicator(state, field) { return field === '' ? this.getDefaultState() : { ...this.sanitizeState(state), [field]: null }; }
|
|
904
|
+
}
|
|
905
|
+
export class Table {
|
|
906
|
+
_columns = [];
|
|
907
|
+
_recordAction = null;
|
|
908
|
+
_recordActions = [];
|
|
909
|
+
_heading = null;
|
|
910
|
+
_headerActions = [];
|
|
911
|
+
_bulkActions = null;
|
|
912
|
+
_recordClasses = null;
|
|
913
|
+
_filters = [];
|
|
914
|
+
_filtersLayout = 'dropdown';
|
|
915
|
+
_defaultSort = null;
|
|
916
|
+
_recordUrl = null;
|
|
917
|
+
_pageOptions = [5, 10, 25, 50];
|
|
918
|
+
_stackedOnMobile = false;
|
|
919
|
+
_label = 'records';
|
|
920
|
+
_groups = [];
|
|
921
|
+
_defaultGroup = null;
|
|
922
|
+
_defaultGroupDirection = 'asc';
|
|
923
|
+
_groupsOnly = false;
|
|
924
|
+
_groupingSettingsHidden = false;
|
|
925
|
+
_groupingDirectionSettingHidden = false;
|
|
926
|
+
_groupingSettingsInDropdownOnDesktop = false;
|
|
927
|
+
_collapsedGroupsByDefault = false;
|
|
928
|
+
/** Regroupements proposés dans la barre d'outils (un identifiant de colonne vaut `Group.make(identifiant)`). */
|
|
929
|
+
groups(groups) { this._groups = groups.map((g) => (typeof g === 'string' ? Group.make(g) : g)); return this; }
|
|
930
|
+
getGroups() { return this._groups; }
|
|
931
|
+
getGroup(id) { return this._groups.find((g) => g.getId() === id) ?? null; }
|
|
932
|
+
/**
|
|
933
|
+
* Regroupement par défaut (port de `defaultGroup()`). S'il fait partie de `groups()`, il est seulement présélectionné à l'ouverture
|
|
934
|
+
* (`initializeTableGrouping`) et l'utilisateur peut le retirer ; sinon il s'applique toujours, sans sélecteur.
|
|
935
|
+
*/
|
|
936
|
+
defaultGroup(group, direction = 'asc') { this._defaultGroup = group; this._defaultGroupDirection = direction === 'desc' ? 'desc' : 'asc'; return this; }
|
|
937
|
+
getDefaultGroup() {
|
|
938
|
+
if (this._defaultGroup === null)
|
|
939
|
+
return null;
|
|
940
|
+
if (this._defaultGroup instanceof Group)
|
|
941
|
+
return this._defaultGroup;
|
|
942
|
+
return this.getGroup(this._defaultGroup) ?? Group.make(this._defaultGroup);
|
|
943
|
+
}
|
|
944
|
+
getDefaultGroupDirection() { return this._defaultGroupDirection; }
|
|
945
|
+
isDefaultGroupSelectable() { const group = this.getDefaultGroup(); return group !== null && this.getGroup(group.getId()) !== null; }
|
|
946
|
+
/** Seuls les en-têtes et résumés des groupes sont affichés, sans les lignes ni la pagination (port de `groupsOnly()`). */
|
|
947
|
+
groupsOnly(condition = true) { this._groupsOnly = condition; return this; }
|
|
948
|
+
isGroupsOnly() { return this._groupsOnly; }
|
|
949
|
+
/** Masque le sélecteur de regroupement de la barre d'outils. */
|
|
950
|
+
groupingSettingsHidden(condition = true) { this._groupingSettingsHidden = condition; return this; }
|
|
951
|
+
areGroupingSettingsHidden() { return this._groupingSettingsHidden; }
|
|
952
|
+
/** Masque le choix du sens (croissant / décroissant) du regroupement. */
|
|
953
|
+
groupingDirectionSettingHidden(condition = true) { this._groupingDirectionSettingHidden = condition; return this; }
|
|
954
|
+
isGroupingDirectionSettingHidden() { return this._groupingDirectionSettingHidden; }
|
|
955
|
+
/** Sur grand écran aussi, le sélecteur de regroupement est dans un menu déroulant. */
|
|
956
|
+
groupingSettingsInDropdownOnDesktop(condition = true) { this._groupingSettingsInDropdownOnDesktop = condition; return this; }
|
|
957
|
+
/** @deprecated Ancien nom de `groupingSettingsInDropdownOnDesktop()`. */
|
|
958
|
+
groupsInDropdownOnDesktop(condition = true) { return this.groupingSettingsInDropdownOnDesktop(condition); }
|
|
959
|
+
areGroupingSettingsInDropdownOnDesktop() { return this._groupingSettingsInDropdownOnDesktop; }
|
|
960
|
+
/** Les groupes repliables s'affichent repliés. */
|
|
961
|
+
collapsedGroupsByDefault(condition = true) { this._collapsedGroupsByDefault = condition; return this; }
|
|
962
|
+
areGroupsCollapsedByDefault() { return this._collapsedGroupsByDefault; }
|
|
963
|
+
_columnsLayout = null;
|
|
964
|
+
columns(columns) {
|
|
965
|
+
this._columns = columns.flatMap((c) => (c instanceof ColumnGroup || c instanceof ColumnLayout ? c.getColumns() : [c]));
|
|
966
|
+
this._columnsLayout = columns.some((c) => c instanceof ColumnLayout) ? columns.filter((c) => !(c instanceof ColumnGroup)) : null;
|
|
967
|
+
return this;
|
|
968
|
+
}
|
|
969
|
+
/** Mise en page des colonnes, ou `null` pour un tableau classique. */
|
|
970
|
+
getColumnsLayout() { return this._columnsLayout; }
|
|
971
|
+
/** Action montée au clic sur une ligne (ex. `'edit'`), à la place d'une URL. */
|
|
972
|
+
recordAction(name) { this._recordAction = name; return this; }
|
|
973
|
+
getRecordAction() { return this._recordAction; }
|
|
974
|
+
/** Titre affiché dans l'en-tête de la table. */
|
|
975
|
+
heading(heading) { this._heading = heading; return this; }
|
|
976
|
+
getHeading() { return this._heading; }
|
|
977
|
+
/** Actions affichées dans l'en-tête de la table. */
|
|
978
|
+
headerActions(actions) { this._headerActions = actions; return this; }
|
|
979
|
+
getHeaderActions() { return this._headerActions; }
|
|
980
|
+
/** Actions groupées (menu « Bulk actions ») ; sans appel, l'hôte fournit ses actions par défaut. */
|
|
981
|
+
groupedBulkActions(actions) { this._bulkActions = actions; return this; }
|
|
982
|
+
getBulkActions() { return this._bulkActions; }
|
|
983
|
+
/** Actions de ligne : des `Action` ou un `ActionGroup` (menu déroulant). */
|
|
984
|
+
recordActions(actions) { this._recordActions = actions; return this; }
|
|
985
|
+
getRecordActions() { return this._recordActions; }
|
|
986
|
+
/** Classes CSS ajoutées à la ligne selon l'enregistrement. */
|
|
987
|
+
recordClasses(resolver) { this._recordClasses = resolver; return this; }
|
|
988
|
+
getRecordClasses(record) { return this._recordClasses ? this._recordClasses(record) : null; }
|
|
989
|
+
filters(filters, options = {}) { this._filters = filters; if (options.layout)
|
|
990
|
+
this._filtersLayout = options.layout; return this; }
|
|
991
|
+
filtersLayout(layout) { this._filtersLayout = layout; return this; }
|
|
992
|
+
getFiltersLayout() { return this._filtersLayout; }
|
|
993
|
+
/** Les filtres ne s'appliquent qu'au bouton « Apply » : c'est déjà le comportement de toutes les tables (méthode gardée pour la compatibilité d'API). */
|
|
994
|
+
deferFilters() { return this; }
|
|
995
|
+
defaultSort(column, direction = 'asc') { this._defaultSort = { column, direction }; return this; }
|
|
996
|
+
recordUrl(resolver) { this._recordUrl = resolver; return this; }
|
|
997
|
+
_reorderColumn = null;
|
|
998
|
+
/** Lignes réordonnables à la main ; `column` reçoit le rang (port de `reorderable()`). */
|
|
999
|
+
reorderable(column) { this._reorderColumn = column; return this; }
|
|
1000
|
+
getReorderColumn() { return this._reorderColumn; }
|
|
1001
|
+
paginationPageOptions(options) { this._pageOptions = options; return this; }
|
|
1002
|
+
_defaultPageOption = null;
|
|
1003
|
+
/** Nombre de lignes par page à l'ouverture (port de `defaultPaginationPageOption()`). */
|
|
1004
|
+
defaultPaginationPageOption(option) { this._defaultPageOption = option; return this; }
|
|
1005
|
+
getDefaultPageOption() { return this._defaultPageOption !== null && this._pageOptions.includes(this._defaultPageOption) ? this._defaultPageOption : (this._pageOptions.includes(10) ? 10 : this._pageOptions[0]); }
|
|
1006
|
+
_paginationMode = 'default';
|
|
1007
|
+
/** `'simple'` : boutons précédent / suivant seulement, sans numéros de page ni total (port de `PaginationMode::Simple`). */
|
|
1008
|
+
paginationMode(mode) { this._paginationMode = mode; return this; }
|
|
1009
|
+
getPaginationMode() { return this._paginationMode; }
|
|
1010
|
+
stackedOnMobile(condition = true) { this._stackedOnMobile = condition; return this; }
|
|
1011
|
+
pluralModelLabel(label) { this._label = label; return this; }
|
|
1012
|
+
getColumns() { return this._columns; }
|
|
1013
|
+
getColumn(name) { return this._columns.find((c) => c.name === name); }
|
|
1014
|
+
getFilters() { return this._filters; }
|
|
1015
|
+
getDefaultSort() { return this._defaultSort; }
|
|
1016
|
+
getRecordUrl(record) { return this._recordUrl ? this._recordUrl(record) : null; }
|
|
1017
|
+
getPageOptions() { return this._pageOptions; }
|
|
1018
|
+
isStackedOnMobile() { return this._stackedOnMobile; }
|
|
1019
|
+
getPluralModelLabel() { return this._label; }
|
|
1020
|
+
hasToggleableColumns() { return this._columns.some((c) => c.isToggleable()); }
|
|
1021
|
+
/** État par défaut du gestionnaire de colonnes (port de getDefaultTableColumnState). */
|
|
1022
|
+
getDefaultColumnState() {
|
|
1023
|
+
return this._columns.map((c) => ({ type: 'column', name: c.name, label: c.getLabel(), isHidden: false, isToggled: !c.isToggleable() || !c.isToggledHiddenByDefault(), isToggleable: c.isToggleable(), isToggledHiddenByDefault: c.isToggleable() ? c.isToggledHiddenByDefault() : null }));
|
|
1024
|
+
}
|
|
1025
|
+
getDefaultFilterState() { return Object.fromEntries(this._filters.map((f) => [f.name, f.getDefaultState()])); }
|
|
1026
|
+
}
|
|
1027
|
+
/** Colonnes affichées : celles que l'état du gestionnaire laisse cochées. L'état du navigateur ne peut masquer qu'une colonne déclarée « toggleable ». */
|
|
1028
|
+
export function getVisibleColumns(table, host) {
|
|
1029
|
+
const hidden = new Set((Array.isArray(host.tableColumns) ? host.tableColumns : []).filter((c) => c && c.isToggled === false).map((c) => c.name));
|
|
1030
|
+
return table.getColumns().filter((c) => !(c.isToggleable() && hidden.has(c.name)));
|
|
1031
|
+
}
|
|
1032
|
+
/** Nettoie un état de colonnes reçu du navigateur : seul `isToggled` des colonnes « toggleable » est repris. */
|
|
1033
|
+
export function sanitizeColumnState(table, state) {
|
|
1034
|
+
const received = new Map((Array.isArray(state) ? state : []).filter((c) => c && typeof c === 'object').map((c) => [String(c.name), c.isToggled]));
|
|
1035
|
+
return table.getDefaultColumnState().map((c) => (c.isToggleable && typeof received.get(c.name) === 'boolean' ? { ...c, isToggled: received.get(c.name) } : c));
|
|
1036
|
+
}
|
|
1037
|
+
/** Requête de liste (recherche, recherches par colonne, filtres, tri) correspondant à l'état de l'hôte, sans pagination.
|
|
1038
|
+
* Seul ce que la table déclare est accepté, quel que soit l'état envoyé par le navigateur. */
|
|
1039
|
+
export function getTableQuery(table, host) {
|
|
1040
|
+
const [column, direction] = (host.tableSort ?? '').split(':');
|
|
1041
|
+
const sortColumn = table.getColumn(column);
|
|
1042
|
+
const query = {
|
|
1043
|
+
search: host.tableSearch,
|
|
1044
|
+
searchColumns: table.getColumns().filter((c) => c.isGloballySearchable()).map((c) => c.getSearchColumn()),
|
|
1045
|
+
columnSearches: Object.fromEntries(table.getColumns().filter((c) => c.isIndividuallySearchable() && String(host.tableColumnSearches?.[c.name] ?? '').trim() !== '').map((c) => [c.name, String(host.tableColumnSearches[c.name])])),
|
|
1046
|
+
sort: sortColumn?.isSortable() ? { column: sortColumn.getSortColumn(), direction: ((direction === 'desc') !== sortColumn.isSortInverted() ? 'desc' : 'asc') } : table.getDefaultSort(),
|
|
1047
|
+
};
|
|
1048
|
+
const grouping = getTableGrouping(table, host);
|
|
1049
|
+
if (grouping)
|
|
1050
|
+
query.groupSort = { column: grouping.group.getColumn(), direction: grouping.direction };
|
|
1051
|
+
// L'hôte peut restreindre la requête (onglet de liste actif).
|
|
1052
|
+
host.modifyTableQuery?.(query);
|
|
1053
|
+
for (const filter of table.getFilters()) {
|
|
1054
|
+
const state = host.tableFilters?.[filter.name];
|
|
1055
|
+
filter.apply(query, state && typeof state === 'object' ? state : filter.getDefaultState());
|
|
1056
|
+
}
|
|
1057
|
+
return query;
|
|
1058
|
+
}
|
|
1059
|
+
/** Regroupement courant : seul un groupe déclaré par la table est accepté, quel que soit l'état envoyé par le navigateur. */
|
|
1060
|
+
export function getTableGrouping(table, host) {
|
|
1061
|
+
if (host.isTableReordering === true)
|
|
1062
|
+
return null;
|
|
1063
|
+
const [id, direction] = String(host.tableGrouping ?? '').split(':');
|
|
1064
|
+
const group = table.getGroup(id);
|
|
1065
|
+
if (group)
|
|
1066
|
+
return { group, direction: direction === 'desc' ? 'desc' : 'asc' };
|
|
1067
|
+
// Comme Filament : un regroupement par défaut absent du sélecteur s'applique toujours ; présent, il ne vaut que s'il est choisi.
|
|
1068
|
+
const fallback = table.isDefaultGroupSelectable() ? null : table.getDefaultGroup();
|
|
1069
|
+
return fallback ? { group: fallback, direction: table.getDefaultGroupDirection() } : null;
|
|
1070
|
+
}
|
|
1071
|
+
/** À l'ouverture de la table (montage de l'hôte) : présélectionne le regroupement par défaut quand l'utilisateur peut le changer. */
|
|
1072
|
+
export function initializeTableGrouping(table, host) {
|
|
1073
|
+
const group = table.getDefaultGroup();
|
|
1074
|
+
if ((host.tableGrouping === null || host.tableGrouping === undefined || host.tableGrouping === '') && group && table.isDefaultGroupSelectable())
|
|
1075
|
+
host.tableGrouping = `${group.getId()}:${table.getDefaultGroupDirection()}`;
|
|
1076
|
+
}
|
|
1077
|
+
export function countActiveFilters(table, host) {
|
|
1078
|
+
// Comme Filament : le compteur est le nombre d'indicateurs ; un filtre sans indicateurs compte pour un quand il est actif.
|
|
1079
|
+
return table.getFilters().reduce((total, f) => { const s = host.tableFilters?.[f.name]; return total + (!s || typeof s !== 'object' ? 0 : f.getIndicators ? f.getIndicators(s).length : f.isActive(s) ? 1 : 0); }, 0);
|
|
1080
|
+
}
|
|
1081
|
+
/** Indicateurs des filtres appliqués (seuls les filtres qui en déclarent en ont). */
|
|
1082
|
+
export function getFilterIndicators(table, host) {
|
|
1083
|
+
return table.getFilters().flatMap((f) => { const s = host.tableFilters?.[f.name]; return s && typeof s === 'object' && f.getIndicators ? f.getIndicators(s).map((indicator) => ({ filter: f.name, ...indicator })) : []; });
|
|
1084
|
+
}
|
|
1085
|
+
/** Port de sortTable() : non trié → asc → desc → non trié. */
|
|
1086
|
+
export function sortTable(host, column = null, direction = null) {
|
|
1087
|
+
const [current, currentDirection] = (host.tableSort ?? '').split(':');
|
|
1088
|
+
if (column === (host.tableSort ? current : null))
|
|
1089
|
+
direction ??= (currentDirection ?? 'asc') === 'asc' ? 'desc' : currentDirection === 'desc' ? null : 'asc';
|
|
1090
|
+
else
|
|
1091
|
+
direction ??= 'asc';
|
|
1092
|
+
host.tableSort = direction ? `${column}:${direction}` : null;
|
|
1093
|
+
host.paginators.page = 1;
|
|
1094
|
+
}
|
|
1095
|
+
/** Charge la page d'enregistrements correspondant à l'état de l'hôte. */
|
|
1096
|
+
export async function getTableRecords(table, host, adapter) {
|
|
1097
|
+
const options = table.getPageOptions();
|
|
1098
|
+
const perPage = options.includes(Number(host.tableRecordsPerPage)) ? Number(host.tableRecordsPerPage) : options[0];
|
|
1099
|
+
return adapter.paginate({ ...getTableQuery(table, host), page: Number(host.paginators.page) || 1, perPage });
|
|
1100
|
+
}
|
|
1101
|
+
/** Éléments de pagination : port de UrlWindow de Laravel avec onEachSide(0), comme Filament. */
|
|
1102
|
+
export function paginationElements(current, last, onEachSide = 0) {
|
|
1103
|
+
const range = (a, b) => Array.from({ length: Math.max(0, b - a + 1) }, (_, i) => a + i);
|
|
1104
|
+
if (last < onEachSide * 2 + 8)
|
|
1105
|
+
return range(1, last);
|
|
1106
|
+
const window = onEachSide + 4;
|
|
1107
|
+
if (current <= window)
|
|
1108
|
+
return [...range(1, window + onEachSide), '...', last - 1, last];
|
|
1109
|
+
if (current > last - window)
|
|
1110
|
+
return [1, 2, '...', ...range(last - (window + (onEachSide - 1)), last)];
|
|
1111
|
+
return [1, 2, '...', ...range(current - onEachSide, current + onEachSide), '...', last - 1, last];
|
|
1112
|
+
}
|
|
1113
|
+
let colorManager = null;
|
|
1114
|
+
export const useColorManager = (manager) => { colorManager = manager; };
|
|
1115
|
+
/** Nombre maximal de lignes affichées (et réordonnées) en mode tri manuel. */
|
|
1116
|
+
export const REORDER_LIMIT = 1000;
|
|
1117
|
+
/** Ligne de résumé (port de `tables::summary.row`) ; `groupsOnly` : intitulé seul en tête, puis une cellule par colonne. */
|
|
1118
|
+
function summaryRowHtml(row, span, groupsOnly, placeholders) {
|
|
1119
|
+
const items = (cell) => cell.items.map((item) => `<div class="fi-ta-${item.type}-summary">${item.label !== null ? `<span class="fi-ta-${item.type}-summary-label">${e(item.label)}</span>` : ''}<span>${e(item.value)}</span></div>`).join('');
|
|
1120
|
+
const heading = groupsOnly
|
|
1121
|
+
? `<th scope="row" class="fi-ta-cell fi-ta-summary-row-heading-cell fi-align-start">${e(row.heading)}</th>`
|
|
1122
|
+
: `<th scope="row"${span > 1 ? ` colspan="${span}"` : ''} class="fi-ta-cell fi-align-start fi-ta-summary-row-heading-cell ">${e(row.heading)}</th>`;
|
|
1123
|
+
return `<tr class="fi-ta-row fi-ta-summary-row${row.striped ? ' fi-striped' : ''}">${placeholders.selection ? '<td></td>' : ''}${heading}${row.cells.map((cell) => `<td class="fi-ta-cell fi-align-${e(cell.alignment)} ">${items(cell)}</td>`).join('')}${placeholders.actions ? '<td></td>' : ''}</tr>`;
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Résumés de la table (port de `tables::summary`) : sans pagination, une seule ligne « Summary » ; avec plusieurs pages, un en-tête
|
|
1127
|
+
* puis « This page » et « All … ». Renvoie aussi de quoi calculer la ligne de résumé d'un groupe (requête restreinte au groupe).
|
|
1128
|
+
*/
|
|
1129
|
+
async function summarize(table, host, adapter, visible, page, options) {
|
|
1130
|
+
// Groupes seuls : la colonne du groupe n'a pas de cellule de résumé (son intitulé est déjà en tête de ligne).
|
|
1131
|
+
const columns = options.groupsOnly && options.group ? visible.filter((c) => c.name !== options.group.getColumn()) : visible;
|
|
1132
|
+
const lists = columns.map((c) => (c instanceof TextColumn ? c.getSummarizers() : []));
|
|
1133
|
+
if (!lists.some((list) => list.length) || !page.records.length)
|
|
1134
|
+
return null;
|
|
1135
|
+
// Comme Filament : la première cellule porte l'intitulé de la ligne ; elle s'étend jusqu'à la première colonne résumée qui la suit.
|
|
1136
|
+
const next = lists.findIndex((list, i) => i > 0 && list.length > 0);
|
|
1137
|
+
const span = options.groupsOnly ? 0 : next < 0 ? columns.length : next;
|
|
1138
|
+
const columnOf = (c) => c.getSortColumn?.() ?? c.name;
|
|
1139
|
+
const names = (kind) => [...new Set(columns.filter((_, i) => lists[i].some((s) => s.kind === kind)).map(columnOf))];
|
|
1140
|
+
const aggregate = (query) => adapter.aggregate(query, { sum: names('sum'), avg: names('avg'), min: names('range'), max: names('range') });
|
|
1141
|
+
const query = getTableQuery(table, host);
|
|
1142
|
+
const all = await aggregate(query);
|
|
1143
|
+
const present = (name) => page.records.map((record) => record[name]).filter((v) => v !== null && v !== undefined && v !== '');
|
|
1144
|
+
const onPage = {
|
|
1145
|
+
count: page.records.length,
|
|
1146
|
+
sum: Object.fromEntries(names('sum').map((name) => [name, present(name).reduce((total, v) => total + Number(v), 0)])),
|
|
1147
|
+
avg: Object.fromEntries(names('avg').map((name) => { const values = present(name); return [name, values.length ? values.reduce((total, v) => total + Number(v), 0) / values.length : 0]; })),
|
|
1148
|
+
min: Object.fromEntries(names('range').map((name) => [name, present(name).map(String).sort()[0] ?? null])),
|
|
1149
|
+
max: Object.fromEntries(names('range').map((name) => [name, present(name).map(String).sort().pop() ?? null])),
|
|
1150
|
+
};
|
|
1151
|
+
const cells = (values) => columns.slice(span).map((c, i) => ({
|
|
1152
|
+
alignment: c.getAlignment() ?? 'start',
|
|
1153
|
+
items: lists[i + span].map((s) => s.summarize(columnOf(c), values)).filter((item) => item !== null),
|
|
1154
|
+
}));
|
|
1155
|
+
const label = table.getPluralModelLabel();
|
|
1156
|
+
const hasPageSummary = !options.groupsOnly && page.lastPage > 1;
|
|
1157
|
+
const rowHtml = (row) => summaryRowHtml(row, span, options.groupsOnly, options.placeholders);
|
|
1158
|
+
const header = hasPageSummary
|
|
1159
|
+
? `<tr class="fi-ta-row fi-ta-summary-header-row fi-striped">${options.placeholders.selection ? '<td></td>' : ''}${columns.map((c, i) => {
|
|
1160
|
+
const heading = i === 0 ? t('filament-tables::table.summary.heading', { label }) : lists[i].length ? c.getLabel() : null;
|
|
1161
|
+
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>`;
|
|
1162
|
+
}).join('')}${options.placeholders.actions ? '<td></td>' : ''}</tr>`
|
|
1163
|
+
: '';
|
|
1164
|
+
const rows = hasPageSummary
|
|
1165
|
+
? [{ heading: t('filament-tables::table.summary.subheadings.page', { label }), cells: cells(onPage), striped: false }, { heading: t('filament-tables::table.summary.subheadings.all', { label }), cells: cells(all), striped: false }]
|
|
1166
|
+
: [{ heading: t('filament-tables::table.summary.heading', { label }), cells: cells(all), striped: true }];
|
|
1167
|
+
return {
|
|
1168
|
+
html: header + rows.map(rowHtml).join(''),
|
|
1169
|
+
/** Ligne de résumé d'un groupe : agrégats de tous les enregistrements du groupe (pas seulement ceux de la page). */
|
|
1170
|
+
group: async (group, key, title) => {
|
|
1171
|
+
const values = await aggregate({ ...query, modifiers: [...(query.modifiers ?? []), (q) => group.scope(q, key)] });
|
|
1172
|
+
return rowHtml({ heading: options.groupsOnly ? (title ?? '') : t('filament-tables::table.summary.subheadings.group', { group: title ?? '', label }), cells: cells(values), striped: false });
|
|
1173
|
+
},
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
/** Plafond d'enregistrements lus pour une table `groupsOnly` (non paginée, comme dans Filament). */
|
|
1177
|
+
export const GROUPS_ONLY_LIMIT = 1000;
|
|
1178
|
+
export async function renderTable(table, host, adapter, options = {}) {
|
|
1179
|
+
const reorderColumn = options.canReorder === false ? null : table.getReorderColumn();
|
|
1180
|
+
const reordering = reorderColumn !== null && host.isTableReordering === true;
|
|
1181
|
+
// Tri manuel : toutes les lignes de la requête, dans l'ordre de la colonne de rang, sans pagination ni actions.
|
|
1182
|
+
if (reordering)
|
|
1183
|
+
options = { ...options, bulkActions: [], headerActions: options.headerActions, rowActions: undefined, rowActionsHtml: undefined, filtersForm: null };
|
|
1184
|
+
const grouping = reordering ? null : getTableGrouping(table, host);
|
|
1185
|
+
// Groupes seuls : ni pagination ni lignes d'enregistrements, seulement une ligne de résumé par groupe (comme Filament).
|
|
1186
|
+
const groupsOnly = grouping !== null && table.isGroupsOnly();
|
|
1187
|
+
const page = reordering
|
|
1188
|
+
? await adapter.paginate({ ...getTableQuery(table, host), sort: { column: reorderColumn, direction: 'asc' }, page: 1, perPage: REORDER_LIMIT })
|
|
1189
|
+
: groupsOnly ? await adapter.paginate({ ...getTableQuery(table, host), page: 1, perPage: GROUPS_ONLY_LIMIT }) : await getTableRecords(table, host, adapter);
|
|
1190
|
+
const [sortColumn, sortDirection] = (host.tableSort ?? '').split(':');
|
|
1191
|
+
const number = (n) => n.toLocaleString('en-US');
|
|
1192
|
+
const visible = getVisibleColumns(table, host);
|
|
1193
|
+
const rows = [];
|
|
1194
|
+
const hasBulk = (options.bulkActions ?? []).length > 0;
|
|
1195
|
+
const selectable = (hasBulk || reordering) && !groupsOnly;
|
|
1196
|
+
const filled = (value) => value !== null && value.trim() !== '';
|
|
1197
|
+
// Comme Filament : un nouveau groupe commence quand le titre change ; le groupe précédent reçoit alors sa ligne de résumé.
|
|
1198
|
+
let previousGroup = { key: null, title: null };
|
|
1199
|
+
const columnsLayout = table.getColumnsLayout();
|
|
1200
|
+
const visibleSet = new Set(visible);
|
|
1201
|
+
for (const record of page.records) {
|
|
1202
|
+
const key = adapter.getKey(record);
|
|
1203
|
+
let groupHeader = null;
|
|
1204
|
+
let summaryBefore = null;
|
|
1205
|
+
const groupKey = grouping ? grouping.group.getStringKey(record) : null;
|
|
1206
|
+
const groupTitle = grouping ? grouping.group.getTitle(record) : null;
|
|
1207
|
+
if (grouping && String(groupTitle ?? '') !== String(previousGroup.title ?? '')) {
|
|
1208
|
+
if (filled(previousGroup.title))
|
|
1209
|
+
summaryBefore = previousGroup;
|
|
1210
|
+
const query = getTableQuery(table, host);
|
|
1211
|
+
const label = grouping.group.isTitlePrefixedWithLabel() ? grouping.group.getLabel() : null;
|
|
1212
|
+
const description = grouping.group.getDescription(record);
|
|
1213
|
+
if (!groupsOnly)
|
|
1214
|
+
groupHeader = {
|
|
1215
|
+
key: groupKey, title: groupTitle, label, description, collapsible: grouping.group.isCollapsible(),
|
|
1216
|
+
// Clés de tout le groupe (pas seulement de la page), pour sa case « tout sélectionner ».
|
|
1217
|
+
keys: hasBulk ? await adapter.listKeys({ ...query, modifiers: [...(query.modifiers ?? []), (q) => grouping.group.scope(q, groupKey)] }) : [],
|
|
1218
|
+
// En-tête sans rien à annoncer : masqué aux technologies d'assistance.
|
|
1219
|
+
ariaHidden: !(filled(groupTitle) || filled(label) || filled(description) || grouping.group.isCollapsible() || selectable),
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
previousGroup = { key: groupKey, title: groupTitle };
|
|
1223
|
+
const cells = [];
|
|
1224
|
+
if (!groupsOnly)
|
|
1225
|
+
for (const column of visible)
|
|
1226
|
+
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() });
|
|
1227
|
+
const recordAction = table.getRecordAction();
|
|
1228
|
+
if (reordering)
|
|
1229
|
+
for (const cell of cells)
|
|
1230
|
+
cell.url = null;
|
|
1231
|
+
let contentHtml = null;
|
|
1232
|
+
if (columnsLayout && !groupsOnly) {
|
|
1233
|
+
contentHtml = '';
|
|
1234
|
+
for (const node of columnsLayout)
|
|
1235
|
+
contentHtml += await renderColumnLayout(node, record, key, visibleSet);
|
|
1236
|
+
}
|
|
1237
|
+
rows.push({
|
|
1238
|
+
contentHtml, summaryBefore, groupSummaryHtml: '',
|
|
1239
|
+
groupHeader, groupKey, collapsedExpression: grouping?.group.isCollapsible() ? `isGroupCollapsed(${js(groupTitle)})` : null,
|
|
1240
|
+
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,
|
|
1241
|
+
wasClickable: reordering && (recordAction !== null || table.getRecordUrl(record) !== null),
|
|
1242
|
+
key, url: reordering ? null : recordAction ? null : table.getRecordUrl(record), cells, actions: groupsOnly ? [] : ((await options.rowActions?.(record)) ?? []),
|
|
1243
|
+
actionsHtml: options.rowActionsHtml && !groupsOnly ? await options.rowActionsHtml(record) : null, classes: table.getRecordClasses(record),
|
|
1244
|
+
// Clic sur la ligne : l'action est montée avec la clé de l'enregistrement (même expression que Filament).
|
|
1245
|
+
mountExpression: reordering ? null : recordAction ? `mountAction(${js(recordAction)}, {}, ${js({ table: true, recordKey: key })})` : null,
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
const hasRowActions = rows.some((row) => row.actionsHtml !== null || row.actions.length > 0);
|
|
1249
|
+
// Résumés : jamais en mode « tri manuel » ; ceux des groupes s'intercalent avant l'en-tête du groupe suivant.
|
|
1250
|
+
const summary = reordering ? null : await summarize(table, host, adapter, visible, page, { group: grouping?.group ?? null, groupsOnly, placeholders: { selection: selectable, actions: hasRowActions } });
|
|
1251
|
+
let trailingGroupSummary = '';
|
|
1252
|
+
if (summary && grouping) {
|
|
1253
|
+
for (const row of rows)
|
|
1254
|
+
if (row.summaryBefore)
|
|
1255
|
+
row.groupSummaryHtml = await summary.group(grouping.group, row.summaryBefore.key, row.summaryBefore.title);
|
|
1256
|
+
// Dernier groupe de la page : résumé seulement s'il ne continue pas sur la page suivante (port de shouldRenderTrailingGroupedTableSummary).
|
|
1257
|
+
let trailing = filled(previousGroup.title);
|
|
1258
|
+
if (trailing && !groupsOnly && page.page < page.lastPage) {
|
|
1259
|
+
const nextRecord = (await adapter.paginate({ ...getTableQuery(table, host), page: page.page * page.perPage + 1, perPage: 1 })).records[0];
|
|
1260
|
+
trailing = !nextRecord || grouping.group.getStringKey(nextRecord) !== previousGroup.key;
|
|
1261
|
+
}
|
|
1262
|
+
if (trailing)
|
|
1263
|
+
trailingGroupSummary = await summary.group(grouping.group, previousGroup.key, previousGroup.title);
|
|
1264
|
+
}
|
|
1265
|
+
const classes = (component, color) => colorManager?.getComponentClasses(component, color) ?? [];
|
|
1266
|
+
for (const filter of table.getFilters()) {
|
|
1267
|
+
const state = host.tableFilters?.[filter.name];
|
|
1268
|
+
if (filter.loadIndicatorData && state && typeof state === 'object')
|
|
1269
|
+
await filter.loadIndicatorData(state, host);
|
|
1270
|
+
}
|
|
1271
|
+
const scopes = host.getRenderHookScopes?.() ?? [];
|
|
1272
|
+
return views.render('filament-tables::index', {
|
|
1273
|
+
contentLayout: columnsLayout !== null,
|
|
1274
|
+
hook: (name, data = {}) => renderHook(name, scopes, data), hasHook: (name) => hasRenderHook(name, scopes),
|
|
1275
|
+
visibleColumns: visible, isReordering: reordering,
|
|
1276
|
+
groups: reordering || table.areGroupingSettingsHidden() ? [] : table.getGroups().map((g) => ({ id: g.getId(), label: g.getLabel() })),
|
|
1277
|
+
grouping: { directionHidden: table.isGroupingDirectionSettingHidden(), inDropdownOnDesktop: table.areGroupingSettingsInDropdownOnDesktop(), collapsedByDefault: table.areGroupsCollapsedByDefault(), groupsOnly },
|
|
1278
|
+
id: host.id, targets: LOADING_TARGETS, table, rows, page, number,
|
|
1279
|
+
reorder: reorderColumn !== null ? { active: reordering } : null,
|
|
1280
|
+
// Comme Filament : pas de cases à cocher sans action groupée (ni en mode « groupes seuls »), pas de colonne d'actions sans action de ligne.
|
|
1281
|
+
selectable, hasRowActions,
|
|
1282
|
+
// Comme Filament (Str::random) : la clé change à chaque rendu pour forcer le remplacement de la case à cocher.
|
|
1283
|
+
randomKey: () => Array.from(randomBytes(16), (b) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[b % 62]).join(''),
|
|
1284
|
+
// Total sélectionnable par « tout sélectionner » : tous les enregistrements de la requête courante.
|
|
1285
|
+
allCount: table.getPaginationMode() === 'simple' ? '' : page.total, resultsCount: table.getPaginationMode() === 'simple' ? rows.length : page.total,
|
|
1286
|
+
columns: visible.map((c) => ({ name: c.name, grouped: c._group !== null, cssName: c.getCssName(), alignment: c.getAlignment(), label: c.getLabel(), sortable: !reordering && c.isSortable(), individuallySearchable: !reordering && c.isIndividuallySearchable(), sort: sortColumn === c.name ? (sortDirection === 'desc' ? 'descending' : 'ascending') : 'none' })),
|
|
1287
|
+
linkClasses: classes('link', 'primary'), dangerLinkClasses: classes('link', 'danger'), primaryButtonClasses: classes('button', 'primary'), badgeClasses: classes('badge', 'primary'), indicatorBadgeClasses: classes('badge', 'primary'),
|
|
1288
|
+
bulkActions: options.bulkActions ?? [],
|
|
1289
|
+
filtersForm: options.filtersForm ?? null, activeFiltersCount: countActiveFilters(table, host), filtersLayout: table.getFiltersLayout(), filterIndicators: getFilterIndicators(table, host),
|
|
1290
|
+
hasColumnManager: !reordering && table.hasToggleableColumns(),
|
|
1291
|
+
afterContainer: options.after ?? '', heading: table.getHeading(), headerActions: options.headerActions ?? [], summaryHtml: summary?.html ?? '', trailingGroupSummary,
|
|
1292
|
+
// Ligne d'intitulés de groupes : une cellule par colonne isolée, une cellule fusionnée par suite de colonnes du même groupe.
|
|
1293
|
+
headerGroups: visible.some((c) => c._group !== null) ? visible.reduce((runs, c) => {
|
|
1294
|
+
const last = runs[runs.length - 1];
|
|
1295
|
+
if (c._group !== null && last && last.label === c._group)
|
|
1296
|
+
last.span++;
|
|
1297
|
+
else
|
|
1298
|
+
runs.push({ label: c._group, span: 1 });
|
|
1299
|
+
return runs;
|
|
1300
|
+
}, []) : null,
|
|
1301
|
+
isSearchable: !reordering && table.getColumns().some((c) => c.isGloballySearchable()),
|
|
1302
|
+
pagination: reordering || groupsOnly ? '' : views.render('filament::components.pagination', {
|
|
1303
|
+
id: host.id, targets: LOADING_TARGETS + ',paginators', page, number, pageOptions: table.getPageOptions(), elements: paginationElements(page.page, page.lastPage), simple: table.getPaginationMode() === 'simple',
|
|
1304
|
+
attrs: (o) => new Attributes(o),
|
|
1305
|
+
}),
|
|
1306
|
+
});
|
|
1307
|
+
}
|