@navette-js/tables 1.0.0-beta.1 → 1.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/index.d.ts +149 -148
- package/lib/index.js +167 -166
- package/lib/query-builder.d.ts +53 -53
- package/lib/query-builder.js +89 -95
- package/package.json +7 -8
- package/views/column-manager.ejs +2 -2
- package/views/index.ejs +14 -15
package/lib/query-builder.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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.
|
|
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.
|
|
9
5
|
import { Action } from '@navette-js/actions';
|
|
10
6
|
import { DatePicker, DateTimePicker, Field, Select, TextInput } from '@navette-js/forms';
|
|
11
7
|
import { Block, Builder, colorsOf, iconActionButton, isItemKey, shortKey, writeState } from '@navette-js/forms/builder';
|
|
@@ -16,16 +12,16 @@ import { createHash } from 'node:crypto';
|
|
|
16
12
|
const tr = (key, replace = {}) => t(`filament-query-builder::query-builder.${key}`, replace);
|
|
17
13
|
const headline = (s) => s.replace(/[_.-]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
|
18
14
|
const scalar = (value) => typeof value === 'string' ? value.slice(0, 1000) : typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)) ? value : null;
|
|
19
|
-
/**
|
|
15
|
+
/** Setting received from the browser: a scalar, or a bounded list of scalars (multiple select). */
|
|
20
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);
|
|
21
|
-
/**
|
|
17
|
+
/** Limits applied to the rule tree received from the browser. */
|
|
22
18
|
export const MAX_RULES = 100;
|
|
23
19
|
export const MAX_NESTING_DEPTH = 3;
|
|
24
|
-
/**
|
|
20
|
+
/** Maximum number of values in a multiple select. */
|
|
25
21
|
export const MAX_LIST_VALUES = 100;
|
|
26
22
|
const OR = 'or';
|
|
27
23
|
const label = (group, name) => (inverse) => tr(`operators.${group}.${name}.label.${inverse ? 'inverse' : 'direct'}`);
|
|
28
|
-
/**
|
|
24
|
+
/** Joins labels as "a, b or c" using the translated glue strings. */
|
|
29
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]}`);
|
|
30
26
|
const summary = (group, name, inverse, replace) => tr(`operators.${group}.${name}.summary.${inverse ? 'inverse' : 'direct'}`, replace);
|
|
31
27
|
const likeEscape = (text) => text.replace(/[\\%_]/g, '\\$&');
|
|
@@ -48,7 +44,7 @@ function textOperator(name, key, pattern) {
|
|
|
48
44
|
function numberOperator(name, key, direct, inverted) {
|
|
49
45
|
return {
|
|
50
46
|
name, label: label('number', key), defaults: () => ({ number: null, aggregate: null }),
|
|
51
|
-
// `aggregate`
|
|
47
|
+
// `aggregate` is only used for relationships; the field exists but stays hidden.
|
|
52
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()],
|
|
53
49
|
resolve: (settings, inverse, constraint) => {
|
|
54
50
|
const raw = settings.number;
|
|
@@ -59,11 +55,10 @@ function numberOperator(name, key, direct, inverted) {
|
|
|
59
55
|
},
|
|
60
56
|
};
|
|
61
57
|
}
|
|
62
|
-
// --- dates
|
|
63
58
|
const pad = (n) => String(n).padStart(2, '0');
|
|
64
59
|
const toDateString = (date) => `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
|
|
65
60
|
const toDateTimeString = (date) => `${toDateString(date)} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
|
|
66
|
-
/**
|
|
61
|
+
/** A valid `YYYY-MM-DD` date, otherwise null. */
|
|
67
62
|
function parseDate(value) {
|
|
68
63
|
const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
|
|
69
64
|
if (!m)
|
|
@@ -71,7 +66,7 @@ function parseDate(value) {
|
|
|
71
66
|
const date = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
|
72
67
|
return toDateString(date) === `${m[1]}-${m[2]}-${m[3]}` ? toDateString(date) : null;
|
|
73
68
|
}
|
|
74
|
-
/**
|
|
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. */
|
|
75
70
|
function parseDateTime(value) {
|
|
76
71
|
const date = parseDate(value);
|
|
77
72
|
if (date === null)
|
|
@@ -83,7 +78,7 @@ function parseDateTime(value) {
|
|
|
83
78
|
return Number(hours) < 24 && Number(minutes) < 60 && Number(seconds) < 60 ? `${date} ${hours}:${minutes}:${seconds}` : null;
|
|
84
79
|
}
|
|
85
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`));
|
|
86
|
-
/**
|
|
81
|
+
/** Formats a date-time as "Mon, Jun 1, 2026 14:30:00". */
|
|
87
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'}`;
|
|
88
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))));
|
|
89
84
|
const UNITS = ['day', 'week', 'month', 'quarter', 'year'];
|
|
@@ -108,7 +103,7 @@ const shift = (now, unit, amount) => {
|
|
|
108
103
|
date.setUTCFullYear(date.getUTCFullYear() + amount);
|
|
109
104
|
return date;
|
|
110
105
|
};
|
|
111
|
-
/**
|
|
106
|
+
/** Relative date presets. */
|
|
112
107
|
const PRESETS = {
|
|
113
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),
|
|
114
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),
|
|
@@ -117,21 +112,21 @@ const PRESETS = {
|
|
|
117
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),
|
|
118
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),
|
|
119
114
|
};
|
|
120
|
-
/**
|
|
115
|
+
/** Minute and hour presets, only offered for `time()` constraints: they compare date and time. */
|
|
121
116
|
const TIME_PRESETS = {
|
|
122
117
|
past_hour: (n) => shift(n, 'hour', -1), past_minute: (n) => shift(n, 'minute', -1),
|
|
123
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())),
|
|
124
119
|
next_minute: (n) => shift(n, 'minute', 1), next_hour: (n) => shift(n, 'hour', 1),
|
|
125
120
|
};
|
|
126
|
-
/**
|
|
121
|
+
/** Preset order in the select (time presets are interleaved only with `time()`). */
|
|
127
122
|
const presetNames = (hasTime) => [
|
|
128
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'] : []),
|
|
129
124
|
'this_decade', 'this_year', 'this_quarter', 'this_month', 'today', ...(hasTime ? ['this_hour', 'this_minute', 'next_minute', 'next_hour'] : []),
|
|
130
125
|
'next_week', 'next_2_weeks', 'next_month', 'next_quarter', 'next_6_months', 'next_year', 'next_2_years', 'next_5_years', 'next_decade', 'custom',
|
|
131
126
|
];
|
|
132
127
|
/**
|
|
133
|
-
* Moment
|
|
134
|
-
*
|
|
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).
|
|
135
130
|
*/
|
|
136
131
|
function resolveRuleMoment(settings, now, hasTime) {
|
|
137
132
|
if (settings.mode !== 'relative') {
|
|
@@ -144,7 +139,7 @@ function resolveRuleMoment(settings, now, hasTime) {
|
|
|
144
139
|
if (preset !== 'custom')
|
|
145
140
|
return { value: toDateString((Object.hasOwn(PRESETS, preset) ? PRESETS[preset] : PRESETS.today)(now)), time: false };
|
|
146
141
|
const value = Math.floor(Number(settings.relative_value));
|
|
147
|
-
//
|
|
142
|
+
// An integer of at least 1, capped to stay within representable dates.
|
|
148
143
|
if (!Number.isFinite(value) || value < 1 || value > 10000)
|
|
149
144
|
return null;
|
|
150
145
|
const units = hasTime ? [...TIME_UNITS, ...UNITS] : UNITS;
|
|
@@ -152,7 +147,7 @@ function resolveRuleMoment(settings, now, hasTime) {
|
|
|
152
147
|
const moment = shift(now, unit, settings.tense === 'future' ? value : -value);
|
|
153
148
|
return TIME_UNITS.includes(unit) ? { value: toDateTimeString(moment), time: true } : { value: toDateString(moment), time: false };
|
|
154
149
|
}
|
|
155
|
-
/** Date (
|
|
150
|
+
/** Date (or date-time, with `hasTime`) targeted by an "after / before" operator's settings. */
|
|
156
151
|
export function resolveRuleDate(settings, now = new Date(), hasTime = false) { return resolveRuleMoment(settings, now, hasTime)?.value ?? null; }
|
|
157
152
|
function windowOperator(name, key, direct, inverted, hasTime) {
|
|
158
153
|
const option = (path, names) => names.map((value) => [value, tr(`${path}.${value}`)]);
|
|
@@ -164,7 +159,6 @@ function windowOperator(name, key, direct, inverted, hasTime) {
|
|
|
164
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'),
|
|
165
160
|
Select.make('preset').label(tr('operators.date.form.preset.label')).live().options(option('operators.date.presets', presetNames(hasTime))).default('past_month')
|
|
166
161
|
.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
162
|
Group.make([
|
|
169
163
|
Select.make('tense').label(tr('operators.date.form.tense.label')).options(option('operators.date.form.tense.options', ['past', 'future'])).default('past').required(),
|
|
170
164
|
TextInput.make('relative_value').label(tr('operators.date.form.relative_value.label')).numeric().minValue(1).default(1).required(),
|
|
@@ -175,7 +169,7 @@ function windowOperator(name, key, direct, inverted, hasTime) {
|
|
|
175
169
|
const moment = resolveRuleMoment(settings, new Date(), hasTime);
|
|
176
170
|
if (moment === null)
|
|
177
171
|
return null;
|
|
178
|
-
//
|
|
172
|
+
// With time, the column is compared as is; otherwise only its date part is compared.
|
|
179
173
|
const condition = moment.time
|
|
180
174
|
? { kind: 'compare', column: constraint.getColumn(), operator: inverse ? inverted : direct, value: moment.value }
|
|
181
175
|
: { kind: 'date', column: constraint.getColumn(), operator: inverse ? inverted : direct, value: moment.value };
|
|
@@ -183,7 +177,7 @@ function windowOperator(name, key, direct, inverted, hasTime) {
|
|
|
183
177
|
},
|
|
184
178
|
};
|
|
185
179
|
}
|
|
186
|
-
/**
|
|
180
|
+
/** "Is filled" operator, offered by `nullable()` constraints. */
|
|
187
181
|
const isFilledOperator = {
|
|
188
182
|
name: 'isFilled', label: (inverse) => tr(`operators.is_filled.label.${inverse ? 'inverse' : 'direct'}`), defaults: () => null, fields: () => [],
|
|
189
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() }) }),
|
|
@@ -226,8 +220,8 @@ const OPERATORS = {
|
|
|
226
220
|
],
|
|
227
221
|
};
|
|
228
222
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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.
|
|
231
225
|
*/
|
|
232
226
|
export class Constraint {
|
|
233
227
|
name;
|
|
@@ -239,24 +233,24 @@ export class Constraint {
|
|
|
239
233
|
_relationship = null;
|
|
240
234
|
_modifyRelationshipQuery = null;
|
|
241
235
|
_nullable = false;
|
|
242
|
-
/**
|
|
236
|
+
/** Objection model of the table's records, used to list related records. */
|
|
243
237
|
_model = null;
|
|
244
238
|
constructor(name, type, icon) {
|
|
245
239
|
if (!isItemKey(name) || name === OR)
|
|
246
|
-
throw new Error(`
|
|
240
|
+
throw new Error(`Invalid constraint name: ${name}`);
|
|
247
241
|
this.name = name;
|
|
248
242
|
this.type = type;
|
|
249
243
|
this._icon = icon;
|
|
250
244
|
}
|
|
251
245
|
label(label) { this._label = label; return this; }
|
|
252
|
-
/**
|
|
246
|
+
/** Attribute name used in rule summaries (defaults to the label). */
|
|
253
247
|
attributeLabel(label) { this._attributeLabel = label; return this; }
|
|
254
248
|
icon(name) { this._icon = name; return this; }
|
|
255
|
-
/**
|
|
249
|
+
/** Queried column (or query alias) when it differs from the name. */
|
|
256
250
|
attribute(column) { this._column = column; return this; }
|
|
257
251
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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`).
|
|
260
254
|
*/
|
|
261
255
|
relationship(name, titleAttribute, modifyQueryUsing = null) {
|
|
262
256
|
this._relationship = name;
|
|
@@ -265,7 +259,7 @@ export class Constraint {
|
|
|
265
259
|
return this;
|
|
266
260
|
}
|
|
267
261
|
modifyRelationshipQueryUsing(callback) { this._modifyRelationshipQuery = callback; return this; }
|
|
268
|
-
/**
|
|
262
|
+
/** Adds the "Is filled" / "Is blank" operator. */
|
|
269
263
|
nullable(condition = true) { this._nullable = condition; return this; }
|
|
270
264
|
isNullable() { return this._nullable; }
|
|
271
265
|
model(model) { this._model = model; return this; }
|
|
@@ -274,11 +268,11 @@ export class Constraint {
|
|
|
274
268
|
getAttributeLabel() { return this._attributeLabel ?? this.getLabel(); }
|
|
275
269
|
getIcon() { return this._icon; }
|
|
276
270
|
getColumn() { return this._column ?? this.name; }
|
|
277
|
-
/**
|
|
271
|
+
/** Relationship whose records hold the attribute, or `null` for a column of the table. */
|
|
278
272
|
getRelationshipName() { return this._relationship; }
|
|
279
273
|
getModifyRelationshipQueryUsing() { return this._modifyRelationshipQuery; }
|
|
280
274
|
getOperators() { return [...OPERATORS[this.type], ...(this._nullable ? [isFilledOperator] : [])]; }
|
|
281
|
-
/** `contains`
|
|
275
|
+
/** `contains` or `contains.inverse` → operator and direction; null if the constraint does not declare it. */
|
|
282
276
|
parseOperator(value) {
|
|
283
277
|
if (typeof value !== 'string')
|
|
284
278
|
return null;
|
|
@@ -287,9 +281,9 @@ export class Constraint {
|
|
|
287
281
|
return operator ? { operator, inverse } : null;
|
|
288
282
|
}
|
|
289
283
|
getOperatorOptions() { return this.getOperators().flatMap((operator) => [[operator.name, operator.label(false)], [`${operator.name}.inverse`, operator.label(true)]]); }
|
|
290
|
-
/**
|
|
284
|
+
/** Setting names known to the constraint across all operators (switching operator keeps the previous settings). */
|
|
291
285
|
getSettingNames() { return [...new Set(this.getOperators().flatMap((operator) => Object.keys(operator.defaults() ?? {})))]; }
|
|
292
|
-
/**
|
|
286
|
+
/** Settings whose value is a list (multiple select). */
|
|
293
287
|
getListSettingNames() { return [...new Set(this.getOperators().flatMap((operator) => Object.entries(operator.defaults() ?? {}).filter(([, value]) => Array.isArray(value)).map(([name]) => name)))]; }
|
|
294
288
|
getDefaultData() { const [first] = this.getOperators(); return { operator: first.name, settings: first.defaults() }; }
|
|
295
289
|
}
|
|
@@ -302,7 +296,7 @@ export class NumberConstraint extends Constraint {
|
|
|
302
296
|
export class BooleanConstraint extends Constraint {
|
|
303
297
|
static make(name) { return configured(new BooleanConstraint(name, 'boolean', 'm-check-circle')); }
|
|
304
298
|
}
|
|
305
|
-
/** Date ;
|
|
299
|
+
/** Date constraint; with `time()`, date and time (date-time picker, minute and hour presets and units). */
|
|
306
300
|
export class DateConstraint extends Constraint {
|
|
307
301
|
_time = false;
|
|
308
302
|
static make(name) { return configured(new DateConstraint(name, 'date', 'm-calendar')); }
|
|
@@ -312,13 +306,13 @@ export class DateConstraint extends Constraint {
|
|
|
312
306
|
return [windowOperator('isAfter', 'is_after', '>=', '<', this._time), windowOperator('isBefore', 'is_before', '<=', '>', this._time), ...super.getOperators()];
|
|
313
307
|
}
|
|
314
308
|
}
|
|
315
|
-
/**
|
|
309
|
+
/** Value picked from a fixed list; with `multiple()`, any of the picked values. */
|
|
316
310
|
export class SelectConstraint extends Constraint {
|
|
317
311
|
_options = [];
|
|
318
312
|
_multiple = false;
|
|
319
313
|
_searchable = false;
|
|
320
314
|
static make(name) { return configured(new SelectConstraint(name, 'select', 'm-chevron-up-down')); }
|
|
321
|
-
/** Options `[
|
|
315
|
+
/** Options as `[value, label]` pairs or `{ value: label }`; only these values can filter. */
|
|
322
316
|
options(options) {
|
|
323
317
|
this._options = (Array.isArray(options) ? options : Object.entries(options)).map(([value, label]) => [String(value), String(label)]);
|
|
324
318
|
return this;
|
|
@@ -335,7 +329,7 @@ export class SelectConstraint extends Constraint {
|
|
|
335
329
|
fields: () => [Select.make(field).label(tr(`operators.select.is.form.${field}.label`)).options(this._options).multiple(this._multiple).searchable(this._searchable).required().columnSpanFull()],
|
|
336
330
|
resolve: (settings, inverse, constraint) => {
|
|
337
331
|
const raw = settings[field];
|
|
338
|
-
//
|
|
332
|
+
// Forged or unknown values are dropped: only declared options remain (otherwise the rule has no effect).
|
|
339
333
|
const values = (Array.isArray(raw) ? raw : this._multiple ? [] : [raw]).filter(known).map(String);
|
|
340
334
|
if (!values.length)
|
|
341
335
|
return null;
|
|
@@ -349,7 +343,7 @@ export class SelectConstraint extends Constraint {
|
|
|
349
343
|
return [is, ...(this._nullable ? [isFilledOperator] : [])];
|
|
350
344
|
}
|
|
351
345
|
}
|
|
352
|
-
/**
|
|
346
|
+
/** Integer related-record count of at least 1 (the `count` setting of RelationshipConstraint operators). */
|
|
353
347
|
const countSetting = (value) => (isNumericValue(value) && Number(value) >= 1 && Number(value) <= 1e9 ? Math.floor(Number(value)) : null);
|
|
354
348
|
const isNumericValue = (value) => typeof value === 'number' ? Number.isFinite(value) : typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value));
|
|
355
349
|
function relationshipCountOperator(name, key, direct, inverted) {
|
|
@@ -365,8 +359,8 @@ function relationshipCountOperator(name, key, direct, inverted) {
|
|
|
365
359
|
};
|
|
366
360
|
}
|
|
367
361
|
/**
|
|
368
|
-
*
|
|
369
|
-
*
|
|
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.
|
|
370
364
|
*/
|
|
371
365
|
export class IsRelatedToOperator {
|
|
372
366
|
_titleAttribute = null;
|
|
@@ -385,7 +379,7 @@ export class IsRelatedToOperator {
|
|
|
385
379
|
optionsLimit(limit) { this._optionsLimit = Math.max(1, Math.floor(limit)); return this; }
|
|
386
380
|
modifyRelationshipQueryUsing(callback) { this._modifyQuery = callback; return this; }
|
|
387
381
|
getModifyRelationshipQueryUsing() { return this._modifyQuery; }
|
|
388
|
-
/**
|
|
382
|
+
/** Builds the query builder operator for the given constraint. */
|
|
389
383
|
toOperator(constraint) {
|
|
390
384
|
const field = constraint.isMultiple() ? 'values' : 'value';
|
|
391
385
|
const variant = constraint.isMultiple() ? 'multiple' : 'single';
|
|
@@ -411,14 +405,14 @@ export class IsRelatedToOperator {
|
|
|
411
405
|
getOptionsLimit() { return this._optionsLimit; }
|
|
412
406
|
}
|
|
413
407
|
/**
|
|
414
|
-
*
|
|
415
|
-
*
|
|
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()…)`).
|
|
416
410
|
*/
|
|
417
411
|
export class RelationshipConstraint extends Constraint {
|
|
418
412
|
_multiple = false;
|
|
419
413
|
_emptyable = null;
|
|
420
414
|
_selectable = null;
|
|
421
|
-
/**
|
|
415
|
+
/** Labels of the related records cited by rules, loaded before indicators are computed. */
|
|
422
416
|
_relatedLabels = new Map();
|
|
423
417
|
static make(name) { return configured(new RelationshipConstraint(name, 'relationship', 'm-arrows-pointing-out')); }
|
|
424
418
|
multiple(condition = true) { this._multiple = condition; return this; }
|
|
@@ -427,7 +421,7 @@ export class RelationshipConstraint extends Constraint {
|
|
|
427
421
|
canBeEmpty() { return this._emptyable ?? this._multiple; }
|
|
428
422
|
selectable(operator) { this._selectable = operator; return this; }
|
|
429
423
|
getSelectableOperator() { return this._selectable; }
|
|
430
|
-
/**
|
|
424
|
+
/** Queried relationship: from `relationship()`, else `attribute()`, else the constraint name. */
|
|
431
425
|
getRelationshipName() { return this._relationship ?? this.getColumn(); }
|
|
432
426
|
getOperators() {
|
|
433
427
|
return [
|
|
@@ -443,11 +437,11 @@ export class RelationshipConstraint extends Constraint {
|
|
|
443
437
|
];
|
|
444
438
|
}
|
|
445
439
|
getRelatedLabel(key) { return this._relatedLabels.get(String(key)) ?? null; }
|
|
446
|
-
/**
|
|
440
|
+
/** Related model, found through the table model's relationship (declared, or the host's Objection adapter model). */
|
|
447
441
|
relatedModel(host) {
|
|
448
442
|
const model = this._model ?? modelOfHost(host);
|
|
449
443
|
if (!model?.getRelation)
|
|
450
|
-
throw new Error(`RelationshipConstraint ${this.name}
|
|
444
|
+
throw new Error(`RelationshipConstraint ${this.name}: table model not found; declare it with model() on the constraint or the QueryBuilder.`);
|
|
451
445
|
return model.getRelation(this.getRelationshipName()).relatedModelClass;
|
|
452
446
|
}
|
|
453
447
|
relatedQuery(operator, host) {
|
|
@@ -457,14 +451,14 @@ export class RelationshipConstraint extends Constraint {
|
|
|
457
451
|
operator.getModifyRelationshipQueryUsing()?.(query);
|
|
458
452
|
return { query, id: `${related.tableName}.${related.idColumn}`, title: `${related.tableName}.${title}` };
|
|
459
453
|
}
|
|
460
|
-
/**
|
|
454
|
+
/** Select options: records of the related model, sorted by title. */
|
|
461
455
|
async loadRelatedOptions(operator, host) {
|
|
462
456
|
const { query, id, title } = this.relatedQuery(operator, host);
|
|
463
|
-
//
|
|
457
|
+
// All options are loaded (no server-side search), capped.
|
|
464
458
|
const rows = await query.clearSelect().select({ __key: id, __title: title }).orderBy(title).limit(1000);
|
|
465
459
|
return rows.map((row) => [String(row.__key), String(row.__title ?? row.__key)]);
|
|
466
460
|
}
|
|
467
|
-
/**
|
|
461
|
+
/** Loads labels of the keys cited by rules (for indicators such as "Author is Ada or Linus"). */
|
|
468
462
|
async loadRelatedLabels(keys, host) {
|
|
469
463
|
if (!this._selectable || !keys.length)
|
|
470
464
|
return;
|
|
@@ -474,7 +468,7 @@ export class RelationshipConstraint extends Constraint {
|
|
|
474
468
|
this._relatedLabels.set(String(row.__key), String(row.__title ?? row.__key));
|
|
475
469
|
}
|
|
476
470
|
}
|
|
477
|
-
/**
|
|
471
|
+
/** Objection model of the host's table (`ObjectionAdapter`), when the application did not declare one. */
|
|
478
472
|
function modelOfHost(host) {
|
|
479
473
|
const adapter = host?.getDataAdapterForTable?.() ?? host?.resource?.adapter ?? null;
|
|
480
474
|
const model = adapter?.model;
|
|
@@ -482,8 +476,8 @@ function modelOfHost(host) {
|
|
|
482
476
|
}
|
|
483
477
|
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
484
478
|
/**
|
|
485
|
-
*
|
|
486
|
-
*
|
|
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).
|
|
487
481
|
*/
|
|
488
482
|
export function sanitizeRules(constraints, raw, depth = 1, budget = { rules: MAX_RULES }) {
|
|
489
483
|
const rules = {};
|
|
@@ -510,21 +504,21 @@ export function sanitizeRules(constraints, raw, depth = 1, budget = { rules: MAX
|
|
|
510
504
|
if (!constraint)
|
|
511
505
|
continue;
|
|
512
506
|
budget.rules--;
|
|
513
|
-
//
|
|
507
|
+
// A list is only accepted for settings whose default is a list (multiple select); elsewhere only a scalar.
|
|
514
508
|
const lists = constraint.getListSettingNames();
|
|
515
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;
|
|
516
510
|
rules[key] = { type: constraint.name, data: { operator: constraint.parseOperator(data.operator) ? data.operator : null, settings } };
|
|
517
511
|
}
|
|
518
512
|
return rules;
|
|
519
513
|
}
|
|
520
|
-
/**
|
|
514
|
+
/** Sanitized rules → conditions and indicator labels (one entry per effective top-level rule). */
|
|
521
515
|
export function compileRules(constraints, rules, iteration = 1) {
|
|
522
516
|
const nodes = [];
|
|
523
517
|
const summaries = {};
|
|
524
518
|
for (const [key, rule] of Object.entries(rules)) {
|
|
525
519
|
if (rule.type === OR) {
|
|
526
520
|
const groups = Object.values((rule.data.groups ?? {})).map((group) => compileRules(constraints, group.rules, iteration + 1));
|
|
527
|
-
//
|
|
521
|
+
// A group without effective rules adds nothing.
|
|
528
522
|
const effective = groups.filter((group) => group.nodes.length);
|
|
529
523
|
if (!effective.length)
|
|
530
524
|
continue;
|
|
@@ -540,7 +534,7 @@ export function compileRules(constraints, rules, iteration = 1) {
|
|
|
540
534
|
const resolved = parsed.operator.resolve(isObject(rule.data.settings) ? rule.data.settings : {}, parsed.inverse, constraint);
|
|
541
535
|
if (!resolved)
|
|
542
536
|
continue;
|
|
543
|
-
//
|
|
537
|
+
// Relationship attribute: the condition applies to the related records, unless the operator targets the base query.
|
|
544
538
|
const relationship = parsed.operator.onBaseQuery ? null : constraint.getRelationshipName();
|
|
545
539
|
const modify = constraint.getModifyRelationshipQueryUsing();
|
|
546
540
|
nodes.push(relationship === null ? resolved.condition : { relationship, nodes: [resolved.condition], ...(modify ? { modify } : {}) });
|
|
@@ -548,7 +542,7 @@ export function compileRules(constraints, rules, iteration = 1) {
|
|
|
548
542
|
}
|
|
549
543
|
return { nodes, summaries };
|
|
550
544
|
}
|
|
551
|
-
/**
|
|
545
|
+
/** Dialect read from the knex client (`query.client.config.client`, or the Objection model's connection); SQLite by default. */
|
|
552
546
|
export function sqlDialectOf(query) {
|
|
553
547
|
const client = query?.client ?? query?.knex?.()?.client ?? query?.modelClass?.()?.knex?.()?.client;
|
|
554
548
|
const configured = client?.config?.client;
|
|
@@ -561,28 +555,28 @@ export function sqlDialectOf(query) {
|
|
|
561
555
|
return 'mssql';
|
|
562
556
|
return 'sqlite';
|
|
563
557
|
}
|
|
564
|
-
/**
|
|
558
|
+
/** Dialect-specific SQL fragments. `??` is the column (bound identifier), never a browser value. */
|
|
565
559
|
const SQL = {
|
|
566
560
|
sqlite: { date: 'date(??)', month: "cast(strftime('%m', ??) as integer)", year: "cast(strftime('%Y', ??) as integer)", text: '??', like: "?? like ? escape '\\'" },
|
|
567
|
-
// MySQL
|
|
561
|
+
// MySQL: backslash is already LIKE's escape character (and would need doubling inside a literal).
|
|
568
562
|
mysql: { date: 'date(??)', month: 'month(??)', year: 'year(??)', text: '??', like: '?? like ?' },
|
|
569
|
-
// PostgreSQL
|
|
563
|
+
// PostgreSQL: LIKE is case-sensitive, hence the lowercase comparison; `cast` handles non-text columns.
|
|
570
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 '\\'" },
|
|
571
565
|
mssql: { date: 'cast(?? as date)', month: 'month(??)', year: 'year(??)', text: '??', like: "?? like ? escape '\\'" },
|
|
572
566
|
};
|
|
573
|
-
/**
|
|
567
|
+
/** Correlated subquery of the records related to the query's records (Objection `relatedQuery`). */
|
|
574
568
|
function relatedQuery(query, relationship, modify) {
|
|
575
569
|
const model = query?.modelClass?.();
|
|
576
570
|
if (typeof model?.relatedQuery !== 'function')
|
|
577
|
-
throw new Error(`
|
|
578
|
-
//
|
|
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.
|
|
579
573
|
const sub = model.relatedQuery(relationship).alias(`${relationship}_related`);
|
|
580
574
|
modify?.(sub);
|
|
581
575
|
return sub;
|
|
582
576
|
}
|
|
583
577
|
/**
|
|
584
|
-
*
|
|
585
|
-
*
|
|
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.
|
|
586
580
|
*/
|
|
587
581
|
export function applyConditions(query, nodes, dialect = sqlDialectOf(query)) {
|
|
588
582
|
const sql = SQL[dialect];
|
|
@@ -593,7 +587,7 @@ export function applyConditions(query, nodes, dialect = sqlDialectOf(query)) {
|
|
|
593
587
|
}
|
|
594
588
|
else if ('nodes' in node) {
|
|
595
589
|
const sub = relatedQuery(query, node.relationship, node.modify);
|
|
596
|
-
//
|
|
590
|
+
// Qualify columns with the related table: the subquery may join a pivot table.
|
|
597
591
|
const table = sub.tableRefFor(sub.modelClass());
|
|
598
592
|
sub.where((inner) => applyConditions(inner, node.nodes.map((child) => qualify(child, table)), dialect));
|
|
599
593
|
query.whereExists(sub);
|
|
@@ -611,7 +605,7 @@ export function applyConditions(query, nodes, dialect = sqlDialectOf(query)) {
|
|
|
611
605
|
else if (node.kind === 'year')
|
|
612
606
|
query.whereRaw(`${sql.year} ${node.operator} ?`, [node.column, node.value]);
|
|
613
607
|
else if (node.kind === 'filled') {
|
|
614
|
-
//
|
|
608
|
+
// Neither NULL nor an empty string (the column is cast to text on PostgreSQL).
|
|
615
609
|
const column = node.column;
|
|
616
610
|
query.where((inner) => (node.negate ? inner.whereNull(column).orWhereRaw(`${sql.text} = ?`, [column, '']) : inner.whereNotNull(column).whereRaw(`not (${sql.text} = ?)`, [column, ''])));
|
|
617
611
|
}
|
|
@@ -622,7 +616,7 @@ export function applyConditions(query, nodes, dialect = sqlDialectOf(query)) {
|
|
|
622
616
|
query.whereIn(node.column, node.values);
|
|
623
617
|
}
|
|
624
618
|
else if (node.kind === 'relationCount') {
|
|
625
|
-
//
|
|
619
|
+
// Count subquery passed as a bound parameter (Objection does not accept it as a `where` column).
|
|
626
620
|
query.whereRaw(`(?) ${node.operator} ?`, [relatedQuery(query, node.relationship, node.modify).count(), node.value]);
|
|
627
621
|
}
|
|
628
622
|
else if (node.kind === 'related') {
|
|
@@ -638,7 +632,7 @@ export function applyConditions(query, nodes, dialect = sqlDialectOf(query)) {
|
|
|
638
632
|
}
|
|
639
633
|
}
|
|
640
634
|
}
|
|
641
|
-
/**
|
|
635
|
+
/** Qualifies a condition's column with the related table (unless already qualified). */
|
|
642
636
|
function qualify(node, table) {
|
|
643
637
|
if ('or' in node)
|
|
644
638
|
return { or: node.or.map((group) => group.map((child) => qualify(child, table))) };
|
|
@@ -646,11 +640,11 @@ function qualify(node, table) {
|
|
|
646
640
|
return node;
|
|
647
641
|
return { ...node, column: `${table}.${node.column}` };
|
|
648
642
|
}
|
|
649
|
-
// ------------------------------------------------------------------
|
|
643
|
+
// ------------------------------------------------------------------ form
|
|
650
644
|
const CONTAINER_GRID = { '@md': 2, '@xl': 3, '!@sm': 2, '!@md': 3 };
|
|
651
645
|
const SETTINGS_GRID = { '@xl': 2, '!@md': 2 };
|
|
652
646
|
const anonymousScope = (path) => `x-data="filamentSchemaComponent(${e(js({ key: null, exposedMethods: [], path, containerPath: path }))})"`;
|
|
653
|
-
/**
|
|
647
|
+
/** Operator settings: a stateful `settings` group, hidden when the operator has no form. */
|
|
654
648
|
class SettingsGroup extends Component {
|
|
655
649
|
fields;
|
|
656
650
|
constructor(fields) {
|
|
@@ -667,13 +661,13 @@ class SettingsGroup extends Component {
|
|
|
667
661
|
}
|
|
668
662
|
async toHtml() { return `<div id="${e(this.getKey())}">${await this.getChildSchemas()[0].toHtml()}</div>`; }
|
|
669
663
|
}
|
|
670
|
-
/**
|
|
664
|
+
/** "Clone" and "delete" buttons of an item; the actions belong to the parent Builder. */
|
|
671
665
|
function itemActions(builder, itemKey, path, cloneDisabled) {
|
|
672
666
|
const label = (name) => t(`filament-forms::components.builder.actions.${name}.label`);
|
|
673
667
|
const danger = colorsOf(builder.livewire)?.getComponentClasses('icon-button', 'danger') ?? [];
|
|
674
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>`;
|
|
675
669
|
}
|
|
676
|
-
/**
|
|
670
|
+
/** Rule row: operator select, settings, actions. */
|
|
677
671
|
class RuleRow extends Component {
|
|
678
672
|
builder;
|
|
679
673
|
itemKey;
|
|
@@ -685,7 +679,7 @@ class RuleRow extends Component {
|
|
|
685
679
|
const constraint = this.constraint;
|
|
686
680
|
const parsed = constraint.parseOperator(this.data.operator);
|
|
687
681
|
const operator = Select.make('operator').label(constraint.getLabel()).placeholder(t('filament-forms::components.select.placeholder')).options(constraint.getOperatorOptions()).live()
|
|
688
|
-
//
|
|
682
|
+
// On operator change, its default settings fill in the gaps without overwriting existing settings.
|
|
689
683
|
.afterStateUpdated(({ state, get, set }) => {
|
|
690
684
|
const next = constraint.parseOperator(state)?.operator.defaults() ?? null;
|
|
691
685
|
const current = get('settings');
|
|
@@ -694,14 +688,14 @@ class RuleRow extends Component {
|
|
|
694
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()
|
|
695
689
|
.components([operator, new SettingsGroup(parsed ? parsed.operator.fields() : [])])];
|
|
696
690
|
}
|
|
697
|
-
/**
|
|
691
|
+
/** The row is a CSS container (`fi-grid-ctn`), which the `@md` / `@xl` columns depend on. */
|
|
698
692
|
async toSchemaHtml(isVisible) { return (await super.toSchemaHtml(isVisible)).replace('class="fi-sc-component"', 'class="fi-sc-component fi-grid-ctn"'); }
|
|
699
693
|
async toHtml() {
|
|
700
694
|
const path = this.container.getStatePath();
|
|
701
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>`;
|
|
702
696
|
}
|
|
703
697
|
}
|
|
704
|
-
/**
|
|
698
|
+
/** Groups of an "OR" block: each group holds its own rule builder (rendered as a repeater). */
|
|
705
699
|
class OrGroups extends Field {
|
|
706
700
|
builder;
|
|
707
701
|
itemKey;
|
|
@@ -759,7 +753,7 @@ class OrGroups extends Field {
|
|
|
759
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>`;
|
|
760
754
|
}
|
|
761
755
|
}
|
|
762
|
-
/**
|
|
756
|
+
/** Rule builder: a Builder whose blocks are the filter's constraints, plus the "OR" block. */
|
|
763
757
|
export class RuleBuilder extends Builder {
|
|
764
758
|
filter;
|
|
765
759
|
depth;
|
|
@@ -782,7 +776,7 @@ export class RuleBuilder extends Builder {
|
|
|
782
776
|
.blockPickerColumns(columns).blockPickerWidth(columns === 2 ? 'md' : columns === 3 ? '2xl' : null)
|
|
783
777
|
.extraClass('fi-fo-builder-not-contained').maxItems(MAX_RULES);
|
|
784
778
|
}
|
|
785
|
-
/**
|
|
779
|
+
/** The "OR" block is neither offered nor accepted beyond the maximum depth; nothing is offered once the rule limit is reached. */
|
|
786
780
|
getBlockPickerBlocks() {
|
|
787
781
|
if (this.isAtRuleLimit())
|
|
788
782
|
return [];
|
|
@@ -790,13 +784,13 @@ export class RuleBuilder extends Builder {
|
|
|
790
784
|
}
|
|
791
785
|
getBlock(name) { return name === OR && this.depth >= MAX_NESTING_DEPTH ? undefined : super.getBlock(name); }
|
|
792
786
|
canAdd() { return !this.isAtRuleLimit(); }
|
|
793
|
-
/**
|
|
787
|
+
/** Counts rules across the whole tree ("OR" blocks excluded), read from the host's deferred filter state. */
|
|
794
788
|
isAtRuleLimit() { return this.filter.countRules(this.livewire.tableDeferredFilters?.[this.filter.name]) >= MAX_RULES; }
|
|
795
|
-
/**
|
|
789
|
+
/** Component action without modal (also used by "OR" groups). */
|
|
796
790
|
makeAction(name, handler) { return this.getActions()[0].constructor.make(name).action(({ arguments: args }) => handler(args)); }
|
|
797
791
|
}
|
|
798
|
-
// ------------------------------------------------------------------
|
|
799
|
-
/**
|
|
792
|
+
// ------------------------------------------------------------------ filter
|
|
793
|
+
/** Query builder table filter. */
|
|
800
794
|
export class QueryBuilder {
|
|
801
795
|
name = 'queryBuilder';
|
|
802
796
|
_constraints = [];
|
|
@@ -805,7 +799,7 @@ export class QueryBuilder {
|
|
|
805
799
|
static make() { return configured(new QueryBuilder()); }
|
|
806
800
|
constraints(constraints) {
|
|
807
801
|
if (new Set(constraints.map((c) => c.name)).size !== constraints.length)
|
|
808
|
-
throw new Error('QueryBuilder
|
|
802
|
+
throw new Error('QueryBuilder: two constraints have the same name.');
|
|
809
803
|
this._constraints = constraints;
|
|
810
804
|
if (this._model)
|
|
811
805
|
for (const constraint of constraints)
|
|
@@ -813,7 +807,7 @@ export class QueryBuilder {
|
|
|
813
807
|
constraint.model(this._model);
|
|
814
808
|
return this;
|
|
815
809
|
}
|
|
816
|
-
/**
|
|
810
|
+
/** Objection model of the table, passed to relationship constraints (related record lists). */
|
|
817
811
|
model(model) {
|
|
818
812
|
this._model = model;
|
|
819
813
|
for (const constraint of this._constraints)
|
|
@@ -821,7 +815,7 @@ export class QueryBuilder {
|
|
|
821
815
|
constraint.model(model);
|
|
822
816
|
return this;
|
|
823
817
|
}
|
|
824
|
-
/**
|
|
818
|
+
/** Before indicators: loads labels of the related records cited by rules (`RelationshipConstraint.selectable`). */
|
|
825
819
|
async loadIndicatorData(state, host) {
|
|
826
820
|
const keys = new Map();
|
|
827
821
|
const walk = (rules) => {
|
|
@@ -849,7 +843,7 @@ export class QueryBuilder {
|
|
|
849
843
|
getLabel() { return tr('label'); }
|
|
850
844
|
getDefaultState() { return { rules: {} }; }
|
|
851
845
|
getFormDefinition() { return { placeholder: '', options: [] }; }
|
|
852
|
-
/**
|
|
846
|
+
/** The filter spans the full width of the filters form. */
|
|
853
847
|
getColumnSpan() { return { default: 'full' }; }
|
|
854
848
|
getFormComponents() { return [new RuleBuilder('rules', this).label(this.getLabel()).hiddenLabel()]; }
|
|
855
849
|
sanitizeState(state) { return { rules: sanitizeRules(this._constraints, isObject(state) ? state.rules : null) }; }
|
|
@@ -860,7 +854,7 @@ export class QueryBuilder {
|
|
|
860
854
|
compile(state) { return compileRules(this._constraints, this.sanitizeState(state).rules); }
|
|
861
855
|
isActive(state) { return this.compile(state).nodes.length > 0; }
|
|
862
856
|
getIndicators(state) { return Object.entries(this.compile(state).summaries).map(([field, label]) => ({ label, field })); }
|
|
863
|
-
/**
|
|
857
|
+
/** Removing an indicator removes the matching top-level rule. */
|
|
864
858
|
removeIndicator(state, field) {
|
|
865
859
|
const rules = { ...this.sanitizeState(state).rules };
|
|
866
860
|
if (Object.hasOwn(rules, field))
|