@navette-js/tables 0.0.0-stage → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,869 @@
1
+ // Query builder filter: users compose rules (constraint + operator + settings), optionally grouped in "OR" blocks.
2
+ // All state comes from the browser: it is sanitized against the declared constraints before any use (query, indicators, render).
3
+ // Not supported yet: relationship aggregates (sum, average… of a related column), constraint groups, server-side option search.
4
+ // Date and search SQL is generated for SQLite, MySQL / MariaDB, PostgreSQL and SQL Server, depending on the query's knex client.
5
+ import { Action } from '@navette-js/actions';
6
+ import { DatePicker, DateTimePicker, Field, Select, TextInput } from '@navette-js/forms';
7
+ import { Block, Builder, colorsOf, iconActionButton, isItemKey, shortKey, writeState } from '@navette-js/forms/builder';
8
+ import { Component, Group, Schema } from '@navette-js/schemas';
9
+ import { e, js, views, configured } from '@navette-js/support';
10
+ import { t } from '@navette-js/support/i18n';
11
+ import { createHash } from 'node:crypto';
12
+ const tr = (key, replace = {}) => t(`filament-query-builder::query-builder.${key}`, replace);
13
+ const headline = (s) => s.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
14
+ const scalar = (value) => typeof value === 'string' ? value.slice(0, 1000) : typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)) ? value : null;
15
+ /** Setting received from the browser: a scalar, or a bounded list of scalars (multiple select). */
16
+ const setting = (value) => Array.isArray(value) ? value.slice(0, MAX_LIST_VALUES).map(scalar).filter((item) => typeof item === 'string' || typeof item === 'number') : scalar(value);
17
+ /** Limits applied to the rule tree received from the browser. */
18
+ export const MAX_RULES = 100;
19
+ export const MAX_NESTING_DEPTH = 3;
20
+ /** Maximum number of values in a multiple select. */
21
+ export const MAX_LIST_VALUES = 100;
22
+ const OR = 'or';
23
+ const label = (group, name) => (inverse) => tr(`operators.${group}.${name}.label.${inverse ? 'inverse' : 'direct'}`);
24
+ /** Joins labels as "a, b or c" using the translated glue strings. */
25
+ const joinLabels = (labels, glueKey) => (labels.length <= 1 ? labels.join('') : `${labels.slice(0, -1).join(tr(`${glueKey}.0`))}${tr(`${glueKey}.final`)}${labels[labels.length - 1]}`);
26
+ const summary = (group, name, inverse, replace) => tr(`operators.${group}.${name}.summary.${inverse ? 'inverse' : 'direct'}`, replace);
27
+ const likeEscape = (text) => text.replace(/[\\%_]/g, '\\$&');
28
+ function textOperator(name, key, pattern) {
29
+ return {
30
+ name, label: label('text', key), defaults: () => ({ text: null }),
31
+ fields: () => [TextInput.make('text').label(tr('operators.text.form.text.label')).required().columnSpanFull()],
32
+ resolve: (settings, inverse, constraint) => {
33
+ const text = typeof settings.text === 'string' ? settings.text : typeof settings.text === 'number' ? String(settings.text) : '';
34
+ if (text === '')
35
+ return null;
36
+ const like = pattern(text);
37
+ return {
38
+ condition: like === null ? { kind: 'compare', column: constraint.getColumn(), operator: inverse ? '!=' : '=', value: text } : { kind: 'like', column: constraint.getColumn(), pattern: like, negate: inverse },
39
+ summary: summary('text', key, inverse, { attribute: constraint.getAttributeLabel(), text }),
40
+ };
41
+ },
42
+ };
43
+ }
44
+ function numberOperator(name, key, direct, inverted) {
45
+ return {
46
+ name, label: label('number', key), defaults: () => ({ number: null, aggregate: null }),
47
+ // `aggregate` is only used for relationships; the field exists but stays hidden.
48
+ fields: () => [TextInput.make('number').label(tr('operators.number.form.number.label')).numeric().required(), Select.make('aggregate').label(tr('operators.number.form.aggregate.label')).hidden()],
49
+ resolve: (settings, inverse, constraint) => {
50
+ const raw = settings.number;
51
+ if (!(typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '')) || !Number.isFinite(Number(raw)))
52
+ return null;
53
+ const number = Number(raw);
54
+ 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) }) };
55
+ },
56
+ };
57
+ }
58
+ const pad = (n) => String(n).padStart(2, '0');
59
+ const toDateString = (date) => `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
60
+ const toDateTimeString = (date) => `${toDateString(date)} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
61
+ /** A valid `YYYY-MM-DD` date, otherwise null. */
62
+ function parseDate(value) {
63
+ const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
64
+ if (!m)
65
+ return null;
66
+ const date = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
67
+ return toDateString(date) === `${m[1]}-${m[2]}-${m[3]}` ? toDateString(date) : null;
68
+ }
69
+ /** A valid `YYYY-MM-DD HH:MM[:SS]` (or `T`-separated) date-time → `YYYY-MM-DD HH:MM:SS`; a bare date means midnight. */
70
+ function parseDateTime(value) {
71
+ const date = parseDate(value);
72
+ if (date === null)
73
+ return null;
74
+ const time = /^.{10}(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(String(value));
75
+ if (!time)
76
+ return null;
77
+ const [hours, minutes, seconds] = [time[1] ?? '00', time[2] ?? '00', time[3] ?? '00'];
78
+ return Number(hours) < 24 && Number(minutes) < 60 && Number(seconds) < 60 ? `${date} ${hours}:${minutes}:${seconds}` : null;
79
+ }
80
+ 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`));
81
+ /** Formats a date-time as "Mon, Jun 1, 2026 14:30:00". */
82
+ 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'}`;
83
+ const MONTHS = Array.from({ length: 12 }, (_, i) => new Intl.DateTimeFormat('en-US', { month: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2024, i, 1))));
84
+ const UNITS = ['day', 'week', 'month', 'quarter', 'year'];
85
+ const TIME_UNITS = ['second', 'minute', 'hour'];
86
+ const shift = (now, unit, amount) => {
87
+ const date = new Date(now.getTime());
88
+ if (unit === 'second')
89
+ date.setUTCSeconds(date.getUTCSeconds() + amount);
90
+ else if (unit === 'minute')
91
+ date.setUTCMinutes(date.getUTCMinutes() + amount);
92
+ else if (unit === 'hour')
93
+ date.setUTCHours(date.getUTCHours() + amount);
94
+ else if (unit === 'day')
95
+ date.setUTCDate(date.getUTCDate() + amount);
96
+ else if (unit === 'week')
97
+ date.setUTCDate(date.getUTCDate() + 7 * amount);
98
+ else if (unit === 'month')
99
+ date.setUTCMonth(date.getUTCMonth() + amount);
100
+ else if (unit === 'quarter')
101
+ date.setUTCMonth(date.getUTCMonth() + 3 * amount);
102
+ else if (unit === 'year')
103
+ date.setUTCFullYear(date.getUTCFullYear() + amount);
104
+ return date;
105
+ };
106
+ /** Relative date presets. */
107
+ const PRESETS = {
108
+ 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),
109
+ 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),
110
+ 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)),
111
+ 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,
112
+ 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),
113
+ 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),
114
+ };
115
+ /** Minute and hour presets, only offered for `time()` constraints: they compare date and time. */
116
+ const TIME_PRESETS = {
117
+ past_hour: (n) => shift(n, 'hour', -1), past_minute: (n) => shift(n, 'minute', -1),
118
+ 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())),
119
+ next_minute: (n) => shift(n, 'minute', 1), next_hour: (n) => shift(n, 'hour', 1),
120
+ };
121
+ /** Preset order in the select (time presets are interleaved only with `time()`). */
122
+ const presetNames = (hasTime) => [
123
+ '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'] : []),
124
+ 'this_decade', 'this_year', 'this_quarter', 'this_month', 'today', ...(hasTime ? ['this_hour', 'this_minute', 'next_minute', 'next_hour'] : []),
125
+ 'next_week', 'next_2_weeks', 'next_month', 'next_quarter', 'next_6_months', 'next_year', 'next_2_years', 'next_5_years', 'next_decade', 'custom',
126
+ ];
127
+ /**
128
+ * Moment targeted by an "after / before" operator's settings: fixed date, preset, or custom offset. `time` is true when the comparison
129
+ * uses date and time (`time()` constraint with a fixed date, a time preset or a sub-day unit).
130
+ */
131
+ function resolveRuleMoment(settings, now, hasTime) {
132
+ if (settings.mode !== 'relative') {
133
+ const value = hasTime ? parseDateTime(settings.date) : parseDate(settings.date);
134
+ return value === null ? null : { value, time: hasTime };
135
+ }
136
+ const preset = typeof settings.preset === 'string' ? settings.preset : 'custom';
137
+ if (hasTime && Object.hasOwn(TIME_PRESETS, preset))
138
+ return { value: toDateTimeString(TIME_PRESETS[preset](now)), time: true };
139
+ if (preset !== 'custom')
140
+ return { value: toDateString((Object.hasOwn(PRESETS, preset) ? PRESETS[preset] : PRESETS.today)(now)), time: false };
141
+ const value = Math.floor(Number(settings.relative_value));
142
+ // An integer of at least 1, capped to stay within representable dates.
143
+ if (!Number.isFinite(value) || value < 1 || value > 10000)
144
+ return null;
145
+ const units = hasTime ? [...TIME_UNITS, ...UNITS] : UNITS;
146
+ const unit = units.includes(String(settings.relative_unit)) ? String(settings.relative_unit) : 'day';
147
+ const moment = shift(now, unit, settings.tense === 'future' ? value : -value);
148
+ return TIME_UNITS.includes(unit) ? { value: toDateTimeString(moment), time: true } : { value: toDateString(moment), time: false };
149
+ }
150
+ /** Date (or date-time, with `hasTime`) targeted by an "after / before" operator's settings. */
151
+ export function resolveRuleDate(settings, now = new Date(), hasTime = false) { return resolveRuleMoment(settings, now, hasTime)?.value ?? null; }
152
+ function windowOperator(name, key, direct, inverted, hasTime) {
153
+ const option = (path, names) => names.map((value) => [value, tr(`${path}.${value}`)]);
154
+ return {
155
+ name, label: label('date', key),
156
+ defaults: () => ({ mode: 'absolute', date: null, preset: 'past_month', tense: 'past', relative_value: 1, relative_unit: 'day' }),
157
+ fields: () => [
158
+ Select.make('mode').label(tr('operators.date.form.mode.label')).live().options(option('operators.date.form.mode.options', ['absolute', 'relative'])).default('absolute'),
159
+ (hasTime ? DateTimePicker.make('date') : DatePicker.make('date')).label(tr('operators.date.form.date.label')).hidden(({ get }) => get('mode') === 'relative').required(({ get }) => get('mode') !== 'relative'),
160
+ Select.make('preset').label(tr('operators.date.form.preset.label')).live().options(option('operators.date.presets', presetNames(hasTime))).default('past_month')
161
+ .hidden(({ get }) => get('mode') !== 'relative').required(({ get }) => get('mode') === 'relative'),
162
+ Group.make([
163
+ Select.make('tense').label(tr('operators.date.form.tense.label')).options(option('operators.date.form.tense.options', ['past', 'future'])).default('past').required(),
164
+ TextInput.make('relative_value').label(tr('operators.date.form.relative_value.label')).numeric().minValue(1).default(1).required(),
165
+ 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(),
166
+ ]).columns(3).columnSpanFull().hidden(({ get }) => get('mode') !== 'relative' || get('preset') !== 'custom'),
167
+ ],
168
+ resolve: (settings, inverse, constraint) => {
169
+ const moment = resolveRuleMoment(settings, new Date(), hasTime);
170
+ if (moment === null)
171
+ return null;
172
+ // With time, the column is compared as is; otherwise only its date part is compared.
173
+ const condition = moment.time
174
+ ? { kind: 'compare', column: constraint.getColumn(), operator: inverse ? inverted : direct, value: moment.value }
175
+ : { kind: 'date', column: constraint.getColumn(), operator: inverse ? inverted : direct, value: moment.value };
176
+ return { condition, summary: summary('date', key, inverse, { attribute: constraint.getAttributeLabel(), date: moment.time ? formatDayDateTime(moment.value) : formatDate(moment.value) }) };
177
+ },
178
+ };
179
+ }
180
+ /** "Is filled" operator, offered by `nullable()` constraints. */
181
+ const isFilledOperator = {
182
+ name: 'isFilled', label: (inverse) => tr(`operators.is_filled.label.${inverse ? 'inverse' : 'direct'}`), defaults: () => null, fields: () => [],
183
+ resolve: (_settings, inverse, constraint) => ({ condition: { kind: 'filled', column: constraint.getColumn(), negate: inverse }, summary: tr(`operators.is_filled.summary.${inverse ? 'inverse' : 'direct'}`, { attribute: constraint.getAttributeLabel() }) }),
184
+ };
185
+ const OPERATORS = {
186
+ text: [
187
+ textOperator('contains', 'contains', (text) => `%${likeEscape(text)}%`), textOperator('startsWith', 'starts_with', (text) => `${likeEscape(text)}%`),
188
+ textOperator('endsWith', 'ends_with', (text) => `%${likeEscape(text)}`), textOperator('equals', 'equals', () => null),
189
+ ],
190
+ number: [numberOperator('isMin', 'is_min', '>=', '<'), numberOperator('isMax', 'is_max', '<=', '>'), numberOperator('equals', 'equals', '=', '!=')],
191
+ boolean: [{
192
+ name: 'isTrue', label: label('boolean', 'is_true'), defaults: () => null, fields: () => [],
193
+ resolve: (_settings, inverse, constraint) => ({ condition: { kind: 'boolean', column: constraint.getColumn(), value: !inverse }, summary: summary('boolean', 'is_true', inverse, { attribute: constraint.getAttributeLabel() }) }),
194
+ }],
195
+ date: [
196
+ {
197
+ name: 'isDate', label: label('date', 'is_date'), defaults: () => ({ date: null }),
198
+ fields: () => [DatePicker.make('date').label(tr('operators.date.form.date.label')).required()],
199
+ resolve: (settings, inverse, constraint) => {
200
+ const date = parseDate(settings.date);
201
+ 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) }) };
202
+ },
203
+ },
204
+ {
205
+ name: 'isMonth', label: label('date', 'is_month'), defaults: () => ({ month: null }),
206
+ 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()],
207
+ resolve: (settings, inverse, constraint) => {
208
+ const month = typeof settings.month === 'string' || typeof settings.month === 'number' ? Math.floor(Number(settings.month)) : NaN;
209
+ 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] }) };
210
+ },
211
+ },
212
+ {
213
+ name: 'isYear', label: label('date', 'is_year'), defaults: () => ({ year: null }),
214
+ fields: () => [TextInput.make('year').label(tr('operators.date.form.year.label')).integer().required()],
215
+ resolve: (settings, inverse, constraint) => {
216
+ const year = (typeof settings.year === 'string' && /^\d{1,4}$/.test(settings.year.trim())) || typeof settings.year === 'number' ? Number(settings.year) : NaN;
217
+ 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 }) };
218
+ },
219
+ },
220
+ ],
221
+ };
222
+ /**
223
+ * Attribute the query builder can filter on. The queried column and relationship are the ones declared here, never browser values:
224
+ * the browser only picks a constraint (by name) and one of its operators.
225
+ */
226
+ export class Constraint {
227
+ name;
228
+ type;
229
+ _label = null;
230
+ _attributeLabel = null;
231
+ _icon;
232
+ _column = null;
233
+ _relationship = null;
234
+ _modifyRelationshipQuery = null;
235
+ _nullable = false;
236
+ /** Objection model of the table's records, used to list related records. */
237
+ _model = null;
238
+ constructor(name, type, icon) {
239
+ if (!isItemKey(name) || name === OR)
240
+ throw new Error(`Invalid constraint name: ${name}`);
241
+ this.name = name;
242
+ this.type = type;
243
+ this._icon = icon;
244
+ }
245
+ label(label) { this._label = label; return this; }
246
+ /** Attribute name used in rule summaries (defaults to the label). */
247
+ attributeLabel(label) { this._attributeLabel = label; return this; }
248
+ icon(name) { this._icon = name; return this; }
249
+ /** Queried column (or query alias) when it differs from the name. */
250
+ attribute(column) { this._column = column; return this; }
251
+ /**
252
+ * Filters on an attribute of related records: `relationship('author', 'name')` filters on the author's name.
253
+ * The relationship must be declared by the Objection model (`relationMappings`).
254
+ */
255
+ relationship(name, titleAttribute, modifyQueryUsing = null) {
256
+ this._relationship = name;
257
+ this._column = titleAttribute;
258
+ this._modifyRelationshipQuery = modifyQueryUsing;
259
+ return this;
260
+ }
261
+ modifyRelationshipQueryUsing(callback) { this._modifyRelationshipQuery = callback; return this; }
262
+ /** Adds the "Is filled" / "Is blank" operator. */
263
+ nullable(condition = true) { this._nullable = condition; return this; }
264
+ isNullable() { return this._nullable; }
265
+ model(model) { this._model = model; return this; }
266
+ getModel() { return this._model; }
267
+ getLabel() { return this._label ?? headline(this.name); }
268
+ getAttributeLabel() { return this._attributeLabel ?? this.getLabel(); }
269
+ getIcon() { return this._icon; }
270
+ getColumn() { return this._column ?? this.name; }
271
+ /** Relationship whose records hold the attribute, or `null` for a column of the table. */
272
+ getRelationshipName() { return this._relationship; }
273
+ getModifyRelationshipQueryUsing() { return this._modifyRelationshipQuery; }
274
+ getOperators() { return [...OPERATORS[this.type], ...(this._nullable ? [isFilledOperator] : [])]; }
275
+ /** `contains` or `contains.inverse` → operator and direction; null if the constraint does not declare it. */
276
+ parseOperator(value) {
277
+ if (typeof value !== 'string')
278
+ return null;
279
+ const inverse = value.endsWith('.inverse');
280
+ const operator = this.getOperators().find((candidate) => candidate.name === (inverse ? value.slice(0, -8) : value));
281
+ return operator ? { operator, inverse } : null;
282
+ }
283
+ getOperatorOptions() { return this.getOperators().flatMap((operator) => [[operator.name, operator.label(false)], [`${operator.name}.inverse`, operator.label(true)]]); }
284
+ /** Setting names known to the constraint across all operators (switching operator keeps the previous settings). */
285
+ getSettingNames() { return [...new Set(this.getOperators().flatMap((operator) => Object.keys(operator.defaults() ?? {})))]; }
286
+ /** Settings whose value is a list (multiple select). */
287
+ getListSettingNames() { return [...new Set(this.getOperators().flatMap((operator) => Object.entries(operator.defaults() ?? {}).filter(([, value]) => Array.isArray(value)).map(([name]) => name)))]; }
288
+ getDefaultData() { const [first] = this.getOperators(); return { operator: first.name, settings: first.defaults() }; }
289
+ }
290
+ export class TextConstraint extends Constraint {
291
+ static make(name) { return configured(new TextConstraint(name, 'text', 'm-language')); }
292
+ }
293
+ export class NumberConstraint extends Constraint {
294
+ static make(name) { return configured(new NumberConstraint(name, 'number', 'm-variable')); }
295
+ }
296
+ export class BooleanConstraint extends Constraint {
297
+ static make(name) { return configured(new BooleanConstraint(name, 'boolean', 'm-check-circle')); }
298
+ }
299
+ /** Date constraint; with `time()`, date and time (date-time picker, minute and hour presets and units). */
300
+ export class DateConstraint extends Constraint {
301
+ _time = false;
302
+ static make(name) { return configured(new DateConstraint(name, 'date', 'm-calendar')); }
303
+ time(condition = true) { this._time = condition; return this; }
304
+ hasTime() { return this._time; }
305
+ getOperators() {
306
+ return [windowOperator('isAfter', 'is_after', '>=', '<', this._time), windowOperator('isBefore', 'is_before', '<=', '>', this._time), ...super.getOperators()];
307
+ }
308
+ }
309
+ /** Value picked from a fixed list; with `multiple()`, any of the picked values. */
310
+ export class SelectConstraint extends Constraint {
311
+ _options = [];
312
+ _multiple = false;
313
+ _searchable = false;
314
+ static make(name) { return configured(new SelectConstraint(name, 'select', 'm-chevron-up-down')); }
315
+ /** Options as `[value, label]` pairs or `{ value: label }`; only these values can filter. */
316
+ options(options) {
317
+ this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
318
+ return this;
319
+ }
320
+ getOptions() { return this._options; }
321
+ multiple(condition = true) { this._multiple = condition; return this; }
322
+ isMultiple() { return this._multiple; }
323
+ searchable(condition = true) { this._searchable = condition; return this; }
324
+ getOperators() {
325
+ const field = this._multiple ? 'values' : 'value';
326
+ const known = (value) => (typeof value === 'string' || typeof value === 'number') && this._options.some(([option]) => option === String(value));
327
+ const is = {
328
+ name: 'is', label: label('select', 'is'), defaults: () => ({ [field]: this._multiple ? [] : null }),
329
+ fields: () => [Select.make(field).label(tr(`operators.select.is.form.${field}.label`)).options(this._options).multiple(this._multiple).searchable(this._searchable).required().columnSpanFull()],
330
+ resolve: (settings, inverse, constraint) => {
331
+ const raw = settings[field];
332
+ // Forged or unknown values are dropped: only declared options remain (otherwise the rule has no effect).
333
+ const values = (Array.isArray(raw) ? raw : this._multiple ? [] : [raw]).filter(known).map(String);
334
+ if (!values.length)
335
+ return null;
336
+ const labels = this._options.filter(([option]) => values.includes(option)).map(([, optionLabel]) => optionLabel);
337
+ const condition = this._multiple
338
+ ? { kind: 'in', column: constraint.getColumn(), values: [...new Set(values)], negate: inverse }
339
+ : { kind: 'compare', column: constraint.getColumn(), operator: inverse ? '!=' : '=', value: values[0] };
340
+ return { condition, summary: summary('select', 'is', inverse, { attribute: constraint.getAttributeLabel(), values: joinLabels(labels, 'operators.select.is.summary.values_glue') }) };
341
+ },
342
+ };
343
+ return [is, ...(this._nullable ? [isFilledOperator] : [])];
344
+ }
345
+ }
346
+ /** Integer related-record count of at least 1 (the `count` setting of RelationshipConstraint operators). */
347
+ const countSetting = (value) => (isNumericValue(value) && Number(value) >= 1 && Number(value) <= 1e9 ? Math.floor(Number(value)) : null);
348
+ const isNumericValue = (value) => typeof value === 'number' ? Number.isFinite(value) : typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value));
349
+ function relationshipCountOperator(name, key, direct, inverted) {
350
+ return {
351
+ name, label: label('relationship', key), defaults: () => ({ count: null }), onBaseQuery: true,
352
+ fields: () => [TextInput.make('count').label(tr('operators.relationship.form.count.label')).numeric().required().minValue(1)],
353
+ resolve: (settings, inverse, constraint) => {
354
+ const count = countSetting(settings.count);
355
+ const relationship = constraint.getRelationshipName();
356
+ const modify = constraint.getModifyRelationshipQueryUsing();
357
+ 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 }) };
358
+ },
359
+ };
360
+ }
361
+ /**
362
+ * "Is" / "Contains" operator of a RelationshipConstraint: related records are picked by key from a list loaded from the related model;
363
+ * `titleAttribute` is the displayed column.
364
+ */
365
+ export class IsRelatedToOperator {
366
+ _titleAttribute = null;
367
+ _multiple = false;
368
+ _searchable = false;
369
+ _preload = false;
370
+ _optionsLimit = 50;
371
+ _modifyQuery = null;
372
+ static make() { return configured(new IsRelatedToOperator()); }
373
+ titleAttribute(attribute) { this._titleAttribute = attribute; return this; }
374
+ getTitleAttribute() { return this._titleAttribute; }
375
+ multiple(condition = true) { this._multiple = condition; return this; }
376
+ isMultiple() { return this._multiple; }
377
+ searchable(condition = true) { this._searchable = condition; return this; }
378
+ preload(condition = true) { this._preload = condition; return this; }
379
+ optionsLimit(limit) { this._optionsLimit = Math.max(1, Math.floor(limit)); return this; }
380
+ modifyRelationshipQueryUsing(callback) { this._modifyQuery = callback; return this; }
381
+ getModifyRelationshipQueryUsing() { return this._modifyQuery; }
382
+ /** Builds the query builder operator for the given constraint. */
383
+ toOperator(constraint) {
384
+ const field = constraint.isMultiple() ? 'values' : 'value';
385
+ const variant = constraint.isMultiple() ? 'multiple' : 'single';
386
+ const keys = (raw) => (Array.isArray(raw) ? raw : [raw]).filter((value) => typeof value === 'string' || typeof value === 'number').slice(0, MAX_LIST_VALUES);
387
+ return {
388
+ name: 'isRelatedTo', label: (inverse) => tr(`operators.relationship.is_related_to.label.${variant}.${inverse ? 'inverse' : 'direct'}`), defaults: () => ({ [field]: this._multiple ? [] : null }), onBaseQuery: true,
389
+ fields: () => [Select.make(field).label(tr(`operators.relationship.is_related_to.form.${field}.label`)).multiple(this._multiple).searchable(this._searchable)
390
+ .options(async (ctx) => constraint.loadRelatedOptions(this, ctx?.livewire)).required().columnSpanFull()],
391
+ resolve: (settings, inverse, c) => {
392
+ const selected = keys(settings[field]);
393
+ if (!selected.length)
394
+ return null;
395
+ const labels = selected.map((key) => constraint.getRelatedLabel(key) ?? String(key));
396
+ const relationship = c.getRelationshipName();
397
+ const modify = this._modifyQuery;
398
+ return {
399
+ condition: { kind: 'related', relationship, keys: [...new Set(selected)], negate: inverse, ...(modify ? { modify } : {}) },
400
+ 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') }),
401
+ };
402
+ },
403
+ };
404
+ }
405
+ getOptionsLimit() { return this._optionsLimit; }
406
+ }
407
+ /**
408
+ * Related records: minimum, maximum or exact count (`multiple()`), empty relationship (`emptyable()`, on by default with `multiple()`),
409
+ * or related to picked records (`selectable(IsRelatedToOperator.make()…)`).
410
+ */
411
+ export class RelationshipConstraint extends Constraint {
412
+ _multiple = false;
413
+ _emptyable = null;
414
+ _selectable = null;
415
+ /** Labels of the related records cited by rules, loaded before indicators are computed. */
416
+ _relatedLabels = new Map();
417
+ static make(name) { return configured(new RelationshipConstraint(name, 'relationship', 'm-arrows-pointing-out')); }
418
+ multiple(condition = true) { this._multiple = condition; return this; }
419
+ isMultiple() { return this._multiple; }
420
+ emptyable(condition = true) { this._emptyable = condition; return this; }
421
+ canBeEmpty() { return this._emptyable ?? this._multiple; }
422
+ selectable(operator) { this._selectable = operator; return this; }
423
+ getSelectableOperator() { return this._selectable; }
424
+ /** Queried relationship: from `relationship()`, else `attribute()`, else the constraint name. */
425
+ getRelationshipName() { return this._relationship ?? this.getColumn(); }
426
+ getOperators() {
427
+ return [
428
+ ...(this._selectable ? [this._selectable.toOperator(this)] : []),
429
+ ...(this._multiple ? [relationshipCountOperator('hasMin', 'has_min', '>=', '<'), relationshipCountOperator('hasMax', 'has_max', '<=', '>'), relationshipCountOperator('equals', 'equals', '=', '!=')] : []),
430
+ ...(this.canBeEmpty() ? [{
431
+ name: 'isEmpty', label: label('relationship', 'is_empty'), defaults: () => null, fields: () => [], onBaseQuery: true,
432
+ resolve: (_settings, inverse) => {
433
+ const modify = this.getModifyRelationshipQueryUsing();
434
+ return { condition: { kind: 'related', relationship: this.getRelationshipName(), keys: null, negate: !inverse, ...(modify ? { modify } : {}) }, summary: summary('relationship', 'is_empty', inverse, { relationship: this.getAttributeLabel() }) };
435
+ },
436
+ }] : []),
437
+ ];
438
+ }
439
+ getRelatedLabel(key) { return this._relatedLabels.get(String(key)) ?? null; }
440
+ /** Related model, found through the table model's relationship (declared, or the host's Objection adapter model). */
441
+ relatedModel(host) {
442
+ const model = this._model ?? modelOfHost(host);
443
+ if (!model?.getRelation)
444
+ throw new Error(`RelationshipConstraint ${this.name}: table model not found; declare it with model() on the constraint or the QueryBuilder.`);
445
+ return model.getRelation(this.getRelationshipName()).relatedModelClass;
446
+ }
447
+ relatedQuery(operator, host) {
448
+ const related = this.relatedModel(host);
449
+ const title = operator.getTitleAttribute() ?? String(related.idColumn);
450
+ const query = related.query();
451
+ operator.getModifyRelationshipQueryUsing()?.(query);
452
+ return { query, id: `${related.tableName}.${related.idColumn}`, title: `${related.tableName}.${title}` };
453
+ }
454
+ /** Select options: records of the related model, sorted by title. */
455
+ async loadRelatedOptions(operator, host) {
456
+ const { query, id, title } = this.relatedQuery(operator, host);
457
+ // All options are loaded (no server-side search), capped.
458
+ const rows = await query.clearSelect().select({ __key: id, __title: title }).orderBy(title).limit(1000);
459
+ return rows.map((row) => [String(row.__key), String(row.__title ?? row.__key)]);
460
+ }
461
+ /** Loads labels of the keys cited by rules (for indicators such as "Author is Ada or Linus"). */
462
+ async loadRelatedLabels(keys, host) {
463
+ if (!this._selectable || !keys.length)
464
+ return;
465
+ const { query, id, title } = this.relatedQuery(this._selectable, host);
466
+ const rows = await query.clearSelect().select({ __key: id, __title: title }).whereIn(id, keys.slice(0, MAX_LIST_VALUES));
467
+ for (const row of rows)
468
+ this._relatedLabels.set(String(row.__key), String(row.__title ?? row.__key));
469
+ }
470
+ }
471
+ /** Objection model of the host's table (`ObjectionAdapter`), when the application did not declare one. */
472
+ function modelOfHost(host) {
473
+ const adapter = host?.getDataAdapterForTable?.() ?? host?.resource?.adapter ?? null;
474
+ const model = adapter?.model;
475
+ return typeof model === 'function' && typeof model.getRelation === 'function' ? model : null;
476
+ }
477
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
478
+ /**
479
+ * Reduces the browser's rule tree to what the filter declares: known block types, the constraint's operators, scalar settings with
480
+ * known names, bounded depth and rule count. An unknown operator is reset to null (the rule has no effect).
481
+ */
482
+ export function sanitizeRules(constraints, raw, depth = 1, budget = { rules: MAX_RULES }) {
483
+ const rules = {};
484
+ if (!isObject(raw))
485
+ return rules;
486
+ for (const [key, rule] of Object.entries(raw)) {
487
+ if (budget.rules <= 0)
488
+ break;
489
+ if (!isItemKey(key) || !isObject(rule) || typeof rule.type !== 'string')
490
+ continue;
491
+ const data = isObject(rule.data) ? rule.data : {};
492
+ if (rule.type === OR) {
493
+ if (depth >= MAX_NESTING_DEPTH)
494
+ continue;
495
+ const groups = {};
496
+ for (const [groupKey, group] of Object.entries(isObject(data.groups) ? data.groups : {}).slice(0, 20)) {
497
+ if (isItemKey(groupKey))
498
+ groups[groupKey] = { rules: sanitizeRules(constraints, isObject(group) ? group.rules : null, depth + 1, budget) };
499
+ }
500
+ rules[key] = { type: OR, data: { groups } };
501
+ continue;
502
+ }
503
+ const constraint = constraints.find((candidate) => candidate.name === rule.type);
504
+ if (!constraint)
505
+ continue;
506
+ budget.rules--;
507
+ // A list is only accepted for settings whose default is a list (multiple select); elsewhere only a scalar.
508
+ const lists = constraint.getListSettingNames();
509
+ 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;
510
+ rules[key] = { type: constraint.name, data: { operator: constraint.parseOperator(data.operator) ? data.operator : null, settings } };
511
+ }
512
+ return rules;
513
+ }
514
+ /** Sanitized rules → conditions and indicator labels (one entry per effective top-level rule). */
515
+ export function compileRules(constraints, rules, iteration = 1) {
516
+ const nodes = [];
517
+ const summaries = {};
518
+ for (const [key, rule] of Object.entries(rules)) {
519
+ if (rule.type === OR) {
520
+ const groups = Object.values((rule.data.groups ?? {})).map((group) => compileRules(constraints, group.rules, iteration + 1));
521
+ // A group without effective rules adds nothing.
522
+ const effective = groups.filter((group) => group.nodes.length);
523
+ if (!effective.length)
524
+ continue;
525
+ nodes.push({ or: effective.map((group) => group.nodes) });
526
+ const parts = effective.map((group) => { const list = Object.values(group.summaries); return list.length > 1 ? `(${list.join(` ${tr('form.rules.item.and')} `)})` : list[0]; });
527
+ summaries[key] = parts.length === 1 ? parts[0] : `${iteration > 1 ? '(' : ''}${parts.join(` ${tr('form.or_groups.block.or')} `)}${iteration > 1 ? ')' : ''}`;
528
+ continue;
529
+ }
530
+ const constraint = constraints.find((candidate) => candidate.name === rule.type);
531
+ const parsed = constraint?.parseOperator(rule.data.operator);
532
+ if (!constraint || !parsed)
533
+ continue;
534
+ const resolved = parsed.operator.resolve(isObject(rule.data.settings) ? rule.data.settings : {}, parsed.inverse, constraint);
535
+ if (!resolved)
536
+ continue;
537
+ // Relationship attribute: the condition applies to the related records, unless the operator targets the base query.
538
+ const relationship = parsed.operator.onBaseQuery ? null : constraint.getRelationshipName();
539
+ const modify = constraint.getModifyRelationshipQueryUsing();
540
+ nodes.push(relationship === null ? resolved.condition : { relationship, nodes: [resolved.condition], ...(modify ? { modify } : {}) });
541
+ summaries[key] = resolved.summary;
542
+ }
543
+ return { nodes, summaries };
544
+ }
545
+ /** Dialect read from the knex client (`query.client.config.client`, or the Objection model's connection); SQLite by default. */
546
+ export function sqlDialectOf(query) {
547
+ const client = query?.client ?? query?.knex?.()?.client ?? query?.modelClass?.()?.knex?.()?.client;
548
+ const configured = client?.config?.client;
549
+ const name = String(typeof configured === 'string' ? configured : (client?.dialect ?? configured?.prototype?.dialect ?? client?.driverName ?? '')).toLowerCase();
550
+ if (/mysql|maria/.test(name))
551
+ return 'mysql';
552
+ if (/^(pg|pgnative|postgres|postgresql|cockroachdb|redshift)$/.test(name))
553
+ return 'pg';
554
+ if (/mssql|sqlserver/.test(name))
555
+ return 'mssql';
556
+ return 'sqlite';
557
+ }
558
+ /** Dialect-specific SQL fragments. `??` is the column (bound identifier), never a browser value. */
559
+ const SQL = {
560
+ sqlite: { date: 'date(??)', month: "cast(strftime('%m', ??) as integer)", year: "cast(strftime('%Y', ??) as integer)", text: '??', like: "?? like ? escape '\\'" },
561
+ // MySQL: backslash is already LIKE's escape character (and would need doubling inside a literal).
562
+ mysql: { date: 'date(??)', month: 'month(??)', year: 'year(??)', text: '??', like: '?? like ?' },
563
+ // PostgreSQL: LIKE is case-sensitive, hence the lowercase comparison; `cast` handles non-text columns.
564
+ pg: { date: 'cast(?? as date)', month: 'extract(month from ??)', year: 'extract(year from ??)', text: 'cast(?? as text)', like: "lower(cast(?? as text)) like ? escape '\\'" },
565
+ mssql: { date: 'cast(?? as date)', month: 'month(??)', year: 'year(??)', text: '??', like: "?? like ? escape '\\'" },
566
+ };
567
+ /** Correlated subquery of the records related to the query's records (Objection `relatedQuery`). */
568
+ function relatedQuery(query, relationship, modify) {
569
+ const model = query?.modelClass?.();
570
+ if (typeof model?.relatedQuery !== 'function')
571
+ throw new Error(`Query builder: relationship ${relationship} requires an Objection query (model with relationMappings).`);
572
+ // Fixed alias (Objection only sets one for some relationships): related columns are qualified with it.
573
+ const sub = model.relatedQuery(relationship).alias(`${relationship}_related`);
574
+ modify?.(sub);
575
+ return sub;
576
+ }
577
+ /**
578
+ * Applies conditions to a knex / objection query. Columns are bound as identifiers and values as parameters; comparison operators
579
+ * come from a closed list. Date and search SQL follows the connection's dialect.
580
+ */
581
+ export function applyConditions(query, nodes, dialect = sqlDialectOf(query)) {
582
+ const sql = SQL[dialect];
583
+ for (const node of nodes) {
584
+ if ('or' in node) {
585
+ query.where((builder) => { for (const group of node.or)
586
+ builder.orWhere((inner) => applyConditions(inner, group, dialect)); });
587
+ }
588
+ else if ('nodes' in node) {
589
+ const sub = relatedQuery(query, node.relationship, node.modify);
590
+ // Qualify columns with the related table: the subquery may join a pivot table.
591
+ const table = sub.tableRefFor(sub.modelClass());
592
+ sub.where((inner) => applyConditions(inner, node.nodes.map((child) => qualify(child, table)), dialect));
593
+ query.whereExists(sub);
594
+ }
595
+ else if (node.kind === 'like')
596
+ query.whereRaw(node.negate ? `not (${sql.like})` : sql.like, [node.column, dialect === 'pg' ? node.pattern.toLowerCase() : node.pattern]);
597
+ else if (node.kind === 'compare')
598
+ query.where(node.column, node.operator, node.value);
599
+ else if (node.kind === 'boolean')
600
+ query.where(node.column, dialect === 'pg' ? node.value : node.value ? 1 : 0);
601
+ else if (node.kind === 'date')
602
+ query.whereRaw(`${sql.date} ${node.operator} ?`, [node.column, node.value]);
603
+ else if (node.kind === 'month')
604
+ query.whereRaw(`${sql.month} ${node.operator} ?`, [node.column, node.value]);
605
+ else if (node.kind === 'year')
606
+ query.whereRaw(`${sql.year} ${node.operator} ?`, [node.column, node.value]);
607
+ else if (node.kind === 'filled') {
608
+ // Neither NULL nor an empty string (the column is cast to text on PostgreSQL).
609
+ const column = node.column;
610
+ query.where((inner) => (node.negate ? inner.whereNull(column).orWhereRaw(`${sql.text} = ?`, [column, '']) : inner.whereNotNull(column).whereRaw(`not (${sql.text} = ?)`, [column, ''])));
611
+ }
612
+ else if (node.kind === 'in') {
613
+ if (node.negate)
614
+ query.whereNotIn(node.column, node.values);
615
+ else
616
+ query.whereIn(node.column, node.values);
617
+ }
618
+ else if (node.kind === 'relationCount') {
619
+ // Count subquery passed as a bound parameter (Objection does not accept it as a `where` column).
620
+ query.whereRaw(`(?) ${node.operator} ?`, [relatedQuery(query, node.relationship, node.modify).count(), node.value]);
621
+ }
622
+ else if (node.kind === 'related') {
623
+ const sub = relatedQuery(query, node.relationship, node.modify);
624
+ if (node.keys !== null) {
625
+ const related = sub.modelClass();
626
+ sub.whereIn(`${sub.tableRefFor(related)}.${related.idColumn}`, node.keys);
627
+ }
628
+ if (node.negate)
629
+ query.whereNotExists(sub);
630
+ else
631
+ query.whereExists(sub);
632
+ }
633
+ }
634
+ }
635
+ /** Qualifies a condition's column with the related table (unless already qualified). */
636
+ function qualify(node, table) {
637
+ if ('or' in node)
638
+ return { or: node.or.map((group) => group.map((child) => qualify(child, table))) };
639
+ if ('nodes' in node || !('column' in node) || node.column.includes('.'))
640
+ return node;
641
+ return { ...node, column: `${table}.${node.column}` };
642
+ }
643
+ // ------------------------------------------------------------------ form
644
+ const CONTAINER_GRID = { '@md': 2, '@xl': 3, '!@sm': 2, '!@md': 3 };
645
+ const SETTINGS_GRID = { '@xl': 2, '!@md': 2 };
646
+ const anonymousScope = (path) => `x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path, containerPath: path }))})"`;
647
+ /** Operator settings: a stateful `settings` group, hidden when the operator has no form. */
648
+ class SettingsGroup extends Component {
649
+ fields;
650
+ constructor(fields) {
651
+ super();
652
+ this._name = 'settings';
653
+ this.fields = fields;
654
+ this._hidden = () => this.fields.length === 0;
655
+ this.columnSpan(SETTINGS_GRID);
656
+ }
657
+ hasStatePath() { return true; }
658
+ getChildSchemas() {
659
+ const c = this.container;
660
+ 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)];
661
+ }
662
+ async toHtml() { return `<div id="${e(this.getKey())}">${await this.getChildSchemas()[0].toHtml()}</div>`; }
663
+ }
664
+ /** "Clone" and "delete" buttons of an item; the actions belong to the parent Builder. */
665
+ function itemActions(builder, itemKey, path, cloneDisabled) {
666
+ const label = (name) => t(`filament-forms::components.builder.actions.${name}.label`);
667
+ const danger = colorsOf(builder.livewire)?.getComponentClasses('icon-button', 'danger') ?? [];
668
+ 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>`;
669
+ }
670
+ /** Rule row: operator select, settings, actions. */
671
+ class RuleRow extends Component {
672
+ builder;
673
+ itemKey;
674
+ constraint;
675
+ data;
676
+ constructor(builder, itemKey, constraint, data) { super(); this.builder = builder; this.itemKey = itemKey; this.constraint = constraint; this.data = data; }
677
+ getChildSchemas() {
678
+ const c = this.container;
679
+ const constraint = this.constraint;
680
+ const parsed = constraint.parseOperator(this.data.operator);
681
+ const operator = Select.make('operator').label(constraint.getLabel()).placeholder(t('filament-forms::components.select.placeholder')).options(constraint.getOperatorOptions()).live()
682
+ // On operator change, its default settings fill in the gaps without overwriting existing settings.
683
+ .afterStateUpdated(({ state, get, set }) => {
684
+ const next = constraint.parseOperator(state)?.operator.defaults() ?? null;
685
+ const current = get('settings');
686
+ set('settings', next === null && !isObject(current) ? null : { ...(next ?? {}), ...(isObject(current) ? current : {}) });
687
+ });
688
+ return [new Schema(c.livewire).key(c.getKey()).statePath(c.getStatePath() || null).operation(c.getOperation()).record(c.getRecord()).root(false).columns(CONTAINER_GRID).dense()
689
+ .components([operator, new SettingsGroup(parsed ? parsed.operator.fields() : [])])];
690
+ }
691
+ /** The row is a CSS container (`fi-grid-ctn`), which the `@md` / `@xl` columns depend on. */
692
+ async toSchemaHtml(isVisible) { return (await super.toSchemaHtml(isVisible)).replace('class="fi-sc-component"', 'class="fi-sc-component fi-grid-ctn"'); }
693
+ async toHtml() {
694
+ const path = this.container.getStatePath();
695
+ 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>`;
696
+ }
697
+ }
698
+ /** Groups of an "OR" block: each group holds its own rule builder (rendered as a repeater). */
699
+ class OrGroups extends Field {
700
+ builder;
701
+ itemKey;
702
+ constructor(builder, itemKey) { super('groups'); this.builder = builder; this.itemKey = itemKey; this._hiddenLabel = true; this._label = tr('form.or_groups.label'); }
703
+ getGroups() {
704
+ const state = this.context().state;
705
+ return isObject(state) ? Object.fromEntries(Object.entries(state).filter(([key, group]) => isItemKey(key) && isObject(group)).slice(0, 20)) : {};
706
+ }
707
+ isBlank(group) { return !isObject(group.rules) || Object.keys(group.rules).length === 0; }
708
+ getDefaultState() { return {}; }
709
+ dehydrateState() { return Object.fromEntries(Object.keys(this.getGroups()).map((key) => [key, {}])); }
710
+ getChildSchemas() {
711
+ const c = this.container;
712
+ 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)
713
+ .components([new RuleBuilder('rules', this.builder.filter, this.builder.depth + 1).hiddenLabel()]));
714
+ }
715
+ canAddGroup() { const groups = Object.values(this.getGroups()); return groups.length < 20 && !groups.some((group) => this.isBlank(group)) && !this.builder.isAtRuleLimit(); }
716
+ canDeleteGroup(key) { const groups = this.getGroups(); return Object.hasOwn(groups, key) && this.isBlank(groups[key]) && Object.keys(groups).length > 2; }
717
+ getActions() {
718
+ const write = (groups) => writeState(this.livewire, this.getStatePath().split('.'), groups);
719
+ return [
720
+ Action.make('add').action(() => { if (this.canAddGroup())
721
+ write({ ...this.getGroups(), [shortKey()]: { rules: {} } }); }),
722
+ Action.make('delete').action(({ arguments: args }) => { const key = String(args.item); if (!this.canDeleteGroup(key))
723
+ return; const groups = this.getGroups(); delete groups[key]; write(groups); }),
724
+ ];
725
+ }
726
+ async toHtml() {
727
+ const key = this.getKey();
728
+ const id = this.getId();
729
+ const livewireKey = this.getLivewireKey() ?? '';
730
+ const groups = Object.keys(this.getGroups());
731
+ const schemas = this.getChildSchemas();
732
+ const context = { ...(this.livewire.getActionMountContext?.() ?? {}), schemaComponent: key };
733
+ const expression = (action, args) => `mountAction(${js(action)}, ${args ? js(args) : '{}'}, ${js(context)})`;
734
+ const danger = colorsOf(this.livewire)?.getComponentClasses('icon-button', 'danger') ?? [];
735
+ const tf = (name, replace = {}) => t(`filament-forms::components.repeater.${name}`, replace);
736
+ const descriptionId = (groupKey) => `${id}-item-${createHash('md5').update(groupKey).digest('hex')}`;
737
+ const rows = [];
738
+ for (const [index, groupKey] of groups.entries()) {
739
+ const path = `${this.getStatePath()}.${groupKey}`;
740
+ const [builder] = schemas[index].getComponents();
741
+ 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>` : '';
742
+ 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>`;
743
+ 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>`);
744
+ if (index < groups.length - 1)
745
+ 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>`);
746
+ }
747
+ const config = { statePath: this.getStatePath(), schemaComponent: key, moveUpAction: 'moveUp', moveDownAction: 'moveDown', reorderAction: 'reorder', message: tf('reordering.moved'), hasCustomReorderAction: false };
748
+ const addExpression = expression('add', null);
749
+ 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>` : '';
750
+ 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('');
751
+ 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>`;
752
+ const path = this.container.getStatePath();
753
+ 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>`;
754
+ }
755
+ }
756
+ /** Rule builder: a Builder whose blocks are the filter's constraints, plus the "OR" block. */
757
+ export class RuleBuilder extends Builder {
758
+ filter;
759
+ depth;
760
+ constructor(name, filter, depth = 1) {
761
+ super(name);
762
+ this.filter = filter;
763
+ this.depth = depth;
764
+ const columns = filter.getConstraintPickerColumns();
765
+ this.label(tr('form.rules.label'))
766
+ .blocks(() => [
767
+ ...filter.getConstraints().map((constraint) => Block.make(constraint.name).label(constraint.getLabel()).icon(constraint.getIcon())
768
+ .defaultData(() => constraint.getDefaultData())
769
+ .schema(({ builder, itemKey, data }) => [new RuleRow(builder, itemKey, constraint, data)])),
770
+ Block.make(OR).label(tr('form.or_groups.block.label')).icon('m-slash')
771
+ .defaultData(() => ({ groups: { [shortKey()]: { rules: {} }, [shortKey()]: { rules: {} } } }))
772
+ .schema(({ builder, itemKey }) => [new OrGroups(builder, itemKey)]),
773
+ ])
774
+ .addActionLabel(tr('actions.add_rule.label')).addActionIcon('c-plus').addActionAlignment('start')
775
+ .labelBetweenItems(tr('item_separators.and')).blockHeaders(false).addBetweenAction(false).storedAsList(false).cloneable().generateUuidUsing(shortKey)
776
+ .blockPickerColumns(columns).blockPickerWidth(columns === 2 ? 'md' : columns === 3 ? '2xl' : null)
777
+ .extraClass('fi-fo-builder-not-contained').maxItems(MAX_RULES);
778
+ }
779
+ /** The "OR" block is neither offered nor accepted beyond the maximum depth; nothing is offered once the rule limit is reached. */
780
+ getBlockPickerBlocks() {
781
+ if (this.isAtRuleLimit())
782
+ return [];
783
+ return this.getBlocks().filter((block) => block.name !== OR || this.depth < MAX_NESTING_DEPTH);
784
+ }
785
+ getBlock(name) { return name === OR && this.depth >= MAX_NESTING_DEPTH ? undefined : super.getBlock(name); }
786
+ canAdd() { return !this.isAtRuleLimit(); }
787
+ /** Counts rules across the whole tree ("OR" blocks excluded), read from the host's deferred filter state. */
788
+ isAtRuleLimit() { return this.filter.countRules(this.livewire.tableDeferredFilters?.[this.filter.name]) >= MAX_RULES; }
789
+ /** Component action without modal (also used by "OR" groups). */
790
+ makeAction(name, handler) { return this.getActions()[0].constructor.make(name).action(({ arguments: args }) => handler(args)); }
791
+ }
792
+ // ------------------------------------------------------------------ filter
793
+ /** Query builder table filter. */
794
+ export class QueryBuilder {
795
+ name = 'queryBuilder';
796
+ _constraints = [];
797
+ _pickerColumns = null;
798
+ _model = null;
799
+ static make() { return configured(new QueryBuilder()); }
800
+ constraints(constraints) {
801
+ if (new Set(constraints.map((c) => c.name)).size !== constraints.length)
802
+ throw new Error('QueryBuilder: two constraints have the same name.');
803
+ this._constraints = constraints;
804
+ if (this._model)
805
+ for (const constraint of constraints)
806
+ if (!constraint.getModel())
807
+ constraint.model(this._model);
808
+ return this;
809
+ }
810
+ /** Objection model of the table, passed to relationship constraints (related record lists). */
811
+ model(model) {
812
+ this._model = model;
813
+ for (const constraint of this._constraints)
814
+ if (!constraint.getModel())
815
+ constraint.model(model);
816
+ return this;
817
+ }
818
+ /** Before indicators: loads labels of the related records cited by rules (`RelationshipConstraint.selectable`). */
819
+ async loadIndicatorData(state, host) {
820
+ const keys = new Map();
821
+ const walk = (rules) => {
822
+ for (const rule of Object.values(rules)) {
823
+ if (rule.type === OR) {
824
+ for (const group of Object.values(rule.data.groups))
825
+ walk(group.rules);
826
+ continue;
827
+ }
828
+ const constraint = this._constraints.find((candidate) => candidate.name === rule.type);
829
+ if (!(constraint instanceof RelationshipConstraint) || !isObject(rule.data.settings))
830
+ continue;
831
+ const raw = rule.data.settings.values ?? rule.data.settings.value;
832
+ const list = (Array.isArray(raw) ? raw : [raw]).filter((value) => typeof value === 'string' || typeof value === 'number');
833
+ keys.set(constraint, [...(keys.get(constraint) ?? []), ...list]);
834
+ }
835
+ };
836
+ walk(this.sanitizeState(state).rules);
837
+ for (const [constraint, list] of keys)
838
+ await constraint.loadRelatedLabels([...new Set(list)], host);
839
+ }
840
+ constraintPickerColumns(columns = 2) { this._pickerColumns = columns; return this; }
841
+ getConstraints() { return this._constraints; }
842
+ getConstraintPickerColumns() { return this._pickerColumns; }
843
+ getLabel() { return tr('label'); }
844
+ getDefaultState() { return { rules: {} }; }
845
+ getFormDefinition() { return { placeholder: '', options: [] }; }
846
+ /** The filter spans the full width of the filters form. */
847
+ getColumnSpan() { return { default: 'full' }; }
848
+ getFormComponents() { return [new RuleBuilder('rules', this).label(this.getLabel()).hiddenLabel()]; }
849
+ sanitizeState(state) { return { rules: sanitizeRules(this._constraints, isObject(state) ? state.rules : null) }; }
850
+ countRules(state) {
851
+ 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);
852
+ return count(this.sanitizeState(state).rules);
853
+ }
854
+ compile(state) { return compileRules(this._constraints, this.sanitizeState(state).rules); }
855
+ isActive(state) { return this.compile(state).nodes.length > 0; }
856
+ getIndicators(state) { return Object.entries(this.compile(state).summaries).map(([field, label]) => ({ label, field })); }
857
+ /** Removing an indicator removes the matching top-level rule. */
858
+ removeIndicator(state, field) {
859
+ const rules = { ...this.sanitizeState(state).rules };
860
+ if (Object.hasOwn(rules, field))
861
+ delete rules[field];
862
+ return { rules };
863
+ }
864
+ apply(query, state) {
865
+ const { nodes } = this.compile(state);
866
+ if (nodes.length)
867
+ (query.modifiers ??= []).push((builder) => { builder.where((inner) => applyConditions(inner, nodes)); });
868
+ }
869
+ }