@navette-js/tables 1.0.0-beta.2 → 1.0.0-beta.4
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/lib/index.d.ts +761 -85
- package/lib/index.js +1377 -236
- package/lib/query-builder.d.ts +92 -2
- package/lib/query-builder.js +164 -23
- package/package.json +6 -6
- package/views/filters.ejs +44 -0
- package/views/index.ejs +129 -106
package/lib/query-builder.d.ts
CHANGED
|
@@ -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
|
-
|
|
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(): {
|
package/lib/query-builder.js
CHANGED
|
@@ -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
|
|
48
|
-
fields: () => [
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
288
|
-
getDefaultData() { const [first] = this.
|
|
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 >=
|
|
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(
|
|
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 <
|
|
905
|
+
return this.getBlocks().filter((block) => block.name !== OR || this.depth < this.filter.getMaxNestingDepth());
|
|
784
906
|
}
|
|
785
|
-
getBlock(name) { return name === OR && this.depth >=
|
|
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() {
|
|
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
|
-
|
|
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
|
+
"version": "1.0.0-beta.4",
|
|
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.
|
|
42
|
-
"@navette-js/database": "1.0.0-beta.
|
|
43
|
-
"@navette-js/actions": "1.0.0-beta.
|
|
44
|
-
"@navette-js/forms": "1.0.0-beta.
|
|
45
|
-
"@navette-js/schemas": "1.0.0-beta.
|
|
41
|
+
"@navette-js/support": "1.0.0-beta.4",
|
|
42
|
+
"@navette-js/database": "1.0.0-beta.4",
|
|
43
|
+
"@navette-js/actions": "1.0.0-beta.4",
|
|
44
|
+
"@navette-js/forms": "1.0.0-beta.4",
|
|
45
|
+
"@navette-js/schemas": "1.0.0-beta.4"
|
|
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>
|