@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.
@@ -0,0 +1,875 @@
1
+ // Filtre « constructeur de requêtes » — port de Filament\Tables\Filters\QueryBuilder et de filament/query-builder.
2
+ // L'utilisateur compose des règles (contrainte + opérateur + réglages), éventuellement regroupées par des blocs « OR ».
3
+ // Tout l'état vient du navigateur : il est nettoyé contre les contraintes déclarées avant de servir (requête, indicateurs, rendu).
4
+ // Portés : TextConstraint, NumberConstraint, BooleanConstraint, DateConstraint (avec ou sans heure), SelectConstraint,
5
+ // RelationshipConstraint (nombre d'enregistrements liés, vide, `selectable(IsRelatedToOperator)`), contraintes sur une relation
6
+ // (`relationship(nom, attribut)`), opérateur « Is filled » (`nullable()`), bloc OR, indicateurs.
7
+ // Non portés : agrégats de relation (somme, moyenne… d'une colonne liée), groupes de contraintes, recherche serveur dans les listes.
8
+ // Le SQL de date et de recherche est écrit pour SQLite, MySQL / MariaDB, PostgreSQL et SQL Server, selon le client knex de la requête.
9
+ import { Action } from '@navette-js/actions';
10
+ import { DatePicker, DateTimePicker, Field, Select, TextInput } from '@navette-js/forms';
11
+ import { Block, Builder, colorsOf, iconActionButton, isItemKey, shortKey, writeState } from '@navette-js/forms/builder';
12
+ import { Component, Group, Schema } from '@navette-js/schemas';
13
+ import { e, js, views, configured } from '@navette-js/support';
14
+ import { t } from '@navette-js/support/i18n';
15
+ import { createHash } from 'node:crypto';
16
+ const tr = (key, replace = {}) => t(`filament-query-builder::query-builder.${key}`, replace);
17
+ const headline = (s) => s.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
18
+ const scalar = (value) => typeof value === 'string' ? value.slice(0, 1000) : typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)) ? value : null;
19
+ /** Réglage reçu du navigateur : un scalaire, ou une liste de scalaires (sélection multiple), bornée. */
20
+ const setting = (value) => Array.isArray(value) ? value.slice(0, MAX_LIST_VALUES).map(scalar).filter((item) => typeof item === 'string' || typeof item === 'number') : scalar(value);
21
+ /** Plafonds appliqués à l'arbre de règles reçu du navigateur. */
22
+ export const MAX_RULES = 100;
23
+ export const MAX_NESTING_DEPTH = 3;
24
+ /** Nombre maximal de valeurs d'une sélection multiple. */
25
+ export const MAX_LIST_VALUES = 100;
26
+ const OR = 'or';
27
+ const label = (group, name) => (inverse) => tr(`operators.${group}.${name}.label.${inverse ? 'inverse' : 'direct'}`);
28
+ /** Liste de libellés à la manière de `Arr::join($labels, ', ', ' or ')`. */
29
+ const joinLabels = (labels, glueKey) => (labels.length <= 1 ? labels.join('') : `${labels.slice(0, -1).join(tr(`${glueKey}.0`))}${tr(`${glueKey}.final`)}${labels[labels.length - 1]}`);
30
+ const summary = (group, name, inverse, replace) => tr(`operators.${group}.${name}.summary.${inverse ? 'inverse' : 'direct'}`, replace);
31
+ const likeEscape = (text) => text.replace(/[\\%_]/g, '\\$&');
32
+ function textOperator(name, key, pattern) {
33
+ return {
34
+ name, label: label('text', key), defaults: () => ({ text: null }),
35
+ fields: () => [TextInput.make('text').label(tr('operators.text.form.text.label')).required().columnSpanFull()],
36
+ resolve: (settings, inverse, constraint) => {
37
+ const text = typeof settings.text === 'string' ? settings.text : typeof settings.text === 'number' ? String(settings.text) : '';
38
+ if (text === '')
39
+ return null;
40
+ const like = pattern(text);
41
+ return {
42
+ condition: like === null ? { kind: 'compare', column: constraint.getColumn(), operator: inverse ? '!=' : '=', value: text } : { kind: 'like', column: constraint.getColumn(), pattern: like, negate: inverse },
43
+ summary: summary('text', key, inverse, { attribute: constraint.getAttributeLabel(), text }),
44
+ };
45
+ },
46
+ };
47
+ }
48
+ function numberOperator(name, key, direct, inverted) {
49
+ return {
50
+ name, label: label('number', key), defaults: () => ({ number: null, aggregate: null }),
51
+ // `aggregate` ne sert qu'aux relations : le champ existe, masqué, comme dans Filament.
52
+ fields: () => [TextInput.make('number').label(tr('operators.number.form.number.label')).numeric().required(), Select.make('aggregate').label(tr('operators.number.form.aggregate.label')).hidden()],
53
+ resolve: (settings, inverse, constraint) => {
54
+ const raw = settings.number;
55
+ if (!(typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '')) || !Number.isFinite(Number(raw)))
56
+ return null;
57
+ const number = Number(raw);
58
+ return { condition: { kind: 'compare', column: constraint.getColumn(), operator: inverse ? inverted : direct, value: number }, summary: summary('number', key, inverse, { attribute: constraint.getAttributeLabel(), number: new Intl.NumberFormat('en-US').format(number) }) };
59
+ },
60
+ };
61
+ }
62
+ // --- dates
63
+ const pad = (n) => String(n).padStart(2, '0');
64
+ const toDateString = (date) => `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
65
+ const toDateTimeString = (date) => `${toDateString(date)} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
66
+ /** Date `AAAA-MM-JJ` réelle, sinon null. */
67
+ function parseDate(value) {
68
+ const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
69
+ if (!m)
70
+ return null;
71
+ const date = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
72
+ return toDateString(date) === `${m[1]}-${m[2]}-${m[3]}` ? toDateString(date) : null;
73
+ }
74
+ /** Date et heure `AAAA-MM-JJ HH:MM[:SS]` (ou avec `T`) réelles → `AAAA-MM-JJ HH:MM:SS` ; une date seule vaut minuit. */
75
+ function parseDateTime(value) {
76
+ const date = parseDate(value);
77
+ if (date === null)
78
+ return null;
79
+ const time = /^.{10}(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(String(value));
80
+ if (!time)
81
+ return null;
82
+ const [hours, minutes, seconds] = [time[1] ?? '00', time[2] ?? '00', time[3] ?? '00'];
83
+ return Number(hours) < 24 && Number(minutes) < 60 && Number(seconds) < 60 ? `${date} ${hours}:${minutes}:${seconds}` : null;
84
+ }
85
+ const formatDate = (date) => new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${date.slice(0, 10)}T00:00:00Z`));
86
+ /** Date et heure comme Carbon `toFormattedDayDateString() . ' ' . format('H:i:s')` : « Mon, Jun 1, 2026 14:30:00 ». */
87
+ const formatDayDateTime = (dateTime) => `${new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: 'UTC' }).format(new Date(`${dateTime.slice(0, 10)}T00:00:00Z`))}, ${formatDate(dateTime)} ${dateTime.length > 10 ? dateTime.slice(11, 19) : '00:00:00'}`;
88
+ const MONTHS = Array.from({ length: 12 }, (_, i) => new Intl.DateTimeFormat('en-US', { month: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2024, i, 1))));
89
+ const UNITS = ['day', 'week', 'month', 'quarter', 'year'];
90
+ const TIME_UNITS = ['second', 'minute', 'hour'];
91
+ const shift = (now, unit, amount) => {
92
+ const date = new Date(now.getTime());
93
+ if (unit === 'second')
94
+ date.setUTCSeconds(date.getUTCSeconds() + amount);
95
+ else if (unit === 'minute')
96
+ date.setUTCMinutes(date.getUTCMinutes() + amount);
97
+ else if (unit === 'hour')
98
+ date.setUTCHours(date.getUTCHours() + amount);
99
+ else if (unit === 'day')
100
+ date.setUTCDate(date.getUTCDate() + amount);
101
+ else if (unit === 'week')
102
+ date.setUTCDate(date.getUTCDate() + 7 * amount);
103
+ else if (unit === 'month')
104
+ date.setUTCMonth(date.getUTCMonth() + amount);
105
+ else if (unit === 'quarter')
106
+ date.setUTCMonth(date.getUTCMonth() + 3 * amount);
107
+ else if (unit === 'year')
108
+ date.setUTCFullYear(date.getUTCFullYear() + amount);
109
+ return date;
110
+ };
111
+ /** Préréglages de fenêtre glissante (port de resolveRelativeDate). */
112
+ const PRESETS = {
113
+ past_decade: (n) => shift(n, 'year', -10), past_5_years: (n) => shift(n, 'year', -5), past_2_years: (n) => shift(n, 'year', -2), past_year: (n) => shift(n, 'year', -1),
114
+ past_6_months: (n) => shift(n, 'month', -6), past_quarter: (n) => shift(n, 'quarter', -1), past_month: (n) => shift(n, 'month', -1), past_2_weeks: (n) => shift(n, 'week', -2), past_week: (n) => shift(n, 'week', -1),
115
+ this_decade: (n) => new Date(Date.UTC(n.getUTCFullYear() - (n.getUTCFullYear() % 10), 0, 1)), this_year: (n) => new Date(Date.UTC(n.getUTCFullYear(), 0, 1)),
116
+ this_quarter: (n) => new Date(Date.UTC(n.getUTCFullYear(), n.getUTCMonth() - (n.getUTCMonth() % 3), 1)), this_month: (n) => new Date(Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), 1)), today: (n) => n,
117
+ next_week: (n) => shift(n, 'week', 1), next_2_weeks: (n) => shift(n, 'week', 2), next_month: (n) => shift(n, 'month', 1), next_quarter: (n) => shift(n, 'quarter', 1), next_6_months: (n) => shift(n, 'month', 6),
118
+ next_year: (n) => shift(n, 'year', 1), next_2_years: (n) => shift(n, 'year', 2), next_5_years: (n) => shift(n, 'year', 5), next_decade: (n) => shift(n, 'year', 10),
119
+ };
120
+ /** Préréglages à la minute ou à l'heure, proposés seulement pour une contrainte `time()` : ils comparent la date et l'heure. */
121
+ const TIME_PRESETS = {
122
+ past_hour: (n) => shift(n, 'hour', -1), past_minute: (n) => shift(n, 'minute', -1),
123
+ this_hour: (n) => new Date(Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate(), n.getUTCHours())), this_minute: (n) => new Date(Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate(), n.getUTCHours(), n.getUTCMinutes())),
124
+ next_minute: (n) => shift(n, 'minute', 1), next_hour: (n) => shift(n, 'hour', 1),
125
+ };
126
+ /** Ordre des préréglages dans la liste, comme Filament (les horaires s'intercalent seulement avec `time()`). */
127
+ const presetNames = (hasTime) => [
128
+ 'past_decade', 'past_5_years', 'past_2_years', 'past_year', 'past_6_months', 'past_quarter', 'past_month', 'past_2_weeks', 'past_week', ...(hasTime ? ['past_hour', 'past_minute'] : []),
129
+ 'this_decade', 'this_year', 'this_quarter', 'this_month', 'today', ...(hasTime ? ['this_hour', 'this_minute', 'next_minute', 'next_hour'] : []),
130
+ 'next_week', 'next_2_weeks', 'next_month', 'next_quarter', 'next_6_months', 'next_year', 'next_2_years', 'next_5_years', 'next_decade', 'custom',
131
+ ];
132
+ /**
133
+ * Moment visé par les réglages d'un opérateur « après / avant » : date fixe, préréglage, ou décalage personnalisé. `time` est vrai quand la
134
+ * comparaison porte sur la date et l'heure (contrainte `time()` avec une date fixe, un préréglage horaire ou une unité de moins d'un jour).
135
+ */
136
+ function resolveRuleMoment(settings, now, hasTime) {
137
+ if (settings.mode !== 'relative') {
138
+ const value = hasTime ? parseDateTime(settings.date) : parseDate(settings.date);
139
+ return value === null ? null : { value, time: hasTime };
140
+ }
141
+ const preset = typeof settings.preset === 'string' ? settings.preset : 'custom';
142
+ if (hasTime && Object.hasOwn(TIME_PRESETS, preset))
143
+ return { value: toDateTimeString(TIME_PRESETS[preset](now)), time: true };
144
+ if (preset !== 'custom')
145
+ return { value: toDateString((Object.hasOwn(PRESETS, preset) ? PRESETS[preset] : PRESETS.today)(now)), time: false };
146
+ const value = Math.floor(Number(settings.relative_value));
147
+ // Comme le formulaire de Filament : un entier d'au moins 1 (borné ici pour rester dans les dates représentables).
148
+ if (!Number.isFinite(value) || value < 1 || value > 10000)
149
+ return null;
150
+ const units = hasTime ? [...TIME_UNITS, ...UNITS] : UNITS;
151
+ const unit = units.includes(String(settings.relative_unit)) ? String(settings.relative_unit) : 'day';
152
+ const moment = shift(now, unit, settings.tense === 'future' ? value : -value);
153
+ return TIME_UNITS.includes(unit) ? { value: toDateTimeString(moment), time: true } : { value: toDateString(moment), time: false };
154
+ }
155
+ /** Date (ou date et heure, avec `hasTime`) visée par les réglages d'un opérateur « après / avant ». */
156
+ export function resolveRuleDate(settings, now = new Date(), hasTime = false) { return resolveRuleMoment(settings, now, hasTime)?.value ?? null; }
157
+ function windowOperator(name, key, direct, inverted, hasTime) {
158
+ const option = (path, names) => names.map((value) => [value, tr(`${path}.${value}`)]);
159
+ return {
160
+ name, label: label('date', key),
161
+ defaults: () => ({ mode: 'absolute', date: null, preset: 'past_month', tense: 'past', relative_value: 1, relative_unit: 'day' }),
162
+ fields: () => [
163
+ Select.make('mode').label(tr('operators.date.form.mode.label')).live().options(option('operators.date.form.mode.options', ['absolute', 'relative'])).default('absolute'),
164
+ (hasTime ? DateTimePicker.make('date') : DatePicker.make('date')).label(tr('operators.date.form.date.label')).hidden(({ get }) => get('mode') === 'relative').required(({ get }) => get('mode') !== 'relative'),
165
+ Select.make('preset').label(tr('operators.date.form.preset.label')).live().options(option('operators.date.presets', presetNames(hasTime))).default('past_month')
166
+ .hidden(({ get }) => get('mode') !== 'relative').required(({ get }) => get('mode') === 'relative'),
167
+ // Dans Filament ces trois champs sont fusionnés (FusedGroup) ; ici ils sont côte à côte.
168
+ Group.make([
169
+ Select.make('tense').label(tr('operators.date.form.tense.label')).options(option('operators.date.form.tense.options', ['past', 'future'])).default('past').required(),
170
+ TextInput.make('relative_value').label(tr('operators.date.form.relative_value.label')).numeric().minValue(1).default(1).required(),
171
+ Select.make('relative_unit').label(tr('operators.date.form.relative_unit.label')).options(option('operators.date.unit_labels', hasTime ? [...TIME_UNITS, ...UNITS] : UNITS)).default('day').required(),
172
+ ]).columns(3).columnSpanFull().hidden(({ get }) => get('mode') !== 'relative' || get('preset') !== 'custom'),
173
+ ],
174
+ resolve: (settings, inverse, constraint) => {
175
+ const moment = resolveRuleMoment(settings, new Date(), hasTime);
176
+ if (moment === null)
177
+ return null;
178
+ // Avec l'heure, la colonne est comparée telle quelle (`where`) ; sinon, sa seule date (`whereDate`), comme Filament.
179
+ const condition = moment.time
180
+ ? { kind: 'compare', column: constraint.getColumn(), operator: inverse ? inverted : direct, value: moment.value }
181
+ : { kind: 'date', column: constraint.getColumn(), operator: inverse ? inverted : direct, value: moment.value };
182
+ return { condition, summary: summary('date', key, inverse, { attribute: constraint.getAttributeLabel(), date: moment.time ? formatDayDateTime(moment.value) : formatDate(moment.value) }) };
183
+ },
184
+ };
185
+ }
186
+ /** Opérateur « Is filled » (port de IsFilledOperator), proposé par les contraintes `nullable()`. */
187
+ const isFilledOperator = {
188
+ name: 'isFilled', label: (inverse) => tr(`operators.is_filled.label.${inverse ? 'inverse' : 'direct'}`), defaults: () => null, fields: () => [],
189
+ resolve: (_settings, inverse, constraint) => ({ condition: { kind: 'filled', column: constraint.getColumn(), negate: inverse }, summary: tr(`operators.is_filled.summary.${inverse ? 'inverse' : 'direct'}`, { attribute: constraint.getAttributeLabel() }) }),
190
+ };
191
+ const OPERATORS = {
192
+ text: [
193
+ textOperator('contains', 'contains', (text) => `%${likeEscape(text)}%`), textOperator('startsWith', 'starts_with', (text) => `${likeEscape(text)}%`),
194
+ textOperator('endsWith', 'ends_with', (text) => `%${likeEscape(text)}`), textOperator('equals', 'equals', () => null),
195
+ ],
196
+ number: [numberOperator('isMin', 'is_min', '>=', '<'), numberOperator('isMax', 'is_max', '<=', '>'), numberOperator('equals', 'equals', '=', '!=')],
197
+ boolean: [{
198
+ name: 'isTrue', label: label('boolean', 'is_true'), defaults: () => null, fields: () => [],
199
+ resolve: (_settings, inverse, constraint) => ({ condition: { kind: 'boolean', column: constraint.getColumn(), value: !inverse }, summary: summary('boolean', 'is_true', inverse, { attribute: constraint.getAttributeLabel() }) }),
200
+ }],
201
+ date: [
202
+ {
203
+ name: 'isDate', label: label('date', 'is_date'), defaults: () => ({ date: null }),
204
+ fields: () => [DatePicker.make('date').label(tr('operators.date.form.date.label')).required()],
205
+ resolve: (settings, inverse, constraint) => {
206
+ const date = parseDate(settings.date);
207
+ return date === null ? null : { condition: { kind: 'date', column: constraint.getColumn(), operator: inverse ? '!=' : '=', value: date }, summary: summary('date', 'is_date', inverse, { attribute: constraint.getAttributeLabel(), date: formatDate(date) }) };
208
+ },
209
+ },
210
+ {
211
+ name: 'isMonth', label: label('date', 'is_month'), defaults: () => ({ month: null }),
212
+ fields: () => [Select.make('month').label(tr('operators.date.form.month.label')).placeholder(t('filament-forms::components.select.placeholder')).options(MONTHS.map((name, i) => [String(i + 1), name])).required()],
213
+ resolve: (settings, inverse, constraint) => {
214
+ const month = typeof settings.month === 'string' || typeof settings.month === 'number' ? Math.floor(Number(settings.month)) : NaN;
215
+ return !(month >= 1 && month <= 12) ? null : { condition: { kind: 'month', column: constraint.getColumn(), operator: inverse ? '!=' : '=', value: month }, summary: summary('date', 'is_month', inverse, { attribute: constraint.getAttributeLabel(), month: MONTHS[month - 1] }) };
216
+ },
217
+ },
218
+ {
219
+ name: 'isYear', label: label('date', 'is_year'), defaults: () => ({ year: null }),
220
+ fields: () => [TextInput.make('year').label(tr('operators.date.form.year.label')).integer().required()],
221
+ resolve: (settings, inverse, constraint) => {
222
+ const year = (typeof settings.year === 'string' && /^\d{1,4}$/.test(settings.year.trim())) || typeof settings.year === 'number' ? Number(settings.year) : NaN;
223
+ return !(Number.isInteger(year) && year >= 1 && year <= 9999) ? null : { condition: { kind: 'year', column: constraint.getColumn(), operator: inverse ? '!=' : '=', value: year }, summary: summary('date', 'is_year', inverse, { attribute: constraint.getAttributeLabel(), year }) };
224
+ },
225
+ },
226
+ ],
227
+ };
228
+ /**
229
+ * Attribut filtrable par le constructeur de requêtes (port de Constraint). La colonne et la relation interrogées sont celles déclarées
230
+ * ici, jamais des valeurs du navigateur : celui-ci ne choisit qu'une contrainte (par son nom) et un opérateur de sa liste.
231
+ */
232
+ export class Constraint {
233
+ name;
234
+ type;
235
+ _label = null;
236
+ _attributeLabel = null;
237
+ _icon;
238
+ _column = null;
239
+ _relationship = null;
240
+ _modifyRelationshipQuery = null;
241
+ _nullable = false;
242
+ /** Modèle Objection interrogé (enregistrements de la table), pour les listes d'enregistrements liés. */
243
+ _model = null;
244
+ constructor(name, type, icon) {
245
+ if (!isItemKey(name) || name === OR)
246
+ throw new Error(`Nom de contrainte invalide : ${name}`);
247
+ this.name = name;
248
+ this.type = type;
249
+ this._icon = icon;
250
+ }
251
+ label(label) { this._label = label; return this; }
252
+ /** Nom de l'attribut dans les résumés des règles (par défaut, le libellé). */
253
+ attributeLabel(label) { this._attributeLabel = label; return this; }
254
+ icon(name) { this._icon = name; return this; }
255
+ /** Colonne (ou alias de la requête) interrogée quand elle diffère du nom. */
256
+ attribute(column) { this._column = column; return this; }
257
+ /**
258
+ * La contrainte porte sur un attribut d'enregistrements liés (`whereHas`) : `relationship('author', 'name')` filtre sur le nom de
259
+ * l'auteur. La relation doit être déclarée par le modèle Objection (`relationMappings`).
260
+ */
261
+ relationship(name, titleAttribute, modifyQueryUsing = null) {
262
+ this._relationship = name;
263
+ this._column = titleAttribute;
264
+ this._modifyRelationshipQuery = modifyQueryUsing;
265
+ return this;
266
+ }
267
+ modifyRelationshipQueryUsing(callback) { this._modifyRelationshipQuery = callback; return this; }
268
+ /** Ajoute l'opérateur « Is filled » / « Is blank » (port de `nullable()`). */
269
+ nullable(condition = true) { this._nullable = condition; return this; }
270
+ isNullable() { return this._nullable; }
271
+ model(model) { this._model = model; return this; }
272
+ getModel() { return this._model; }
273
+ getLabel() { return this._label ?? headline(this.name); }
274
+ getAttributeLabel() { return this._attributeLabel ?? this.getLabel(); }
275
+ getIcon() { return this._icon; }
276
+ getColumn() { return this._column ?? this.name; }
277
+ /** Relation dont les enregistrements portent l'attribut, ou `null` pour une colonne de la table. */
278
+ getRelationshipName() { return this._relationship; }
279
+ getModifyRelationshipQueryUsing() { return this._modifyRelationshipQuery; }
280
+ getOperators() { return [...OPERATORS[this.type], ...(this._nullable ? [isFilledOperator] : [])]; }
281
+ /** `contains` ou `contains.inverse` → opérateur et sens ; null si la contrainte ne le déclare pas. */
282
+ parseOperator(value) {
283
+ if (typeof value !== 'string')
284
+ return null;
285
+ const inverse = value.endsWith('.inverse');
286
+ const operator = this.getOperators().find((candidate) => candidate.name === (inverse ? value.slice(0, -8) : value));
287
+ return operator ? { operator, inverse } : null;
288
+ }
289
+ getOperatorOptions() { return this.getOperators().flatMap((operator) => [[operator.name, operator.label(false)], [`${operator.name}.inverse`, operator.label(true)]]); }
290
+ /** Noms de réglages connus de la contrainte, tous opérateurs confondus (un changement d'opérateur conserve les anciens, comme Filament). */
291
+ getSettingNames() { return [...new Set(this.getOperators().flatMap((operator) => Object.keys(operator.defaults() ?? {})))]; }
292
+ /** Réglages dont la valeur est une liste (sélection multiple). */
293
+ getListSettingNames() { return [...new Set(this.getOperators().flatMap((operator) => Object.entries(operator.defaults() ?? {}).filter(([, value]) => Array.isArray(value)).map(([name]) => name)))]; }
294
+ getDefaultData() { const [first] = this.getOperators(); return { operator: first.name, settings: first.defaults() }; }
295
+ }
296
+ export class TextConstraint extends Constraint {
297
+ static make(name) { return configured(new TextConstraint(name, 'text', 'm-language')); }
298
+ }
299
+ export class NumberConstraint extends Constraint {
300
+ static make(name) { return configured(new NumberConstraint(name, 'number', 'm-variable')); }
301
+ }
302
+ export class BooleanConstraint extends Constraint {
303
+ static make(name) { return configured(new BooleanConstraint(name, 'boolean', 'm-check-circle')); }
304
+ }
305
+ /** Date ; avec `time()`, la date et l'heure (sélecteur date-heure, préréglages et unités à la minute ou à l'heure). */
306
+ export class DateConstraint extends Constraint {
307
+ _time = false;
308
+ static make(name) { return configured(new DateConstraint(name, 'date', 'm-calendar')); }
309
+ time(condition = true) { this._time = condition; return this; }
310
+ hasTime() { return this._time; }
311
+ getOperators() {
312
+ return [windowOperator('isAfter', 'is_after', '>=', '<', this._time), windowOperator('isBefore', 'is_before', '<=', '>', this._time), ...super.getOperators()];
313
+ }
314
+ }
315
+ /** Valeur choisie dans une liste fixe (port de SelectConstraint) ; `multiple()` : l'une des valeurs choisies. */
316
+ export class SelectConstraint extends Constraint {
317
+ _options = [];
318
+ _multiple = false;
319
+ _searchable = false;
320
+ static make(name) { return configured(new SelectConstraint(name, 'select', 'm-chevron-up-down')); }
321
+ /** Options `[valeur, libellé]` ou `{ valeur: libellé }` ; seules ces valeurs filtrent. */
322
+ options(options) {
323
+ this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
324
+ return this;
325
+ }
326
+ getOptions() { return this._options; }
327
+ multiple(condition = true) { this._multiple = condition; return this; }
328
+ isMultiple() { return this._multiple; }
329
+ searchable(condition = true) { this._searchable = condition; return this; }
330
+ getOperators() {
331
+ const field = this._multiple ? 'values' : 'value';
332
+ const known = (value) => (typeof value === 'string' || typeof value === 'number') && this._options.some(([option]) => option === String(value));
333
+ const is = {
334
+ name: 'is', label: label('select', 'is'), defaults: () => ({ [field]: this._multiple ? [] : null }),
335
+ fields: () => [Select.make(field).label(tr(`operators.select.is.form.${field}.label`)).options(this._options).multiple(this._multiple).searchable(this._searchable).required().columnSpanFull()],
336
+ resolve: (settings, inverse, constraint) => {
337
+ const raw = settings[field];
338
+ // Valeurs forgées ou inconnues écartées : il ne reste que des options déclarées (sinon la règle est sans effet).
339
+ const values = (Array.isArray(raw) ? raw : this._multiple ? [] : [raw]).filter(known).map(String);
340
+ if (!values.length)
341
+ return null;
342
+ const labels = this._options.filter(([option]) => values.includes(option)).map(([, optionLabel]) => optionLabel);
343
+ const condition = this._multiple
344
+ ? { kind: 'in', column: constraint.getColumn(), values: [...new Set(values)], negate: inverse }
345
+ : { kind: 'compare', column: constraint.getColumn(), operator: inverse ? '!=' : '=', value: values[0] };
346
+ return { condition, summary: summary('select', 'is', inverse, { attribute: constraint.getAttributeLabel(), values: joinLabels(labels, 'operators.select.is.summary.values_glue') }) };
347
+ },
348
+ };
349
+ return [is, ...(this._nullable ? [isFilledOperator] : [])];
350
+ }
351
+ }
352
+ /** Nombre entier d'enregistrements liés, d'au moins 1 (formulaire `count` des opérateurs de RelationshipConstraint). */
353
+ const countSetting = (value) => (isNumericValue(value) && Number(value) >= 1 && Number(value) <= 1e9 ? Math.floor(Number(value)) : null);
354
+ const isNumericValue = (value) => typeof value === 'number' ? Number.isFinite(value) : typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value));
355
+ function relationshipCountOperator(name, key, direct, inverted) {
356
+ return {
357
+ name, label: label('relationship', key), defaults: () => ({ count: null }), onBaseQuery: true,
358
+ fields: () => [TextInput.make('count').label(tr('operators.relationship.form.count.label')).numeric().required().minValue(1)],
359
+ resolve: (settings, inverse, constraint) => {
360
+ const count = countSetting(settings.count);
361
+ const relationship = constraint.getRelationshipName();
362
+ const modify = constraint.getModifyRelationshipQueryUsing();
363
+ return count === null ? null : { condition: { kind: 'relationCount', relationship, operator: inverse ? inverted : direct, value: count, ...(modify ? { modify } : {}) }, summary: summary('relationship', key, inverse, { relationship: constraint.getAttributeLabel(), count }) };
364
+ },
365
+ };
366
+ }
367
+ /**
368
+ * Opérateur « Is » / « Contains » d'une RelationshipConstraint (port de IsRelatedToOperator) : les enregistrements liés sont choisis dans
369
+ * une liste tirée du modèle lié, par leur clé ; `titleAttribute` est la colonne affichée.
370
+ */
371
+ export class IsRelatedToOperator {
372
+ _titleAttribute = null;
373
+ _multiple = false;
374
+ _searchable = false;
375
+ _preload = false;
376
+ _optionsLimit = 50;
377
+ _modifyQuery = null;
378
+ static make() { return configured(new IsRelatedToOperator()); }
379
+ titleAttribute(attribute) { this._titleAttribute = attribute; return this; }
380
+ getTitleAttribute() { return this._titleAttribute; }
381
+ multiple(condition = true) { this._multiple = condition; return this; }
382
+ isMultiple() { return this._multiple; }
383
+ searchable(condition = true) { this._searchable = condition; return this; }
384
+ preload(condition = true) { this._preload = condition; return this; }
385
+ optionsLimit(limit) { this._optionsLimit = Math.max(1, Math.floor(limit)); return this; }
386
+ modifyRelationshipQueryUsing(callback) { this._modifyQuery = callback; return this; }
387
+ getModifyRelationshipQueryUsing() { return this._modifyQuery; }
388
+ /** Opérateur du constructeur pour la contrainte donnée. */
389
+ toOperator(constraint) {
390
+ const field = constraint.isMultiple() ? 'values' : 'value';
391
+ const variant = constraint.isMultiple() ? 'multiple' : 'single';
392
+ const keys = (raw) => (Array.isArray(raw) ? raw : [raw]).filter((value) => typeof value === 'string' || typeof value === 'number').slice(0, MAX_LIST_VALUES);
393
+ return {
394
+ name: 'isRelatedTo', label: (inverse) => tr(`operators.relationship.is_related_to.label.${variant}.${inverse ? 'inverse' : 'direct'}`), defaults: () => ({ [field]: this._multiple ? [] : null }), onBaseQuery: true,
395
+ fields: () => [Select.make(field).label(tr(`operators.relationship.is_related_to.form.${field}.label`)).multiple(this._multiple).searchable(this._searchable)
396
+ .options(async (ctx) => constraint.loadRelatedOptions(this, ctx?.livewire)).required().columnSpanFull()],
397
+ resolve: (settings, inverse, c) => {
398
+ const selected = keys(settings[field]);
399
+ if (!selected.length)
400
+ return null;
401
+ const labels = selected.map((key) => constraint.getRelatedLabel(key) ?? String(key));
402
+ const relationship = c.getRelationshipName();
403
+ const modify = this._modifyQuery;
404
+ return {
405
+ condition: { kind: 'related', relationship, keys: [...new Set(selected)], negate: inverse, ...(modify ? { modify } : {}) },
406
+ summary: tr(`operators.relationship.is_related_to.summary.${variant}.${inverse ? 'inverse' : 'direct'}`, { relationship: c.getAttributeLabel(), values: joinLabels(labels, 'operators.relationship.is_related_to.summary.values_glue') }),
407
+ };
408
+ },
409
+ };
410
+ }
411
+ getOptionsLimit() { return this._optionsLimit; }
412
+ }
413
+ /**
414
+ * Enregistrements liés (port de RelationshipConstraint) : nombre minimal, maximal ou exact (`multiple()`), relation vide (`emptyable()`,
415
+ * par défaut avec `multiple()`), ou liés à des enregistrements choisis (`selectable(IsRelatedToOperator.make()…)`).
416
+ */
417
+ export class RelationshipConstraint extends Constraint {
418
+ _multiple = false;
419
+ _emptyable = null;
420
+ _selectable = null;
421
+ /** Libellés des enregistrements liés cités par les règles, chargés avant le calcul des indicateurs. */
422
+ _relatedLabels = new Map();
423
+ static make(name) { return configured(new RelationshipConstraint(name, 'relationship', 'm-arrows-pointing-out')); }
424
+ multiple(condition = true) { this._multiple = condition; return this; }
425
+ isMultiple() { return this._multiple; }
426
+ emptyable(condition = true) { this._emptyable = condition; return this; }
427
+ canBeEmpty() { return this._emptyable ?? this._multiple; }
428
+ selectable(operator) { this._selectable = operator; return this; }
429
+ getSelectableOperator() { return this._selectable; }
430
+ /** Relation interrogée : celle de `relationship()`, sinon `attribute()`, sinon le nom de la contrainte. */
431
+ getRelationshipName() { return this._relationship ?? this.getColumn(); }
432
+ getOperators() {
433
+ return [
434
+ ...(this._selectable ? [this._selectable.toOperator(this)] : []),
435
+ ...(this._multiple ? [relationshipCountOperator('hasMin', 'has_min', '>=', '<'), relationshipCountOperator('hasMax', 'has_max', '<=', '>'), relationshipCountOperator('equals', 'equals', '=', '!=')] : []),
436
+ ...(this.canBeEmpty() ? [{
437
+ name: 'isEmpty', label: label('relationship', 'is_empty'), defaults: () => null, fields: () => [], onBaseQuery: true,
438
+ resolve: (_settings, inverse) => {
439
+ const modify = this.getModifyRelationshipQueryUsing();
440
+ return { condition: { kind: 'related', relationship: this.getRelationshipName(), keys: null, negate: !inverse, ...(modify ? { modify } : {}) }, summary: summary('relationship', 'is_empty', inverse, { relationship: this.getAttributeLabel() }) };
441
+ },
442
+ }] : []),
443
+ ];
444
+ }
445
+ getRelatedLabel(key) { return this._relatedLabels.get(String(key)) ?? null; }
446
+ /** Modèle lié, retrouvé par la relation du modèle de la table (déclaré, ou celui de l'adaptateur Objection de l'hôte). */
447
+ relatedModel(host) {
448
+ const model = this._model ?? modelOfHost(host);
449
+ if (!model?.getRelation)
450
+ throw new Error(`RelationshipConstraint ${this.name} : modèle de la table introuvable ; déclarez-le avec model() sur la contrainte ou le QueryBuilder.`);
451
+ return model.getRelation(this.getRelationshipName()).relatedModelClass;
452
+ }
453
+ relatedQuery(operator, host) {
454
+ const related = this.relatedModel(host);
455
+ const title = operator.getTitleAttribute() ?? String(related.idColumn);
456
+ const query = related.query();
457
+ operator.getModifyRelationshipQueryUsing()?.(query);
458
+ return { query, id: `${related.tableName}.${related.idColumn}`, title: `${related.tableName}.${title}` };
459
+ }
460
+ /** Options de la liste : enregistrements du modèle lié, triés par leur titre. */
461
+ async loadRelatedOptions(operator, host) {
462
+ const { query, id, title } = this.relatedQuery(operator, host);
463
+ // Comme une liste non cherchable de Filament : toutes les options, ici plafonnées.
464
+ const rows = await query.clearSelect().select({ __key: id, __title: title }).orderBy(title).limit(1000);
465
+ return rows.map((row) => [String(row.__key), String(row.__title ?? row.__key)]);
466
+ }
467
+ /** Charge les libellés des clés citées par des règles (indicateurs « Author is Ada or Linus »). */
468
+ async loadRelatedLabels(keys, host) {
469
+ if (!this._selectable || !keys.length)
470
+ return;
471
+ const { query, id, title } = this.relatedQuery(this._selectable, host);
472
+ const rows = await query.clearSelect().select({ __key: id, __title: title }).whereIn(id, keys.slice(0, MAX_LIST_VALUES));
473
+ for (const row of rows)
474
+ this._relatedLabels.set(String(row.__key), String(row.__title ?? row.__key));
475
+ }
476
+ }
477
+ /** Modèle Objection de la table de l'hôte (adaptateur `ObjectionAdapter`), quand l'application ne l'a pas déclaré. */
478
+ function modelOfHost(host) {
479
+ const adapter = host?.getDataAdapterForTable?.() ?? host?.resource?.adapter ?? null;
480
+ const model = adapter?.model;
481
+ return typeof model === 'function' && typeof model.getRelation === 'function' ? model : null;
482
+ }
483
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
484
+ /**
485
+ * Arbre de règles reçu du navigateur → arbre ne contenant que ce que le filtre déclare : types de blocs connus, opérateurs de la contrainte,
486
+ * réglages scalaires aux noms connus, profondeur et nombre de règles bornés. Un opérateur inconnu est remis à vide (règle sans effet).
487
+ */
488
+ export function sanitizeRules(constraints, raw, depth = 1, budget = { rules: MAX_RULES }) {
489
+ const rules = {};
490
+ if (!isObject(raw))
491
+ return rules;
492
+ for (const [key, rule] of Object.entries(raw)) {
493
+ if (budget.rules <= 0)
494
+ break;
495
+ if (!isItemKey(key) || !isObject(rule) || typeof rule.type !== 'string')
496
+ continue;
497
+ const data = isObject(rule.data) ? rule.data : {};
498
+ if (rule.type === OR) {
499
+ if (depth >= MAX_NESTING_DEPTH)
500
+ continue;
501
+ const groups = {};
502
+ for (const [groupKey, group] of Object.entries(isObject(data.groups) ? data.groups : {}).slice(0, 20)) {
503
+ if (isItemKey(groupKey))
504
+ groups[groupKey] = { rules: sanitizeRules(constraints, isObject(group) ? group.rules : null, depth + 1, budget) };
505
+ }
506
+ rules[key] = { type: OR, data: { groups } };
507
+ continue;
508
+ }
509
+ const constraint = constraints.find((candidate) => candidate.name === rule.type);
510
+ if (!constraint)
511
+ continue;
512
+ budget.rules--;
513
+ // Une liste n'est acceptée que pour un réglage qui en est une par défaut (sélection multiple) ; ailleurs, seulement un scalaire.
514
+ const lists = constraint.getListSettingNames();
515
+ const settings = isObject(data.settings) ? Object.fromEntries(constraint.getSettingNames().filter((name) => Object.hasOwn(data.settings, name)).map((name) => [name, lists.includes(name) ? setting(data.settings[name]) : scalar(data.settings[name])])) : null;
516
+ rules[key] = { type: constraint.name, data: { operator: constraint.parseOperator(data.operator) ? data.operator : null, settings } };
517
+ }
518
+ return rules;
519
+ }
520
+ /** Règles nettoyées → conditions et libellés d'indicateurs (une entrée par règle de premier niveau qui a un effet). */
521
+ export function compileRules(constraints, rules, iteration = 1) {
522
+ const nodes = [];
523
+ const summaries = {};
524
+ for (const [key, rule] of Object.entries(rules)) {
525
+ if (rule.type === OR) {
526
+ const groups = Object.values((rule.data.groups ?? {})).map((group) => compileRules(constraints, group.rules, iteration + 1));
527
+ // Un groupe sans règle effective n'ajoute rien (dans Filament, `orWhere` avec une clôture vide est ignoré).
528
+ const effective = groups.filter((group) => group.nodes.length);
529
+ if (!effective.length)
530
+ continue;
531
+ nodes.push({ or: effective.map((group) => group.nodes) });
532
+ const parts = effective.map((group) => { const list = Object.values(group.summaries); return list.length > 1 ? `(${list.join(` ${tr('form.rules.item.and')} `)})` : list[0]; });
533
+ summaries[key] = parts.length === 1 ? parts[0] : `${iteration > 1 ? '(' : ''}${parts.join(` ${tr('form.or_groups.block.or')} `)}${iteration > 1 ? ')' : ''}`;
534
+ continue;
535
+ }
536
+ const constraint = constraints.find((candidate) => candidate.name === rule.type);
537
+ const parsed = constraint?.parseOperator(rule.data.operator);
538
+ if (!constraint || !parsed)
539
+ continue;
540
+ const resolved = parsed.operator.resolve(isObject(rule.data.settings) ? rule.data.settings : {}, parsed.inverse, constraint);
541
+ if (!resolved)
542
+ continue;
543
+ // Attribut d'une relation : la condition porte sur les enregistrements liés (`whereHas`), comme Operator::applyToBaseQuery.
544
+ const relationship = parsed.operator.onBaseQuery ? null : constraint.getRelationshipName();
545
+ const modify = constraint.getModifyRelationshipQueryUsing();
546
+ nodes.push(relationship === null ? resolved.condition : { relationship, nodes: [resolved.condition], ...(modify ? { modify } : {}) });
547
+ summaries[key] = resolved.summary;
548
+ }
549
+ return { nodes, summaries };
550
+ }
551
+ /** Dialecte lu dans le client knex (`query.client.config.client`, ou celui de la connexion du modèle Objection) ; SQLite par défaut. */
552
+ export function sqlDialectOf(query) {
553
+ const client = query?.client ?? query?.knex?.()?.client ?? query?.modelClass?.()?.knex?.()?.client;
554
+ const configured = client?.config?.client;
555
+ const name = String(typeof configured === 'string' ? configured : (client?.dialect ?? configured?.prototype?.dialect ?? client?.driverName ?? '')).toLowerCase();
556
+ if (/mysql|maria/.test(name))
557
+ return 'mysql';
558
+ if (/^(pg|pgnative|postgres|postgresql|cockroachdb|redshift)$/.test(name))
559
+ return 'pg';
560
+ if (/mssql|sqlserver/.test(name))
561
+ return 'mssql';
562
+ return 'sqlite';
563
+ }
564
+ /** Fragments SQL qui diffèrent d'une base à l'autre. `??` est la colonne (identifiant lié), jamais une valeur du navigateur. */
565
+ const SQL = {
566
+ sqlite: { date: 'date(??)', month: "cast(strftime('%m', ??) as integer)", year: "cast(strftime('%Y', ??) as integer)", text: '??', like: "?? like ? escape '\\'" },
567
+ // MySQL : la barre oblique inverse est déjà le caractère d'échappement de LIKE (et doit être doublée dans un littéral).
568
+ mysql: { date: 'date(??)', month: 'month(??)', year: 'year(??)', text: '??', like: '?? like ?' },
569
+ // PostgreSQL : LIKE sensible à la casse, d'où la comparaison en minuscules (comme Filament) ; `cast` pour les colonnes non textuelles.
570
+ pg: { date: 'cast(?? as date)', month: 'extract(month from ??)', year: 'extract(year from ??)', text: 'cast(?? as text)', like: "lower(cast(?? as text)) like ? escape '\\'" },
571
+ mssql: { date: 'cast(?? as date)', month: 'month(??)', year: 'year(??)', text: '??', like: "?? like ? escape '\\'" },
572
+ };
573
+ /** Requête des enregistrements liés à ceux de la requête (sous-requête corrélée d'Objection, `relatedQuery`). */
574
+ function relatedQuery(query, relationship, modify) {
575
+ const model = query?.modelClass?.();
576
+ if (typeof model?.relatedQuery !== 'function')
577
+ throw new Error(`Constructeur de requêtes : la relation ${relationship} demande une requête Objection (modèle avec relationMappings).`);
578
+ // Alias fixé ici (sinon Objection en pose un seulement pour certaines relations) : les colonnes liées sont qualifiées par lui.
579
+ const sub = model.relatedQuery(relationship).alias(`${relationship}_related`);
580
+ modify?.(sub);
581
+ return sub;
582
+ }
583
+ /**
584
+ * Applique des conditions à une requête knex / objection. Colonnes passées en identifiants, valeurs en paramètres liés ; les opérateurs
585
+ * de comparaison viennent d'une liste fermée. Le SQL de date et de recherche suit le dialecte de la connexion.
586
+ */
587
+ export function applyConditions(query, nodes, dialect = sqlDialectOf(query)) {
588
+ const sql = SQL[dialect];
589
+ for (const node of nodes) {
590
+ if ('or' in node) {
591
+ query.where((builder) => { for (const group of node.or)
592
+ builder.orWhere((inner) => applyConditions(inner, group, dialect)); });
593
+ }
594
+ else if ('nodes' in node) {
595
+ const sub = relatedQuery(query, node.relationship, node.modify);
596
+ // Colonne qualifiée par la table liée : la sous-requête peut joindre une table pivot.
597
+ const table = sub.tableRefFor(sub.modelClass());
598
+ sub.where((inner) => applyConditions(inner, node.nodes.map((child) => qualify(child, table)), dialect));
599
+ query.whereExists(sub);
600
+ }
601
+ else if (node.kind === 'like')
602
+ query.whereRaw(node.negate ? `not (${sql.like})` : sql.like, [node.column, dialect === 'pg' ? node.pattern.toLowerCase() : node.pattern]);
603
+ else if (node.kind === 'compare')
604
+ query.where(node.column, node.operator, node.value);
605
+ else if (node.kind === 'boolean')
606
+ query.where(node.column, dialect === 'pg' ? node.value : node.value ? 1 : 0);
607
+ else if (node.kind === 'date')
608
+ query.whereRaw(`${sql.date} ${node.operator} ?`, [node.column, node.value]);
609
+ else if (node.kind === 'month')
610
+ query.whereRaw(`${sql.month} ${node.operator} ?`, [node.column, node.value]);
611
+ else if (node.kind === 'year')
612
+ query.whereRaw(`${sql.year} ${node.operator} ?`, [node.column, node.value]);
613
+ else if (node.kind === 'filled') {
614
+ // Port de IsFilledOperator : ni NULL ni chaîne vide (la colonne est lue en texte sous PostgreSQL).
615
+ const column = node.column;
616
+ query.where((inner) => (node.negate ? inner.whereNull(column).orWhereRaw(`${sql.text} = ?`, [column, '']) : inner.whereNotNull(column).whereRaw(`not (${sql.text} = ?)`, [column, ''])));
617
+ }
618
+ else if (node.kind === 'in') {
619
+ if (node.negate)
620
+ query.whereNotIn(node.column, node.values);
621
+ else
622
+ query.whereIn(node.column, node.values);
623
+ }
624
+ else if (node.kind === 'relationCount') {
625
+ // Sous-requête de comptage en paramètre lié (Objection ne l'accepte pas comme colonne de `where`).
626
+ query.whereRaw(`(?) ${node.operator} ?`, [relatedQuery(query, node.relationship, node.modify).count(), node.value]);
627
+ }
628
+ else if (node.kind === 'related') {
629
+ const sub = relatedQuery(query, node.relationship, node.modify);
630
+ if (node.keys !== null) {
631
+ const related = sub.modelClass();
632
+ sub.whereIn(`${sub.tableRefFor(related)}.${related.idColumn}`, node.keys);
633
+ }
634
+ if (node.negate)
635
+ query.whereNotExists(sub);
636
+ else
637
+ query.whereExists(sub);
638
+ }
639
+ }
640
+ }
641
+ /** Colonne d'une condition qualifiée par la table des enregistrements liés (sauf si elle l'est déjà). */
642
+ function qualify(node, table) {
643
+ if ('or' in node)
644
+ return { or: node.or.map((group) => group.map((child) => qualify(child, table))) };
645
+ if ('nodes' in node || !('column' in node) || node.column.includes('.'))
646
+ return node;
647
+ return { ...node, column: `${table}.${node.column}` };
648
+ }
649
+ // ------------------------------------------------------------------ formulaire
650
+ const CONTAINER_GRID = { '@md': 2, '@xl': 3, '!@sm': 2, '!@md': 3 };
651
+ const SETTINGS_GRID = { '@xl': 2, '!@md': 2 };
652
+ const anonymousScope = (path) => `x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path, containerPath: path }))})"`;
653
+ /** Réglages d'un opérateur : groupe à état `settings`, masqué quand l'opérateur n'a pas de formulaire. */
654
+ class SettingsGroup extends Component {
655
+ fields;
656
+ constructor(fields) {
657
+ super();
658
+ this._name = 'settings';
659
+ this.fields = fields;
660
+ this._hidden = () => this.fields.length === 0;
661
+ this.columnSpan(SETTINGS_GRID);
662
+ }
663
+ hasStatePath() { return true; }
664
+ getChildSchemas() {
665
+ const c = this.container;
666
+ return [new Schema(c.livewire).key(this.getKey()).statePath(this.getStatePath()).operation(c.getOperation()).record(c.getRecord()).root(false).columns(SETTINGS_GRID).dense().components(this.fields)];
667
+ }
668
+ async toHtml() { return `<div id="${e(this.getKey())}">${await this.getChildSchemas()[0].toHtml()}</div>`; }
669
+ }
670
+ /** Boutons « dupliquer » et « supprimer » d'un élément, portés par le Builder parent. */
671
+ function itemActions(builder, itemKey, path, cloneDisabled) {
672
+ const label = (name) => t(`filament-forms::components.builder.actions.${name}.label`);
673
+ const danger = colorsOf(builder.livewire)?.getComponentClasses('icon-button', 'danger') ?? [];
674
+ return `<div wire:key="" ${anonymousScope(path)}><div class="fi-sc-actions"><div class="fi-ac">${iconActionButton({ label: label('clone'), icon: 'm-square-2-stack', expression: builder.actionExpression('clone', { item: itemKey }), disabled: cloneDisabled })}${iconActionButton({ label: label('delete'), icon: 'm-trash', expression: builder.actionExpression('delete', { item: itemKey }), colorClasses: danger })}</div></div></div>`;
675
+ }
676
+ /** Ligne d'une règle : sélecteur d'opérateur, réglages, actions (port du Flex de Constraint::getBuilderBlock). */
677
+ class RuleRow extends Component {
678
+ builder;
679
+ itemKey;
680
+ constraint;
681
+ data;
682
+ constructor(builder, itemKey, constraint, data) { super(); this.builder = builder; this.itemKey = itemKey; this.constraint = constraint; this.data = data; }
683
+ getChildSchemas() {
684
+ const c = this.container;
685
+ const constraint = this.constraint;
686
+ const parsed = constraint.parseOperator(this.data.operator);
687
+ const operator = Select.make('operator').label(constraint.getLabel()).placeholder(t('filament-forms::components.select.placeholder')).options(constraint.getOperatorOptions()).live()
688
+ // Changement d'opérateur : ses réglages par défaut complètent les réglages existants (comme `fill()` dans Filament).
689
+ .afterStateUpdated(({ state, get, set }) => {
690
+ const next = constraint.parseOperator(state)?.operator.defaults() ?? null;
691
+ const current = get('settings');
692
+ set('settings', next === null && !isObject(current) ? null : { ...(next ?? {}), ...(isObject(current) ? current : {}) });
693
+ });
694
+ return [new Schema(c.livewire).key(c.getKey()).statePath(c.getStatePath() || null).operation(c.getOperation()).record(c.getRecord()).root(false).columns(CONTAINER_GRID).dense()
695
+ .components([operator, new SettingsGroup(parsed ? parsed.operator.fields() : [])])];
696
+ }
697
+ /** Le conteneur de la ligne est un conteneur de requêtes CSS (`gridContainer()`), dont dépendent les colonnes `@md` / `@xl`. */
698
+ async toSchemaHtml(isVisible) { return (await super.toSchemaHtml(isVisible)).replace('class="fi-sc-component"', 'class="fi-sc-component fi-grid-ctn"'); }
699
+ async toHtml() {
700
+ const path = this.container.getStatePath();
701
+ return `<div role="group" aria-label="${e(this.constraint.getLabel())}" class="fi-sc-flex fi-from-default"><div wire:key="" ${anonymousScope(path)} class="fi-growable"><div>${await this.getChildSchemas()[0].toHtml()}</div></div>${itemActions(this.builder, this.itemKey, path, this.builder.isAtRuleLimit())}</div>`;
702
+ }
703
+ }
704
+ /** Groupes d'un bloc « OR » : chaque groupe contient son propre constructeur de règles (port du Repeater `groups`). */
705
+ class OrGroups extends Field {
706
+ builder;
707
+ itemKey;
708
+ constructor(builder, itemKey) { super('groups'); this.builder = builder; this.itemKey = itemKey; this._hiddenLabel = true; this._label = tr('form.or_groups.label'); }
709
+ getGroups() {
710
+ const state = this.context().state;
711
+ return isObject(state) ? Object.fromEntries(Object.entries(state).filter(([key, group]) => isItemKey(key) && isObject(group)).slice(0, 20)) : {};
712
+ }
713
+ isBlank(group) { return !isObject(group.rules) || Object.keys(group.rules).length === 0; }
714
+ getDefaultState() { return {}; }
715
+ dehydrateState() { return Object.fromEntries(Object.keys(this.getGroups()).map((key) => [key, {}])); }
716
+ getChildSchemas() {
717
+ const c = this.container;
718
+ return Object.keys(this.getGroups()).map((key) => new Schema(c.livewire).key(`${this.getKey()}.${key}`).statePath(`${this.getStatePath()}.${key}`).operation(c.getOperation()).record(c.getRecord()).root(false)
719
+ .components([new RuleBuilder('rules', this.builder.filter, this.builder.depth + 1).hiddenLabel()]));
720
+ }
721
+ canAddGroup() { const groups = Object.values(this.getGroups()); return groups.length < 20 && !groups.some((group) => this.isBlank(group)) && !this.builder.isAtRuleLimit(); }
722
+ canDeleteGroup(key) { const groups = this.getGroups(); return Object.hasOwn(groups, key) && this.isBlank(groups[key]) && Object.keys(groups).length > 2; }
723
+ getActions() {
724
+ const write = (groups) => writeState(this.livewire, this.getStatePath().split('.'), groups);
725
+ return [
726
+ Action.make('add').action(() => { if (this.canAddGroup())
727
+ write({ ...this.getGroups(), [shortKey()]: { rules: {} } }); }),
728
+ Action.make('delete').action(({ arguments: args }) => { const key = String(args.item); if (!this.canDeleteGroup(key))
729
+ return; const groups = this.getGroups(); delete groups[key]; write(groups); }),
730
+ ];
731
+ }
732
+ async toHtml() {
733
+ const key = this.getKey();
734
+ const id = this.getId();
735
+ const livewireKey = this.getLivewireKey() ?? '';
736
+ const groups = Object.keys(this.getGroups());
737
+ const schemas = this.getChildSchemas();
738
+ const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: key };
739
+ const expression = (action, args) => `mountAction(${js(action)}, ${args ? js(args) : '{}'}, ${js(context)})`;
740
+ const danger = colorsOf(this.livewire)?.getComponentClasses('icon-button', 'danger') ?? [];
741
+ const tf = (name, replace = {}) => t(`filament-forms::components.repeater.${name}`, replace);
742
+ const descriptionId = (groupKey) => `${id}-item-${createHash('md5').update(groupKey).digest('hex')}`;
743
+ const rows = [];
744
+ for (const [index, groupKey] of groups.entries()) {
745
+ const path = `${this.getStatePath()}.${groupKey}`;
746
+ const [builder] = schemas[index].getComponents();
747
+ const remove = this.canDeleteGroup(groupKey) ? `<div wire:key="" ${anonymousScope(path)}><div class="fi-sc-actions"><div class="fi-ac">${iconActionButton({ label: tf('actions.delete.label'), icon: 'm-trash', expression: expression('delete', { item: groupKey }), colorClasses: danger })}</div></div></div>` : '';
748
+ const content = `<div class="fi-grid fi-sc fi-sc-has-gap" style="--cols-default: repeat(1, minmax(0, 1fr));"><div ${anonymousScope(path)} class="fi-grid-col" style="--col-span-default: span 1 / span 1;"><div class="fi-sc-component"><div role="group" aria-label="${e(tr('form.or_groups.group.label'))}" class="fi-sc-flex fi-from-default fi-vertical-align-center"><div wire:key="${e(builder.getLivewireKey() ?? '')}" x-data="filamentSchemaComponent(${e(js(builder.getAlpineScopeConfiguration()))})" class="fi-growable">${await builder.toHtml()}</div>${remove}</div></div></div></div>`;
749
+ rows.push(`<li wire:ignore.self wire:key="${e(livewireKey)}.${e(groupKey)}.item" x-data="item(${e(js({ isCollapsed: false, collapseKey: null }))})" x-on:repeater-expand.window="expandFromEvent" x-on:repeater-collapse.window="collapseFromEvent" x-on:expand="expand" x-sortable-item="${e(groupKey)}" class="fi-fo-repeater-item" x-bind:class="itemClasses"><div x-show="! isCollapsed" class="fi-fo-repeater-item-content">${content}</div></li>`);
750
+ if (index < groups.length - 1)
751
+ rows.push(`<li class="fi-fo-repeater-label-between-items-ctn"><div class="fi-fo-repeater-label-between-items-divider-before"></div><span class="fi-fo-repeater-label-between-items">${e(tr('item_separators.or'))}</span><div class="fi-fo-repeater-label-between-items-divider-after"></div></li>`);
752
+ }
753
+ const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tf('reordering.moved'), hasCustomReorderAction: false };
754
+ const addExpression = expression('add', null);
755
+ const add = this.canAddGroup() ? `<div class="fi-fo-repeater-add fi-align-end"><button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="fi-ac-btn-action fi-btn fi-size-sm" wire:click="${e(addExpression)}">${e(tr('actions.add_rule_group.label'))}</button></div>` : '';
756
+ const descriptions = groups.map((groupKey, index) => `<span class="fi-sr-only" data-reorder-description="${e(groupKey)}" data-reorder-label="${e(tf('reordering.item'))}" id="${e(descriptionId(groupKey))}">${e(tf('reordering.position', { label: tf('reordering.item'), position: String(index + 1), count: String(groups.length) }))}</span>`).join('');
757
+ const repeater = `<div aria-labelledby="${e(id)}-label" id="${e(id)}" role="group" data-reorder-owner="data-reorder-owner" data-reorder-items="${e(JSON.stringify(groups))}" x-load="x-load" x-load-src="${e(views.render('filament-forms::asset', { path: 'repeater' }).trim())}" x-data="repeaterFormComponent(${e(js(config))})" class="fi-fo-repeater"><span wire:ignore x-ref="reorderStatus" class="fi-sr-only" role="status" aria-live="polite" aria-atomic="true"></span><ul x-sortable data-sortable-animation-duration="300" x-on:end.stop="reorder" x-ref="reorderItems" class="fi-grid fi-fo-repeater-items" style="--cols-default: repeat(1, minmax(0, 1fr));">${rows.join('')}</ul>${add}${descriptions}</div>`;
758
+ const path = this.container.getStatePath();
759
+ return `<div role="group" aria-label="${e(tr('form.or_groups.block.label'))}" class="fi-sc-flex fi-from-default"><div wire:key="${e(livewireKey)}" ${anonymousScope(path)} class="fi-growable">${await this.wrap(repeater, { labelAsDiv: true })}</div>${itemActions(this.builder, this.itemKey, path, this.builder.isAtRuleLimit())}</div>`;
760
+ }
761
+ }
762
+ /** Constructeur de règles (port de RuleBuilder) : un Builder dont les blocs sont les contraintes du filtre, plus le bloc « OR ». */
763
+ export class RuleBuilder extends Builder {
764
+ filter;
765
+ depth;
766
+ constructor(name, filter, depth = 1) {
767
+ super(name);
768
+ this.filter = filter;
769
+ this.depth = depth;
770
+ const columns = filter.getConstraintPickerColumns();
771
+ this.label(tr('form.rules.label'))
772
+ .blocks(() => [
773
+ ...filter.getConstraints().map((constraint) => Block.make(constraint.name).label(constraint.getLabel()).icon(constraint.getIcon())
774
+ .defaultData(() => constraint.getDefaultData())
775
+ .schema(({ builder, itemKey, data }) => [new RuleRow(builder, itemKey, constraint, data)])),
776
+ Block.make(OR).label(tr('form.or_groups.block.label')).icon('m-slash')
777
+ .defaultData(() => ({ groups: { [shortKey()]: { rules: {} }, [shortKey()]: { rules: {} } } }))
778
+ .schema(({ builder, itemKey }) => [new OrGroups(builder, itemKey)]),
779
+ ])
780
+ .addActionLabel(tr('actions.add_rule.label')).addActionIcon('c-plus').addActionAlignment('start')
781
+ .labelBetweenItems(tr('item_separators.and')).blockHeaders(false).addBetweenAction(false).storedAsList(false).cloneable().generateUuidUsing(shortKey)
782
+ .blockPickerColumns(columns).blockPickerWidth(columns === 2 ? 'md' : columns === 3 ? '2xl' : null)
783
+ .extraClass('fi-fo-builder-not-contained').maxItems(MAX_RULES);
784
+ }
785
+ /** Le bloc « OR » n'est plus proposé (ni accepté) quand il dépasserait la profondeur autorisée ; rien ne l'est une fois le plafond de règles atteint. */
786
+ getBlockPickerBlocks() {
787
+ if (this.isAtRuleLimit())
788
+ return [];
789
+ return this.getBlocks().filter((block) => block.name !== OR || this.depth < MAX_NESTING_DEPTH);
790
+ }
791
+ getBlock(name) { return name === OR && this.depth >= MAX_NESTING_DEPTH ? undefined : super.getBlock(name); }
792
+ canAdd() { return !this.isAtRuleLimit(); }
793
+ /** Nombre de règles de l'arbre entier (les blocs « OR » ne comptent pas), lu dans l'état différé de l'hôte. */
794
+ isAtRuleLimit() { return this.filter.countRules(this.livewire.tableDeferredFilters?.[this.filter.name]) >= MAX_RULES; }
795
+ /** Action de composant sans modale (utilisée aussi par les groupes « OR »). */
796
+ makeAction(name, handler) { return this.getActions()[0].constructor.make(name).action(({ arguments: args }) => handler(args)); }
797
+ }
798
+ // ------------------------------------------------------------------ filtre
799
+ /** Filtre « constructeur de requêtes » d'une table. */
800
+ export class QueryBuilder {
801
+ name = 'queryBuilder';
802
+ _constraints = [];
803
+ _pickerColumns = null;
804
+ _model = null;
805
+ static make() { return configured(new QueryBuilder()); }
806
+ constraints(constraints) {
807
+ if (new Set(constraints.map((c) => c.name)).size !== constraints.length)
808
+ throw new Error('QueryBuilder : deux contraintes portent le même nom.');
809
+ this._constraints = constraints;
810
+ if (this._model)
811
+ for (const constraint of constraints)
812
+ if (!constraint.getModel())
813
+ constraint.model(this._model);
814
+ return this;
815
+ }
816
+ /** Modèle Objection de la table, transmis aux contraintes de relation (listes d'enregistrements liés). */
817
+ model(model) {
818
+ this._model = model;
819
+ for (const constraint of this._constraints)
820
+ if (!constraint.getModel())
821
+ constraint.model(model);
822
+ return this;
823
+ }
824
+ /** Avant les indicateurs : charge les libellés des enregistrements liés que citent les règles (`RelationshipConstraint.selectable`). */
825
+ async loadIndicatorData(state, host) {
826
+ const keys = new Map();
827
+ const walk = (rules) => {
828
+ for (const rule of Object.values(rules)) {
829
+ if (rule.type === OR) {
830
+ for (const group of Object.values(rule.data.groups))
831
+ walk(group.rules);
832
+ continue;
833
+ }
834
+ const constraint = this._constraints.find((candidate) => candidate.name === rule.type);
835
+ if (!(constraint instanceof RelationshipConstraint) || !isObject(rule.data.settings))
836
+ continue;
837
+ const raw = rule.data.settings.values ?? rule.data.settings.value;
838
+ const list = (Array.isArray(raw) ? raw : [raw]).filter((value) => typeof value === 'string' || typeof value === 'number');
839
+ keys.set(constraint, [...(keys.get(constraint) ?? []), ...list]);
840
+ }
841
+ };
842
+ walk(this.sanitizeState(state).rules);
843
+ for (const [constraint, list] of keys)
844
+ await constraint.loadRelatedLabels([...new Set(list)], host);
845
+ }
846
+ constraintPickerColumns(columns = 2) { this._pickerColumns = columns; return this; }
847
+ getConstraints() { return this._constraints; }
848
+ getConstraintPickerColumns() { return this._pickerColumns; }
849
+ getLabel() { return tr('label'); }
850
+ getDefaultState() { return { rules: {} }; }
851
+ getFormDefinition() { return { placeholder: '', options: [] }; }
852
+ /** Le filtre occupe toute la largeur du formulaire des filtres. */
853
+ getColumnSpan() { return { default: 'full' }; }
854
+ getFormComponents() { return [new RuleBuilder('rules', this).label(this.getLabel()).hiddenLabel()]; }
855
+ sanitizeState(state) { return { rules: sanitizeRules(this._constraints, isObject(state) ? state.rules : null) }; }
856
+ countRules(state) {
857
+ const count = (rules) => Object.values(rules).reduce((total, rule) => total + (rule.type === OR ? Object.values(rule.data.groups).reduce((sum, group) => sum + count(group.rules), 0) : 1), 0);
858
+ return count(this.sanitizeState(state).rules);
859
+ }
860
+ compile(state) { return compileRules(this._constraints, this.sanitizeState(state).rules); }
861
+ isActive(state) { return this.compile(state).nodes.length > 0; }
862
+ getIndicators(state) { return Object.entries(this.compile(state).summaries).map(([field, label]) => ({ label, field })); }
863
+ /** Retrait d'un indicateur : la règle de premier niveau correspondante disparaît. */
864
+ removeIndicator(state, field) {
865
+ const rules = { ...this.sanitizeState(state).rules };
866
+ if (Object.hasOwn(rules, field))
867
+ delete rules[field];
868
+ return { rules };
869
+ }
870
+ apply(query, state) {
871
+ const { nodes } = this.compile(state);
872
+ if (nodes.length)
873
+ (query.modifiers ??= []).push((builder) => { builder.where((inner) => applyConditions(inner, nodes)); });
874
+ }
875
+ }