@navette-js/tables 1.0.0-beta.3 → 1.0.0-beta.5

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.
@@ -55,7 +55,24 @@ export type Condition = {
55
55
  keys: Key[] | null;
56
56
  negate: boolean;
57
57
  modify?: RelationshipQueryModifier;
58
+ }
59
+ /** Application-defined condition (custom operator). */
60
+ | {
61
+ kind: 'custom';
62
+ apply: (query: any) => void;
63
+ }
64
+ /** Sum, average, minimum or maximum of a column of the related records, compared to a value. */
65
+ | {
66
+ kind: 'aggregate';
67
+ relationship: string;
68
+ column: string;
69
+ aggregate: Aggregate;
70
+ operator: Comparison;
71
+ value: number;
72
+ modify?: RelationshipQueryModifier;
58
73
  };
74
+ declare const AGGREGATES: readonly ["sum", "avg", "min", "max"];
75
+ type Aggregate = typeof AGGREGATES[number];
59
76
  /** `{ relationship, nodes }`: the nodes apply to the related records (`whereExists` subquery). */
60
77
  export type ConditionNode = Condition | {
61
78
  or: ConditionNode[][];
@@ -65,9 +82,11 @@ export type ConditionNode = Condition | {
65
82
  modify?: RelationshipQueryModifier;
66
83
  };
67
84
  type Settings = Record<string, unknown>;
85
+ /** `onBaseQuery`: the condition applies to the table's query even for a relationship constraint (it queries the relationship itself). */
68
86
  type Resolved = {
69
87
  condition: Condition;
70
88
  summary: string;
89
+ onBaseQuery?: boolean;
71
90
  };
72
91
  /** Constraint operator: its settings form and how it turns into a condition. */
73
92
  type Operator = {
@@ -105,6 +124,10 @@ export declare class Constraint {
105
124
  protected _model: any;
106
125
  constructor(name: string, type: ConstraintType, icon: string);
107
126
  label(label: string): this;
127
+ private _pickerGroup;
128
+ /** Group of the constraint in the constraint picker. */
129
+ pickerGroup(label: string | null): this;
130
+ getPickerGroup(): string | null;
108
131
  /** Attribute name used in rule summaries (defaults to the label). */
109
132
  attributeLabel(label: string | null): this;
110
133
  icon(name: string): this;
@@ -129,6 +152,14 @@ export declare class Constraint {
129
152
  getRelationshipName(): string | null;
130
153
  getModifyRelationshipQueryUsing(): RelationshipQueryModifier | null;
131
154
  getOperators(): Operator[];
155
+ protected _operatorsOverride: Operator[] | null;
156
+ protected _extraOperators: Operator[];
157
+ /** Replaces the operators of the constraint. */
158
+ operators(operators: (QueryBuilderOperator | Operator)[]): this;
159
+ /** Adds operators after the default ones. */
160
+ pushOperators(operators: (QueryBuilderOperator | Operator)[]): this;
161
+ /** Operators offered to the user: the default ones, or those set with `operators()` / `pushOperators()`. */
162
+ getAvailableOperators(): Operator[];
132
163
  /** `contains` or `contains.inverse` → operator and direction; null if the constraint does not declare it. */
133
164
  parseOperator(value: unknown): {
134
165
  operator: Operator;
@@ -141,11 +172,50 @@ export declare class Constraint {
141
172
  getListSettingNames(): string[];
142
173
  getDefaultData(): Record<string, unknown>;
143
174
  }
175
+ /** A constraint built only from your own operators: `Constraint.make('name').operators([...])`. */
176
+ export declare class CustomConstraint extends Constraint {
177
+ static make(name: string): CustomConstraint;
178
+ }
179
+ /**
180
+ * Operator of a query builder constraint: its label, its settings form, the condition it adds to the query, and the summary
181
+ * shown in the filter indicator.
182
+ */
183
+ export declare class QueryBuilderOperator {
184
+ readonly name: string;
185
+ private _label;
186
+ private _inverseLabel;
187
+ private _fields;
188
+ private _query;
189
+ private _summary;
190
+ constructor(name: string);
191
+ static make(name: string): QueryBuilderOperator;
192
+ /** Labels of the operator and of its inverse ("is not ..."). */
193
+ label(label: string, inverseLabel?: string | null): this;
194
+ /** Settings fields; their values reach `query()` and `summary()`, keyed by field name, as scalars from the browser. */
195
+ schema(fields: Component[]): this;
196
+ /** Adds the condition to the query. Check `settings` before using them, and pass them as bound parameters. */
197
+ query(callback: (query: any, ctx: {
198
+ settings: Record<string, unknown>;
199
+ inverse: boolean;
200
+ column: string;
201
+ }) => void): this;
202
+ /** Text of the filter indicator. */
203
+ summary(callback: (ctx: {
204
+ attribute: string;
205
+ settings: Record<string, unknown>;
206
+ inverse: boolean;
207
+ }) => string): this;
208
+ toOperator(): Operator;
209
+ }
144
210
  export declare class TextConstraint extends Constraint {
145
211
  static make(name: string): TextConstraint;
146
212
  }
147
213
  export declare class NumberConstraint extends Constraint {
214
+ private _integer;
148
215
  static make(name: string): NumberConstraint;
216
+ /** Whole numbers only: an integer field, and a rule with a decimal value has no effect. */
217
+ integer(condition?: boolean): this;
218
+ getOperators(): Operator[];
149
219
  }
150
220
  export declare class BooleanConstraint extends Constraint {
151
221
  static make(name: string): BooleanConstraint;
@@ -226,6 +296,13 @@ export declare class RelationshipConstraint extends Constraint {
226
296
  /** Loads labels of the keys cited by rules (for indicators such as "Author is Ada or Linus"). */
227
297
  loadRelatedLabels(keys: Key[], host: unknown): Promise<void>;
228
298
  }
299
+ /**
300
+ * `[key, title]` pairs of the records of a relationship of `model` (an Objection model with `relationMappings`), sorted by
301
+ * title; only `keys` when given. `titleAttribute` is a column of the related table, written by the application.
302
+ */
303
+ export declare function loadRelationshipOptions(model: any, relationship: string, titleAttribute: string | null, modify?: RelationshipQueryModifier | null, keys?: string[] | null, limit?: number): Promise<[string, string][]>;
304
+ /** Objection model of the host's table (`ObjectionAdapter`), when the application did not declare one. */
305
+ export declare function modelOfHost(host: unknown): any;
229
306
  type Rule = {
230
307
  type: string;
231
308
  data: Record<string, any>;
@@ -237,7 +314,7 @@ type Rules = Record<string, Rule>;
237
314
  */
238
315
  export declare function sanitizeRules(constraints: Constraint[], raw: unknown, depth?: number, budget?: {
239
316
  rules: number;
240
- }): Rules;
317
+ }, maxDepth?: number): Rules;
241
318
  /** Sanitized rules → conditions and indicator labels (one entry per effective top-level rule). */
242
319
  export declare function compileRules(constraints: Constraint[], rules: Rules, iteration?: number): {
243
320
  nodes: ConditionNode[];
@@ -272,8 +349,17 @@ export declare class QueryBuilder implements TableFilter {
272
349
  private _constraints;
273
350
  private _pickerColumns;
274
351
  private _model;
352
+ private _maxRules;
353
+ private _maxNestingDepth;
275
354
  static make(): QueryBuilder;
276
- constraints(constraints: Constraint[]): this;
355
+ /** Maximum number of rules (at most `MAX_RULES`, 100). Extra rules sent by the browser are dropped. */
356
+ maxRules(count: number): this;
357
+ getMaxRules(): number;
358
+ /** Maximum nesting depth of "or" groups: 1 forbids them (at most `MAX_NESTING_DEPTH`, 3). */
359
+ maxNestingDepth(depth: number): this;
360
+ getMaxNestingDepth(): number;
361
+ /** Constraints, or `{ 'Group label': [constraints] }` to group them in the constraint picker. */
362
+ constraints(list: Constraint[] | Record<string, Constraint[]>): this;
277
363
  /** Objection model of the table, passed to relationship constraints (related record lists). */
278
364
  model(model: any): this;
279
365
  /** Before indicators: loads labels of the related records cited by rules (`RelationshipConstraint.selectable`). */
@@ -281,6 +367,10 @@ export declare class QueryBuilder implements TableFilter {
281
367
  constraintPickerColumns(columns?: number | null): this;
282
368
  getConstraints(): Constraint[];
283
369
  getConstraintPickerColumns(): number | null;
370
+ private _pickerWidth;
371
+ /** Width of the constraint picker dropdown: `xs` to `7xl` (wider by default with 2 or 3 columns). */
372
+ constraintPickerWidth(width: string | null): this;
373
+ getConstraintPickerWidth(): string | null;
284
374
  getLabel(): string;
285
375
  getDefaultState(): FilterState;
286
376
  getFormDefinition(): {
@@ -20,6 +20,8 @@ export const MAX_NESTING_DEPTH = 3;
20
20
  /** Maximum number of values in a multiple select. */
21
21
  export const MAX_LIST_VALUES = 100;
22
22
  const OR = 'or';
23
+ const AGGREGATES = ['sum', 'avg', 'min', 'max'];
24
+ const AGGREGATE_KEYS = { sum: 'sum', avg: 'average', min: 'min', max: 'max' };
23
25
  const label = (group, name) => (inverse) => tr(`operators.${group}.${name}.label.${inverse ? 'inverse' : 'direct'}`);
24
26
  /** Joins labels as "a, b or c" using the translated glue strings. */
25
27
  const joinLabels = (labels, glueKey) => (labels.length <= 1 ? labels.join('') : `${labels.slice(0, -1).join(tr(`${glueKey}.0`))}${tr(`${glueKey}.final`)}${labels[labels.length - 1]}`);
@@ -41,17 +43,31 @@ function textOperator(name, key, pattern) {
41
43
  },
42
44
  };
43
45
  }
44
- function numberOperator(name, key, direct, inverted) {
46
+ function numberOperator(name, key, direct, inverted, integer = false, aggregatable = false) {
45
47
  return {
46
48
  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
+ // `aggregate` is offered for relationship constraints: the sum, average, minimum or maximum of the related records.
50
+ fields: () => [
51
+ (integer ? TextInput.make('number').integer() : TextInput.make('number').numeric()).label(tr('operators.number.form.number.label')).required(),
52
+ Select.make('aggregate').label(tr('operators.number.form.aggregate.label')).options(AGGREGATES.map((aggregate) => [aggregate, tr(`operators.number.aggregates.${AGGREGATE_KEYS[aggregate]}.label`)])).hidden(!aggregatable),
53
+ ],
49
54
  resolve: (settings, inverse, constraint) => {
50
55
  const raw = settings.number;
51
56
  if (!(typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '')) || !Number.isFinite(Number(raw)))
52
57
  return null;
53
58
  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) }) };
59
+ if (integer && !Number.isInteger(number))
60
+ return null;
61
+ const relationship = constraint.getRelationshipName();
62
+ // Only a known aggregate, and only on a relationship constraint: anything else compares each related value.
63
+ const aggregate = aggregatable && relationship !== null && AGGREGATES.includes(String(settings.aggregate)) ? settings.aggregate : null;
64
+ const attribute = aggregate ? tr(`operators.number.aggregates.${AGGREGATE_KEYS[aggregate]}.summary`, { attribute: constraint.getAttributeLabel() }) : constraint.getAttributeLabel();
65
+ const text = summary('number', key, inverse, { attribute, number: new Intl.NumberFormat('en-US').format(number) });
66
+ if (aggregate) {
67
+ const modify = constraint.getModifyRelationshipQueryUsing();
68
+ return { condition: { kind: 'aggregate', relationship: relationship, column: constraint.getColumn(), aggregate, operator: inverse ? inverted : direct, value: number, ...(modify ? { modify } : {}) }, summary: text, onBaseQuery: true };
69
+ }
70
+ return { condition: { kind: 'compare', column: constraint.getColumn(), operator: inverse ? inverted : direct, value: number }, summary: text };
55
71
  },
56
72
  };
57
73
  }
@@ -243,6 +259,10 @@ export class Constraint {
243
259
  this._icon = icon;
244
260
  }
245
261
  label(label) { this._label = label; return this; }
262
+ _pickerGroup = null;
263
+ /** Group of the constraint in the constraint picker. */
264
+ pickerGroup(label) { this._pickerGroup = label; return this; }
265
+ getPickerGroup() { return this._pickerGroup; }
246
266
  /** Attribute name used in rule summaries (defaults to the label). */
247
267
  attributeLabel(label) { this._attributeLabel = label; return this; }
248
268
  icon(name) { this._icon = name; return this; }
@@ -271,27 +291,100 @@ export class Constraint {
271
291
  /** Relationship whose records hold the attribute, or `null` for a column of the table. */
272
292
  getRelationshipName() { return this._relationship; }
273
293
  getModifyRelationshipQueryUsing() { return this._modifyRelationshipQuery; }
274
- getOperators() { return [...OPERATORS[this.type], ...(this._nullable ? [isFilledOperator] : [])]; }
294
+ getOperators() { return [...(OPERATORS[this.type] ?? []), ...(this._nullable ? [isFilledOperator] : [])]; }
295
+ _operatorsOverride = null;
296
+ _extraOperators = [];
297
+ /** Replaces the operators of the constraint. */
298
+ operators(operators) { this._operatorsOverride = operators.map(toOperator); return this; }
299
+ /** Adds operators after the default ones. */
300
+ pushOperators(operators) { this._extraOperators = [...this._extraOperators, ...operators.map(toOperator)]; return this; }
301
+ /** Operators offered to the user: the default ones, or those set with `operators()` / `pushOperators()`. */
302
+ getAvailableOperators() {
303
+ const list = this._operatorsOverride ?? [...this.getOperators(), ...this._extraOperators];
304
+ if (new Set(list.map((operator) => operator.name)).size !== list.length)
305
+ throw new Error(`Constraint ${this.name}: two operators have the same name.`);
306
+ return list;
307
+ }
275
308
  /** `contains` or `contains.inverse` → operator and direction; null if the constraint does not declare it. */
276
309
  parseOperator(value) {
277
310
  if (typeof value !== 'string')
278
311
  return null;
279
312
  const inverse = value.endsWith('.inverse');
280
- const operator = this.getOperators().find((candidate) => candidate.name === (inverse ? value.slice(0, -8) : value));
313
+ const operator = this.getAvailableOperators().find((candidate) => candidate.name === (inverse ? value.slice(0, -8) : value));
281
314
  return operator ? { operator, inverse } : null;
282
315
  }
283
- getOperatorOptions() { return this.getOperators().flatMap((operator) => [[operator.name, operator.label(false)], [`${operator.name}.inverse`, operator.label(true)]]); }
316
+ getOperatorOptions() { return this.getAvailableOperators().flatMap((operator) => [[operator.name, operator.label(false)], [`${operator.name}.inverse`, operator.label(true)]]); }
284
317
  /** 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() ?? {})))]; }
318
+ getSettingNames() { return [...new Set(this.getAvailableOperators().flatMap((operator) => Object.keys(operator.defaults() ?? {})))]; }
286
319
  /** 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() }; }
320
+ getListSettingNames() { return [...new Set(this.getAvailableOperators().flatMap((operator) => Object.entries(operator.defaults() ?? {}).filter(([, value]) => Array.isArray(value)).map(([name]) => name)))]; }
321
+ getDefaultData() { const [first] = this.getAvailableOperators(); return { operator: first.name, settings: first.defaults() }; }
289
322
  }
323
+ /** A constraint built only from your own operators: `Constraint.make('name').operators([...])`. */
324
+ export class CustomConstraint extends Constraint {
325
+ static make(name) { return configured(new CustomConstraint(name, 'custom', 'm-funnel')); }
326
+ }
327
+ /**
328
+ * Operator of a query builder constraint: its label, its settings form, the condition it adds to the query, and the summary
329
+ * shown in the filter indicator.
330
+ */
331
+ export class QueryBuilderOperator {
332
+ name;
333
+ _label;
334
+ _inverseLabel;
335
+ _fields = [];
336
+ _query = null;
337
+ _summary = null;
338
+ constructor(name) {
339
+ if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name))
340
+ throw new Error(`Invalid operator name: ${name}`);
341
+ this.name = name;
342
+ this._label = headline(name);
343
+ this._inverseLabel = `Not ${headline(name).toLowerCase()}`;
344
+ }
345
+ static make(name) { return configured(new QueryBuilderOperator(name)); }
346
+ /** Labels of the operator and of its inverse ("is not ..."). */
347
+ label(label, inverseLabel = null) { this._label = label; if (inverseLabel !== null)
348
+ this._inverseLabel = inverseLabel; return this; }
349
+ /** Settings fields; their values reach `query()` and `summary()`, keyed by field name, as scalars from the browser. */
350
+ schema(fields) { this._fields = fields; return this; }
351
+ /** Adds the condition to the query. Check `settings` before using them, and pass them as bound parameters. */
352
+ query(callback) { this._query = callback; return this; }
353
+ /** Text of the filter indicator. */
354
+ summary(callback) { this._summary = callback; return this; }
355
+ toOperator() {
356
+ const fields = this._fields;
357
+ return {
358
+ name: this.name,
359
+ label: (inverse) => (inverse ? this._inverseLabel : this._label),
360
+ defaults: () => (fields.length ? Object.fromEntries(fields.filter((field) => typeof field.getName === 'function').map((field) => [field.getName(), typeof field.getDefaultState === 'function' ? field.getDefaultState() : null])) : null),
361
+ fields: () => fields,
362
+ resolve: (settings, inverse, constraint) => {
363
+ if (!this._query)
364
+ return null;
365
+ const column = constraint.getColumn();
366
+ const query = this._query;
367
+ return {
368
+ condition: { kind: 'custom', apply: (builder) => query(builder, { settings, inverse, column }) },
369
+ summary: this._summary ? this._summary({ attribute: constraint.getAttributeLabel(), settings, inverse }) : `${constraint.getAttributeLabel()} ${(inverse ? this._inverseLabel : this._label).toLowerCase()}`,
370
+ };
371
+ },
372
+ };
373
+ }
374
+ }
375
+ const toOperator = (operator) => (operator instanceof QueryBuilderOperator ? operator.toOperator() : operator);
290
376
  export class TextConstraint extends Constraint {
291
377
  static make(name) { return configured(new TextConstraint(name, 'text', 'm-language')); }
292
378
  }
293
379
  export class NumberConstraint extends Constraint {
380
+ _integer = false;
294
381
  static make(name) { return configured(new NumberConstraint(name, 'number', 'm-variable')); }
382
+ /** Whole numbers only: an integer field, and a rule with a decimal value has no effect. */
383
+ integer(condition = true) { this._integer = condition; return this; }
384
+ getOperators() {
385
+ const integer = this._integer, aggregatable = this._relationship !== null;
386
+ return [numberOperator('isMin', 'is_min', '>=', '<', integer, aggregatable), numberOperator('isMax', 'is_max', '<=', '>', integer, aggregatable), numberOperator('equals', 'equals', '=', '!=', integer, aggregatable), ...(this._nullable ? [isFilledOperator] : [])];
387
+ }
295
388
  }
296
389
  export class BooleanConstraint extends Constraint {
297
390
  static make(name) { return configured(new BooleanConstraint(name, 'boolean', 'm-check-circle')); }
@@ -468,8 +561,28 @@ export class RelationshipConstraint extends Constraint {
468
561
  this._relatedLabels.set(String(row.__key), String(row.__title ?? row.__key));
469
562
  }
470
563
  }
564
+ /**
565
+ * `[key, title]` pairs of the records of a relationship of `model` (an Objection model with `relationMappings`), sorted by
566
+ * title; only `keys` when given. `titleAttribute` is a column of the related table, written by the application.
567
+ */
568
+ export async function loadRelationshipOptions(model, relationship, titleAttribute, modify = null, keys = null, limit = 1000) {
569
+ if (typeof model?.getRelation !== 'function')
570
+ throw new Error(`Relationship ${relationship}: the table's records must come from an Objection model with relationMappings.`);
571
+ const related = model.getRelation(relationship).relatedModelClass;
572
+ const id = `${related.tableName}.${related.idColumn}`;
573
+ const title = `${related.tableName}.${titleAttribute ?? related.idColumn}`;
574
+ const query = related.query();
575
+ modify?.(query);
576
+ if (keys !== null) {
577
+ if (!keys.length)
578
+ return [];
579
+ query.whereIn(id, keys.slice(0, MAX_LIST_VALUES));
580
+ }
581
+ const rows = await query.clearSelect().select({ __key: id, __title: title }).orderBy(title).limit(limit);
582
+ return rows.map((row) => [String(row.__key), String(row.__title ?? row.__key)]);
583
+ }
471
584
  /** Objection model of the host's table (`ObjectionAdapter`), when the application did not declare one. */
472
- function modelOfHost(host) {
585
+ export function modelOfHost(host) {
473
586
  const adapter = host?.getDataAdapterForTable?.() ?? host?.resource?.adapter ?? null;
474
587
  const model = adapter?.model;
475
588
  return typeof model === 'function' && typeof model.getRelation === 'function' ? model : null;
@@ -479,7 +592,7 @@ const isObject = (value) => value !== null && typeof value === 'object' && !Arra
479
592
  * Reduces the browser's rule tree to what the filter declares: known block types, the constraint's operators, scalar settings with
480
593
  * known names, bounded depth and rule count. An unknown operator is reset to null (the rule has no effect).
481
594
  */
482
- export function sanitizeRules(constraints, raw, depth = 1, budget = { rules: MAX_RULES }) {
595
+ export function sanitizeRules(constraints, raw, depth = 1, budget = { rules: MAX_RULES }, maxDepth = MAX_NESTING_DEPTH) {
483
596
  const rules = {};
484
597
  if (!isObject(raw))
485
598
  return rules;
@@ -490,12 +603,12 @@ export function sanitizeRules(constraints, raw, depth = 1, budget = { rules: MAX
490
603
  continue;
491
604
  const data = isObject(rule.data) ? rule.data : {};
492
605
  if (rule.type === OR) {
493
- if (depth >= MAX_NESTING_DEPTH)
606
+ if (depth >= maxDepth)
494
607
  continue;
495
608
  const groups = {};
496
609
  for (const [groupKey, group] of Object.entries(isObject(data.groups) ? data.groups : {}).slice(0, 20)) {
497
610
  if (isItemKey(groupKey))
498
- groups[groupKey] = { rules: sanitizeRules(constraints, isObject(group) ? group.rules : null, depth + 1, budget) };
611
+ groups[groupKey] = { rules: sanitizeRules(constraints, isObject(group) ? group.rules : null, depth + 1, budget, maxDepth) };
499
612
  }
500
613
  rules[key] = { type: OR, data: { groups } };
501
614
  continue;
@@ -535,7 +648,7 @@ export function compileRules(constraints, rules, iteration = 1) {
535
648
  if (!resolved)
536
649
  continue;
537
650
  // 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();
651
+ const relationship = parsed.operator.onBaseQuery || resolved.onBaseQuery ? null : constraint.getRelationshipName();
539
652
  const modify = constraint.getModifyRelationshipQueryUsing();
540
653
  nodes.push(relationship === null ? resolved.condition : { relationship, nodes: [resolved.condition], ...(modify ? { modify } : {}) });
541
654
  summaries[key] = resolved.summary;
@@ -615,10 +728,19 @@ export function applyConditions(query, nodes, dialect = sqlDialectOf(query)) {
615
728
  else
616
729
  query.whereIn(node.column, node.values);
617
730
  }
731
+ else if (node.kind === 'aggregate') {
732
+ // Aggregate subquery of the related records, passed as a bound parameter like the count below.
733
+ const sub = relatedQuery(query, node.relationship, node.modify);
734
+ const column = `${sub.tableRefFor(sub.modelClass())}.${node.column}`;
735
+ query.whereRaw(`(?) ${node.operator} ?`, [sub.clearSelect()[node.aggregate](column), node.value]);
736
+ }
618
737
  else if (node.kind === 'relationCount') {
619
738
  // Count subquery passed as a bound parameter (Objection does not accept it as a `where` column).
620
739
  query.whereRaw(`(?) ${node.operator} ?`, [relatedQuery(query, node.relationship, node.modify).count(), node.value]);
621
740
  }
741
+ else if (node.kind === 'custom') {
742
+ query.where((inner) => { node.apply(inner); });
743
+ }
622
744
  else if (node.kind === 'related') {
623
745
  const sub = relatedQuery(query, node.relationship, node.modify);
624
746
  if (node.keys !== null) {
@@ -764,7 +886,7 @@ export class RuleBuilder extends Builder {
764
886
  const columns = filter.getConstraintPickerColumns();
765
887
  this.label(tr('form.rules.label'))
766
888
  .blocks(() => [
767
- ...filter.getConstraints().map((constraint) => Block.make(constraint.name).label(constraint.getLabel()).icon(constraint.getIcon())
889
+ ...filter.getConstraints().map((constraint) => Block.make(constraint.name).label(constraint.getLabel()).icon(constraint.getIcon()).pickerGroup(constraint.getPickerGroup())
768
890
  .defaultData(() => constraint.getDefaultData())
769
891
  .schema(({ builder, itemKey, data }) => [new RuleRow(builder, itemKey, constraint, data)])),
770
892
  Block.make(OR).label(tr('form.or_groups.block.label')).icon('m-slash')
@@ -773,19 +895,24 @@ export class RuleBuilder extends Builder {
773
895
  ])
774
896
  .addActionLabel(tr('actions.add_rule.label')).addActionIcon('c-plus').addActionAlignment('start')
775
897
  .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);
898
+ .blockPickerColumns(columns).blockPickerWidth(filter.getConstraintPickerWidth() ?? (columns === 2 ? 'md' : columns === 3 ? '2xl' : null))
899
+ .extraClass('fi-fo-builder-not-contained').maxItems(filter.getMaxRules());
778
900
  }
779
901
  /** The "OR" block is neither offered nor accepted beyond the maximum depth; nothing is offered once the rule limit is reached. */
780
902
  getBlockPickerBlocks() {
781
903
  if (this.isAtRuleLimit())
782
904
  return [];
783
- return this.getBlocks().filter((block) => block.name !== OR || this.depth < MAX_NESTING_DEPTH);
905
+ return this.getBlocks().filter((block) => block.name !== OR || this.depth < this.filter.getMaxNestingDepth());
784
906
  }
785
- getBlock(name) { return name === OR && this.depth >= MAX_NESTING_DEPTH ? undefined : super.getBlock(name); }
907
+ getBlock(name) { return name === OR && this.depth >= this.filter.getMaxNestingDepth() ? undefined : super.getBlock(name); }
786
908
  canAdd() { return !this.isAtRuleLimit(); }
787
909
  /** 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; }
910
+ isAtRuleLimit() {
911
+ const livewire = this.livewire;
912
+ // Live filters (`deferFilters(false)`) edit the applied state directly.
913
+ const state = livewire.getTable?.().hasDeferredFilters?.() === false ? livewire.tableFilters : livewire.tableDeferredFilters;
914
+ return this.filter.countRules(state?.[this.filter.name]) >= this.filter.getMaxRules();
915
+ }
789
916
  /** Component action without modal (also used by "OR" groups). */
790
917
  makeAction(name, handler) { return this.getActions()[0].constructor.make(name).action(({ arguments: args }) => handler(args)); }
791
918
  }
@@ -796,8 +923,18 @@ export class QueryBuilder {
796
923
  _constraints = [];
797
924
  _pickerColumns = null;
798
925
  _model = null;
926
+ _maxRules = MAX_RULES;
927
+ _maxNestingDepth = MAX_NESTING_DEPTH;
799
928
  static make() { return configured(new QueryBuilder()); }
800
- constraints(constraints) {
929
+ /** Maximum number of rules (at most `MAX_RULES`, 100). Extra rules sent by the browser are dropped. */
930
+ maxRules(count) { this._maxRules = Math.min(MAX_RULES, Math.max(1, Math.floor(count))); return this; }
931
+ getMaxRules() { return this._maxRules; }
932
+ /** Maximum nesting depth of "or" groups: 1 forbids them (at most `MAX_NESTING_DEPTH`, 3). */
933
+ maxNestingDepth(depth) { this._maxNestingDepth = Math.min(MAX_NESTING_DEPTH, Math.max(1, Math.floor(depth))); return this; }
934
+ getMaxNestingDepth() { return this._maxNestingDepth; }
935
+ /** Constraints, or `{ 'Group label': [constraints] }` to group them in the constraint picker. */
936
+ constraints(list) {
937
+ const constraints = Array.isArray(list) ? list : Object.entries(list).flatMap(([group, items]) => items.map((constraint) => constraint.pickerGroup(group)));
801
938
  if (new Set(constraints.map((c) => c.name)).size !== constraints.length)
802
939
  throw new Error('QueryBuilder: two constraints have the same name.');
803
940
  this._constraints = constraints;
@@ -840,13 +977,17 @@ export class QueryBuilder {
840
977
  constraintPickerColumns(columns = 2) { this._pickerColumns = columns; return this; }
841
978
  getConstraints() { return this._constraints; }
842
979
  getConstraintPickerColumns() { return this._pickerColumns; }
980
+ _pickerWidth = null;
981
+ /** Width of the constraint picker dropdown: `xs` to `7xl` (wider by default with 2 or 3 columns). */
982
+ constraintPickerWidth(width) { this._pickerWidth = width !== null && /^(xs|sm|md|lg|xl|[2-7]xl)$/.test(width) ? width : null; return this; }
983
+ getConstraintPickerWidth() { return this._pickerWidth; }
843
984
  getLabel() { return tr('label'); }
844
985
  getDefaultState() { return { rules: {} }; }
845
986
  getFormDefinition() { return { placeholder: '', options: [] }; }
846
987
  /** The filter spans the full width of the filters form. */
847
988
  getColumnSpan() { return { default: 'full' }; }
848
989
  getFormComponents() { return [new RuleBuilder('rules', this).label(this.getLabel()).hiddenLabel()]; }
849
- sanitizeState(state) { return { rules: sanitizeRules(this._constraints, isObject(state) ? state.rules : null) }; }
990
+ sanitizeState(state) { return { rules: sanitizeRules(this._constraints, isObject(state) ? state.rules : null, 1, { rules: this._maxRules }, this._maxNestingDepth) }; }
850
991
  countRules(state) {
851
992
  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
993
  return count(this.sanitizeState(state).rules);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navette-js/tables",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "Interactive tables for Navette admin panels.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,11 +38,11 @@
38
38
  "node"
39
39
  ],
40
40
  "dependencies": {
41
- "@navette-js/support": "1.0.0-beta.3",
42
- "@navette-js/database": "1.0.0-beta.3",
43
- "@navette-js/actions": "1.0.0-beta.3",
44
- "@navette-js/forms": "1.0.0-beta.3",
45
- "@navette-js/schemas": "1.0.0-beta.3"
41
+ "@navette-js/support": "1.0.0-beta.5",
42
+ "@navette-js/database": "1.0.0-beta.5",
43
+ "@navette-js/actions": "1.0.0-beta.5",
44
+ "@navette-js/forms": "1.0.0-beta.5",
45
+ "@navette-js/schemas": "1.0.0-beta.5"
46
46
  },
47
47
  "publishConfig": {
48
48
  "access": "public",
@@ -0,0 +1,44 @@
1
+ <%# Filters panel: heading, reset link (header or footer), form, and the apply button of deferred filters. %>
2
+ <% const resetButton = (extra) => `<button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="${cls(extra)}" data-table-filter-action="reset" wire:click="resetTableFiltersForm">${loadingIndicator(attrs({ 'wire:loading.delay.default': '', 'wire:target': 'resetTableFiltersForm' }))}`; %>
3
+ <div <%- attributes %>>
4
+ <div class="fi-ta-filters-header">
5
+ <h2 class="fi-ta-filters-heading">
6
+ <%= t('filament-tables::table.filters.heading') %>
7
+ </h2>
8
+ <% if (resetPosition === 'header' && !filterActions.reset.hidden) { %>
9
+
10
+ <div>
11
+ <% if (filterActions.reset.customized) { %><%- filterActionHtml(filterActions.reset, 'type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" data-table-filter-action="reset" wire:click="resetTableFiltersForm"') %><% } else { %>
12
+ <%- resetButton(['fi-ac-link-action', 'fi-link', 'fi-size-md', ...dangerLinkClasses]) %>
13
+ <span class="fi-link-label">
14
+ <%= t('filament-tables::table.filters.actions.reset.label') %>
15
+ </span>
16
+ </button>
17
+ <% } %>
18
+ </div>
19
+ <% } %>
20
+ </div>
21
+
22
+ <%- form %>
23
+ <% const showApply = deferred && !filterActions.apply.hidden, showFooterReset = resetPosition === 'footer' && !filterActions.reset.hidden; %>
24
+ <% if (showApply || showFooterReset) { %>
25
+
26
+ <div class="fi-ta-filters-actions-ctn">
27
+ <% if (showApply && filterActions.apply.customized) { %>
28
+ <%- filterActionHtml(filterActions.apply, 'type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" data-table-filter-action="apply" wire:click="applyTableFilters"' + (closeModal ? ' x-on:click="' + e("$dispatch('close-modal', { id: " + js(closeModal) + ' })') + '"' : '')) %>
29
+ <% } else if (showApply) { %>
30
+ <button type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" class="<%- cls(['fi-ac-btn-action', 'fi-btn', 'fi-size-md', ...primaryButtonClasses]) %>" data-table-filter-action="apply" wire:click="applyTableFilters"<% if (closeModal) { %> x-on:click="$dispatch('close-modal', { id: <%= js(closeModal) %> })"<% } %>>
31
+ <%- loadingIndicator(attrs({ 'wire:loading.delay.default': '', 'wire:target': 'applyTableFilters' })) %>
32
+ <%= t('filament-tables::table.filters.actions.apply.label') %>
33
+ </button>
34
+ <% } %>
35
+ <% if (showFooterReset && filterActions.reset.customized) { %>
36
+ <%- filterActionHtml(filterActions.reset, 'type="button" wire:loading.attr="aria-disabled" wire:loading.class="fi-disabled" data-table-filter-action="reset" wire:click="resetTableFiltersForm"') %>
37
+ <% } else if (showFooterReset) { %>
38
+ <%- resetButton(['fi-ac-btn-action', 'fi-btn', 'fi-size-md', ...grayButtonClasses]) %>
39
+ <%= t('filament-tables::table.filters.actions.reset.label') %>
40
+ </button>
41
+ <% } %>
42
+ </div>
43
+ <% } %>
44
+ </div>